Chiave primaria composita nel database SQL Server multi-tenant


16

Sto creando un'app multi-tenant (database singolo, schema singolo) usando l'API Web ASP, Entity Framework e il database SQL Server / Azure. Questa app verrà utilizzata da 1000-5000 clienti. Tutte le tabelle avranno il campo TenantId(Guid / UNIQUEIDENTIFIER). In questo momento, utilizzo la chiave primaria a campo singolo che è Id (Guid). Ma utilizzando solo il campo ID, devo verificare se i dati forniti dall'utente provengono da / per l'inquilino giusto. Ad esempio, ho una SalesOrdertabella che ha un CustomerIdcampo. Ogni volta che gli utenti pubblicano / aggiornano un ordine cliente, devo verificare se CustomerIdproviene dallo stesso inquilino. Peggiora perché ogni inquilino potrebbe avere diversi sbocchi. Quindi devo controllare TenantIde OutletId. È davvero un incubo per la manutenzione e un male per le prestazioni.

Sto pensando di aggiungere TenantIdalla chiave primaria insieme a Id. E forse anche aggiungere OutletId. Quindi la chiave primaria della SalesOrdertabella sarà: Id, TenantId, e OutletId. Qual è il rovescio della medaglia di questo approccio? Le prestazioni potrebbero ferire gravemente usando una chiave composita? L'ordine delle chiavi composite è importante? Ci sono soluzioni migliori per il mio problema?

Risposte:


34

