Pattern di database per multi-tenancy con esempi in Go

Guida completa ai modelli di database multi-tenant

Indice

Multi-tenancy è un modello architetturale fondamentale per le applicazioni SaaS, che consente a diversi clienti (inquilini) di condividere la stessa infrastruttura applicativa mantenendo l’isolamento dei dati.

La scelta del modello di database giusto è cruciale per la scalabilità, la sicurezza e l’efficienza operativa.

databases-scheme

Panoramica dei Modelli di Multi-Tenancy

Quando si progetta un’applicazione multi-inquilino, si hanno tre modelli principali di architettura del database da considerare:

  1. Database condiviso, schema condiviso (più comune)
  2. Database condiviso, schema separato
  3. Database separato per ogni inquilino

Ogni modello ha caratteristiche distinte, compromessi e casi d’uso. Esploriamo ciascuno nel dettaglio.

Modello 1: Database condiviso, schema condiviso

Questo è il modello più comune per la multi-tenancy, dove tutti gli inquilini condividono lo stesso database e schema, con una colonna tenant_id utilizzata per distinguere i dati degli inquilini.

Architettura

┌─────────────────────────────────────┐
│     Singolo Database                 │
│  ┌───────────────────────────────┐  │
│  │  Schema Condiviso                │  │
│  │  - utenti (tenant_id, ...)     │  │
│  │  - ordini (tenant_id, ...)    │  │
│  │  - prodotti (tenant_id, ...)  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

Esempio di Implementazione

Quando si implementano i modelli multi-inquilino, comprendere i fondamenti SQL è cruciale. Per un riferimento completo sui comandi e sulla sintassi SQL, consulta il nostro SQL Cheatsheet. Ecco come impostare il modello di schema condiviso:

-- Tabella utenti con tenant_id
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    tenant_id INTEGER NOT NULL,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW(),
    FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);

-- Indice su tenant_id per prestazioni
CREATE INDEX idx_users_tenant_id ON users(tenant_id);

-- Sicurezza a livello di riga (esempio PostgreSQL)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON users
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant')::INTEGER);

Per ulteriori funzionalità e comandi specifici di PostgreSQL, tra cui le politiche RLS, la gestione degli schemi e l’ottimizzazione delle prestazioni, consulta il nostro PostgreSQL Cheatsheet.

Filtraggio a livello applicativo

Quando si lavora con applicazioni Go, la scelta del giusto ORM può influenzare significativamente l’implementazione multi-inquilino. Gli esempi seguenti utilizzano GORM, ma ci sono diverse ottime opzioni disponibili. Per un confronto dettagliato degli ORM Go, tra cui GORM, Ent, Bun e sqlc, vedi la nostra guida completa agli ORM Go per PostgreSQL.

// Esempio in Go con GORM
func GetUserByEmail(db *gorm.DB, tenantID uint, email string) (*User, error) {
    var user User
    err := db.Where("tenant_id = ? AND email = ?", tenantID, email).First(&user).Error
    return &user, err
}

// Middleware per impostare il contesto dell'inquilino
func TenantMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tenantID := extractTenantID(r) // Dal sottodominio, header o JWT
        ctx := context.WithValue(r.Context(), "tenant_id", tenantID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Vantaggi dello schema condiviso

  • Costo più basso: Un’istanza di database, infrastruttura minima
  • Operazioni più semplici: Un solo database da backup, monitorare e mantenere
  • Cambiamenti dello schema semplici: Le migrazioni si applicano a tutti gli inquilini contemporaneamente
  • Migliore per un alto numero di inquilini: Utilizzo efficiente delle risorse
  • Analisi tra inquilini: Facile aggregare dati tra inquilini

Svantaggi dello schema condiviso

  • Isolamento più debole: Rischio di perdita di dati se le query dimenticano il filtro tenant_id
  • Inquilino rumoroso: Il carico di lavoro pesante di un inquilino può influenzare gli altri
  • Personalizzazione limitata: Tutti gli inquilini condividono lo stesso schema
  • Sfide di conformità: Più difficile soddisfare i requisiti di isolamento dei dati rigorosi
  • Complessità del backup: Non è facile ripristinare facilmente i dati di un singolo inquilino

Migliore per lo schema condiviso

  • Applicazioni SaaS con molti inquilini piccoli o medi
  • Applicazioni dove gli inquilini non necessitano di schemi personalizzati
  • Start-up sensibili ai costi
  • Quando il numero di inquilini è alto (migliaia+)

Modello 2: Database condiviso, schema separato

Ogni inquilino riceve il proprio schema all’interno dello stesso database, fornendo un isolamento migliore mentre condivide l’infrastruttura.

Architettura schema separato

┌─────────────────────────────────────┐
│     Singolo Database                 │
│  ┌──────────┐  ┌──────────┐         │
│  │ Schema A │  │ Schema B │  ...    │
│  │ (Inquilino1)│  │ (Inquilino2)│         │
│  └──────────┘  └──────────┘         │
└─────────────────────────────────────┘

Implementazione schema separato

Le schemi di PostgreSQL sono una funzionalità potente per la multi-tenancy. Per informazioni dettagliate sulla gestione degli schemi di PostgreSQL, le stringhe di connessione e i comandi di amministrazione del database, consulta il nostro PostgreSQL Cheatsheet.

-- Creare schema per inquilino
CREATE SCHEMA tenant_123;

-- Impostare il percorso di ricerca per le operazioni dell'inquilino
SET search_path TO tenant_123, public;

-- Creare tabelle nello schema dell'inquilino
CREATE TABLE tenant_123.users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW()
);

