Questo documento descrive i metodi per amministratori di database e sviluppatori di applicazioni per generare sequenze numeriche univoche nelle applicazioni che utilizzano Spanner.
Introduzione
Spesso si verificano situazioni in cui un'attività richiede un ID numerico semplice e unico, ad esempio un numero dipendente o un numero di fattura. I database relazionali convenzionali spesso includono una funzionalità per generare sequenze di numeri uniche e monotonicamente crescenti. Queste sequenze vengono utilizzate per generare identificatori univoci (chiavi di riga) per gli oggetti archiviati nel database.
Tuttavia, l'utilizzo di valori monotonici che aumentano (o diminuiscono) come chiavi di riga potrebbe non seguire le best practice in Spanner perché crea hotspot nel database, portando a una riduzione delle prestazioni. Questo documento propone meccanismi per implementare un generatore di sequenze utilizzando una tabella di database Spanner e la logica a livello di applicazione.
In alternativa, Spanner supporta un generatore di sequenza integrato con invertire i bit. Per saperne di più sul generatore di sequenze Spanner, consulta Creare e gestire sequenze.
Requisiti per un generatore di sequenze
Ogni generatore di sequenze deve generare un valore univoco per ogni transazione.
A seconda del caso d'uso, un generatore di sequenze potrebbe anche dover creare sequenze con le seguenti caratteristiche:
- Ordinato: i valori più bassi nella sequenza non devono essere emessi dopo valori più elevati.
- Senza intervalli: la sequenza non deve contenere interruzioni.
Il generatore di sequenze deve inoltre generare valori alla frequenza richiesta dall'applicazione.
Può essere difficile soddisfare tutti questi requisiti, soprattutto in un sistema distribuito. Se necessario per raggiungere i tuoi obiettivi di prestazioni, puoi compromettere i requisiti per ordinare la sequenza senza interruzioni.
Altri motori di database dispongono di modi per gestire questi requisiti. Ad esempio, le sequenze nelle colonne PostgreSQL e AUTO_INCREMENT in MySQL possono generare valori univoci per transazioni separate, ma non possono produrre valori gapless se viene eseguito il rollback delle transazioni. Per maggiori informazioni, consulta la documentazione relativa alle note in PostgreSQL e le implicazioni AUTO_INCREMENT in MySQL.
Generatori di sequenze che utilizzano le righe delle tabelle di database
L'applicazione può implementare un generatore di sequenze utilizzando una tabella di database per archiviare i nomi delle sequenze e il valore successivo nella sequenza.
La lettura e l'incremento della cella next_value
della sequenza all'interno di una transazione di database genera valori univoci, senza richiedere ulteriori sincronizzazione tra i processi dell'applicazione.
Innanzitutto, definisci la tabella nel seguente modo:
CREATE TABLE sequences (
name STRING(64) NOT NULL,
next_value INT64 NOT NULL,
) PRIMARY KEY (name)
Puoi creare sequenze inserendo nella tabella una riga con il nuovo nome
della sequenza e il valore iniziale, ad esempio ("invoice_id", 1)
. Tuttavia, poiché la cella next_value
viene incrementata per ogni valore di sequenza generato, il rendimento è limitato dalla frequenza di aggiornamento della riga.
Le librerie client di Spanner utilizzano le transazioni ripetibili per risolvere i conflitti. Se le celle (valori di colonna) lette durante una transazione di lettura-scrittura vengono modificate altrove, la transazione verrà bloccata fino al completamento dell'altra transazione. In seguito, verrà interrotta e ritentata in modo da leggere i valori aggiornati. Questo riduce al minimo la durata dei blocchi in scrittura, ma significa anche che potrebbe essere possibile tentare una transazione più volte prima di eseguire correttamente il commit.
Poiché su una riga può verificarsi una sola transazione alla volta, la frequenza massima di emissione di valori di sequenza è inversamente proporzionale alla latenza totale della transazione.
La latenza totale delle transazioni dipende da diversi fattori, come la latenza tra l'applicazione client e i nodi Spanner, la latenza tra i nodi Spanner e l'incertezza TrueTime. Ad esempio, la configurazione multiregionale ha una latenza delle transazioni più elevata, perché per essere completata deve attendere il quorum di conferme di scrittura dai nodi in regioni diverse.
Ad esempio, se una transazione di aggiornamento di lettura su una singola cella (una colonna in una singola riga) ha una latenza di 10 millisecondi (ms), la frequenza teorica massima di emissione dei valori di sequenza è 100 al secondo. Il valore massimo si applica all'intero database, indipendentemente dal numero di istanze dell'applicazione client o dal numero di nodi nel database. Questo perché una singola riga è sempre gestita da un singolo nodo.
La sezione seguente descrive come aggirare questa limitazione.
Implementazione lato applicazione
Il codice dell'applicazione deve leggere e aggiornare la cella next_value
nel
database. È possibile farlo in vari modi, ognuno dei quali presenta
caratteristiche di rendimento e svantaggi diversi.
Semplice generatore di sequenze all'interno delle transazioni
Il modo più semplice per gestire la generazione della sequenza consiste nell'incrementare il valore della colonna all'interno della transazione ogni volta che l'applicazione ha bisogno di un nuovo valore sequenziale.
In una singola transazione, l'applicazione svolge le seguenti operazioni:
- Legge la cella
next_value
per il nome della sequenza da utilizzare nell'applicazione. - Incrementa e aggiorna la cella
next_value
per il nome della sequenza. - Utilizza il valore recuperato per qualsiasi valore di colonna necessario all'applicazione.
- Completa il resto della transazione dell'applicazione.
Questo processo genera una sequenza in ordine e senza interruzioni. Se nulla aggiorna la cella next_value
del database a un valore inferiore, anche la sequenza sarà univoca.
Poiché il valore della sequenza viene recuperato nell'ambito della transazione dell'applicazione più ampia, la frequenza massima di generazione della sequenza dipende dalla complessità della transazione dell'applicazione nel suo complesso. Una transazione complessa avrà una latenza maggiore e, di conseguenza, una frequenza massima possibile inferiore.
In un sistema distribuito, è possibile tentare molte transazioni contemporaneamente, generando un'elevata contesa sul valore della sequenza. Poiché la cella next_value
viene aggiornata nell'ambito della transazione dell'applicazione, qualsiasi altra transazione che tenti di incrementare contemporaneamente la cella next_value
verrà bloccata dalla prima transazione e verrà tentata di nuovo.
Ciò porta a significativi incrementi del tempo necessario per il completamento della transazione da parte dell'applicazione, il che può causare problemi di prestazioni.
Il seguente codice fornisce un esempio di semplice generatore di sequenze in-transazione che restituisce un solo valore di sequenza per transazione. Questa limitazione esiste perché le scritture in una transazione utilizzando l'API Mutation sono visibili solo dopo il commit della transazione, anche nelle letture nella stessa transazione. Di conseguenza, se chiami questa funzione più volte nella stessa transazione, verrà restituito sempre lo stesso valore di sequenza.
Il seguente codice di esempio mostra come implementare una funzione getNext()
sincrona:
Il codice di esempio seguente mostra come viene utilizzata la funzione sincrona getNext()
in una transazione:
Miglioramento del generatore di sequenze sincrono all'interno delle transazioni
Puoi modificare l'astrazione precedente per produrre più valori in una singola transazione tenendo traccia dei valori di sequenza emessi all'interno di una transazione.
In una singola transazione, l'applicazione svolge le seguenti operazioni:
- Legge la cella
next_value
per il nome della sequenza da utilizzare nell'applicazione. - Archivia questo valore come variabile internamente.
- Ogni volta che viene richiesto un nuovo valore di sequenza, incrementa la variabile
next_value
archiviata e memorizza nel buffer una scrittura che imposta il valore aggiornato della cella nel database. - Completa il resto della transazione dell'applicazione.
Se utilizzi un'astrazione, l'oggetto per questa astrazione deve essere creato
all'interno della transazione. L'oggetto esegue una singola lettura quando viene richiesto il primo valore. L'oggetto tiene traccia all'interno della cella next_value
, per consentire
di generare più valori.
Gli stessi avvisi riguardanti latenza e contese applicati alla versione precedente si applicano anche a questa versione.
Il seguente codice di esempio mostra come implementare una funzione getNext()
sincrona:
Il seguente codice di esempio mostra come utilizzare la funzione getNext()
sincrona in una richiesta di due valori di sequenza:
Generatore di sequenze fuori transazione (asincrona)
Nelle due implementazioni precedenti, le prestazioni del generatore dipendono dalla latenza della transazione dell'applicazione. Puoi migliorare la frequenza massima, a scapito di tollerare lacune nella sequenza, incrementando la sequenza in una transazione separata. (Questo è l'approccio utilizzato da PostgreSQL). Devi recuperare i valori della sequenza da utilizzare prima che l'applicazione inizi la transazione.
L'applicazione svolge le seguenti operazioni:
- Crea una prima transazione per ottenere e aggiornare il valore della sequenza:
- Legge la cella
next_value
per il nome della sequenza da utilizzare nell'applicazione. - Memorizza questo valore come variabile.
- Incrementa e aggiorna la cella
next_value
nel database per il nome della sequenza. - Completa la transazione.
- Legge la cella
- Utilizza il valore restituito in una transazione separata.
La latenza di questa transazione separata sarà vicina alla latenza minima, con prestazioni che si avvicinano alla frequenza massima teorica di 100 valori al secondo (supponendo una latenza delle transazioni di 10 ms). Poiché i valori di sequenza vengono recuperati separatamente, la latenza della transazione dell'applicazione non cambia e la contesa è ridotta al minimo.
Tuttavia, se un valore della sequenza viene richiesto e non utilizzato, rimane un divario nella sequenza perché non è possibile eseguire il rollback dei valori della sequenza richiesti. Questo può verificarsi se l'applicazione viene interrotta o ha esito negativo durante la transazione dopo aver richiesto un valore di sequenza.
Il codice di esempio seguente mostra come implementare una funzione che recupera e incrementa la cella next_value
nel database:
Puoi utilizzare facilmente questa funzione per recuperare un singolo nuovo valore di sequenza, come mostrato nella seguente implementazione di una funzione getNext()
asincrona:
Il seguente codice di esempio mostra come utilizzare la funzione getNext()
asincrona in una richiesta di due valori di sequenza:
Nell'esempio di codice precedente, puoi vedere che i valori della sequenza sono richiesti al di fuori della transazione dell'applicazione. Questo perché Cloud Spanner non supporta l'esecuzione di una transazione all'interno di un'altra transazione nello stesso thread (nota anche come transazioni nidificate).
Puoi aggirare questa limitazione richiedendo il valore della sequenza utilizzando un thread in background e attendendo il risultato:
Generatore di sequenze batch
Puoi ottenere un miglioramento significativo del rendimento se elimini anche il requisito che i valori della sequenza devono essere in ordine. Ciò consente all'applicazione di prenotare un batch di valori di sequenza e di emetterli internamente. Le singole istanze di applicazione hanno un proprio batch di valori separato, quindi i valori emessi non sono in ordine. Inoltre, le istanze dell'applicazione che non utilizzano l'intero batch di valori (ad esempio se l'istanza dell'applicazione è arrestata) lasceranno vuoti i valori inutilizzati nella sequenza.
L'applicazione svolgerà le seguenti operazioni:
- Mantenere uno stato interno per ogni sequenza che contiene il valore iniziale e la dimensione del batch e il valore successivo disponibile.
- Richiedere un valore sequenza dal batch.
- Se non ci sono valori rimanenti nel batch:
- Crea una transazione per leggere e aggiornare il valore della sequenza.
- Leggi la cella
next_value
per la sequenza. - Archivia questo valore internamente come valore iniziale del nuovo batch.
- Aumenta la cella
next_value
nel database di una quantità uguale alla dimensione del batch. - Completa la transazione.
- Restituisci il successivo valore disponibile e incrementa lo stato interno.
- Utilizza il valore restituito nella transazione.
Con questo metodo, le transazioni che utilizzano un valore di sequenza subiranno un aumento della latenza solo quando è necessario prenotare un nuovo batch di valori di sequenza.
Il vantaggio è che aumentando la dimensione dei batch, le prestazioni possono essere aumentate a qualsiasi livello, perché il fattore limitante diventa il numero di batch emessi al secondo.
Ad esempio, con una dimensione batch pari a 100 (supponendo che la latenza sia di 10 ms per ottenere un nuovo batch, e quindi un massimo di 100 batch al secondo), possono essere generati massimo 10.000 valori sequenza al secondo.
Il codice di esempio riportato di seguito mostra come implementare una funzione getNext()
utilizzando i batch. Nota che il codice riutilizza la funzione getAndIncrementNextValueInDB()
definita in precedenza per recuperare nuovi batch di valori di sequenza dal database.
Il seguente codice di esempio mostra come utilizzare la funzione getNext()
asincrona in una richiesta di due valori di sequenza:
Anche in questo caso, i valori devono essere richiesti al di fuori della transazione (o utilizzando un thread in background) poiché Spanner non supporta le transazioni nidificate.
Generatore di sequenze batch asincrono
Per le applicazioni ad alte prestazioni in cui qualsiasi aumento della latenza non è accettabile, puoi migliorare le prestazioni del generatore batch precedente avendo un nuovo batch di valori pronto per l'esaurimento del batch di valori attuale.
Puoi raggiungere questo obiettivo impostando una soglia che indica quando il numero di valori di sequenza rimanenti in un batch è troppo basso. Quando viene raggiunta la soglia, il generatore di sequenze inizia a richiedere un nuovo batch di valori in un thread in background.
Come nella versione precedente, i valori non vengono emessi in ordine e nella sequenza saranno presenti intervalli vuoti di valori inutilizzati se le transazioni non vanno a buon fine o se le istanze dell'applicazione vengono arrestate.
L'applicazione svolgerà le seguenti operazioni:
- Mantenere uno stato interno per ogni sequenza, contenente il valore iniziale del batch e il valore successivo disponibile.
- Richiedere un valore sequenza dal batch.
- Se i valori rimanenti nel batch sono inferiori alla soglia, procedi nel seguente modo in un thread in background:
- Crea una transazione per leggere e aggiornare il valore della sequenza.
- Leggi la cella
next_value
per il nome della sequenza da utilizzare nell'applicazione. - Archivia questo valore internamente come valore iniziale del batch next.
- Aumenta la cella
next_value
nel database di una quantità uguale alla dimensione del batch - Completa la transazione.
- Se nel batch non sono presenti valori rimanenti, recupera il valore iniziale del batch successivo dal thread in background (attendi che venga completato, se necessario) e crea un nuovo batch utilizzando il valore iniziale recuperato come valore successivo.
- Restituisci il valore successivo e incrementa lo stato interno.
- Utilizza il valore restituito nella transazione.
Per prestazioni ottimali, il thread in background deve essere avviato e completato prima di esaurire i valori di sequenza nel batch attuale. In caso contrario, l'applicazione dovrà attendere il batch successivo e la latenza aumenterà. Di conseguenza, dovrai regolare la dimensione del batch e la soglia bassa, a seconda della frequenza dei valori di sequenza inviati.
Ad esempio, supponiamo un tempo di transazione di 20 ms per recuperare un nuovo batch di valori, una dimensione del batch di 1000 e una frequenza massima di emissione della sequenza di 500 valori al secondo (un valore ogni 2 ms). Durante i 20 ms quando viene emesso un nuovo batch di valori, verranno emessi 10 valori di sequenza. Di conseguenza, la soglia per il numero di valori di sequenza rimanenti deve essere maggiore di 10, in modo che il batch successivo sia disponibile quando necessario.
Il codice di esempio riportato di seguito mostra come implementare una funzione getNext()
utilizzando i batch. Nota che il codice utilizza la funzione getAndIncrementNextValueInDB()
definita in precedenza per recuperare un batch di valori di sequenza utilizzando un
thread di sfondo.
Il codice di esempio seguente mostra come viene utilizzata la funzione getNext()
batch asincrona in una richiesta di due valori da usare nella transazione:
Nota che in questo caso i valori possono essere richiesti all'interno della transazione, perché il recupero di un nuovo batch di valori avviene in un thread in background.
Riepilogo
La seguente tabella mette a confronto le caratteristiche dei quattro tipi di generatori di sequenza:
Sincrona | Asincrona | Batch | Batch asincrono | |
---|---|---|---|---|
Valori univoci | Sì | Yes | Yes | Sì |
Valori ordinati a livello globale | Sì | Sì | No Tuttavia, con un carico sufficientemente elevato e una dimensione del batch sufficientemente ridotta, i valori saranno vicini |
No Tuttavia, con un carico sufficientemente elevato e una dimensione del batch sufficientemente ridotta, i valori saranno vicini |
Senza intervalli | Sì | No | No | No |
Prestazioni | Latenza 1/transazione, (circa 25 valori al secondo) |
50-100 valori al secondo | 50-100 batch di valori al secondo | 50-100 batch di valori al secondo |
Aumento della latenza | > 10 ms Molto più alto con contesa elevata (quando una transazione richiede molto tempo) |
10 ms per ogni transazione Molto più alta con molte contese |
10 ms, ma solo quando viene recuperato un nuovo batch di valori | Zero, se la dimensione del batch e la soglia bassa sono impostate sui valori appropriati |
La tabella precedente illustra anche il fatto che potrebbe essere necessario compromettere i requisiti per i valori ordinati a livello globale e le serie di valori senza intervalli per generare valori univoci, rispettando al contempo i requisiti di prestazioni generali.
Test delle prestazioni
Puoi utilizzare uno strumento di test/analisi delle prestazioni, che si trova nello stesso repository GitHub delle precedenti classi del generatore di sequenze, per testare ciascuno di questi generatori di sequenze e dimostrare le caratteristiche di prestazioni e latenza. Lo strumento simula una latenza delle transazioni delle applicazioni di 10 ms ed esegue contemporaneamente più thread che richiedono valori di sequenza.
I test delle prestazioni richiedono solo un'istanza Spanner a nodo singolo per eseguire il test, perché viene modificata una sola riga.
Ad esempio, l'output seguente mostra un confronto tra prestazioni e latenza in modalità sincrona con 10 thread:
$ ITERATIONS=2000
$ MODE=SYNC
$ NUMTHREADS=10
$ java -jar sequence-generator.jar \
$INSTANCE_ID $DATABASE_ID $MODE $ITERATIONS $NUMTHREADS
2000 iterations (10 parallel threads) in 58739 milliseconds: 34.048928 values/s
Latency: 50%ile 27 ms
Latency: 75%ile 31 ms
Latency: 90%ile 1189 ms
Latency: 99%ile 2703 ms
La seguente tabella confronta i risultati per varie modalità e numeri di thread paralleli, incluso il numero di valori che possono essere emessi al secondo e la latenza al 50°, 90° e 99° percentile:
Modalità e parametri | Num thread | Valori/sec | Latenza 50° percentile (ms) | Latenza 90° percentile (ms) | Latenza del 99° percentile (ms) |
---|---|---|---|---|---|
SINCRONIZZA | 10 | 34 | 27 | 1189 | 2703 |
SINCRONIZZA | 50 | 30,6 | 1191 | 3513 | 5982 |
ASIN | 10 | 66,5 | 28 | 611 | 1460 |
ASIN | 50 | 78,1 | 29 | 1695 | 3442 |
BATCH (dimensione 200) |
10 | 494 | 18 | 20 | 38 |
BATCH (dimensione batch 200) | 50 | 1195 | 27 | 55 | 168 |
ASYNC BATCH (dimensione lotto 200, LT 50) |
10 | 512 | 18 | 20 | 30 |
ASYNC BATCH (dimensione lotto 200, LT 50) |
50 | 1622 | 24 | 28 | 30 |
Come puoi notare, nella modalità sincrona (SYNC), con l'aumento del numero di thread, si crea una maggiore contesa. Ciò porta a un aumento significativo delle latenze di transazione.
Nella modalità asincrona (ASYNC), poiché la transazione per ottenere la sequenza è più piccola e separata dalla transazione dell'applicazione, esistono meno conflitti e la frequenza è più alta. Tuttavia, può ancora verificarsi contesa, con un conseguente aumento della latenza del 90° percentile.
In modalità batch (BATCH), la latenza si riduce notevolmente, ad eccezione del 99° percentile, che corrisponde a quando il generatore deve richiedere in modo sincrono un altro batch di valori di sequenza dal database. Le prestazioni sono molto più elevate in modalità BATCH rispetto alla modalità ASYNC.
La modalità batch a 50 thread ha una latenza maggiore perché le sequenze vengono emesse così velocemente che il fattore limitante è la potenza dell'istanza della macchina virtuale (VM) (in questo caso, durante il test una macchina con 4 vCPU era in esecuzione al 350% di CPU). L'utilizzo di più macchine e processi multipli mostrerà risultati complessivi simili alla modalità batch a 10 thread.
In modalità BATCH ASYNC, la variazione della latenza è minima e le prestazioni sono più elevate, anche con un numero elevato di thread, perché la latenza della richiesta di un nuovo batch al database è completamente indipendente dalla transazione dell'applicazione.
Passaggi successivi
- Scopri le best practice per la progettazione dello schema in Spanner.
- Scopri come scegliere chiavi e indici per le tabelle Spanner.
- Esplora le architetture di riferimento, i diagrammi e le best practice su Google Cloud. Visita il nostro Cloud Architecture Center.