Best practice di progettazione dello schema

L'architettura distribuita di Spanner consente di progettare lo schema in modo da evitare gli hotspot, ovvero le situazioni in cui vengono inviate troppe richieste allo stesso server che saturano le risorse del server e possono causare latenze elevate.

Questa pagina descrive le best practice per la progettazione degli schemi al fine di evitare la creazione di hotspot. Un modo per evitare gli hotspot è modificare la progettazione dello schema per consentire a Spanner di suddividere e distribuire i dati tra più server. La distribuzione dei dati tra i server consente al database Spanner di funzionare in modo efficiente, in particolare durante l'inserimento di dati collettivi.

Scegli una chiave primaria per impedire gli hotspot

Come menzionato in Schema e modello dei dati, quando scegli una chiave primaria nella progettazione dello schema devi fare attenzione a non creare accidentalmente hotspot nel tuo database. Una causa degli hotspot è avere una colonna il cui valore cambia monotonicamente come prima parte della chiave, in quanto tutti gli insert vengono eseguiti alla fine dello spazio delle chiavi. Questo pattern è sconsigliato perché Spanner utilizza intervalli di chiavi per suddividere i dati tra i server, il che significa che tutti gli insert sono indirizzati a un unico server che finisce per svolgere tutto il lavoro.

Ad esempio, supponi di voler mantenere una colonna con il timestamp dell'ultimo accesso nelle righe della tabella UserAccessLog. La seguente definizione della tabella utilizza una chiave primaria basata su timestamp come prima parte della chiave. Questa opzione non è consigliata se la tabella registra un tasso di inserimento elevato:

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 sono scritte in questa tabella in ordine di timestamp dell'ultimo accesso e, dato che i timestamp dell'ultimo accesso sono sempre in aumento, vengono sempre scritti alla fine della tabella. L'hotspot viene creato perché un singolo server Spanner riceve tutte le scritture, sovraccaricando lo stesso server.

Il diagramma seguente 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 una sorta di azione utente a circa un millisecondo di distanza l'uno dall'altro. Il diagramma annota inoltre l'ordine in cui Spanner inserisce le righe (le frecce etichettate indicano l'ordine delle scritture per ogni riga). Poiché gli inserti sono ordinati per timestamp e il valore del timestamp è sempre in aumento, Spanner aggiunge sempre gli inserti alla fine della tabella e li indirizza nella 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 particolare suddivisione finisce per gestire tutte le richieste di inserimento. Con l'aumento della frequenza degli eventi di accesso utente, aumenta anche quella delle richieste di inserimento al server corrispondente. Il server diventa quindi soggetto a diventare un hotspot, con lo sfondo rosso e il bordo rosso qui sopra. Tieni presente che in questa illustrazione semplificata ogni server gestisce al massimo una suddivisione, ma in realtà Spanner può assegnare a ogni server più di una suddivisione.

Quando Spanner aggiunge più righe alla tabella, la suddivisione cresce 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 potenziale hotspot.

Quando si verificano hotspot, potresti notare che gli insert 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 inserite nella parte superiore della tabella, il che invia comunque tutti gli inserti a un singolo server.

Best practice per la progettazione dello schema n. 1: 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 da RFC 4122 come chiave primaria. Consigliamo di usare l'UUID versione 4, poiché utilizza valori casuali nella sequenza di bit. Gli UUID versione 1 non sono consigliati perché archiviano il timestamp nei bit di ordine elevato.

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 valori UUID.

Esistono alcuni svantaggi nell'utilizzo di un UUID:

  • Sono leggermente grandi e utilizzano almeno 16 byte. Altre opzioni per le chiavi primarie non utilizzano così tanto spazio di archiviazione.
  • Non includono informazioni relative al 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.

Inverti bit dei valori sequenziali

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 hotspot su larga scala. Un modo per evitare questo problema consiste nell'invertire i bit dei valori sequenziali, assicurandoti di distribuire i valori chiave primaria in modo uniforme nello spazio delle chiavi.

Spanner supporta una sequenza invertita con bit, che genera valori interi e univoci invertiti con bit. Puoi utilizzare una sequenza nel primo (o solo) componente in una chiave primaria per evitare problemi di hotspot. Per ulteriori informazioni, consulta Sequenza con bit invertito.

Inverti l'ordine dei tasti

