PostgreSQL
 sql >> Database >  >> RDS >> PostgreSQL

Opzioni multi-tenant per PostgreSQL

La multi-tenancy in un sistema software è chiamata separazione dei dati secondo una serie di criteri al fine di soddisfare una serie di obiettivi. L'entità/estensione, la natura e l'attuazione finale di questa separazione dipendono da tali criteri e obiettivi. La multi-tenancy è fondamentalmente un caso di partizionamento dei dati, ma cercheremo di evitare questo termine per le ovvie ragioni (il termine in PostgreSQL ha un significato molto specifico ed è riservato, poiché il partizionamento dichiarativo delle tabelle è stato introdotto in postgresql 10).

I criteri potrebbero essere:

  1. secondo l'id di un'importante tabella master, che simboleggia l'id tenant che potrebbe rappresentare:
    1. una società/organizzazione all'interno di un gruppo holding più ampio
    2. Un dipartimento all'interno di un'azienda/organizzazione
    3. un ufficio/succursale regionale della stessa società/organizzazione
  2. in base alla posizione/IP di un utente
  3. secondo la posizione di un utente all'interno dell'azienda/organizzazione

Gli obiettivi potrebbero essere:

  1. separazione delle risorse fisiche o virtuali
  2. separazione delle risorse di sistema
  3. sicurezza
  4. accuratezza e comodità di gestione/utenti ai vari livelli dell'azienda/organizzazione

Nota che soddisfacendo un obiettivo soddisfiamo anche tutti gli obiettivi sottostanti, ovvero soddisfacendo A soddisfiamo anche B, C e D, soddisfacendo B soddisfiamo anche C e D e così via.

Se vogliamo raggiungere l'obiettivo A, possiamo scegliere di distribuire ogni tenant come un cluster di database separato all'interno del proprio server fisico/virtuale. Ciò offre la massima separazione delle risorse e della sicurezza, ma dà scarsi risultati quando abbiamo bisogno di vedere tutti i dati come uno, cioè la visione consolidata dell'intero sistema.

Se vogliamo raggiungere solo l'obiettivo B, potremmo distribuire ogni tenant come un'istanza postgresql separata nello stesso server. Questo ci darebbe il controllo su quanto spazio verrebbe assegnato a ciascuna istanza e anche un certo controllo (a seconda del sistema operativo) sull'utilizzo della CPU/mem. Questo caso non è essenzialmente diverso da A. Nell'era moderna del cloud computing, il divario tra A e B tende a ridursi sempre più, quindi A sarà molto probabilmente il modo preferito rispetto a B.

Se vogliamo raggiungere l'obiettivo C, ovvero la sicurezza, è sufficiente disporre di un'istanza di database e distribuire ciascun tenant come database separato.

E infine, se ci preoccupiamo solo della separazione "morbida" dei dati, o in altre parole di viste diverse dello stesso sistema, possiamo ottenere ciò con una sola istanza di database e un database, utilizzando una pletora di tecniche discusse di seguito come ultima (e major) argomento di questo blog. Parlando di multi-tenancy, dal punto di vista del DBA, i casi A, B e C presentano molte somiglianze. Questo perché in tutti i casi abbiamo database diversi e per collegare questi database, è necessario utilizzare strumenti e tecnologie speciali. Tuttavia, se la necessità di farlo proviene dai dipartimenti di analisi o Business Intelligence, allora potrebbe non essere necessario alcun bridging, poiché i dati potrebbero essere replicati molto bene su un server centrale dedicato a tali attività, rendendo superfluo il bridging. Se davvero è necessario un tale bridging, allora dobbiamo usare strumenti come dblink o tabelle straniere. Le tabelle esterne tramite i wrapper di dati esterni sono al giorno d'oggi il modo preferito.

Se utilizziamo l'opzione D, tuttavia, il consolidamento è già fornito di default, quindi ora la parte difficile è l'opposto:la separazione. Quindi possiamo generalmente classificare le varie opzioni in due categorie principali:

  • Separazione morbida
  • Separazione dura