Avendo lavorato su un sistema multi-tenant su larga scala (approccio federato con i clienti distribuiti su oltre 18 server, ogni server con schema identico, solo clienti diversi e migliaia di transazioni al secondo per ciascun server), posso dire:

  1. Ci sono alcune persone (almeno alcune) che saranno d'accordo sulla scelta del GUID come ID sia per "TenantID" che per qualsiasi entità "ID". Ma no, non è una buona scelta. A parte tutte le altre considerazioni, quella scelta da sola danneggerà in alcuni modi: frammentazione per cominciare, enormi quantità di spazio sprecato (non dire che il disco è economico quando si pensa allo storage aziendale - SAN - o alle query che impiegano più tempo a causa di ogni pagina di dati contenere un numero di file inferiore a quello possibile con INTo BIGINTanche), supporto e manutenzione più difficili, ecc. I GUID sono ottimi per la portabilità. I dati vengono generati in un sistema e poi trasferiti a un altro? Se no, poi passare ad un tipo più compatto dati (ad esempio TINYINT, SMALLINT, INT, o BIGINT), e l'incremento sequenziale via IDENTITYoSEQUENCE.

  2. Con l'elemento 1 fuori mano, devi davvero avere il campo TenantID in OGNI tabella con dati utente. In questo modo puoi filtrare qualsiasi cosa senza bisogno di un JOIN aggiuntivo. Ciò significa anche che TUTTE le query su tabelle di dati client devono avere la TenantIDcondizione JOIN e / o la clausola WHERE. Ciò consente inoltre di non mescolare accidentalmente i dati di clienti diversi o di mostrare i dati del Locatario A del Locatario B.

  3. Sto pensando di aggiungere TenantId come chiave primaria insieme a Id. E possibilmente aggiungere anche OutletId. Quindi le chiavi primarie nella tabella degli ordini di vendita saranno Id, TenantId, OutletId.

    Sì, gli indici cluster nelle tabelle dei dati client devono essere chiavi composte, incluso TenantIDe ID ** . Ciò garantisce anche che TenantIDsi trovi in ​​ogni indice non cluster (poiché quelli includono le chiavi dell'indice cluster) di cui avresti comunque bisogno poiché il 98,45% delle query su tabelle di dati client necessiterà di TenantID(l'eccezione principale è la spazzatura che raccoglie vecchi dati in base su CreatedDatee non si tratta di cura TenantID).

    No, non includeresti FK come OutletIDnel PK. Il PK deve identificare in modo univoco la riga e l'aggiunta di FK non sarebbe di aiuto. In effetti, aumenterebbe le possibilità di dati duplicati, supponendo che OrderID fosse univoco per ciascuno TenantID, anziché unico per ciascuno OutletIDall'interno di ciascuno TenantID.

    Inoltre, non è necessario aggiungere OutletIDal PK per garantire che gli sbocchi del locatario A non vengano confusi con il locatario B. Dato che tutte le tabelle di dati utente avranno TenantIDnel PK, ciò significa che TenantIDsaranno presenti anche negli FK . Ad esempio, la Outlettabella ha un PK di (TenantID, OutletID)e la Ordertabella ha un PK di (TenantID, OrderID) e un FK di (TenantID, OutletID)cui fa riferimento il PK sulla Outlettabella. Gli FK correttamente definiti impediranno il mescolamento dei dati degli inquilini.

  4. L'ordine delle chiavi composite è importante?

    Bene, qui è dove ci si diverte. C'è un dibattito su quale campo dovrebbe venire per primo. La regola "tipica" per la progettazione di buoni indici è quella di selezionare il campo più selettivo come campo principale. TenantID, per sua stessa natura, non sarà il campo più selettivo; il IDcampo è il campo più selettivo. Ecco alcuni pensieri:

    • Prima ID: questo è il campo più selettivo (cioè più unico). Ma essendo un campo di incremento automatico (o casuale se si utilizzano ancora i GUID), i dati di ciascun cliente vengono distribuiti su ogni tabella. Ciò significa che ci sono momenti in cui un cliente ha bisogno di 100 righe e che richiede quasi 100 pagine di dati lette dal disco (non velocemente) nel pool di buffer (occupando più spazio di 10 pagine di dati). Aumenta anche la contesa sulle pagine di dati poiché sarà più frequente che più clienti debbano aggiornare la stessa pagina di dati.

      Tuttavia, in genere non ci si imbatte in altrettanti problemi di sniffing dei parametri / cattivi piani memorizzati nella cache in quanto le statistiche tra i diversi valori ID sono abbastanza coerenti. Potresti non ottenere i piani più ottimali, ma avrai meno probabilità di ottenere piani orribili. Questo metodo essenzialmente sacrifica le prestazioni (leggermente) tra tutti i clienti per ottenere il vantaggio di problemi meno frequenti.

    • Primo inquilino:Questo non è assolutamente selettivo. Potrebbero esserci variazioni minime su 1 milione di righe se si hanno solo 100 ID tenant. Ma le statistiche per queste query sono più accurate poiché SQL Server saprà che una query per il titolare A arretrerà di 500.000 righe ma quella stessa query per il titolare è di soli 50 righe. Questo è il punto principale del dolore. Questo metodo aumenta notevolmente le possibilità di avere problemi di sniffing dei parametri in cui la prima esecuzione di una Stored Procedure è per il Locatario A e agisce in modo appropriato in base allo Strumento per ottimizzare le query vedendo tali statistiche e sapendo che deve essere efficiente ottenendo 500k righe. Ma quando il Locatario B, con solo 50 righe, viene eseguito, quel piano di esecuzione non è più appropriato e, di fatto, è del tutto inappropriato. E, poiché i dati non vengono inseriti nell'ordine del campo principale,

      Tuttavia, affinché il primo TenantID esegua una Stored procedure, le prestazioni dovrebbero essere migliori rispetto a quelle dell'altro approccio poiché i dati (almeno dopo aver effettuato la manutenzione dell'indice) saranno organizzati fisicamente e logicamente in modo tale che siano necessarie molte meno pagine di dati per soddisfare interrogazioni. Ciò significa meno I / O fisico, meno letture logiche, meno contese tra gli inquilini per le stesse pagine di dati, meno spazio sprecato occupato nel pool di buffer (quindi miglioramento dell'aspettativa di vita della pagina) ecc.

      Ci sono due costi principali per ottenere questo miglioramento delle prestazioni. Il primo non è così difficile: è necessario eseguire una manutenzione periodica dell'indice per contrastare l'aumento della frammentazione. Il secondo è un po 'meno divertente.

      Per contrastare i maggiori problemi di sniffing dei parametri, è necessario separare i piani di esecuzione tra gli inquilini. L'approccio semplicistico consiste nell'utilizzare WITH RECOMPILEprocs o il OPTION (RECOMPILE)suggerimento per le query, ma si tratta di un successo nelle prestazioni che potrebbe spazzare via tutti i guadagni ottenuti mettendo al TenantIDprimo posto. Il metodo che ho trovato ha funzionato meglio è usare Dynamic SQL parametrizzato tramite sp_executesql. Il motivo per cui è necessario SQL dinamico è consentire la concatenazione di TenantID nel testo della query, mentre tutti gli altri predicati che normalmente sarebbero parametri sono ancora parametri. Ad esempio, se stavi cercando un particolare Ordine, faresti qualcosa del tipo:

      DECLARE @GetOrderSQL NVARCHAR(MAX);
      SET @GetOrderSQL = N'
        SELECT ord.field1, ord.field2, etc.
        FROM   dbo.Orders ord
        WHERE  ord.TenantID = ' + CONVERT(NVARCHAR(10), @TenantID) + N'
        AND    ord.OrderID = @OrderID_dyn;
      ';
      
      EXEC sp_executesql
         @GetOrderSQL,
         N'@OrderID_dyn INT',
         @OrderID_dyn = @OrderID;

      L'effetto che ciò ha è quello di creare un piano di query riutilizzabile solo per quel TenantID che corrisponderà al volume di dati di quel particolare Tenant. Se lo stesso inquilino A esegue nuovamente la procedura memorizzata per un'altra @OrderID, riutilizzerà quel piano di query memorizzato nella cache. Un tenant diverso che esegue la stessa Stored procedure genererebbe un testo di query diverso solo nel valore di TenantID, ma qualsiasi differenza nel testo di query è sufficiente per generare un piano diverso. E il piano generato per il Locatario B non corrisponderà solo al volume di dati per il Locatario B, ma sarà anche riutilizzabile per il Locatario B per valori diversi di @OrderID(poiché tale predicato è ancora parametrizzato).

      Gli svantaggi di questo approccio sono:

      • È un po 'più di lavoro che digitare una semplice query (ma non tutte le query devono essere Dynamic SQL, solo quelle che finiscono per avere il problema di sniffing dei parametri).
      • A seconda del numero di titolari presenti su un sistema, aumenta la dimensione della cache del piano poiché ogni query ora richiede 1 piano per TenantID che lo chiama. Questo potrebbe non essere un problema, ma è almeno qualcosa di cui essere consapevoli.
      • SQL dinamico interrompe la catena di proprietà, il che significa che l'accesso in lettura / scrittura alle tabelle non può essere assunto avendo l' EXECUTEautorizzazione sulla Stored Procedure. La soluzione semplice ma meno sicura è solo quella di dare all'utente l'accesso diretto alle tabelle. Questo non è certamente l'ideale, ma di solito è il compromesso per una rapida e facile. L'approccio più sicuro consiste nell'utilizzare la sicurezza basata su certificati. Significato, creare un certificato, quindi creare un utente da quel certificato, concedere a tale utente le autorizzazioni desiderate (un utente o un accesso basato su certificato non può connettersi a SQL Server da solo), quindi firmare le Stored procedure che utilizzano Dynamic SQL con quello stesso certificato tramite AGGIUNGI FIRMA .

        Per ulteriori informazioni sulla firma del modulo e sui certificati, consultare: ModuleSigning.Info
         

    Si prega di consultare la sezione AGGIORNAMENTO verso la fine per ulteriori argomenti relativi alla questione della gestione della mitigazione dei problemi statistici derivanti da questa decisione.


** Personalmente, non mi piace usare solo "ID" per il nome del campo PK su ogni tabella in quanto non è significativo e non è coerente tra gli FK poiché il PK è sempre "ID" e il campo nella tabella figlio deve includere il nome della tabella padre. Ad esempio: Orders.ID-> OrderItems.OrderID. Trovo molto più semplice gestire un modello di dati che ha: Orders.OrderID-> OrderItems.OrderID. È più leggibile e riduce il numero di volte in cui verrà visualizzato l'errore "riferimento colonna ambiguo" :-).


AGGIORNARE

  • Il OPTIMIZE FOR UNKNOWN suggerimento per le query (introdotto in SQL Server 2008) sarebbe di aiuto in entrambi gli ordini del PK composito?

    Non proprio. Questa opzione aggira i problemi di sniffing dei parametri, ma sostituisce semplicemente un problema con un altro. In questo caso, anziché ricordare le informazioni statistiche per i valori dei parametri dell'esecuzione iniziale della procedura memorizzata o della query con parametri (che è sicuramente eccezionale per alcuni, ma potenzialmente mediocre per alcuni e potenzialmente orribile per alcuni), utilizza un generale statistica della distribuzione dei dati per stimare il conteggio delle righe. Ciò è incostante su quante (e in che misura) query saranno influenzate positivamente, negativamente o per niente. Almeno con l'annusamento dei parametri alcune query sono state garantite a beneficio. Se il sistema ha tenant con volumi di dati molto diversi, ciò potrebbe compromettere le prestazioni di tutte le query.

    Questa opzione ha lo stesso effetto della copia dei parametri di input nelle variabili locali e quindi dell'utilizzo delle variabili locali nella query (ho verificato questo, ma non c'è spazio per quello qui). Ulteriori informazioni sono disponibili in questo post del blog: http://www.brentozar.com/archive/2013/06/optimize-for-unknown-sql-server-parameter-sniffing/ . Leggendo i commenti, Daniel Pepermans è giunto a una conclusione simile alla mia per quanto riguarda l'uso di Dynamic SQL che ha una variazione limitata.

  • Se ID è il campo principale nell'indice cluster, sarebbe utile / sufficiente avere un indice non cluster su (TenantID, ID) o solo (TenantID) per avere statistiche accurate per le query che elaborano molte righe di un singolo tenant?

    Sì, sarebbe d'aiuto. Il grande sistema su cui ho parlato lavorando per anni si basava su un disegno di indice di avere il IDENTITYcampo come campo principale perché era più selettivo e riduceva i problemi di sniffing dei parametri. Tuttavia, quando abbiamo dovuto operare su una buona parte dei dati di un determinato inquilino, le prestazioni non hanno retto. In effetti, un progetto per la migrazione di tutti i dati in nuovi database ha dovuto essere sospeso perché i controller SAN sono stati massimizzati in termini di throughput. La correzione consisteva nell'aggiungere gli indici non cluster a tutte le tabelle di dati dei tenant per essere solo (TenantID). Non è necessario (TenantID, ID) poiché l'ID è già nell'indice cluster, quindi la struttura interna dell'indice non cluster era naturalmente (TenantID, ID).

    Sebbene ciò abbia risolto il problema immediato di poter eseguire query basate su TenantID in modo molto più efficiente, non erano ancora così efficienti come avrebbero potuto essere se fosse stato l'indice cluster che fosse nello stesso ordine. E ora avevamo ancora un altro indice su ogni tavolo. Ciò ha aumentato la quantità di spazio SAN che stavamo utilizzando, ha aumentato le dimensioni dei nostri backup, ha reso più lungo il completamento dei backup, ha aumentato il potenziale di blocco e deadlock, ha ridotto le prestazioni INSERTe le DELETEoperazioni, ecc.

    E ci rimaneva ancora l'inefficienza generale di avere i dati di un inquilino sparsi su molte pagine di dati, mescolati a molti altri dati dell'inquilino. Come accennato in precedenza, ciò aumenta la quantità di contesa in queste pagine e riempie il pool di buffer con molte pagine di dati che contengono 1 o 2 righe utili, specialmente quando alcune delle righe su quelle pagine erano destinate ai clienti che erano inattivi ma non erano stati ancora raccolti. In questo approccio il potenziale di riutilizzo delle pagine di dati nel pool di buffer è molto inferiore, pertanto la nostra aspettativa di vita della pagina era piuttosto bassa. E questo significa più tempo per tornare su disco per caricare più pagine.


