Best practice di progettazione dello schema

L'architettura distribuita di Spanner ti consente di progettare lo schema per evitare gli hotspot, ossia situazioni in cui vengono inviate troppe richieste allo stesso server, con conseguente saturazione delle risorse del server e possibili latenze elevate.

In questa pagina vengono descritte le best practice per la progettazione degli schemi in modo da evitare la creazione di hotspot. Un modo per evitare gli hotspot è modificare la progettazione dello schema in modo da consentire a Spanner di suddividere e distribuire i dati su più server. La distribuzione dei dati tra i server aiuta il database Spanner a funzionare in modo efficiente, in particolare durante l'inserimento di dati in blocco.

Scegli una chiave primaria per impedire gli hotspot

Come menzionato in Schema e modello dei dati, devi fare attenzione quando scegli una chiave primaria nella progettazione dello schema per non creare accidentalmente hotspot nel tuo database. Una delle cause degli hotspot è la presenza di una colonna il cui valore cambia monotonicamente come prima parte della chiave, perché in questo modo tutti gli inserimenti si verificano alla fine dello spazio della chiave. Questo pattern è indesiderato perché Spanner utilizza gli intervalli di chiavi per dividere i dati tra i server, il che significa che tutti gli inserimenti sono indirizzati a un singolo server che finisce per svolgere tutto il lavoro.

Ad esempio, supponi di voler mantenere una colonna di timestamp dell'ultimo accesso sulle righe della tabella UserAccessLog. La seguente definizione della tabella utilizza una chiave primaria basata su timestamp come prima parte della chiave. Sconsigliamo di farlo se la tabella registra un alto tasso di inserimento:

GoogleSQL


CREATE TABLE UserAccessLog (
LastAccess TIMESTAMP NOT NULL,
UserId     INT64 NOT NULL,
...
) PRIMARY KEY (LastAccess, UserId);

PostgreSQL


CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (LastAccess, UserId)
);

Il problema qui è che le righe vengono scritte in questa tabella in base al timestamp dell'ultimo accesso e, dato che i timestamp dell'ultimo accesso aumentano sempre, vengono sempre scritte alla fine della tabella. L'hotspot viene creato perché un singolo server Spanner riceve tutte le scritture, sovraccaricando quel server.

Il seguente diagramma illustra questa insidia:

Tabella UserAccessLog ordinata per timestamp con l'hotspot corrispondente

La tabella UserAccessLog riportata sopra include cinque righe di dati di esempio, che rappresentano cinque utenti diversi che eseguono un'azione utente a circa un millisecondo di distanza l'uno dall'altro. Il diagramma annota anche l'ordine in cui Spanner inserisce le righe (le frecce etichettate indicano l'ordine delle scritture per ogni riga). Poiché gli inserimenti sono ordinati per timestamp e il valore del timestamp aumenta sempre, Spanner aggiunge sempre gli inserimenti alla fine della tabella e li indirizza alla stessa suddivisione. Come discusso in Schema e modello dei dati, una suddivisione è un insieme di righe di una o più tabelle correlate che Spanner archivia in ordine di chiave di riga.

Questo è un problema perché Spanner assegna il lavoro a server diversi in unità di suddivisioni, quindi il server assegnato a questa suddivisione specifica finirà per gestire tutte le richieste di inserimento. Con l'aumento della frequenza degli eventi di accesso utente, aumenta anche la frequenza delle richieste di inserimento al server corrispondente. Il server diventa quindi un hotspot e appare come il bordo rosso e lo sfondo in alto. Tieni presente che in questa illustrazione semplificata ogni server gestisce al massimo una suddivisione, ma in realtà Spanner può assegnare a ciascun server più di una suddivisione.

Quando Spanner aggiunge altre righe alla tabella, la suddivisione aumenta e quando raggiunge circa 8 GB, Spanner crea un'altra suddivisione, come descritto in Suddivisione basata sul carico. Spanner aggiunge nuove righe successive a questa nuova suddivisione e il server assegnato alla suddivisione diventa il nuovo hotspot potenziale.

Quando si verificano hotspot, potresti notare che gli inserimenti sono lenti e che le altre operazioni sullo stesso server potrebbero rallentare. La modifica dell'ordine della colonna LastAccess in ordine crescente non risolve il problema perché tutte le scritture vengono invece inserite nella parte superiore della tabella, che invia comunque tutti gli inserimenti a un singolo server.

Best practice n. 1 per la progettazione dello schema: non scegliere una colonna il cui valore aumenta o diminuisce monotonicamente come prima parte della chiave per una tabella con frequenza di scrittura elevata.

Utilizza un UUID (Universally Unique Identifier)

Puoi utilizzare un UUID (Universally Unique Identifier) come definito nel documento RFC 4122 come chiave primaria. Consigliamo di usare l'UUID versione 4, perché utilizza valori casuali nella sequenza di bit. Sconsigliamo gli UUID della versione 1 perché memorizzano il timestamp in bit di ordine superiore.

Esistono diversi modi per archiviare l'UUID come chiave primaria:

  • In una colonna STRING(36).
  • In una coppia di INT64 colonne.
  • In una colonna BYTES(16).

Per una colonna STRING(36), puoi utilizzare la funzione GENERATE_UUID() di Spanner (GoogleSQL o PostgreSQL) come valore predefinito della colonna per fare in modo che Spanner generi automaticamente i valori UUID.

L'utilizzo di un UUID presenta alcuni svantaggi:

  • Sono leggermente grandi e utilizzano almeno 16 byte. Altre opzioni per le chiavi primarie non utilizzano così tanto spazio di archiviazione.
  • Non contengono informazioni sul record. Ad esempio, una chiave primaria di SingerId e AlbumId ha un significato intrinseco, mentre un UUID no.
  • Perdi la località tra i record correlati, motivo per cui l'utilizzo di un UUID elimina gli hotspot.

Valori sequenziali inversi in bit

Devi assicurarti che le chiavi primarie numeriche (INT64 in GoogleSQL o bigint in PostgreSQL) non aumentino o diminuiscano in sequenza. Le chiavi primarie sequenziali possono causare hotspotting su larga scala. Un modo per evitare questo problema è l'inversione di bit dei valori sequenziali, assicurandosi di distribuire le chiave primaria-valore in modo uniforme nello spazio delle chiavi.

Spanner supporta la sequenza invertita di bit, che genera valori interi univoci invertiti di bit. Puoi utilizzare una sequenza nel primo (o solo) componente in una chiave primaria per evitare problemi di hotspot. Per maggiori informazioni, consulta la pagina relativa alla sequenza invertita tra bit.

Inverti l'ordine delle chiavi

Un modo per distribuire le scritture in modo più uniforme sullo spazio delle chiavi è scambiare l'ordine delle chiavi in modo che la colonna che contiene il valore monotonico non sia la prima parte della chiave:

GoogleSQL

CREATE TABLE UserAccessLog (
LastAccess TIMESTAMP NOT NULL,
UserId     INT64 NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess);

PostgreSQL

CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (UserId, LastAccess)
);