Gestione delle connessioni del database

La gestione efficiente delle connessioni al database è cruciale per le applicazioni multi-inquilino. Il codice di gestione delle connessioni seguente utilizza GORM, ma potresti voler esplorare altre opzioni ORM. Per un confronto dettagliato degli ORM Go, tra cui la gestione del pooling delle connessioni, le caratteristiche di prestazione e i casi d’uso, consulta la nostra guida al confronto degli ORM Go.

// Stringa di connessione con percorso di ricerca dello schema
func GetTenantDB(tenantID uint) *gorm.DB {
    db := initializeDB()
    db.Exec(fmt.Sprintf("SET search_path TO tenant_%d, public", tenantID))
    return db
}

// Oppure utilizzare la stringa di connessione PostgreSQL
// postgresql://user:pass@host/db?search_path=tenant_123

Vantaggi dello schema separato

  • Migliore isolamento: L’isolamento a livello di schema riduce il rischio di perdita di dati
  • Personalizzazione: Ogni inquilino può avere strutture di tabelle diverse
  • Costo moderato: Ancora un’istanza di database singola
  • Backup per inquilino più semplice: È possibile backuppare gli schemi individuali
  • Migliore per la conformità: Più forte del modello di schema condiviso

Svantaggi dello schema separato

  • Complessità della gestione degli schemi: Le migrazioni devono essere eseguite per inquilino
  • Overhead delle connessioni: È necessario impostare search_path per ogni connessione
  • Scalabilità limitata: Il numero di schemi è limitato (PostgreSQL ~10k schemi)
  • Query tra inquilini: Più complesse, richiedono riferimenti dinamici agli schemi
  • Limiti delle risorse: Risorse del database condivise

Migliore per lo schema separato

  • SaaS di media scala (decine a centinaia di inquilini)
  • Quando gli inquilini necessitano di personalizzazione degli schemi
  • Applicazioni che necessitano di un isolamento migliore rispetto allo schema condiviso
  • Quando i requisiti di conformità sono moderati

Modello 3: Database separato per ogni inquilino

Ogni inquilino riceve la propria istanza completa del database, fornendo l’isolamento massimo.

Architettura database separato

┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  Database 1  │  │  Database 2  │  │  Database 3  │
│  (Inquilino A)  │  │  (Inquilino B)  │  │  (Inquilino C)  │
└──────────────┘  └──────────────┘  └──────────────┘

Implementazione database separato

-- Creare database per inquilino
CREATE DATABASE tenant_enterprise_corp;

-- Connettersi al database dell'inquilino
\c tenant_enterprise_corp

-- Creare tabelle (non necessario tenant_id!)
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW()
);

Gestione dinamica delle connessioni

// Gestore del pool di connessioni
type TenantDBManager struct {
    pools map[uint]*gorm.DB
    mu    sync.RWMutex
}

func (m *TenantDBManager) GetDB(tenantID uint) (*gorm.DB, error) {
    m.mu.RLock()
    if db, exists := m.pools[tenantID]; exists {
        m.mu.RUnlock()
        return db, nil
    }
    m.mu.RUnlock()

    m.mu.Lock()
    defer m.mu.Unlock()

    // Verifica doppia dopo aver acquisito il blocco di scrittura
    if db, exists := m.pools[tenantID]; exists {
        return db, nil
    }

    // Creare una nuova connessione
    db, err := gorm.Open(postgres.Open(fmt.Sprintf(
        "host=localhost user=dbuser password=dbpass dbname=tenant_%d sslmode=disable",
        tenantID,
    )), &gorm.Config{})
    
    if err != nil {
        return nil, err
    }

    m.pools[tenantID] = db
    return db, nil
}

