sql >> Base de Datos >  >> RDS >> PostgreSQL

Opciones de tenencia múltiple para PostgreSQL

La tenencia múltiple en un sistema de software se denomina separación de datos de acuerdo con un conjunto de criterios para satisfacer un conjunto de objetivos. La magnitud/extensión, la naturaleza y la implementación final de esta separación depende de esos criterios y objetivos. La tenencia múltiple es básicamente un caso de partición de datos, pero intentaremos evitar este término por razones obvias (el término en PostgreSQL tiene un significado muy específico y está reservado, ya que la partición de tabla declarativa se introdujo en postgresql 10).

Los criterios pueden ser:

  1. según la identificación de una tabla maestra importante, que simboliza la identificación del arrendatario que podría representar:
    1. una empresa/organización dentro de un grupo holding más grande
    2. un departamento dentro de una empresa/organización
    3. una oficina regional/sucursal de la misma empresa/organización
  2. según la ubicación/IP del usuario
  3. según la posición del usuario dentro de la empresa/organización

Los objetivos pueden ser:

  1. separación de recursos físicos o virtuales
  2. separación de los recursos del sistema
  3. seguridad
  4. precisión y comodidad de gestión/usuarios en los distintos niveles de la empresa/organización

Tenga en cuenta que al cumplir un objetivo también cumplimos todos los objetivos que se encuentran debajo, es decir, al cumplir A también cumplimos B, C y D, al cumplir B también cumplimos C y D, y así sucesivamente.

Si queremos cumplir el objetivo A, podemos optar por implementar cada arrendatario como un clúster de base de datos independiente dentro de su propio servidor físico/virtual. Esto brinda la máxima separación de recursos y seguridad, pero da malos resultados cuando necesitamos ver todos los datos como uno solo, es decir, la vista consolidada de todo el sistema.

Si solo queremos lograr el objetivo B, podemos implementar cada inquilino como una instancia de postgresql separada en el mismo servidor. Esto nos daría control sobre la cantidad de espacio que se asignaría a cada instancia y también cierto control (según el sistema operativo) sobre la utilización de CPU/mem. Este caso no es esencialmente diferente de A. En la era moderna de la computación en la nube, la brecha entre A y B tiende a ser cada vez más pequeña, por lo que lo más probable es que A sea la forma preferida sobre B.

Si queremos lograr el objetivo C, es decir, la seguridad, basta con tener una instancia de base de datos e implementar cada arrendatario como una base de datos separada.

Y finalmente, si solo nos importa la separación "suave" de datos, o en otras palabras, diferentes vistas del mismo sistema, podemos lograr esto con solo una instancia de base de datos y una base de datos, usando una plétora de técnicas discutidas a continuación como la final (y principal) tema de este blog. Hablando de tenencia múltiple, desde la perspectiva del DBA, los casos A, B y C tienen muchas similitudes. Esto se debe a que en todos los casos tenemos diferentes bases de datos y para unir esas bases de datos, se deben usar herramientas y tecnologías especiales. Sin embargo, si la necesidad de hacerlo proviene de los departamentos de análisis o Business Intelligence, es posible que no se necesite ningún puente, ya que los datos podrían replicarse muy bien en algún servidor central dedicado a esas tareas, haciendo que el puente sea innecesario. Si de hecho se necesita dicho puente, entonces debemos usar herramientas como dblink o tablas externas. Tablas foráneas a través de Contenedores de datos foráneos es hoy en día la forma preferida.

Sin embargo, si usamos la opción D, la consolidación ya viene dada por defecto, por lo que ahora la parte difícil es la opuesta:la separación. Por lo tanto, generalmente podemos clasificar las diversas opciones en dos categorías principales:

  • Separación suave
  • Separación difícil

Separación dura a través de diferentes bases de datos en el mismo clúster

Supongamos que tenemos que diseñar un sistema para un negocio imaginario que ofrece alquiler de autos y botes, pero debido a que esos dos se rigen por diferentes legislaciones, diferentes controles, auditorías, cada empresa debe mantener departamentos de contabilidad separados y, por lo tanto, nos gustaría mantener sus sistemas. apartado. En este caso elegimos tener una base de datos diferente para cada empresa:rentaldb_cars y rentaldb_boats, que tendrán 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)

Supongamos que tenemos los siguientes alquileres. En alquilerdb_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)

y en 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)

Ahora, a la gerencia le gustaría tener una vista consolidada del sistema, p. una forma unificada de ver los alquileres. Podemos resolver esto a través de la aplicación, pero si no queremos actualizar la aplicación o no tenemos acceso al código fuente, podemos resolverlo creando una base de datos central rentaldb y haciendo uso de tablas foráneas, de la siguiente manera:

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 ver todos los alquileres y los clientes en toda la organización simplemente hacemos:

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)

Esto se ve bien, el aislamiento y la seguridad están garantizados, se logra la consolidación, pero todavía hay problemas:

  • los clientes deben mantenerse por separado, lo que significa que el mismo cliente puede terminar con dos cuentas
  • La aplicación debe respetar la noción de una columna especial (como tent_db) y agregarla a cada consulta, lo que la hace propensa a errores
  • Las vistas resultantes no se actualizan automáticamente (ya que contienen UNION)

Separación suave en la misma base de datos

Cuando se elige este enfoque, la consolidación se da de inmediato y ahora la parte difícil es la separación. PostgreSQL nos ofrece una plétora de soluciones para implementar la separación:

  • Vistas
  • Seguridad de nivel de función
  • Esquemas