2
Hai considerato o testato OTTIMIZZA PER SCONOSCIUTO in questo spazio problematico? Solo curioso.
RLF

1
@RLF Sì, abbiamo cercato questa opzione e non dovrebbe essere almeno migliore, e forse peggiore, delle prestazioni meno che ottimali ottenute dall'avere prima il campo IDENTITY. Non ricordo dove l'ho letto, ma presumibilmente fornisce le stesse statistiche "medie" della riassegnazione di un parametro di input a una variabile locale. Ma questo articolo spiega perché questa opzione non risolve davvero il problema: brentozar.com/archive/2013/06/… Leggendo i commenti, Daniel Pepermans è giunto a una conclusione simile per quanto riguarda: SQL dinamico con variazione limitata :)
Solomon Rutzky

3
Cosa succede se l'indice cluster è attivo (ID, TenantID)e si crea anche un indice non cluster (TenantID, ID)o semplicemente (TenantID)per disporre di statistiche accurate per le query che elaborano la maggior parte delle righe di un singolo tenant?
Vladimir Baranov,

1
@VladimirBaranov Ottima domanda. L'ho affrontato in una nuova sezione di AGGIORNAMENTO verso la fine della risposta :-).
Solomon Rutzky,

4
bel punto sulla sql dinamica per generare piani per ogni cliente.
Max Vernon,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.