L'architettura distribuita di Spanner consente di progettare lo schema per evitare hotspot, ovvero situazioni in cui vengono inviate troppe richieste allo stesso server, il che satura le risorse del server e causa potenzialmente latenze elevate.
Questa pagina descrive le best practice per progettare gli schemi in modo da evitare la creazione di hotspot. Un modo per evitare gli hotspot è modificare la progettazione dello schema per consentire a Spanner di dividere e distribuire i dati su più server. La distribuzione dei dati su più server consente al database Spanner di operare in modo efficiente, in particolare durante l'inserimento di dati collettivi.
Scegli una chiave primaria per evitare gli hotspot
Come indicato in Schema e modello di dati, devi prestare attenzione quando scegli una chiave primaria nella progettazione dello schema per non creare accidentalmente hotspot nel database. Una delle cause degli hotspot è la presenza di una colonna il cui valore cambia monotonicamente come prima parte della chiave, perché ciò comporta l'inserimento di tutti i dati alla fine dello spazio delle chiavi. Questo pattern non è auspicabile perché Spanner utilizza 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 fare tutto il lavoro.
Ad esempio, supponiamo di voler mantenere una colonna con il timestamp dell'ultimo accesso nelle righe
della tabella UserAccessLogs
. La seguente definizione di tabella utilizza una
chiave primaria basata sul timestamp come prima parte della chiave. Non lo consigliamo se
la tabella registra un alto tasso di inserimento:
GoogleSQL
CREATE TABLE UserAccessLogs ( LastAccess TIMESTAMP NOT NULL, UserId STRING(1024), ... ) PRIMARY KEY (LastAccess, UserId);
PostgreSQL
CREATE TABLE useraccesslogs ( 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 aumentano sempre, vengono sempre scritti alla fine della tabella. L'hotspot viene creato perché un singolo server Spanner riceve tutte le scritture, sovraccaricando quel server.
Il seguente diagramma illustra questo problema:
La tabella UserAccessLogs
precedente include cinque righe di dati di esempio, che
rappresentano cinque utenti diversi che eseguono un'azione utente a
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
di scrittura per ogni riga). Poiché gli inserimenti sono ordinati per timestamp e il valore del timestamp è sempre crescente, Spanner aggiunge sempre gli inserimenti alla fine della tabella e li indirizza alla stessa suddivisione. Come
descritto in Schema e modello
dei dati, una suddivisione è un insieme di
righe di una o più tabelle correlate che Spanner
memorizza in ordine di chiave di riga.
Questo è problematico perché Spanner assegna il lavoro a server diversi in unità di suddivisione, quindi il server assegnato a questa particolare suddivisione finisce per gestire tutte le richieste di inserimento. Man mano che la frequenza degli eventi di accesso degli utenti aumenta, aumenta anche la frequenza delle richieste di inserimento al server corrispondente. Il server diventa quindi soggetto a diventare un hotspot e appare come il bordo e lo 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ù di una suddivisione.
Quando Spanner aggiunge altre righe alla tabella, la suddivisione aumenta e poi crea nuove suddivisioni in base alle esigenze. Per scoprire di più su come vengono create le suddivisioni, consulta la sezione Suddivisione basata sul carico. Spanner aggiunge 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 anche altre operazioni
sullo stesso server potrebbero rallentare. Modificare l'ordine della colonna LastAccess
in ordine crescente non risolve il problema perché tutti gli inserimenti
vengono inseriti nella parte superiore della tabella, il che invia comunque tutti gli inserimenti
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.
Utilizzare 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 l'UUID versione 4, perché utilizza valori casuali nella sequenza di bit. Non consigliamo gli UUID di versione 1 perché memorizzano il timestamp nei bit di ordine superiore.
Esistono diversi modi per archiviare l'UUID come chiave primaria:
- 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 Spanner
GENERATE_UUID()
(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:
GoogleSQL
CREATE TABLE UserAccessLogs (
LogEntryId STRING(36) NOT NULL,
LastAccess TIMESTAMP NOT NULL,
UserId STRING(1024),
...
) PRIMARY KEY (LogEntryId, LastAccess, UserId);
PostgreSQL
CREATE TABLE useraccesslogs (
logentryid VARCHAR(36) NOT NULL,
lastaccess timestamptz NOT NULL,
userid text,
...
PRIMARY KEY (lastaccess, userid)
);
Puoi inserire GENERATE_UUID()
per generare i valori di LogEntryId
.
GENERATE_UUID()
produce un valore STRING
, quindi la colonna LogEntryId
deve utilizzare
il tipo STRING
per GoogleSQL o il tipo text
per
PostgreSQL.
GoogleSQL
INSERT INTO
UserAccessLogs (LogEntryId, LastAccess, UserId)
VALUES
(GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');
PostgreSQL
INSERT INTO
useraccesslogs (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 16 byte o più. Le 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
eAlbumId
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.
Esegui l'inversione dei 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 è 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 univoci con inversione dei bit. Puoi utilizzare una sequenza nel primo (o unico) componente di una chiave primaria per evitare problemi di hotspot. Per ulteriori informazioni, vedi Sequenza inversione di bit.
Invertire l'ordine delle chiavi
Un modo per distribuire le scritture in modo più uniforme nello spazio delle chiavi è scambiare l'ordine delle chiavi in modo che la colonna che contiene il valore monotono non sia la prima parte della chiave:
GoogleSQL
CREATE TABLE UserAccessLogs ( UserId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, ... ) PRIMARY KEY (UserId, LastAccess);
PostgreSQL
CREATE TABLE useraccesslogs ( userid bigint NOT NULL, lastaccess TIMESTAMPTZ NOT NULL, ... PRIMARY KEY (UserId, LastAccess) );
In questo schema modificato, gli inserimenti vengono ora ordinati prima in base a UserId
, anziché
in base al timestamp dell'ultimo accesso cronologico. Questo schema distribuisce 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 UserAccessLogs
che
Spanner ordina con UserId
anziché con il timestamp di accesso:
In questo caso, Spanner potrebbe dividere i dati UserAccessLogs
in tre
suddivisioni, ognuna contenente circa mille righe di valori UserId
ordinati. Anche se gli eventi utente si sono verificati a distanza di circa un millisecondo,
ogni evento è stato generato da un utente diverso, quindi l'ordine degli inserimenti ha molte meno
probabilità di creare un hotspot rispetto all'utilizzo del timestamp per l'ordinamento. Per
scoprire di più su come vengono create le suddivisioni, consulta
Suddivisione basata sul carico.
Consulta anche la best practice correlata per l'ordinamento delle chiavi basate sul timestamp.
Esegui l'hashing della chiave univoca e distribuisci le scritture tra gli shard logici
Un'altra tecnica comune per distribuire il carico su più server consiste nel creare una colonna che contenga 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 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 partizionamento fisico, le righe sono distribuite su più server di database. In un database partizionato logicamente, i dati nella tabella definiscono
i partizionamenti. Ad esempio, per distribuire le scritture nella tabella UserAccessLogs
su N
shard logici, puoi anteporre una colonna della chiave ShardId
alla tabella:
GoogleSQL
CREATE TABLE UserAccessLogs ( ShardId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, UserId INT64 NOT NULL, ... ) PRIMARY KEY (ShardId, LastAccess, UserId);
PostgreSQL
CREATE TABLE useraccesslogs ( 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:
GoogleSQL
ShardId = hash(LastAccess and UserId) % N
La scelta della funzione hash e della combinazione di colonne determina la distribuzione delle righe nello spazio delle chiavi. Spanner creerà quindi suddivisioni tra le righe per ottimizzare le prestazioni.
Il seguente diagramma 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:
Qui la tabella UserAccessLogs
è ordinata in base a ShardId
, che viene calcolato come una
funzione hash delle colonne chiave. Le cinque righe UserAccessLogs
sono suddivise in tre shard logici, ognuno dei quali si trova in una divisione diversa. Gli
inserimenti sono distribuiti uniformemente tra le suddivisioni, il che bilancia la velocità effettiva di scrittura
sui tre server che gestiscono le suddivisioni.
Spanner 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:
GoogleSQL
CREATE TABLE UserAccessLogs (
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 la distribuzione delle inserzioni nell'intervallo di chiavi. Non è necessario un hash crittografico, anche se 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.
- Leggi l'efficienza. Le letture in tutti i valori hash sono più veloci se ci sono meno valori hash da scansionare.
- 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'ordine decrescente per la colonna della chiave se si verifica una delle seguenti condizioni:
- Se vuoi leggere la cronologia più recente, stai utilizzando una tabella
interlacciata per la cronologia e stai leggendo la riga principale. In questo caso,
con una colonna timestamp
DESC
, le voci della cronologia più recenti vengono memorizzate adiacenti alla riga principale. In caso contrario, la lettura della riga del genitore e della sua cronologia recente richiederà una ricerca al centro per saltare la cronologia meno recente. - 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, devi iniziare con le voci più recenti e leggere in sequenza le voci meno recenti finché la condizione non è 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 impostare la chiave del timestamp in ordine decrescente. Ad esempio:
GoogleSQL
CREATE TABLE UserAccessLogs ( UserId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, ... ) PRIMARY KEY (UserId, LastAccess DESC);
Best practice di progettazione dello schema n. 2: l'ordine decrescente o crescente dipende dalle query degli utenti, ad esempio, in alto le più recenti o in alto le più vecchie.
Quando utilizzare un indice interleaved
Analogamente all'esempio precedente di chiave primaria da evitare, è anche una cattiva idea creare indici senza interleaving su colonne i cui valori aumentano o diminuiscono monotonicamente, anche se non sono colonne di chiavi primarie.
Ad esempio, supponiamo di definire la seguente tabella, in cui LastAccess
è una
colonna non 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
interrogare rapidamente il database per gli accessi degli utenti "dall'ora 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, ciò comporta lo stesso problema descritto nella best practice precedente, perché Spanner implementa gli indici come tabelle, e la tabella dell'indice risultante utilizza una colonna il cui valore aumenta in modo monotono come prima parte della chiave.
È possibile creare un indice interleaved in cui le righe dell'ultimo accesso sono interleaved sotto la riga utente corrispondente. Questo perché è improbabile che una singola riga principale produca migliaia di eventi al secondo.
GoogleSQL
CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(UserId, LastAccess), INTERLEAVE IN Users;
PostgreSQL
CREATE INDEX usersbylastaccess ON users(userid, lastaccess) WHERE lastaccess IS NOT NULL, INTERLEAVE IN Users;
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. Utilizza un indice interleaved o tecniche simili a quelle che utilizzeresti per la progettazione della chiave primaria della tabella di base durante la progettazione delle colonne dell'indice, ad esempio aggiungi `shardId`.
Passaggi successivi
- Consulta gli esempi di progettazione dello schema.
- Scopri di più sul caricamento collettivo dei dati.