Best practice di progettazione dello schema

L'architettura distribuita di Cloud Spanner ti consente di progettare lo schema per evitare hotspot: difetti strutturali nelle tabelle che forzano accidentalmente i loro server sottostanti a gestire molte richieste simili, invece di distribuire tali richieste su più server in parallelo.

In questa pagina vengono descritte le best practice per la progettazione degli schemi al fine di evitare questi hotspot. Questo aiuterà il tuo database Cloud Spanner a operare in modo efficiente, in particolare quando esegui inserimenti di dati collettivi.

Scegli una chiave primaria per impedire gli hotspot

Come menzionato in Schema e modello dei dati, devi essere prudente quando scegli una chiave primaria per non creare accidentalmente hotspot nel tuo database. Una delle cause degli hotspot è la presenza di una colonna il cui valore aumenta monotonicamente come prima parte della chiave, perché in questo modo si verificano tutti gli inserimenti alla fine dello spazio della chiave. Questo pattern è sconsigliato perché Cloud Spanner suddivide i dati tra i server in base agli intervalli di chiavi, il che significa che tutti i tuoi inserimenti saranno indirizzati a un singolo server che finirà per svolgere tutto il lavoro.

Ad esempio, supponiamo che tu voglia mantenere una colonna di timestamp dell'ultimo accesso sulle righe della tabella UserAccessLog. La seguente definizione di tabella che utilizza una chiave primaria basata su timestamp come prima parte di una chiave è un anti-pattern se la tabella rileverà un'elevata frequenza di inserimento:

SQL standard di Google

-- ANTI-PATTERN: USING A COLUMN WHOSE VALUE MONOTONICALLY INCREASES OR
-- DECREASES AS THE FIRST KEY PART OF A HIGH WRITE RATE TABLE

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

PostgreSQL

-- ANTI-PATTERN: USING A COLUMN WHOSE VALUE MONOTONICALLY INCREASES OR
-- DECREASES AS THE FIRST KEY PART OF A HIGH WRITE RATE TABLE

CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (LastAccess, UserId)
);

Il problema qui è che le righe vengono 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 scritte alla fine della tabella. L'hotspot viene creato perché un singolo server Cloud Spanner riceverà tutte le scritture, che sovraccaricano quel server.

Il diagramma seguente illustra questa insidia:

Tabella UserAccessLog ordinata per timestamp con hotspot corrispondente

La tabella UserAccessLog riportata sopra include cinque righe di dati di esempio, che rappresentano cinque utenti diversi intervengono l'uno all'altro a un millisecondo l'uno dall'altro. Il diagramma riporta anche l'ordine di inserimento delle righe (le frecce etichettate indicano l'ordine delle scritture per ogni riga). Poiché gli inserimenti sono ordinati per timestamp e il relativo valore è sempre in aumento, gli inserimenti vengono sempre aggiunti alla fine della tabella e vengono indirizzati alla stessa suddivisione. Come spiegato in Schema e modello dei dati, una suddivisione è un insieme di righe di una o più tabelle correlate archiviate in ordine di chiave riga.

Questo è un problema, perché Cloud Spanner assegna il lavoro a server diversi in unità di suddivisione. Di conseguenza, 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 la frequenza delle richieste di inserimento al server corrispondente. Il server diventa quindi soggetto a hotspot, come indicato dal bordo rosso e dallo sfondo sopra. Tieni presente che in questa illustrazione semplificata, ogni server gestisce al massimo una suddivisione, ma in realtà ogni server Cloud Spanner può essere assegnato a più di una suddivisione.

Quando vengono aggiunte più righe alla tabella, la suddivisione aumenta e, quando raggiunge circa 8 GB, Cloud Spanner crea un'altra suddivisione, come descritto nella suddivisione basata sul carico. Le nuove righe successive vengono aggiunte a questa nuova suddivisione e il server a cui è assegnato diventa il nuovo potenziale hotspot.

