L'architettura distribuita di Spanner consente di progettare lo schema per evitare gli hotspot, ovvero le situazioni in cui vengono inviate troppe richieste allo stesso server, il che esaurisce le risorse del server e causa potenzialmente latenze elevate.
Questa pagina descrive le best practice per progettare gli schemi in modo da evitare di creare 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 su più server consente al database Spanner di funzionare in modo efficiente, in particolare quando vengono eseguite inserzioni collettive di dati.
Scegli una chiave primaria per evitare hotspot
Come indicato in Schema e modello di dati, devi fare attenzione quando scegli una chiave primaria nel design dello schema per non creare accidentalmente hotspot nel database. Una causa degli hotspot è la presenza di una colonna il cui valore cambia monotonicamente come prima parte della chiave, perché tutti gli inserimenti avvengono alla fine dello spazio della chiave. Questo modello non è auspicabile perché Spanner utilizza intervalli di chiavi per suddividere 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, supponiamo che tu voglia mantenere una colonna del timestamp dell'ultimo accesso nelle righe della tabella UserAccessLog
. La seguente definizione di tabella utilizza una chiave primaria basata su timestamp come prima parte della chiave. Questa opzione non è consigliata se la tabella presenta un tasso di inserzione elevato:
CREATE TABLE UserAccessLogs ( LastAccess TIMESTAMP NOT NULL, UserId STRING(1024), ... ) PRIMARY KEY (LastAccess, UserId);
CREATE TABLE useraccesslog ( lastaccess timestamptz NOT NULL, userid text, ... PRIMARY KEY (lastaccess, userid) );
Il problema è che le righe vengono scritte in questa tabella in ordine di timestamp dell'ultimo accesso e, poiché 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, il che ne causa il sovraccarico.
Il seguente diagramma illustra questo problema:
La tabella UserAccessLog
precedente 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 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 in base al timestamp e il valore del timestamp è sempre in aumento, 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 è problematico perché Spanner assegna il lavoro a diversi server in unità di suddivisioni, quindi il server assegnato a questa particolare suddivisione finisce per gestire tutte le richieste di inserimento. Con l'aumentare della frequenza degli eventi di accesso utente, aumenta anche la frequenza delle richieste di inserimento al server corrispondente. Il server diventa quindi incline a diventare un hotspot e ha lo stesso aspetto del bordo e dello sfondo rossi mostrati nell'immagine precedente. In questa illustrazione semplificata, ogni server gestisce al massimo una suddivisione, ma Spanner può assegnare a ogni server più suddivisioni.
Quando Spanner aggiunge altre righe alla tabella, la suddivisione aumenta e, quando raggiunge circa 8 GB, Spanner ne crea un'altra, come descritto in Suddivisione in base al carico. Spanner appende le 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 inserimenti sono lenti e che altre attività sul medesimo 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 inserimenti
a un singolo server.
Best practice per la progettazione dello schema 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 dal documento RFC 4122 come chiave primaria. Ti consigliamo di utilizzare la versione 4 dell'UUID, perché utilizza valori casuali nella sequenza di bit. Sconsigliamo gli UUID versione 1 perché memorizzano il timestamp nei bit di ordine superiore.
Esistono diversi modi per memorizzare l'UUID come chiave principale:
- In una colonna
STRING(36)
. - In una coppia di colonne
INT64
. - 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.
Ad esempio, per la seguente tabella:
CREATE TABLE UserAccessLogs (
LogEntryId STRING(36) NOT NULL,
LastAccess TIMESTAMP NOT NULL,
UserId STRING(1024),
...
) PRIMARY KEY (LogEntryId, LastAccess, UserId);
CREATE TABLE useraccesslog (
logentryid VARCHAR(36) NOT NULL,
lastaccess timestamptz NOT NULL,
userid text,
...
PRIMARY KEY (lastaccess, userid)
);
Puoi inserire GENERATE_UUID()
per generare i valori LogEntryId
.
GENERATE_UUID()
produce un valore STRING
, pertanto la colonna LogEntryId
deve utilizzare il tipo STRING
per GoogleSQL o il tipo text
per PostgreSQL.
INSERT INTO
UserAccessLog (LogEntryId, LastAccess, UserId)
VALUES
(GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');
INSERT INTO
useraccesslog (logentryid, lastaccess, userid)
VALUES
(spanner.generate_uuid(),'2016-01-25 10:10:10.555555-05:00', 'TomSmith');
L'utilizzo di un UUID presenta alcuni svantaggi:
- Sono leggermente grandi, in quanto utilizzano almeno 16 byte. Altre opzioni per le chiavi principali non utilizzano tanto spazio di archiviazione.
- Non contengono informazioni sul record. Ad esempio, una chiave primaria di
SingerId
eAlbumId
ha un significato intrinseco, mentre un UUID no. - Perderai la località tra i record correlati, motivo per cui l'utilizzo di un UUID elimina gli hotspot.
Valori sequenziali con inversione dei bit
Assicurati che le chiavi primarie numeriche (INT64
in GoogleSQL o
bigint
in PostgreSQL) non siano in aumento o in diminuzione in modo sequenziale. Le chiavi primarie sequenziali possono causare hotspot su larga scala. Un modo per
evitare questo problema è invertire i bit dei valori sequenziali, assicurandosi di
distribuire i valori della chiave primaria in modo uniforme nello spazio delle chiavi.
Spanner supporta la sequenza con inversione dei bit, che genera valori interi con inversione dei bit univoci. Per evitare problemi di hotspot, puoi utilizzare una sequenza nel primo (o unico) componente di una chiave primaria. Per ulteriori informazioni, consulta la sezione Sequenza con inversione di bit.
Scambia l'ordine delle chiavi
Un modo per distribuire le scritture nello spazio delle chiavi in modo più uniforme è scambiare l'ordine delle chiavi in modo che la colonna contenente il valore monotonico non sia la prima parte della chiave:
CREATE TABLE UserAccessLog ( UserId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, ... ) PRIMARY KEY (UserId, LastAccess);
CREATE TABLE useraccesslog ( userid bigint NOT NULL, lastaccess TIMESTAMPTZ NOT NULL, ... PRIMARY KEY (UserId, LastAccess) );
In questo schema modificato, gli inserimenti vengono ora ordinati in base a UserId
, anziché in base al timestamp dell'ultimo accesso cronologico. Questo schema suddivide le scritture tra diverse suddivisioni perché è improbabile che un singolo utente produca migliaia di eventi al secondo.
L'immagine seguente mostra le cinque righe della tabella UserAccessLog
che
Spanner ordina con UserId
anziché con il timestamp di accesso:
In questo caso, Spanner suddivide i dati UserAccessLog
in tre suddivisioni, ciascuna contenente circa mille righe di valori UserId
ordinati. Si tratta di una stima ragionevole di come potrebbero essere suddivisi i dati utente, assumendo che ogni riga contenga circa 1 MB di dati utente e data una dimensione massima della suddivisione di circa 8 GB. Anche se gli eventi utente si sono verificati con circa un millisecondo di differenza, 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'hash della chiave univoca e distribuisci le scritture tra gli shard logici
Un'altra tecnica comune per distribuire il carico su più server è 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 aiuta a evitare gli hotspot, perché le nuove righe vengono distribuite in modo più uniforme nello spazio della chiave.
Puoi utilizzare il valore hash per creare partizioni o shard logici nel database. In un database con partizioni fisiche, le righe sono distribuite su più
server di database. In un database con partizioni logiche, i dati nella tabella definiscono
gli shard. Ad esempio, per distribuire le scritture nella tabella UserAccessLog
su N frammenti logici, puoi anteporre alla tabella una colonna chiave ShardId
:
CREATE TABLE UserAccessLog ( ShardId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, UserId INT64 NOT NULL, ... ) PRIMARY KEY (ShardId, LastAccess, UserId);
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'hash di una combinazione di colonne della 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 modalità di distribuzione delle righe nello spazio delle chiavi. Spanner creerà quindi suddivisioni tra le righe per ottimizzare il rendimento.
Il seguente diagramma illustra come l'utilizzo di un hash per creare tre frammenti logici può distribuire in modo più uniforme la produttività in scrittura tra i server:
Qui la tabella UserAccessLog
è ordinata in base a ShardId
, che viene calcolato come funzione hash delle colonne chiave. Le cinque righe UserAccessLog
sono suddivise in tre frammenti logici, ognuno dei quali si trova per coincidenza in una suddivisione diversa. Gli inserimenti vengono distribuiti uniformemente tra le suddivisioni, il che bilancia il throughput di scrittura per i tre server che gestiscono le suddivisioni.
Spanner ti consente anche di creare una funzione hash in una colonna generata.
Per farlo in GoogleSQL, utilizza la funzione FARM_FINGERPRINT durante la scrittura, come mostrato nell'esempio seguente:
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 di hash determina l'efficacia con cui le inserzioni vengono distribuite nell'intervallo di chiavi. Non è necessario un hash crittografico, anche se potrebbe essere una buona scelta. Quando scegli una funzione di hashing, devi prendere in considerazione i seguenti fattori:
- Evitamento di hotspot. Una funzione che genera più valori hash tende a ridurre gli hotspot.
- Leggi l'efficienza. Le letture di tutti i valori hash sono più veloci se sono presenti meno valori hash da eseguire la scansione.
- Conteggio nodi.
Utilizza l'ordine decrescente per le chiavi basate su timestamp
Se hai una tabella per la cronologia che utilizza il timestamp come chiave, valuta la possibilità di utilizzare l'ordinamento decrescente per la colonna della chiave se si applica una delle seguenti condizioni:
- Se vuoi leggere la cronologia più recente, utilizzi una tabella interlacciata per la cronologia e stai leggendo la riga principale. In questo caso, con una colonna di timestamp
DESC
, le voci della cronologia più recenti vengono memorizzate adiacenti alla riga principale. In caso contrario, la lettura della riga principale e della relativa cronologia recente richiederà una ricerca nel mezzo per saltare la cronologia precedente. - Se stai leggendo voci sequenziali in ordine cronologico inverso e
non sai esattamente quanto indietro stai andando. Ad esempio, potresti
utilizzare una query SQL con un
LIMIT
per ottenere gli N eventi più recenti oppure potresti pianificare di annullare la lettura dopo aver letto un determinato numero di righe. In questi casi, è consigliabile iniziare con le voci più recenti e leggere in sequenza le voci precedenti finché la condizione non viene soddisfatta, il che avviene in modo più efficiente per le chiavi timestamp che Spanner memorizza in ordine decrescente.
Aggiungi la parola chiave DESC
per impostare la chiave del timestamp in ordine decrescente. Ad esempio:
CREATE TABLE UserAccessLog ( UserId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, ... ) PRIMARY KEY (UserId, LastAccess DESC);
Best practice di progettazione dello schema 2: l'ordinamento decrescente o crescente dipende dalle query degli utenti, ad esempio, i risultati principali sono i più recenti o i più vecchi.
Quando utilizzare un indice interlacciato
Analogamente all'esempio precedente di chiave primaria da evitare, è anche sconsigliabile creare indici senza interleaving su colonne i cui valori sono in aumento o in diminuzione in modo monotonico, anche se non sono colonne di chiavi primarie.
Ad esempio, supponiamo di definire la seguente tabella, in cui LastAccess
è una colonna di chiave non principale:
CREATE TABLE Users ( UserId INT64 NOT NULL, LastAccess TIMESTAMP, ... ) PRIMARY KEY (UserId);
CREATE TABLE Users ( userid bigint NOT NULL, lastaccess TIMESTAMPTZ, ... PRIMARY KEY (userid) );
Potrebbe sembrare conveniente definire un indice sulla colonna LastAccess
per eseguire rapidamente query sul database per gli accessi utente "dall'ora X", ad esempio:
CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);
CREATE INDEX usersbylastaccess ON users(lastaccess) WHERE lastaccess IS NOT NULL;
Tuttavia, si verifica lo stesso problema descritto nella precedente best practice, perché Spanner implementa gli indici come tabelle sotto il cofano e la tabella di indici risultante utilizza una colonna il cui valore aumenta in modo monotonico come prima parte della chiave.
Tuttavia, è possibile creare un indice interlacciato come questo, perché le righe degli indici interlacciati sono interlacciate nelle righe principali corrispondenti ed è improbabile che una singola riga principale produca migliaia di eventi al secondo.
Best practice per la progettazione dello schema 3: non creare un indice senza interleaving su una colonna con frequenza di scrittura elevata il cui valore aumenta o diminuisce monotonicamente. Utilizza un indice interlacciato o tecniche come quelle che utilizzeresti per la progettazione della chiave primaria della tabella di base quando progetti le colonne dell'indice, ad esempio aggiungi "shardId".
Passaggi successivi
- Consulta gli esempi di design dello schema.
- Scopri di più sul caricamento collettivo dei dati.