In questo schema modificato, gli inserimenti ora vengono ordinati per UserId anziché in base al timestamp dell'ultimo accesso in ordine cronologico. Questo schema suddivide le scritture tra suddivisioni diverse perché è improbabile che un singolo utente produca migliaia di eventi al secondo.

Di seguito sono indicate le cinque righe della tabella UserAccessLog che Spanner ordina con UserId anziché il timestamp di accesso:

Tabella UserAccessLog ordinata per UserId con velocità effettiva di scrittura bilanciata

In questo caso Spanner suddivide i dati di UserAccessLog in tre suddivisioni, ciascuna delle quali contiene circa un migliaio di righe di valori UserId ordinati. Si tratta di una stima ragionevole delle modalità di suddivisione dei dati utente, supponendo che ogni riga contenga circa 1 MB di dati utente e che venga data una dimensione di suddivisione massima di circa 8 GB. Anche se gli eventi utente si sono verificati a circa un millisecondo l'uno dall'altro, ogni evento è stato generato da un utente diverso, pertanto è molto meno probabile che l'ordine degli inserimenti crei un hotspot rispetto all'utilizzo del timestamp per l'ordinamento.

Consulta anche la best practice correlata per ordinare le chiavi basate su timestamp.

Esegui l'hashing della chiave univoca e distribuisci le scritture tra shard logici

Un'altra tecnica comune per distribuire il carico su più server consiste nel creare una colonna che contiene l'hash della chiave univoca effettiva, quindi utilizzare la colonna hash (o la colonna hash e le colonne delle chiavi univoche insieme) come chiave primaria. Questo pattern consente di evitare gli hotspot, perché le nuove righe sono distribuite in modo più uniforme nello spazio della chiave.

Puoi utilizzare il valore hash per creare shard logici, o partizioni, nel tuo database. In un database con sharding fisico, le righe sono distribuite in più server di database. In un database con sharding logico, i dati della tabella definiscono gli shard. Ad esempio, per distribuire le scritture nella tabella UserAccessLog tra N shard logici, puoi anteporre alla tabella una colonna chiave ShardId:

GoogleSQL

CREATE TABLE UserAccessLog (
ShardId     INT64 NOT NULL,
LastAccess  TIMESTAMP NOT NULL,
UserId      INT64 NOT NULL,
...
) PRIMARY KEY (ShardId, LastAccess, UserId);

PostgreSQL

CREATE TABLE UserAccessLog (
ShardId bigint NOT NULL,
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (ShardId, LastAccess, UserId)
);

Per calcolare ShardId, esegui l'hashing di una combinazione delle colonne di chiave primaria e poi calcola il modulo N dell'hash. Ad esempio:

ShardId = hash(LastAccess and UserId) % N

La scelta della funzione hash e della combinazione di colonne determina la distribuzione delle righe nello spazio della chiave. Spanner creerà quindi suddivisioni tra le righe per ottimizzare le prestazioni.

Il diagramma seguente illustra come l'utilizzo di un hash per creare tre shard logici può distribuire la velocità effettiva di scrittura in modo più uniforme tra i server:

Tabella UserAccessLog ordinata per ShardId con velocità effettiva di scrittura bilanciata