Difficile separazione tramite database diversi nello stesso cluster

Supponiamo di dover progettare un sistema per un'impresa immaginaria che offre il noleggio di auto e barche, ma poiché questi due sono regolati da legislazioni diverse, controlli, audit diversi, ogni azienda deve mantenere reparti contabili separati e quindi vorremmo mantenere i loro sistemi separato. In questo caso scegliamo di avere un database diverso per ogni compagnia:rentaldb_cars e rentaldb_boats, che avranno schemi identici:

# \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)

Supponiamo di avere i seguenti noleggi. In 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 in 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)

Ora la direzione vorrebbe avere una visione consolidata del sistema, ad es. un modo unificato per visualizzare gli affitti. Possiamo risolverlo tramite l'applicazione, ma se non vogliamo aggiornare l'applicazione o non abbiamo accesso al codice sorgente, allora potremmo risolverlo creando un database centrale rentaldb e facendo uso di tabelle estere, come 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;

Per visualizzare tutti i noleggi ei clienti dell'intera organizzazione basta fare:

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)

Sembra buono, isolamento e sicurezza sono garantiti, il consolidamento è raggiunto, ma ci sono ancora problemi:

  • I clienti devono essere gestiti separatamente, il che significa che lo stesso cliente potrebbe ritrovarsi con due account
  • L'applicazione deve rispettare la nozione di colonna speciale (come tenant_db) e aggiungerla a ogni query, rendendola soggetta a errori
  • Le viste risultanti non sono aggiornabili automaticamente (poiché contengono UNION)

Separazione graduale nello stesso database

Quando viene scelto questo approccio, il consolidamento viene dato fuori dagli schemi e ora la parte difficile è la separazione. PostgreSQL ci offre una miriade di soluzioni per implementare la separazione:

  • Viste
  • Sicurezza a livello di ruolo
  • Schemi

Con le viste, l'applicazione deve impostare un'impostazione interrogabile come nome_applicazione, nascondiamo la tabella principale dietro una vista, e quindi in ogni query su una qualsiasi delle tabelle figli (come nella dipendenza FK), se presenti, di questa tabella principale si uniscono con Questa vista. Lo vedremo nel seguente esempio in un database che chiamiamo rentaldb_one. Incorporiamo l'identificazione della società locataria nella tabella principale:

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)
Scarica il whitepaper oggi Gestione e automazione di PostgreSQL con ClusterControlScopri cosa devi sapere per distribuire, monitorare, gestire e ridimensionare PostgreSQLScarica il whitepaper

Lo schema dei clienti della tabella rimane lo stesso. Vediamo il contenuto attuale del database:

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)

Usiamo il nuovo nome rental_one per nasconderlo dietro la nuova vista che avrà lo stesso nome della tabella che l'applicazione si aspetta:rental. L'applicazione dovrà impostare il nome dell'applicazione per indicare il tenant. Quindi in questo esempio avremo tre istanze dell'applicazione, una per le auto, una per le barche e una per il top management. Il nome dell'applicazione è impostato come:

rentaldb_one=# set application_name to 'cars';

Ora creiamo 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:manteniamo le stesse colonne e nomi di tabelle/viste il più possibile, il punto chiave nelle soluzioni multi-tenant è mantenere le stesse cose sul lato dell'applicazione e le modifiche devono essere minime e gestibili.

Facciamo alcune selezioni:

rentaldb_one=# imposta nome_applicazione su 'auto';

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 3a istanza dell'applicazione che deve impostare il nome dell'applicazione su "tutti" è destinata all'utilizzo da parte del top management in vista dell'intero database.

Una soluzione più robusta, dal punto di vista della sicurezza, può essere basata su RLS (sicurezza a livello di riga). Per prima cosa ripristiniamo il nome della tabella, ricorda che non vogliamo disturbare l'applicazione:

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