Un modo per distribuire le scritture nello spazio delle chiavi in modo più uniforme consiste nello invertire 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 insert vengono ora ordinati in base a UserId, anziché in base al timestamp cronologico dell'ultimo accesso. Questo schema suddivide le scritture tra diverse suddivisioni perché è improbabile che un singolo utente produca migliaia di eventi al secondo.

Il diagramma seguente illustra le cinque righe della tabella UserAccessLog ordinate da Spanner con il timestamp UserId invece del timestamp di accesso:

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

Qui Spanner suddivide i dati UserAccessLog in tre suddivisioni, ciascuna delle quali contiene circa mille righe di valori UserId ordinati. Questa è una stima ragionevole di come i dati utente potrebbero essere suddivisi, presupponendo che ogni riga contenga circa 1 MB di dati utente e abbia una dimensione massima di suddivisione di circa 8 GB. Anche se gli eventi utente si sono verificati a circa un millisecondo di distanza l'uno dall'altro, ogni evento è stato generato da un utente diverso, quindi l'ordine degli inserti ha molte meno probabilità di creare un hotspot rispetto all'uso del timestamp per l'ordinamento.

Consulta anche la best practice correlata per l'ordine di chiavi basate su timestamp.

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

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

Puoi utilizzare il valore hash per creare shard logici, o partizioni, nel tuo database. In un database con sharding fisico, le righe sono distribuite su diversi server di database. In un database con sharding logica, i dati nella tabella definiscono i shard. Ad esempio, per distribuire le scritture nella tabella UserAccessLog in N shard logici, puoi anteporre alla tabella una colonna della 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 il valore ShardId, esegui l'hashing di una combinazione delle colonne di chiave primaria, quindi 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 il modo in cui le righe sono distribuite nello spazio delle chiavi. Spanner creerà quindi suddivisioni tra le righe per ottimizzare il rendimento.

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 è coincidente con una suddivisione diversa. Gli inserti vengono distribuiti uniformemente 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 SQL di Google, utilizza la funzione FARM_FINGERprint in fase di 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 distribuzione degli inserti nell'intervallo di chiavi. Non è necessario un hash di crittografia, anche se un hash di crittografia potrebbe essere una buona scelta. Per scegliere 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. Le letture su tutti i valori hash sono più veloci se sono meno valori hash da analizzare.
  • Conteggio dei nodi.

Metti in ordine decrescente le chiavi basate su timestamp

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

  • Se vuoi leggere la cronologia più recente, stai utilizzando una tabella con interleaving per la cronologia e stai leggendo la riga padre. In questo caso, con una colonna di timestamp DESC, le voci più recenti della cronologia vengono archiviate accanto alla riga principale. In caso contrario, per leggere la riga principale e la relativa cronologia recente sarà necessario eseguire una ricerca nel mezzo per saltare la cronologia precedente.
  • Se si leggono voci sequenziali in ordine cronologico inverso e non si sa esattamente quanto indietro è passato. Ad esempio, puoi utilizzare una query SQL con LIMIT per recuperare gli eventi N più recenti oppure pianificare di annullare la lettura dopo aver letto un determinato numero di righe. In questi casi, vuoi iniziare con le voci più recenti e leggere quelle precedenti in sequenza fino a quando la condizione non è stata soddisfatta, cosa che Spanner fa in modo più efficiente per le chiavi timestamp che Spanner archivia in ordine decrescente.

Aggiungi la parola chiave DESC per rendere decrescente la chiave del timestamp. Ad esempio:

GoogleSQL

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

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

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

Analogamente all'esempio di chiave primaria precedente da evitare, è una cattiva idea anche creare indici senza interleaving sulle colonne i cui valori sono crescenti o diminuiti monotonicamente, anche se non sono colonne di chiave primaria.

Ad esempio, supponi di definire la seguente tabella, in cui LastAccess è una colonna di chiave non 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 pratico definire un indice nella colonna LastAccess per eseguire rapidamente query sul database per gli accessi degli utenti "dall'ora X", come segue:

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

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

Tuttavia, questo comporta lo stesso errore descritto nella best practice precedente, poiché Spanner implementa gli indici come tabelle di base e la tabella degli indici risultante utilizza una colonna il cui valore aumenta monotonicamente come prima parte della chiave.

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

Best practice per la progettazione dello schema n. 3: non creare un indice senza interleaving su una colonna con frequenza di scrittura elevata il cui valore aumenta o diminuisce monotonicamente. Anziché utilizzare gli 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 aggiungendo "shardId".

Passaggi successivi