Questo documento descrive i metodi per gli amministratori di database e gli sviluppatori di applicazioni per generare sequenze numeriche uniche nelle applicazioni che utilizzano Spanner.
Introduzione
Spesso si verificano situazioni in cui un'attività richiede un ID numerico semplice e univoco, ad esempio un numero di dipendente o un numero di fattura. I database relazionali convenzionali spesso includono una funzionalità per generare sequenze univoche di numeri che aumentano monotonicamente. Queste sequenze vengono utilizzate per generare identificatori univoci (chiavi di riga) per gli oggetti memorizzati nel database.
Tuttavia, l'utilizzo di valori che aumentano (o diminuiscono) in modo monotono come chiavi di riga potrebbe non seguire le best practice in Spanner perché crea hotspot nel database, con conseguente riduzione delle prestazioni. Questo documento propone meccanismi per implementare un generatore di sequenze utilizzando una tabella del database Spanner e la logica del livello applicazione.
In alternativa, Spanner supporta un generatore di sequenze con inversione di bit integrato. Per ulteriori informazioni sul generatore di sequenze Spanner, vedi 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 della sequenza non devono essere emessi dopo i valori più alti.
- Senza interruzioni: non devono esserci interruzioni nella sequenza.
Il generatore di sequenze deve anche generare valori alla frequenza richiesta dall'applicazione.
Soddisfare tutti questi requisiti può essere difficile, soprattutto in un sistema distribuito. Se necessario per raggiungere i tuoi obiettivi di rendimento, puoi scendere a compromessi sui requisiti di ordinamento e continuità della sequenza.
Altri motori di database hanno modi per gestire questi requisiti. Ad esempio, le sequenze in PostgreSQL e le colonne AUTO_INCREMENT in MySQL possono generare valori univoci per transazioni separate, ma non possono produrre valori senza spazi se le transazioni vengono sottoposte a rollback. Per saperne di più, consulta Note nella documentazione di PostgreSQL) e Implicazioni di AUTO_INCREMENT in MySQL).
Generatori di sequenze che utilizzano le righe delle tabelle del database
La tua applicazione può implementare un generatore di sequenze utilizzando una tabella di database per memorizzare 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 generano valori unici, senza richiedere un'ulteriore 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 una riga nella tabella 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, le
prestazioni sono limitate dalla frequenza con cui la riga può essere aggiornata.
Le librerie client di Spanner utilizzano transazioni ripetibili per risolvere i conflitti. Se le celle (valori delle colonne) lette durante una transazione di lettura/scrittura vengono modificate altrove, la transazione verrà bloccata fino al completamento dell'altra transazione, quindi verrà interrotta e ritentata in modo da leggere i valori aggiornati. In questo modo si riduce al minimo la durata dei blocchi di scrittura, ma significa anche che un tentativo di transazione potrebbe essere eseguito più volte prima di essere completato correttamente.
Poiché in una riga può verificarsi una sola transazione alla volta, la frequenza massima di emissione dei valori di sequenza è inversamente proporzionale alla latenza totale della transazione.
La latenza totale della transazione dipende da diversi fattori, ad esempio 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é deve attendere un quorum di conferme di scrittura dai nodi in regioni diverse per essere completata.
Ad esempio, se una transazione di lettura-aggiornamento 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 è di 100 al secondo. Questo 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 i modi per ovviare a questa limitazione.
Implementazione lato applicazione
Il codice dell'applicazione deve leggere e aggiornare la cella next_value
nel
database. Esistono diversi modi per farlo, ognuno dei quali presenta caratteristiche di prestazioni e svantaggi diversi.
Generatore di sequenze semplice all'interno della transazione
Il modo più semplice per gestire la generazione di sequenze è 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 esegue 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 di cui l'applicazione ha bisogno.
- Completa il resto della transazione dell'applicazione.
Questo processo genera una sequenza ordinata e senza spazi vuoti. Se nulla
aggiorna la cella next_value
nel 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 complessiva. Una transazione complessa avrà una latenza maggiore e, pertanto, una frequenza massima possibile inferiore.
In un sistema distribuito, è possibile tentare molte transazioni contemporaneamente,
il che comporta un'elevata contesa sul valore della sequenza. Poiché la cella next_value
viene aggiornata all'interno della transazione dell'applicazione, qualsiasi altra transazione
che tenti di incrementare la cella next_value
contemporaneamente verrà bloccata
dalla prima transazione e
verrà ritentata.
Ciò comporta un aumento significativo del tempo necessario all'applicazione per completare correttamente la transazione, il che può causare problemi di prestazioni.
Il seguente codice fornisce un esempio di un semplice generatore di sequenze in-transaction che restituisce un solo valore di sequenza per transazione. Questa limitazione esiste perché le scritture all'interno di una transazione che utilizza l'API Mutation non sono visibili fino al commit della transazione, nemmeno per le letture nella stessa transazione. Pertanto, chiamare questa funzione più volte nella stessa transazione restituirà sempre lo stesso valore di sequenza.
Il seguente codice di esempio mostra come implementare una funzione getNext()
sincrona:
Il seguente codice di esempio mostra come viene utilizzata la funzione sincrona getNext()
in una transazione:
Generatore di sequenze sincrone all'interno della transazione migliorato
Puoi modificare l'astrazione precedente per produrre più valori all'interno di una singola transazione monitorando i valori di sequenza emessi all'interno di una transazione.
In una singola transazione, l'applicazione esegue le seguenti operazioni:
- Legge la cella
next_value
per il nome della sequenza da utilizzare nell'applicazione. - Memorizza questo valore internamente come variabile.
- Ogni volta che viene richiesto un nuovo valore di sequenza, incrementa la variabile
next_value
memorizzata e memorizza temporaneamente una scrittura che imposta il valore della cella aggiornato 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 internamente della cella next_value
, in modo che
possa essere generato più di un valore.
Gli stessi avvisi relativi a latenza e contesa che si applicavano 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 sincrona getNext()
in una richiesta di due valori di sequenza:
Generatore di sequenze fuori transazione (asincrono)
Nelle due implementazioni precedenti, il rendimento del generatore dipende dalla latenza della transazione dell'applicazione. Puoi migliorare la frequenza massima, a scapito della tolleranza di 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 avvii la transazione.
L'applicazione esegue 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 teorica massima di 100 valori al secondo (supponendo una latenza della transazione di 10 ms). Poiché i valori della sequenza vengono recuperati separatamente, la latenza della transazione dell'applicazione stessa non viene modificata e la contesa viene ridotta al minimo.
Tuttavia, se viene richiesto un valore di sequenza e non viene utilizzato, nella sequenza rimane un intervallo perché non è possibile eseguire il rollback dei valori di sequenza richiesti. Ciò può verificarsi se l'applicazione viene interrotta o non riesce durante la transazione dopo aver richiesto un valore di sequenza.
Il seguente codice di esempio 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 asincrona getNext()
:
Il seguente codice di esempio mostra come utilizzare la funzione asincrona getNext()
in una richiesta di due valori di sequenza:
Nell'esempio di codice precedente, puoi notare che i valori della sequenza vengono 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 (note 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 delle prestazioni se elimini anche il requisito che i valori della sequenza devono essere in ordine. In questo modo l'applicazione può riservare un batch di valori di sequenza ed emetterli internamente. Le singole istanze dell'applicazione hanno il proprio batch separato di valori, 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 viene chiusa) lasceranno spazi vuoti di valori inutilizzati nella sequenza.
L'applicazione eseguirà le seguenti operazioni:
- Mantiene uno stato interno per ogni sequenza che contiene il valore iniziale e la dimensione del batch, nonché il valore successivo disponibile.
- Richiedi un valore di sequenza dal batch.
- Se non sono rimasti valori nel batch, procedi nel seguente modo:
- Crea una transazione per leggere e aggiornare il valore della sequenza.
- Leggi la cella
next_value
per la sequenza. - Memorizza questo valore internamente come valore iniziale del nuovo batch.
- Incrementa la cella
next_value
nel database di un importo pari alla dimensione del batch. - Completa la transazione.
- Restituisce il valore successivo 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 riservare un nuovo batch di valori di sequenza.
Il vantaggio è che aumentando le dimensioni del batch, il rendimento può essere aumentato a qualsiasi livello, perché il fattore limitante diventa il numero di batch emessi al secondo.
Ad esempio, con una dimensione batch di 100, supponendo una latenza di 10 ms per ottenere un nuovo batch e quindi un massimo di 100 batch al secondo,è possibile emettere 10.000 valori di sequenza al secondo.
Il seguente codice di esempio mostra come implementare una funzione getNext()
utilizzando i batch. Tieni presente 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 asincrona getNext()
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) perché Spanner non supporta le transazioni nidificate.
Generatore di sequenze di batch asincroni
Per le applicazioni ad alte prestazioni in cui qualsiasi aumento della latenza non è accettabile, puoi migliorare le prestazioni del generatore di batch precedente preparando un nuovo batch di valori per quando il batch di valori corrente è esaurito.
Puoi farlo 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 di valori inutilizzati se le transazioni non vanno a buon fine o se le istanze dell'applicazione vengono chiuse.
L'applicazione eseguirà le seguenti operazioni:
- Mantiene uno stato interno per ogni sequenza, contenente il valore iniziale del batch e il valore successivo disponibile.
- Richiedi un valore di sequenza dal batch.
- Se i valori rimanenti nel batch sono inferiori alla soglia, esegui le seguenti operazioni 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. - Memorizza questo valore internamente come valore iniziale del prossimo batch.
- Incrementa la cella
next_value
nel database di un importo pari alla dimensione del batch - Completa la transazione.
- Se non sono rimasti valori nel batch, recupera il valore iniziale del batch successivo dal thread in background (attendendo il completamento, se necessario) e crea un nuovo batch utilizzando il valore iniziale recuperato come valore successivo.
- Restituisce il valore successivo e incrementa lo stato interno.
- Utilizza il valore restituito nella transazione.
Per un rendimento ottimale, il thread in background deve essere avviato e completato prima che i valori di sequenza nel batch corrente si esauriscano. In caso contrario, l'applicazione dovrà attendere il batch successivo e la latenza aumenterà. Pertanto, dovrai modificare le dimensioni del batch e la soglia bassa, a seconda della frequenza di emissione dei valori di sequenza.
Ad esempio, supponiamo un tempo di transazione di 20 ms per recuperare un nuovo batch di valori, un batch size di 1000 e una frequenza massima di emissione della sequenza di 500 valori al secondo (un valore ogni 2 ms). Durante i 20 ms in cui viene emesso un nuovo batch di valori, verranno emessi 10 valori di sequenza. Pertanto, 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 seguente codice di esempio mostra come implementare una funzione getNext()
utilizzando i batch. Tieni presente che il codice utilizza la funzione getAndIncrementNextValueInDB()
definita in precedenza per recuperare un batch di valori di sequenza utilizzando un
thread in background.
Il seguente codice di esempio mostra come viene utilizzata la funzione batch asincrona getNext()
in una richiesta di due valori da utilizzare nella transazione:
Tieni presente 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 tabella che segue mette a confronto le caratteristiche dei quattro tipi di generatori di sequenze:
Sincrona | Asincrona | Batch | Batch asincrono | |
---|---|---|---|---|
Valori univoci | Sì | Sì | Sì | Sì |
Valori ordinati a livello globale | Sì | Sì | No Ma con un carico sufficientemente elevato e una dimensione del batch sufficientemente piccola, i valori saranno vicini tra loro |
No Ma con un carico sufficientemente elevato e una dimensione del batch sufficientemente piccola, i valori saranno vicini tra loro |
Gapless | Sì | No | No | No |
Prestazioni | 1/latenza 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 in caso di contesa elevata (quando una transazione richiede molto tempo) |
10 ms per ogni transazione Molto più elevato in caso di contesa elevata |
10 ms, ma solo quando viene recuperato un nuovo batch di valori | Zero, se la dimensione del batch e la soglia bassa sono impostate su valori appropriati |
La tabella precedente illustra anche il fatto che potresti dover scendere a compromessi sui requisiti per i valori ordinati a livello globale e le serie di valori senza spazi vuoti per generare valori univoci, soddisfacendo al contempo i requisiti di rendimento complessivi.
Test delle prestazioni
Puoi utilizzare uno strumento di test/analisi delle prestazioni, che si trova nello stesso repository GitHub delle classi del generatore di sequenze precedenti, per testare ciascuno di questi generatori di sequenze e dimostrare le caratteristiche di prestazioni e latenza. Lo strumento simula una latenza della transazione dell'applicazione di 10 ms ed esegue contemporaneamente diversi thread che richiedono valori di sequenza.
I test delle prestazioni richiedono solo un'istanza Spanner a un solo nodo per il test, perché viene modificata una sola riga.
Ad esempio, l'output seguente mostra un confronto tra rendimento 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 tabella seguente 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 al 90° percentile (ms) | Latenza (ms) - 99° percentile |
---|---|---|---|---|---|
SINCRONIZZA | 10 | 34 | 27 | 1189 | 2703 |
SINCRONIZZA | 50 | 30,6 | 1191 | 3513 | 5982 |
ASYNC | 10 | 66,5 | 28 | 611 | 1460 |
ASYNC | 50 | 78,1 | 29 | 1695 | 3442 |
BATCH (dimensione 200) |
10 | 494 | 18 | 20 | 38 |
BATCH (dimensione batch 200) | 50 | 1195 | 27 | 55 | 168 |
BATCH ASINCRONO (dimensione batch 200, LT 50) |
10 | 512 | 18 | 20 | 30 |
BATCH ASINCRONO (dimensione batch 200, LT 50) |
50 | 1622 | 24 | 28 | 30 |
Puoi notare che in modalità sincrona (SYNC), con l'aumento del numero di thread, la contesa aumenta. Ciò comporta latenze delle transazioni significativamente più elevate.
In modalità asincrona (ASYNC), poiché la transazione per ottenere la sequenza è più piccola e separata dalla transazione dell'applicazione, c'è meno contesa e la frequenza è maggiore. Tuttavia, possono ancora verificarsi contese, con conseguente aumento delle latenze del 90° percentile.
Nella modalità batch (BATCH), la latenza è notevolmente ridotta, ad eccezione del 99° percentile, che corrisponde al momento in cui il generatore deve richiedere in modo sincrono un altro batch di valori di sequenza dal database. Le prestazioni sono molte volte superiori 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 di macchina virtuale (VM) (in questo caso, una macchina a 4 vCPU funzionava al 350% della CPU durante il test). L'utilizzo di più macchine e più processi mostrerà risultati complessivi simili a quelli della modalità batch a 10 thread.
In modalità ASYNC BATCH 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 dal database è completamente indipendente dalla transazione dell'applicazione.
Passaggi successivi
- Scopri di più sulle best practice per la progettazione dello schema in Spanner.
- Leggi informazioni su come scegliere chiavi e indici per le tabelle Spanner.
- Esplora architetture, diagrammi e best practice di riferimento su Google Cloud. Consulta il nostro Cloud Architecture Center.