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, 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:

Tabella UserAccessLog ordinata per timestamp con l'hotspot corrispondente

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

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:

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

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:

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

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