Con las vistas, la aplicación debe establecer una configuración consultable como application_name, ocultamos la tabla principal detrás de una vista, y luego en cada consulta en cualquiera de las tablas secundarias (como en la dependencia FK), si las hay, de esta tabla principal se une con esta vista. Veremos esto en el siguiente ejemplo en una base de datos que llamamos rentaldb_one. Incrustamos la identificación de la empresa arrendataria en la tabla 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)
Descargue el documento técnico hoy Administración y automatización de PostgreSQL con ClusterControlObtenga información sobre lo que necesita saber para implementar, monitorear, administrar y escalar PostgreSQLDescargar el documento técnico

El esquema de los clientes de la mesa sigue siendo el mismo. Veamos el contenido actual de la base de datos:

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 el nuevo nombre rental_one para ocultar esto detrás de la nueva vista que tendrá el mismo nombre de la tabla que espera la aplicación:alquiler. La aplicación deberá establecer el nombre de la aplicación para indicar el arrendatario. Entonces, en este ejemplo, tendremos tres instancias de la aplicación, una para automóviles, una para barcos y otra para la alta dirección. El nombre de la aplicación se establece como:

rentaldb_one=# set application_name to 'cars';

Ahora creamos la vista:

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');

Nota:Mantenemos las mismas columnas y nombres de tablas/vistas en la medida de lo posible, el punto clave en las soluciones multiinquilino es mantener las mismas cosas en el lado de la aplicación y que los cambios sean mínimos y manejables.

Hagamos algunas selecciones:

rentaldb_one=# establece application_name en '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)

La tercera instancia de la aplicación que debe establecer el nombre de la aplicación en "todos" está diseñada para que la use la alta dirección con vistas a toda la base de datos.

Una solución más robusta, en cuanto a seguridad, puede basarse en RLS (seguridad de nivel de fila). Primero restauramos el nombre de la tabla, recuerda que no queremos perturbar la aplicación:

rentaldb_one=# alter view rental rename to rental_view;
rentaldb_one=# alter table rental_one rename TO rental;

Primero creamos los dos grupos de usuarios para cada empresa (barcos, automóviles) que deben ver su propio subconjunto de datos:

rentaldb_one=# create role cars_employees;
rentaldb_one=# create role boats_employees;

Ahora creamos políticas de seguridad 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');

Después de otorgar las concesiones requeridas a los dos roles:

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 ;

creamos un usuario en cada rol

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;

Y prueba:

[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=>

Lo bueno de este enfoque es que no necesitamos muchas instancias de la aplicación. Todo el aislamiento se realiza a nivel de la base de datos según los roles del usuario. Por lo tanto, para crear un usuario en la alta dirección, todo lo que tenemos que hacer es otorgar a este usuario ambos roles:

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)

Al observar esas dos soluciones, vemos que la solución de visualización requiere cambiar el nombre de la tabla básica, lo que puede ser bastante intrusivo, ya que es posible que debamos ejecutar exactamente el mismo esquema en una solución que no sea multiinquilino, o con una aplicación que no sea consciente de nombre_aplicación , mientras que la segunda solución vincula a las personas con inquilinos específicos. ¿Qué pasa si la misma persona trabaja, p. en los barcos arrendatario por la mañana y en los coches arrendatario por la tarde? Veremos una tercera solución basada en esquemas, que en mi opinión es la más versátil y no sufre de ninguna de las advertencias de las dos soluciones descritas anteriormente. Permite que la aplicación se ejecute de forma independiente del arrendatario y que los ingenieros del sistema agreguen arrendatarios sobre la marcha a medida que surjan las necesidades. Mantendremos el mismo diseño que antes, con los mismos datos de prueba (seguiremos trabajando en la base de datos de ejemplo de rentaldb_one). La idea aquí es agregar una capa delante de la tabla principal en forma de un objeto de base de datos en un esquema separado. que será lo suficientemente temprano en search_path para ese inquilino específico. El search_path se puede configurar (idealmente a través de una función especial, que brinda más opciones) en la configuración de conexión de la fuente de datos en la capa del servidor de aplicaciones (por lo tanto, fuera del código de la aplicación). Primero creamos los dos esquemas:

rentaldb_one=# create schema cars;
rentaldb_one=# create schema boats;

Luego creamos los objetos de la base de datos (vistas) en 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';

El siguiente paso es establecer la ruta de búsqueda en cada arrendatario de la siguiente manera:

  • Para el arrendatario de los barcos:

    set search_path TO 'boats, "$user", public';
  • Para el arrendatario de coches:

    set search_path TO 'cars, "$user", public';
  • Para el inquilino de administración superior, déjelo en el valor predeterminado

Probemos:

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 para PostgreSQL Desencadenadores de PostgreSQL y conceptos básicos de funciones almacenadas Ajuste de operaciones de entrada/salida (E/S) para PostgreSQL

En lugar de establecer search_path, podemos escribir una función más compleja para manejar una lógica más compleja y llamarla en la configuración de conexión de nuestra aplicación o agrupador de conexiones.

En el ejemplo anterior, usamos la misma tabla central que reside en el esquema público (public.rental) y dos vistas adicionales para cada inquilino, aprovechando el hecho afortunado de que esas dos vistas son simples y, por lo tanto, se pueden escribir. En lugar de vistas, podemos usar la herencia, creando una tabla secundaria para cada inquilino que hereda de la tabla pública. Esta es una buena combinación para la herencia de tablas, una característica única de PostgreSQL. La tabla superior puede estar configurada con reglas para no permitir inserciones. En la solución de herencia, se necesitaría una conversión para llenar las tablas secundarias y evitar el acceso de inserción a la tabla principal, por lo que esto no es tan simple como en el caso de las vistas, que funciona con un impacto mínimo en el diseño. Podríamos escribir un blog especial sobre cómo hacerlo.

Los tres enfoques anteriores pueden combinarse para brindar aún más opciones.