Quando si verificano hotspot, potresti notare che i tuoi inserimenti sono lenti e che altri lavori sullo stesso server potrebbero rallentare. Se modifichi l'ordine della colonna LastAccess in ordine crescente, il problema non viene risolto perché tutte le scritture vengono inserite nella parte superiore della tabella, il che invia comunque tutti gli inserimenti a un singolo server.

Best practice di 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.

Inverti l'ordine delle chiavi

Un modo per distribuire le scritture sullo spazio delle chiavi è cambiare l'ordine delle chiavi in modo che la colonna che contiene il valore di aumento o diminuzione monotonicamente non sia la prima parte della chiave:

SQL standard di Google

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 sono ora ordinati per UserId anziché in base al timestamp dell'ultimo accesso cronologico. Questo schema distribuisce le scritture tra suddivisioni diverse perché è improbabile che un singolo utente produca migliaia di eventi al secondo.

Il diagramma seguente illustra le cinque righe della tabella UserAccessLog ordinate in base a UserId anziché in base al timestamp di accesso:

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

In questo caso, i dati di UserAccessLog vengono suddivisi in tre suddivisioni, ognuna delle quali contiene un ordine di mille righe di valori UserId ordinati. Si tratta di una stima ragionevole di come potrebbero essere suddivisi i dati utente, supponendo che ogni riga contenga circa 1 MB di dati utente e abbia una dimensione massima di circa 8 GB. Anche se gli eventi utente si sono verificati circa un millisecondo a parte, ogni evento è stato generato da un utente diverso, pertanto l'ordine di inserzione ha molte meno probabilità di creare un hotspot rispetto all'ordine in base al timestamp.

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

Esegui l'hashing della chiave unica e distribuisci le scritture su shard logici

Un'altra tecnica comune per distribuire il carico su più server consiste nel creare una colonna che contiene 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 distribuite in modo più uniforme nello spazio chiave.