Vantaggi del database separato

  • Isolamento massimo: Separazione completa dei dati
  • Migliore sicurezza: Nessun rischio di accesso ai dati tra inquilini
  • Personalizzazione completa: Ogni inquilino può avere schemi completamente diversi
  • Scalabilità indipendente: Scalare individualmente i database degli inquilini
  • Conformità facile: Rispetta i requisiti di isolamento dei dati più rigorosi
  • Backup per inquilino: Backup e ripristino semplice e indipendente
  • Nessun inquilino rumoroso: I carichi di lavoro degli inquilini non influenzano gli altri

Svantaggi del database separato

  • Costo più alto: Molte istanze di database richiedono più risorse
  • Complessità operativa: Gestire molti database (backup, monitoraggio, migrazioni)
  • Limiti delle connessioni: Ogni istanza di database ha limiti di connessione
  • Analisi tra inquilini: Richiede federazione dei dati o ETL
  • Complessità delle migrazioni: Deve eseguire le migrazioni su tutti i database
  • Overhead delle risorse: Maggiore utilizzo di memoria, CPU e storage

Migliore per il database separato

  • SaaS enterprise con clienti ad alto valore
  • Requisiti di conformità rigorosi (HIPAA, GDPR, SOC 2)
  • Quando gli inquilini necessitano di personalizzazione significativa
  • Numero di inquilini basso a medio (decine a centinaia)
  • Quando gli inquilini hanno modelli di dati molto diversi

Considerazioni sulla Sicurezza

Indipendentemente dal modello scelto, la sicurezza è fondamentale:

1. Sicurezza a livello di riga (RLS)

La RLS di PostgreSQL filtra automaticamente le query per inquilino, fornendo uno strato di sicurezza a livello di database. Questa funzione è particolarmente potente per le applicazioni multi-inquilino. Per ulteriori dettagli sulla RLS di PostgreSQL, le politiche di sicurezza e altre funzionalità avanzate di PostgreSQL, vedi il nostro PostgreSQL Cheatsheet.

-- Abilitare RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Politica per isolare per inquilino
CREATE POLICY tenant_isolation ON orders
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant')::INTEGER);

-- Applicazione imposta contesto inquilino
SET app.current_tenant = '123';

2. Filtraggio a livello applicativo

Filtra sempre per tenant_id nel codice dell’applicazione. Gli esempi seguenti utilizzano GORM, ma diversi ORM hanno approcci diversi per la costruzione delle query. Per linee guida sulla scelta del giusto ORM per la tua applicazione multi-inquilino, consulta la nostra confronto degli ORM Go.

// ❌ Cattivo - Mancante filtro inquilino
db.Where("email = ?", email).First(&user)

// ✅ Buono - Includere sempre il filtro inquilino
db.Where("tenant_id = ? AND email = ?", tenantID, email).First(&user)

// ✅ Migliore - Utilizzare scope o middleware
db.Scopes(TenantScope(tenantID)).Where("email = ?", email).First(&user)

3. Pooling delle connessioni

Utilizzare pooler di connessioni che supportano il contesto dell’inquilino:

// PgBouncer con pooling delle transazioni
// Oppure utilizzare routing delle connessioni a livello applicativo

4. Log di audit

Tracciare l’accesso a tutti i dati degli inquilini:

type AuditLog struct {
    ID        uint
    TenantID  uint
    UserID    uint
    Action    string
    Table     string
    RecordID  uint
    Timestamp time.Time
    IPAddress string
}

Ottimizzazione delle Prestazioni

Strategia di Indicizzazione

L’indicizzazione corretta è cruciale per le prestazioni del database multi-inquilino. Comprendere le strategie di indicizzazione SQL, tra cui gli indici composti e gli indici parziali, è essenziale. Per un riferimento completo sui comandi SQL, tra cui CREATE INDEX e ottimizzazione delle query, vedi il nostro SQL Cheatsheet. Per le funzionalità specifiche di indicizzazione e ottimizzazione delle prestazioni di PostgreSQL, consulta il nostro PostgreSQL Cheatsheet.

-- Indici composti per query degli inquilini
CREATE INDEX idx_orders_tenant_created ON orders(tenant_id, created_at DESC);
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status);

-- Indici parziali per query comuni specifiche degli inquilini
CREATE INDEX idx_orders_active_tenant ON orders(tenant_id, created_at)
WHERE status = 'active';

Ottimizzazione delle Query

// Utilizzare istruzioni preparate per le query degli inquilini
stmt := db.Prepare("SELECT * FROM users WHERE tenant_id = $1 AND email = $2")

// Operazioni in batch per inquilino
db.Where("tenant_id = ?", tenantID).Find(&users)

// Utilizzare il pooling delle connessioni per inquilino (per il modello database separato)