Per prima cosa creiamo i due gruppi di utenti per ogni azienda (barche, automobili) che devono vedere il proprio sottoinsieme di dati:

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

Ora creiamo criteri di sicurezza per ogni gruppo:

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

Dopo aver concesso i contributi richiesti ai due ruoli:

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 ;

creiamo un utente in ogni ruolo

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 prova:

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

La cosa bella di questo approccio è che non abbiamo bisogno di molte istanze dell'applicazione. Tutto l'isolamento viene eseguito a livello di database in base ai ruoli dell'utente. Quindi per creare un utente nel top management non dobbiamo fare altro che assegnare a questo utente entrambi i ruoli:

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)

Osservando queste due soluzioni, vediamo che la soluzione di visualizzazione richiede la modifica del nome della tabella di base, il che potrebbe essere piuttosto invadente in quanto potrebbe essere necessario eseguire esattamente lo stesso schema in una soluzione non multitenant o con un'app che non è a conoscenza di nome_applicazione , mentre la seconda soluzione lega le persone a specifici tenant. Cosa succede se la stessa persona lavora ad es. sull'inquilino delle barche al mattino e sull'inquilino delle auto il pomeriggio? Vedremo una terza soluzione basata su schemi, che secondo me è la più versatile e non soffre di nessuno dei caveat delle due soluzioni sopra descritte. Consente all'applicazione di essere eseguita in modo indipendente dal tenant e agli ingegneri di sistema di aggiungere tenant in movimento in base alle esigenze. Manterremo lo stesso design di prima, con gli stessi dati di test (continueremo a lavorare sul db di esempio rentaldb_one). L'idea qui è quella di aggiungere un livello davanti alla tabella principale sotto forma di un oggetto database in uno schema separato che sarà abbastanza presto nel percorso_ricerca per quello specifico inquilino. Il percorso_ricerca può essere impostato (idealmente tramite una funzione speciale, che offre più opzioni) nella configurazione della connessione dell'origine dati a livello del server delle applicazioni (quindi al di fuori del codice dell'applicazione). Per prima cosa creiamo i due schemi:

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

Quindi creiamo gli oggetti del database (viste) in ogni schema:

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

Il passaggio successivo consiste nell'impostare il percorso di ricerca in ogni tenant come segue:

  • Per l'inquilino delle barche:

    set search_path TO 'boats, "$user", public';
  • Per l'inquilino delle auto:

    set search_path TO 'cars, "$user", public';
  • Per l'inquilino top mgmt lascialo come predefinito

Proviamo:

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)
Risorse correlate ClusterControl per PostgreSQL Trigger di PostgreSQL e nozioni di base sulle funzioni archiviate Ottimizzazione delle operazioni di input/output (I/O) per PostgreSQL

Invece di impostare search_path, potremmo scrivere una funzione più complessa per gestire una logica più complessa e chiamarla nella configurazione della connessione della nostra applicazione o pooler di connessioni.

Nell'esempio sopra abbiamo utilizzato la stessa tabella centrale che risiede su uno schema pubblico (public.rental) e due viste aggiuntive per ogni tenant, sfruttando il fatto fortunato che queste due viste sono semplici e quindi scrivibili. Invece delle viste possiamo usare l'ereditarietà, creando una tabella figlio per ogni tenant che eredita dalla tabella pubblica. Questa è una buona corrispondenza per l'ereditarietà delle tabelle, una caratteristica unica di PostgreSQL. La tabella superiore potrebbe essere configurata con regole per non consentire gli inserimenti. Nella soluzione di ereditarietà sarebbe necessaria una conversione per popolare le tabelle figli e per impedire l'accesso di inserimento alla tabella padre, quindi questo non è così semplice come nel caso delle viste, che funziona con un impatto minimo sul design. Potremmo scrivere un blog speciale su come farlo.

I tre approcci precedenti possono essere combinati per offrire ancora più opzioni.