In questo caso la tabella UserAccessLog è ordinata in base al valore ShardId, calcolato come funzione hash delle colonne chiave. Le cinque righe UserAccessLog sono suddivise in tre shard logici, ognuno dei quali è casuale in una suddivisione diversa. Gli inserimenti vengono distribuiti in modo uniforme tra le suddivisioni, il che bilancia la velocità effettiva di scrittura nei tre server che gestiscono le suddivisioni.

Spanner consente anche di creare una funzione hash in una colonna generata.

Per farlo in Google SQL, utilizza la funzione FARM_FINGERPRINT durante la scrittura, come mostrato nell'esempio seguente:

GoogleSQL

CREATE TABLE UserAccessLog (
ShardId INT64 NOT NULL
AS (MOD(FARM_FINGERPRINT(CAST(LastAccess AS STRING)), 2048)) STORED,
LastAccess TIMESTAMP NOT NULL,
UserId    INT64 NOT NULL,
) PRIMARY KEY (ShardId, LastAccess, UserId);

La scelta della funzione hash determina il livello di diffusione degli inserimenti nell'intervallo di chiavi. Non hai bisogno di un hash di crittografia, anche se un hash di crittografia potrebbe essere una buona scelta. Quando scegli una funzione hash, devi considerare i seguenti fattori:

  • Evitare gli hotspot. Una funzione che genera più valori hash tende a ridurre gli hotspot.
  • Efficienza di lettura. La lettura di tutti i valori hash è più rapida se ci sono meno valori hash da analizzare.
  • Conteggio dei nodi.

Utilizza l'ordine decrescente per le chiavi basate su timestamp

Se disponi di una tabella per la cronologia che utilizza il timestamp come chiave, valuta la possibilità di utilizzare l'ordine decrescente per la colonna della chiave se si applica una delle seguenti condizioni:

  • Se vuoi leggere la cronologia più recente, utilizzi una tabella con interleaving per la cronologia e leggi la riga padre. In questo caso, con una colonna del timestamp DESC, le voci della cronologia più recenti sono archiviate accanto alla riga principale. In caso contrario, per leggere la riga principale e la relativa cronologia recente sarà necessaria una ricerca al centro per saltare la cronologia precedente.
  • Se leggi voci sequenziali in ordine cronologico inverso e non sai esattamente quanto indietro ti permette di fare. Ad esempio, puoi utilizzare una query SQL con un LIMIT per ottenere i N eventi più recenti o potresti pianificare di annullare la lettura dopo aver letto un certo numero di righe. In questi casi, vuoi iniziare con le voci più recenti e leggere in sequenza le voci meno recenti fino a quando la condizione non viene soddisfatta, cosa che Spanner esegue in modo più efficiente le chiavi timestamp che Spanner archivia in ordine decrescente.

Aggiungi la parola chiave DESC per impostare la chiave timestamp in ordine decrescente. Ad esempio:

GoogleSQL

CREATE TABLE UserAccessLog (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess DESC);

Best practice n. 2 per la progettazione dello schema: l'ordine decrescente o crescente dipende dalle query dell'utente, ad esempio se il primo è il più recente oppure il primo è il meno recente.

Utilizzare un indice con interleaving su una colonna il cui valore aumenta o diminuisce monotonicamente

Come nel caso del precedente esempio di chiave primaria che dovresti evitare, non è consigliabile creare indici senza interleaving nelle colonne i cui valori aumentano o diminuiscono monotonicamente, anche se non sono colonne di chiave primaria.

Ad esempio, supponi di definire la seguente tabella, in cui LastAccess è una colonna non a chiave primaria:

GoogleSQL

CREATE TABLE Users (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP,
...
) PRIMARY KEY (UserId);

PostgreSQL

CREATE TABLE Users (
UserId     bigint NOT NULL,
LastAccess TIMESTAMPTZ,
...
PRIMARY KEY (UserId)
);

Potrebbe sembrare conveniente definire un indice nella colonna LastAccess per eseguire rapidamente query sul database per gli accessi degli utenti "dal momento della X", in questo modo:

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

CREATE INDEX UsersByLastAccess ON Users(LastAccess)
WHERE LastAccess IS NOT NULL;

Tuttavia, il risultato è la stessa insidia descritta nella best practice precedente, perché Spanner implementa gli indici come tabelle in background e la tabella di indice risultante utilizza una colonna il cui valore aumenta monotonicamente come prima parte chiave.

Tuttavia, è corretto creare un indice con interfoliazione come questo, perché le righe di indici con interfoliazione sono interlealate nelle righe padre corrispondenti ed è improbabile che una singola riga padre produca migliaia di eventi al secondo.

Best practice n. 3 per la progettazione dello schema: non creare un indice senza interleaving su una colonna con frequenza di scrittura elevata il cui valore aumenta o diminuisce monotonicamente. Invece di utilizzare indici con interleaving, usa tecniche come quelle che utilizzeresti per la progettazione della chiave primaria della tabella di base durante la progettazione delle colonne di indice. Ad esempio, aggiungi "shardId".

Passaggi successivi