Puoi utilizzare il valore hash per creare partizioni logiche o partizioni nel tuo database. (In un database con partizionamento orizzontale, le righe sono distribuite in diversi database. In un database con suddivisione logica, gli shard sono definiti dai dati nella tabella. Ad esempio, per distribuire le scritture nella tabella UserAccessLog tra partizioni logiche, puoi anteporre la colonna di una chiave ShardId alla tabella:

SQL standard di Google

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 ShardId, esegui l'hashing di una combinazione delle colonne di chiave primaria e calcola il modulo N dell'hash - 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 chiave. Cloud Spanner creerà quindi suddivisioni nelle righe per ottimizzare le prestazioni. Tieni presente che le suddivisioni potrebbero non essere in linea con gli aspetti logici.

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 da ShardId con velocità effettiva di scrittura bilanciata

Qui la tabella UserAccessLog è ordinata per ShardId, calcolata come una funzione hash di colonne chiave. Le cinque righe UserAccessLog sono suddivise in tre shard logici, ognuno dei quali si trova in modo casuale in una diversa divisione. Gli inserti sono distribuiti uniformemente tra le suddivisioni, il che bilancia la velocità effettiva di scrittura sui tre server che gestiscono le suddivisioni.

La scelta della funzione hash determinerà la diffusione degli inserimenti nell'intervallo di chiavi. Non è necessario un hash crittografico, anche se un hash crittografico può essere una buona scelta. Quando scegli una funzione hash, devi prendere in considerazione diversi fattori:

  • Evita gli hotspot. Una funzione che comporta un aumento dei valori hash tende a ridurre gli hotspot.
  • Efficienza di lettura. Le letture di tutti i valori hash sono più veloci se ci sono meno valori hash da scansionare.
  • Conteggio nodi.

Utilizzare un UUID (Universally Unique Identifier)

Puoi utilizzare un UUID (Universally Unique Identifier) come definito da RFC 4122 come chiave primaria. Si consiglia l'uso di UUID versione 4, perché utilizza valori casuali nella sequenza di bit. L'UUID versione 1 memorizza il timestamp nei bit dell'ordine elevato e non è consigliato.

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

  • In una colonna STRING(36).
  • In una coppia di colonne INT64.
  • In una colonna BYTES(16).

L'utilizzo di un UUID presenta alcuni svantaggi:

  • Sono leggermente di grandi dimensioni e utilizzano almeno 16 byte. 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. Per questo motivo, l'utilizzo di un UUID elimina gli hotspot.

Inverti i valori sequenziali

Quando generi chiavi primarie univoche numeriche, i bit di ordine elevato dei numeri successivi devono essere distribuiti in modo più o meno uniforme sull'intero spazio numerico. Un modo per farlo è generare numeri sequenziali con i metodi convenzionali e poi invertirli per ottenere i valori finali.

Invertendo i bit vengono mantenuti valori univoci tra le chiavi primarie. Devi archiviare solo il valore invertito perché puoi ricalcolare il valore originale nel codice dell'applicazione.

Usa l'ordine decrescente per le chiavi basate su timestamp

Se disponi di una tabella per la tua cronologia in base al timestamp, valuta la possibilità di utilizzare l'ordine decrescente per le colonne principali, se si verifica una delle seguenti condizioni:

  • Se utilizzi una tabella con interfoliazione per la cronologia e leggi anche la riga principale. In questo caso, con una colonna timestamp DESC, le voci della cronologia più recenti vengono archiviate accanto alla riga principale. In caso contrario, la lettura della riga principale e della relativa cronologia recente richiederà di cercare nel mezzo per saltare la cronologia precedente.
  • Se leggi voci sequenziali in ordine cronologico inverso e non sai esattamente a che ora vai indietro. Ad esempio, puoi utilizzare una query SQL con un LIMIT per recuperare gli eventi N più recenti oppure pianificare l'annullamento della lettura dopo aver letto un determinato numero di righe. In questi casi, vuoi iniziare con le voci più recenti e leggere le voci sequenziali più vecchie fino a quando non viene soddisfatta la condizione, che Cloud Spanner utilizza in modo più efficiente per le chiavi del timestamp archiviate in ordine decrescente.

Aggiungi la parola chiave DESC in modo che la chiave del timestamp sia decrescente. Ad esempio:

SQL standard di Google

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

Best practice di progettazione dello schema n. 2: utilizza l'ordine decrescente per le chiavi basate su timestamp.

Utilizza un indice con interfoliazione in una colonna il cui valore aumenta o diminuisce monotonicamente

Come per il precedente anti-motivo della chiave primaria, è sconsigliabile anche creare indici senza interfoliazione su colonne i cui valori aumentano o diminuiscono monotonicamente, anche se non sono colonne di chiavi primarie.

Ad esempio, supponiamo che tu definisca la seguente tabella, in cui LastAccess è una colonna non principale:

SQL standard di Google

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 essere pratico definire un indice nella colonna LastAccess per eseguire rapidamente query sul database per gli accessi degli utenti"poiché la data X", come riportato di seguito:

SQL standard di Google

-- ANTI-PATTERN: CREATING A NON-INTERLEAVED INDEX ON A COLUMN WHOSE VALUE
-- MONOTONICALLY INCREASES OR DECREASES ON A HIGH WRITE RATE COLUMN

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

-- ANTI-PATTERN: CREATING A NON-INTERLEAVED INDEX ON A COLUMN WHOSE VALUE
-- MONOTONICALLY INCREASES OR DECREASES ON A HIGH WRITE RATE COLUMN

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

Tuttavia, ciò causa la stessa incertezza descritta nella best practice precedente, poiché gli indici vengono implementati come tabelle sottostanti e la tabella di indice risultante utilizza una colonna il cui valore aumenta monotonicamente come prima parte della chiave.

Tuttavia, è possibile creare un indice con interfoliazione come questo, perché le righe di indici con interfoliazione sono interfoliate nelle righe principali corrispondenti ed è improbabile che una singola riga principale generi migliaia di eventi al secondo.

Best practice di progettazione dello schema n. 3: non creare un indice senza interfoliazione su una colonna con frequenza di scrittura elevata il cui valore aumenta o diminuisce monotonicamente.

Passaggi successivi