Best practice di progettazione dello schema

L'architettura distribuita di Spanner consente di progettare lo schema per evitare hotspot, ovvero situazioni in cui vengono inviate troppe richieste allo stesso server, che satura le risorse del server e potenzialmente causa elevate latente.

In questa pagina vengono descritte le best practice per la progettazione degli schemi al fine di 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 tra i server aiuta il tuo database Spanner a funzionare in modo efficiente, in particolare quando esegui gli inserimenti di dati collettivi.

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 delle cause degli hotspot è la presenza di una colonna il cui valore in modo monotonico modifiche come prima parte chiave, perché ciò determina tutti gli inserimenti che avvengono alla fine dello spazio delle chiavi. Questo pattern è indesiderato perché Spanner utilizza intervalli di chiavi per dividere i dati tra i server, il che significa che tutti gli inserimenti diretto a un singolo server che si occupa di 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. Lo sconsigliamo se la tabella registra un tasso di inserimento elevato:

GoogleSQL

CREATE TABLE UserAccessLogs (
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslog (
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

Il problema qui è che le righe vengono scritte in questa tabella in ordine di ultimo accesso e, poiché i timestamp dell'ultimo accesso sono sempre in aumento, sempre scritto alla fine della tabella. L'hotspot viene creato perché Il server Spanner riceve tutte le scritture, sovraccaricandole server web.

Il seguente diagramma illustra questa insidia:

Tabella UserAccessLog ordinata per timestamp con l'hotspot corrispondente

La tabella UserAccessLog precedente include cinque righe di dati di esempio, che rappresentare cinque utenti diversi che eseguono un'azione utente su un millisecondi 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 è un problema perché Spanner assegna il lavoro a server diversi in di suddivisioni, quindi il server assegnato a questa particolare suddivisione per gestire tutte le richieste di inserimento. Come frequenza degli eventi di accesso dell'utente aumenta, anche la frequenza delle richieste di inserimento al server corrispondente aumenta. 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, 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 RFC 4122 come chiave primaria. Ti consigliamo di utilizzare la versione 4 dell'UUID, perché utilizza valori casuali nella sequenza di bit. Sconsigliamo di usare gli UUID della versione 1 perché memorizzano il timestamp le parti di ordine superiore.

Esistono diversi modi per memorizzare l'UUID come chiave principale:

  • 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.

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

GoogleSQL

INSERT INTO
  UserAccessLog (LogEntryId, LastAccess, UserId)
VALUES
  (GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');

PostgreSQL

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 e 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 e AlbumId ha un significato intrinseco, mentre un UUID no.
  • Perdi la località tra i record correlati: ecco perché usare un UUID elimina gli hotspot.

Valori sequenziali inversi in 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. Solo andata evitare questo problema è l'inversione dei bit dei valori sequenziali, assicurandosi che distribuire le chiavi primarie 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, vedi Bit invertito sequenza.

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:

GoogleSQL

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

PostgreSQL

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 Ordini Spanner con UserId anziché 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, con ogni suddivisione contenente circa un migliaio di righe ordinate di UserId e i relativi valori. 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 a circa un millisecondo l'uno dall'altro, ogni evento è stato generato da un utente diverso, quindi è molto meno probabile che crei un hotspot rispetto all'uso timestamp per l'ordine.

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 è 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 gli hotspot, perché le nuove righe sono più distribuite in modo uniforme sullo spazio dei tasti.

Puoi utilizzare il valore hash per creare shard logici, o partizioni, nella tua per configurare un 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 i frammenti. 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 il valore ShardId, esegui l'hashing di una combinazione delle colonne di chiave primaria e poi calcolare il modulo N dell'hash. Ad esempio:

GoogleSQL

ShardId = hash(LastAccess and UserId) % N

La scelta della funzione di 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 le prestazioni.

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:

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. La gli inserimenti vengono distribuiti uniformemente tra le suddivisioni, il che bilancia la velocità effettiva di scrittura dai tre server che gestiscono le suddivisioni.

Spanner ti consente anche di creare una funzione hash in una risorsa generata .

Per farlo in GoogleSQL, 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 l'efficacia della distribuzione 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, considerare 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 dei nodi.

Utilizza l'ordine decrescente per le chiavi basate su timestamp

Se hai una tabella per la cronologia che utilizza il timestamp come chiave, considera utilizzando l'ordine decrescente per la colonna chiave se si applica 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 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 leggi voci sequenziali in ordine cronologico inverso e non sai esattamente quanto indietro stai andando a ritroso. 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 queste casi, vuoi iniziare con le voci più recenti e leggere in sequenza le voci meno recenti fino a quando la condizione non è stata soddisfatta, cosa che viene fatto da Spanner 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 UserAccessLog (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess DESC);

Best practice n. 2 per la progettazione dello schema: ordine decrescente o crescente dipende dalle query degli utenti, ad esempio "superiore" è il più recente o "superiore" è il meno recente.

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

Analogamente al precedente esempio di chiave primaria che dovresti evitare, è presente anche una cattiva idea di creare indici senza interleaving nelle colonne i cui valori sono aumentando o diminuendo in modo monotonico, anche se non sono colonne di chiave primaria.

Ad esempio, supponiamo che tu definisca la seguente tabella, in cui LastAccess è un 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 sulla colonna LastAccess per eseguire rapidamente query sul database per gli accessi utente "dall'ora X", ad esempio:

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

CREATE INDEX usersbylastaccess ON users(lastaccess)
WHERE lastaccess IS NOT NULL;

Tuttavia, si verifica lo stesso problema descritto nella best practice precedente, perché Spanner implementa gli indici come tabelle sotto il cofano e la tabella di indice risultante utilizza una colonna il cui valore aumenta monotonicamente 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 di 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. Anziché utilizzare indici interlacciati, utilizza tecniche come quelle che useresti per la progettazione della chiave primaria della tabella di base quando progetti le colonne dell'indice, ad esempio aggiungi "shardId".

Passaggi successivi