A multilocação em um sistema de software é chamada de separação de dados de acordo com um conjunto de critérios para satisfazer um conjunto de objetivos. A magnitude/extensão, a natureza e a implementação final desta separação dependem desses critérios e objetivos. Multi-tenancy é basicamente um caso de particionamento de dados, mas tentaremos evitar esse termo pelas razões óbvias (o termo no PostgreSQL tem um significado muito específico e é reservado, pois o particionamento de tabela declarativo foi introduzido no postgresql 10).
Os critérios podem ser:
- de acordo com o id de uma tabela mestra importante, que simboliza o id do inquilino que pode representar:
- uma empresa/organização dentro de um grupo de controle maior
- um departamento dentro de uma empresa/organização
- um escritório/filial regional da mesma empresa/organização
- de acordo com a localização/IP de um usuário
- de acordo com a posição do usuário dentro da empresa/organização
Os objetivos podem ser:
- separação de recursos físicos ou virtuais
- separação de recursos do sistema
- segurança
- precisão e conveniência de gerenciamento/usuários nos vários níveis da empresa/organização
Observe que, ao cumprir um objetivo, também cumprimos todos os objetivos abaixo, ou seja, ao cumprir A, também cumprimos B, C e D, ao cumprir B, também cumprimos C e D, e assim por diante.
Se quisermos cumprir o objetivo A, podemos optar por implantar cada locatário como um cluster de banco de dados separado em seu próprio servidor físico/virtual. Isso oferece separação máxima de recursos e segurança, mas dá resultados ruins quando precisamos ver todos os dados como um só, ou seja, a visão consolidada de todo o sistema.
Se quisermos atingir apenas o objetivo B, podemos implantar cada locatário como uma instância postgresql separada no mesmo servidor. Isso nos daria controle sobre quanto espaço seria atribuído a cada instância e também algum controle (dependendo do sistema operacional) sobre a utilização da CPU/mem. Este caso não é essencialmente diferente de A. Na era moderna da computação em nuvem, a diferença entre A e B tende a ficar cada vez menor, de modo que A será provavelmente o caminho preferido em relação a B.
Se quisermos atingir o objetivo C, ou seja, segurança, basta ter uma instância de banco de dados e implantar cada locatário como um banco de dados separado.
E, finalmente, se nos preocuparmos apenas com a separação “suave” de dados, ou em outras palavras, diferentes visões do mesmo sistema, podemos conseguir isso por apenas uma instância de banco de dados e um banco de dados, usando uma infinidade de técnicas discutidas abaixo como o final (e major) deste blog. Falando em multilocação, do ponto de vista do DBA, os casos A, B e C têm muitas semelhanças. Isso ocorre porque em todos os casos temos bancos de dados diferentes e para fazer a ponte entre esses bancos de dados, ferramentas e tecnologias especiais devem ser usadas. No entanto, se a necessidade de fazer isso vier dos departamentos de análise ou Business Intelligence, talvez não seja necessário fazer ponte, pois os dados podem ser muito bem replicados para algum servidor central dedicado a essas tarefas, tornando a ponte desnecessária. Se de fato essa ponte for necessária, devemos usar ferramentas como dblink ou tabelas estrangeiras. Tabelas estrangeiras via Foreign Data Wrappers são hoje em dia a maneira preferida.
Se usarmos a opção D, no entanto, a consolidação já é fornecida por padrão, então agora a parte difícil é o oposto:a separação. Portanto, podemos geralmente categorizar as várias opções em duas categorias principais:
- Separação suave
- Separação difícil
Separação rígida por meio de diferentes bancos de dados no mesmo cluster
Vamos supor que temos que projetar um sistema para uma empresa imaginária que oferece aluguel de carros e barcos, mas como esses dois são regidos por legislações diferentes, controles diferentes, auditorias, cada empresa deve manter departamentos de contabilidade separados e, portanto, gostaríamos de manter seus sistemas separados. Neste caso optamos por ter um banco de dados diferente para cada empresa:rentaldb_cars e rentaldb_boats, que terão esquemas idênticos:
# \d customers
Table "public.customers"
Column | Type | Collation | Nullable | Default
-------------+---------------+-----------+----------+---------------------------------------
id | integer | | not null | nextval('customers_id_seq'::regclass)
cust_name | text | | not null |
birth_date | date | | |
sex | character(10) | | |
nationality | text | | |
Indexes:
"customers_pkey" PRIMARY KEY, btree (id)
Referenced by:
TABLE "rental" CONSTRAINT "rental_customerid_fkey" FOREIGN KEY (customerid) REFERENCES customers(id)
# \d rental
Table "public.rental"
Column | Type | Collation | Nullable | Default
------------+---------+-----------+----------+---------------------------------
id | integer | | not null | nextval('rental_id_seq'::regclass)
customerid | integer | | not null |
vehicleno | text | | |
datestart | date | | not null |
dateend | date | | |
Indexes:
"rental_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
"rental_customerid_fkey" FOREIGN KEY (customerid) REFERENCES customers(id)
Vamos supor que temos os seguintes aluguéis. Em rentaldb_cars:
rentaldb_cars=# select cust.cust_name,rent.vehicleno,rent.datestart FROM rental rent JOIN customers cust on (rent.customerid=cust.id);
cust_name | vehicleno | datestart
-----------------+-----------+------------
Valentino Rossi | INI 8888 | 2018-08-10
(1 row)
e em rentaldb_boats:
rentaldb_boats=# select cust.cust_name,rent.vehicleno,rent.datestart FROM rental rent JOIN customers cust on (rent.customerid=cust.id);
cust_name | vehicleno | datestart
----------------+-----------+------------
Petter Solberg | INI 9999 | 2018-08-10
(1 row)
Agora, a gerência gostaria de ter uma visão consolidada do sistema, por exemplo. uma forma unificada de visualizar os aluguéis. Podemos resolver isso através do aplicativo, mas se não quisermos atualizar o aplicativo ou não tivermos acesso ao código-fonte, podemos resolver isso criando um banco de dados central rentaldb e fazendo uso de tabelas estrangeiras, como segue:
CREATE EXTENSION IF NOT EXISTS postgres_fdw WITH SCHEMA public;
CREATE SERVER rentaldb_boats_srv FOREIGN DATA WRAPPER postgres_fdw OPTIONS (
dbname 'rentaldb_boats'
);
CREATE USER MAPPING FOR postgres SERVER rentaldb_boats_srv;
CREATE SERVER rentaldb_cars_srv FOREIGN DATA WRAPPER postgres_fdw OPTIONS (
dbname 'rentaldb_cars'
);
CREATE USER MAPPING FOR postgres SERVER rentaldb_cars_srv;
CREATE FOREIGN TABLE public.customers_boats (
id integer NOT NULL,
cust_name text NOT NULL
)
SERVER rentaldb_boats_srv
OPTIONS (
table_name 'customers'
);
CREATE FOREIGN TABLE public.customers_cars (
id integer NOT NULL,
cust_name text NOT NULL
)
SERVER rentaldb_cars_srv
OPTIONS (
table_name 'customers'
);
CREATE VIEW public.customers AS
SELECT 'cars'::character varying(50) AS tenant_db,
customers_cars.id,
customers_cars.cust_name
FROM public.customers_cars
UNION
SELECT 'boats'::character varying AS tenant_db,
customers_boats.id,
customers_boats.cust_name
FROM public.customers_boats;
CREATE FOREIGN TABLE public.rental_boats (
id integer NOT NULL,
customerid integer NOT NULL,
vehicleno text NOT NULL,
datestart date NOT NULL
)
SERVER rentaldb_boats_srv
OPTIONS (
table_name 'rental'
);
CREATE FOREIGN TABLE public.rental_cars (
id integer NOT NULL,
customerid integer NOT NULL,
vehicleno text NOT NULL,
datestart date NOT NULL
)
SERVER rentaldb_cars_srv
OPTIONS (
table_name 'rental'
);
CREATE VIEW public.rental AS
SELECT 'cars'::character varying(50) AS tenant_db,
rental_cars.id,
rental_cars.customerid,
rental_cars.vehicleno,
rental_cars.datestart
FROM public.rental_cars
UNION
SELECT 'boats'::character varying AS tenant_db,
rental_boats.id,
rental_boats.customerid,
rental_boats.vehicleno,
rental_boats.datestart
FROM public.rental_boats;
Para visualizar todos os aluguéis e os clientes em toda a organização, basta fazer:
rentaldb=# select cust.cust_name, rent.* FROM rental rent JOIN customers cust ON (rent.tenant_db=cust.tenant_db AND rent.customerid=cust.id);
cust_name | tenant_db | id | customerid | vehicleno | datestart
-----------------+-----------+----+------------+-----------+------------
Petter Solberg | boats | 1 | 1 | INI 9999 | 2018-08-10
Valentino Rossi | cars | 1 | 2 | INI 8888 | 2018-08-10
(2 rows)
Isso parece bom, o isolamento e a segurança são garantidos, a consolidação é alcançada, mas ainda há problemas:
- os clientes devem ser mantidos separadamente, o que significa que o mesmo cliente pode acabar com duas contas
- O aplicativo deve respeitar a noção de uma coluna especial (como tenant_db) e anexá-la a cada consulta, tornando-a propensa a erros
- As visualizações resultantes não podem ser atualizadas automaticamente (já que contêm UNION)
Separação suave no mesmo banco de dados
Quando esta abordagem é escolhida, a consolidação é dada imediatamente e agora a parte difícil é a separação. O PostgreSQL nos oferece uma infinidade de soluções para implementar a separação:
- Visualizações
- Segurança em nível de função
- Esquemas
Com visualizações, o aplicativo deve definir uma configuração que pode ser consultada, como application_name, ocultamos a tabela principal atrás de uma visualização e, em cada consulta em qualquer uma das tabelas filhas (como na dependência FK), se houver, dessa tabela principal, junte-se a esta vista. Veremos isso no exemplo a seguir em um banco de dados que chamamos de rentaldb_one. Incorporamos a identificação da empresa locatária na tabela principal:
rentaldb_one=# \d rental_one
Table "public.rental_one"
Column | Type | Collation | Nullable | Default
------------+-----------------------+-----------+----------+------------------------------------
company | character varying(50) | | not null |
id | integer | | not null | nextval('rental_id_seq'::regclass)
customerid | integer | | not null |
vehicleno | text | | |
datestart | date | | not null |
dateend | date | | |
Indexes:
"rental_pkey" PRIMARY KEY, btree (id)
Check constraints:
"rental_company_check" CHECK (company::text = ANY (ARRAY['cars'::character varying, 'boats'::character varying]::text[]))
Foreign-key constraints:
"rental_customerid_fkey" FOREIGN KEY (customerid) REFERENCES customers(id)
Baixe o whitepaper hoje PostgreSQL Management &Automation with ClusterControlSaiba o que você precisa saber para implantar, monitorar, gerenciar e dimensionar o PostgreSQLBaixe o whitepaper O esquema dos clientes da tabela permanece o mesmo. Vamos ver o conteúdo atual do banco de dados:
rentaldb_one=# select * from customers;
id | cust_name | birth_date | sex | nationality
----+-----------------+------------+-----+-------------
2 | Valentino Rossi | 1979-02-16 | |
1 | Petter Solberg | 1974-11-18 | |
(2 rows)
rentaldb_one=# select * from rental_one ;
company | id | customerid | vehicleno | datestart | dateend
---------+----+------------+-----------+------------+---------
cars | 1 | 2 | INI 8888 | 2018-08-10 |
boats | 2 | 1 | INI 9999 | 2018-08-10 |
(2 rows)
Usamos o novo nome rental_one para ocultar isso atrás da nova visão que terá o mesmo nome da tabela que o aplicativo espera:rental. O aplicativo precisará definir o nome do aplicativo para denotar o inquilino. Portanto, neste exemplo, teremos três instâncias do aplicativo, uma para carros, uma para barcos e outra para a alta administração. O nome do aplicativo é definido como:
rentaldb_one=# set application_name to 'cars';
Agora criamos a visão:
create or replace view rental as select company as "tenant_db",id,customerid,vehicleno,datestart,dateend from rental_one where (company = current_setting('application_name') OR current_setting('application_name')='all');
Observação:mantemos as mesmas colunas e nomes de tabela/exibição possíveis. O ponto-chave nas soluções multilocatários é manter as mesmas coisas no lado do aplicativo e as alterações serem mínimas e gerenciáveis.
Vamos fazer algumas seleções:
rentaldb_one=# configure application_name para 'cars';
rentaldb_one=# set application_name to 'cars';
SET
rentaldb_one=# select * from rental;
tenant_db | id | customerid | vehicleno | datestart | dateend
-----------+----+------------+-----------+------------+---------
cars | 1 | 2 | INI 8888 | 2018-08-10 |
(1 row)
rentaldb_one=# set application_name to 'boats';
SET
rentaldb_one=# select * from rental;
tenant_db | id | customerid | vehicleno | datestart | dateend
-----------+----+------------+-----------+------------+---------
boats | 2 | 1 | INI 9999 | 2018-08-10 |
(1 row)
rentaldb_one=# set application_name to 'all';
SET
rentaldb_one=# select * from rental;
tenant_db | id | customerid | vehicleno | datestart | dateend
-----------+----+------------+-----------+------------+---------
cars | 1 | 2 | INI 8888 | 2018-08-10 |
boats | 2 | 1 | INI 9999 | 2018-08-10 |
(2 rows)
A 3ª instância da aplicação que deve definir o nome da aplicação para “todos” destina-se a ser utilizada pela gestão de topo com vista a toda a base de dados.
Uma solução mais robusta, em termos de segurança, pode ser baseada em RLS (segurança em nível de linha). Primeiro restauramos o nome da tabela, lembre-se que não queremos atrapalhar a aplicação:
rentaldb_one=# alter view rental rename to rental_view;
rentaldb_one=# alter table rental_one rename TO rental;
Primeiro, criamos os dois grupos de usuários para cada empresa (barcos, carros) que devem ver seu próprio subconjunto de dados:
rentaldb_one=# create role cars_employees;
rentaldb_one=# create role boats_employees;
Agora criamos políticas de segurança para cada grupo:
rentaldb_one=# create policy boats_plcy ON rental to boats_employees USING(company='boats');
rentaldb_one=# create policy cars_plcy ON rental to cars_employees USING(company='cars');
Depois de conceder as concessões necessárias para as duas funções:
rentaldb_one=# grant ALL on SCHEMA public to boats_employees ;
rentaldb_one=# grant ALL on SCHEMA public to cars_employees ;
rentaldb_one=# grant ALL on ALL tables in schema public TO cars_employees ;
rentaldb_one=# grant ALL on ALL tables in schema public TO boats_employees ;
criamos um usuário em cada função
rentaldb_one=# create user boats_user password 'boats_user' IN ROLE boats_employees;
rentaldb_one=# create user cars_user password 'cars_user' IN ROLE cars_employees;
E teste:
[email protected]:~> psql -U cars_user rentaldb_one
Password for user cars_user:
psql (10.5)
Type "help" for help.
rentaldb_one=> select * from rental;
company | id | customerid | vehicleno | datestart | dateend
---------+----+------------+-----------+------------+---------
cars | 1 | 2 | INI 8888 | 2018-08-10 |
(1 row)
rentaldb_one=> \q
[email protected]:~> psql -U boats_user rentaldb_one
Password for user boats_user:
psql (10.5)
Type "help" for help.
rentaldb_one=> select * from rental;
company | id | customerid | vehicleno | datestart | dateend
---------+----+------------+-----------+------------+---------
boats | 2 | 1 | INI 9999 | 2018-08-10 |
(1 row)
rentaldb_one=>
O bom dessa abordagem é que não precisamos de muitas instâncias do aplicativo. Todo o isolamento é feito no nível do banco de dados com base nas funções do usuário. Portanto, para criar um usuário na alta administração, tudo o que precisamos fazer é conceder a esse usuário as duas funções:
rentaldb_one=# create user all_user password 'all_user' IN ROLE boats_employees, cars_employees;
[email protected]:~> psql -U all_user rentaldb_one
Password for user all_user:
psql (10.5)
Type "help" for help.
rentaldb_one=> select * from rental;
company | id | customerid | vehicleno | datestart | dateend
---------+----+------------+-----------+------------+---------
cars | 1 | 2 | INI 8888 | 2018-08-10 |
boats | 2 | 1 | INI 9999 | 2018-08-10 |
(2 rows)
Observando essas duas soluções, vemos que a solução de exibição requer a alteração do nome da tabela básica, o que pode ser bastante intrusivo, pois podemos precisar executar exatamente o mesmo esquema em uma solução não multilocatário ou com um aplicativo que não esteja ciente de nome_do_aplicativo , enquanto a segunda solução vincula as pessoas a inquilinos específicos. E se a mesma pessoa trabalha, por exemplo. no inquilino dos barcos de manhã e no inquilino dos carros à tarde? Veremos uma 3ª solução baseada em esquemas, que na minha opinião é a mais versátil, e não sofre nenhuma das ressalvas das duas soluções descritas acima. Ele permite que o aplicativo seja executado de maneira independente de locatário e que os engenheiros do sistema adicionem locatários em movimento conforme as necessidades surgirem. Manteremos o mesmo design de antes, com os mesmos dados de teste (continuaremos trabalhando no banco de exemplo rentaldb_one). A idéia aqui é adicionar uma camada na frente da tabela principal na forma de um objeto de banco de dados em um esquema separado que será cedo o suficiente no search_path para aquele inquilino específico. O search_path pode ser definido (idealmente por meio de uma função especial, que oferece mais opções) na configuração da conexão da fonte de dados na camada do servidor de aplicativos (portanto, fora do código do aplicativo). Primeiro criamos os dois esquemas:
rentaldb_one=# create schema cars;
rentaldb_one=# create schema boats;
Em seguida, criamos os objetos de banco de dados (views) em cada esquema:
CREATE OR REPLACE VIEW boats.rental AS
SELECT rental.company,
rental.id,
rental.customerid,
rental.vehicleno,
rental.datestart,
rental.dateend
FROM public.rental
WHERE rental.company::text = 'boats';
CREATE OR REPLACE VIEW cars.rental AS
SELECT rental.company,
rental.id,
rental.customerid,
rental.vehicleno,
rental.datestart,
rental.dateend
FROM public.rental
WHERE rental.company::text = 'cars';
A próxima etapa é definir o caminho de pesquisa em cada locatário da seguinte maneira:
-
Para o inquilino dos barcos:
set search_path TO 'boats, "$user", public';
-
Para o inquilino dos carros:
set search_path TO 'cars, "$user", public';
- Para o principal locatário de gerenciamento, deixe-o como padrão
Vamos testar:
rentaldb_one=# select * from rental;
company | id | customerid | vehicleno | datestart | dateend
---------+----+------------+-----------+------------+---------
cars | 1 | 2 | INI 8888 | 2018-08-10 |
boats | 2 | 1 | INI 9999 | 2018-08-10 |
(2 rows)
rentaldb_one=# set search_path TO 'boats, "$user", public';
SET
rentaldb_one=# select * from rental;
company | id | customerid | vehicleno | datestart | dateend
---------+----+------------+-----------+------------+---------
boats | 2 | 1 | INI 9999 | 2018-08-10 |
(1 row)
rentaldb_one=# set search_path TO 'cars, "$user", public';
SET
rentaldb_one=# select * from rental;
company | id | customerid | vehicleno | datestart | dateend
---------+----+------------+-----------+------------+---------
cars | 1 | 2 | INI 8888 | 2018-08-10 |
(1 row)
Recursos relacionados ClusterControl for PostgreSQL PostgreSQL Triggers e Stored Function Basics Ajuste de operações de entrada/saída (E/S) para PostgreSQL Em vez de definir search_path, podemos escrever uma função mais complexa para lidar com uma lógica mais complexa e chamá-la na configuração de conexão de nosso aplicativo ou pool de conexões.
No exemplo acima, usamos a mesma tabela central que reside no esquema público (public.rental) e duas visualizações adicionais para cada locatário, usando o fato de que essas duas visualizações são simples e, portanto, graváveis. Em vez de visualizações, podemos usar herança, criando uma tabela filha para cada inquilino herdando da tabela pública. Esta é uma boa combinação para herança de tabelas, um recurso exclusivo do PostgreSQL. A tabela superior pode ser configurada com regras para não permitir inserções. Na solução de herança seria necessária uma conversão para preencher as tabelas filhas e impedir o acesso de inserção à tabela pai, portanto isso não é tão simples quanto no caso das visualizações, que funcionam com impacto mínimo no design. Podemos escrever um blog especial sobre como fazer isso.
As três abordagens acima podem ser combinadas para fornecer ainda mais opções.