Monitoraggio

Strumenti essenziali per la gestione del database sono necessari per monitorare le applicazioni multi-inquilino. Dovrai tracciare le prestazioni delle query, l’utilizzo delle risorse e la salute del database per tutti gli inquilini. Per confrontare gli strumenti di gestione del database che possono aiutarti in questo, consulta la nostra confronto tra DBeaver e Beekeeper. Entrambi gli strumenti offrono funzionalità eccellenti per la gestione e il monitoraggio dei database PostgreSQL in ambienti multi-inquilino.

Monitorare le metriche per inquilino:

  • Prestazioni delle query per inquilino
  • Utilizzo delle risorse per inquilino
  • Conteggio delle connessioni per inquilino
  • Dimensione del database per inquilino

Strategia di Migrazione

Modello schema condiviso

Quando si implementano le migrazioni del database, la scelta dell’ORM influisce su come si gestiscono i cambiamenti nello schema. Gli esempi seguenti utilizzano la funzionalità AutoMigrate di GORM, ma diversi ORM hanno diverse strategie di migrazione. Per informazioni dettagliate su come diversi ORM Go gestiscono le migrazioni e la gestione dello schema, vedi la nostra confronto degli ORM Go.

// Le migrazioni si applicano automaticamente a tutti gli inquilini
func Migrate(db *gorm.DB) error {
    return db.AutoMigrate(&User{}, &Order{}, &Product{})
}

Modello schema/database separato

// Le migrazioni devono essere eseguite per inquilino
func MigrateAllTenants(tenantIDs []uint) error {
    for _, tenantID := range tenantIDs {
        db := GetTenantDB(tenantID)
        if err := db.AutoMigrate(&User{}, &Order{}); err != nil {
            return fmt.Errorf("tenant %d: %w", tenantID, err)
        }
    }
    return nil
}

Matrice di Decisione

Fattore Schema condiviso Schema separato Database separato
Isolamento Basso Medio Alto
Costo Basso Medio Alto
Scalabilità Alta Media Bassa-Media
Personalizzazione Nessuna Media Alta
Complessità operativa Basso Media Alta
Conformità Limitata Buona Eccellente
Migliore numero di inquilini 1000+ 10-1000 1-100

Approccio ibrido

Puoi combinare i modelli per diversi livelli di inquilino:

// Inquilini piccoli: Schema condiviso
if tenant.Tier == "standard" {
    return GetSharedDB(tenant.ID)
}

// Inquilini enterprise: Database separato
if tenant.Tier == "enterprise" {
    return GetTenantDB(tenant.ID)
}

Linee guida per la migliore pratica

  1. Filtra sempre per inquilino: Mai fidarsi del codice dell’applicazione da solo; utilizza RLS quando possibile. Comprendere i fondamenti SQL aiuta a garantire la costruzione corretta delle query—consulta il nostro SQL Cheatsheet per le migliori pratiche di query.
  2. Monitora l’utilizzo delle risorse degli inquilini: Identifica e limita gli inquilini rumorosi. Utilizza strumenti di gestione del database come quelli confrontati nella nostra guida DBeaver vs Beekeeper per tracciare le metriche di prestazione.
  3. Implementa middleware per il contesto degli inquilini: Centralizza l’estrazione e la validazione degli inquilini. La tua scelta dell’ORM influisce su come implementi questo—vedi la nostra guida al confronto degli ORM Go per diversi approcci.
  4. Utilizza il pooling delle connessioni: Gestisci in modo efficiente le connessioni al database. Le strategie specifiche di pooling delle connessioni di PostgreSQL sono coperte nel nostro PostgreSQL Cheatsheet.
  5. Pianifica la migrazione degli inquilini: La capacità di spostare gli inquilini tra modelli
  6. Implementa il soft delete: Utilizza deleted_at invece di cancellazioni dure per i dati degli inquilini
  7. Audit tutto: Registra l’accesso a tutti i dati degli inquilini per la conformità
  8. Testa l’isolamento: Audit di sicurezza regolari per prevenire la perdita di dati tra inquilini

Conclusione

La scelta del modello giusto per la multi-tenancy dipende dai requisiti specifici per l’isolamento, il costo, la scalabilità e la complessità operativa. Il modello Database condiviso, Schema condiviso funziona bene per la maggior parte delle applicazioni SaaS, mentre il modello Database separato per ogni inquilino è necessario per i clienti enterprise con requisiti di conformità rigorosi.

Inizia con il modello più semplice che soddisfa i tuoi requisiti e pianifica la migrazione a un modello più isolato man mano che i tuoi bisogni evolvono. Priorizza sempre la sicurezza e l’isolamento dei dati, indipendentemente dal modello scelto.