Generazione di sequenze in Spanner

Questo documento descrive i metodi a disposizione degli amministratori di database e degli sviluppatori di applicazioni per generare sequenze numeriche univoche nelle applicazioni che utilizzano Spanner.

Introduzione

Spesso capita che un'attività richieda un ID numerico semplice e univoco, ad esempio un numero di dipendente o di fattura. I database relazionali convenzionali includono spesso una funzionalità per generare sequenze di numeri univoci e in aumento monotonico. Queste sequenze vengono utilizzate per generare identificatori univoci (chiavi di riga) per gli oggetti memorizzati nel database.

Tuttavia, l'utilizzo di valori in aumento (o in diminuzione) monotonici come chiavi di riga potrebbe non rispettare 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 di applicazione.

In alternativa, Spanner supporta un generatore di sequenze con inversione dei bit integrato. Per ulteriori informazioni sul generatore di sequenze Spanner, consulta Creare e gestire le 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:

  • Ordinati: i valori inferiori nella sequenza non devono essere emessi dopo i valori superiori.
  • Senza interruzioni: non devono esserci lacune nella sequenza.

Il generatore di sequenze deve anche generare valori con la frequenza richiesta dall'applicazione.

Può essere difficile soddisfare tutti questi requisiti, soprattutto in un sistema distribuito. Se necessario per raggiungere i tuoi obiettivi di rendimento, puoi fare compromessi sui requisiti relativi all'ordinamento e all'assenza di spazi nella sequenza.

Altri motori del 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 annullate. Per ulteriori informazioni, consulta le note nella documentazione di PostgreSQL e le implicazioni di 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 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 del database genera valori univoci, senza richiedere ulteriore sincronizzazione tra i processi dell'applicazione.

Innanzitutto, definisci la tabella come segue:

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 nome della nuova 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 di colonna) lette durante una transazione di lettura/scrittura vengono modificate altrove, la transazione verrà bloccata fino al completamento dell'altra transazione, poi verrà interrotta e verrà eseguito un nuovo tentativo in modo da leggere i valori aggiornati. In questo modo, la durata dei blocchi in scrittura viene ridotta al minimo, ma significa anche che un'operazione potrebbe essere tentata più volte prima di essere eseguita correttamente.

Poiché in una riga può verificarsi una sola transazione alla volta, la frequenza massima degli valori della sequenza di emissione è inversamente proporzionale alla latenza totale della transazione.

Questa latenza totale della transazione dipende da diversi fattori, come la latenza tra l'applicazione client e i nodi Spanner, la latenza tra i nodi Spanner e l'incertezza di 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 aggiornamento della lettura in una singola cella (una colonna in una singola riga) ha una latenza di 10 millisecondi (ms), la frequenza massima teorica di emissione dei valori di sequenza è 100 al secondo. Questo 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 viene sempre gestita da un singolo nodo.

La sezione seguente descrive i modi per aggirare 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 con caratteristiche e svantaggi di prestazioni diversi.

Generatore di sequenze all'interno di transazioni semplici

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.
  • Aumenta 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 ordinata e senza spazi. Se non viene aggiornato la cella next_value nel database con 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 più elevata e, di conseguenza, una frequenza massima possibile inferiore.

In un sistema distribuito, potrebbero essere tentate molte transazioni contemporaneamente, inducendo una contesa elevata sul valore della sequenza. Poiché la cella next_value viene aggiornata all'interno della transazione dell'applicazione, qualsiasi altra transazione che tenta di incrementare la cella next_value contemporaneamente verrà bloccata dalla prima transazione e verrà riprovata. Ciò comporta un notevole aumento 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 a dopo l'commit della transazione, anche per le letture nella stessa transazione. Pertanto, chiamare questa funzione più volte nella stessa transazione restituirà sempre lo stesso valore di sequenza.

Il seguente esempio di codice mostra come implementare una funzione getNext() sincrona:

/**
 * Returns the next value from this sequence.
 *
 * <p>Should only be called once per transaction.
 */
long getNext(TransactionContext txn) {
  Struct result =
      txn.readRow(
          SEQUENCES_TABLE, Key.of(sequenceName), Collections.singletonList(NEXT_VALUE_COLUMN));
  if (result == null) {
    throw new NoSuchElementException(
        "Sequence " + sequenceName + " not found in table " + SEQUENCES_TABLE);
  }
  long value = result.getLong(0);
  txn.buffer(
      Mutation.newUpdateBuilder(SEQUENCES_TABLE)
          .set(SEQUENCE_NAME_COLUMN)
          .to(sequenceName)
          .set(NEXT_VALUE_COLUMN)
          .to(value + 1)
          .build());
  return value;
}

Il seguente codice di esempio mostra come viene utilizzata la funzione getNext() sincrona in una transazione:

// Simple Sequence generator created outside transaction, eg as field.
private SimpleSequenceGenerator simpleSequence = new SimpleSequenceGenerator("my Sequence");

public void usingSimpleSequenceGenerator() {
  dbClient
      .readWriteTransaction()
      .run(
          new TransactionCallable<Void>() {
            @Nullable
            @Override
            public Void run(TransactionContext txn) {
              // Get a sequence value
              long nextValue = simpleSequence.getNext(txn);
              // Use nextValue in the transaction
              // ...
              return null;
            }
          });
}

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, viene incrementata la variabile next_value memorizzata e viene memorizzata nella memoria intermedia 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 internamente della cella next_value, in modo da poter generare più di un valore.

A questa versione si applicano gli stessi avvertimenti relativi a latenza e contesa che si applicavano alla versione precedente.

Il seguente esempio di codice mostra come implementare una funzione getNext() sincrona:

private final TransactionContext txn;
@Nullable private Long nextValue;

/** Creates a sequence generator for this transaction. */
public SynchronousSequenceGenerator(String sequenceName, TransactionContext txn) {
  super(sequenceName);
  this.txn = txn;
}

/**
 * Returns the next value from this sequence.
 *
 * <p>Can be called multiple times in a transaction.
 */
public long getNext() {
  if (nextValue == null) {
    // nextValue is unknown - read it.
    Struct result =
        txn.readRow(
            SEQUENCES_TABLE, Key.of(sequenceName), Collections.singletonList(NEXT_VALUE_COLUMN));
    if (result == null) {
      throw new NoSuchElementException(
          "Sequence " + sequenceName + " not found in table " + SEQUENCES_TABLE);
    }
    nextValue = result.getLong(0);
  }
  long value = nextValue;
  // increment and write nextValue to the database.
  nextValue++;
  txn.buffer(
      Mutation.newUpdateBuilder(SEQUENCES_TABLE)
          .set(SEQUENCE_NAME_COLUMN)
          .to(sequenceName)
          .set(NEXT_VALUE_COLUMN)
          .to(nextValue)
          .build());
  return value;
}

Il seguente codice di esempio mostra come utilizzare la funzione getNext() sincrona in una richiesta di due valori di sequenza:

public void usingSynchronousSequenceGenerator() {
  dbClient
      .readWriteTransaction()
      .run(
          new TransactionCallable<Void>() {
            @Nullable
            @Override
            public Void run(TransactionContext txn) {
              // Create the sequence generator object within the transaction
              SynchronousSequenceGenerator syncSequence =
                  new SynchronousSequenceGenerator("my_sequence", txn);
              // Get two sequence values
              long key1 = syncSequence.getNext();
              long key2 = syncSequence.getNext();
              // Use the 2 key values in the transaction
              // ...
              return null;
            }
          });
}

Generatore di sequenze al di fuori della transazione (asincrona)

Nelle due implementazioni precedenti, il rendimento del generatore dipende dalla latenza della transazione dell'applicazione. Puoi migliorare la frequenza massima, a spese della tolleranza di spazi nella sequenza, incrementando la sequenza in una transazione separata. Questo è l'approccio utilizzato da PostgreSQL. Devi recuperare prima i valori di sequenza da utilizzare, prima che l'applicazione inizi 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.
    • Aumenta e aggiorna la cella next_value nel database per il nome della sequenza.
    • Completa la transazione.
  • Utilizza il valore restituito in una transazione separata.

La latenza di questa transazione separata sarà vicina alla latenza minima, con un rendimento che si avvicina alla frequenza teorica massima di 100 valori al secondo (supponendo una latenza di transazione di 10 ms). Poiché i valori di sequenza vengono recuperati separatamente, la latenza della transazione dell'applicazione stessa non viene modificata e le contese vengono ridotte al minimo.

Tuttavia, se un valore di sequenza viene richiesto e non utilizzato, nella sequenza viene lasciato un vuoto perché non è possibile eseguire il rollback dei valori di sequenza richiesti. Ciò può verificarsi se l'applicazione viene interrotta o non va a buon fine 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:

/**
 * Gets the next sequence value from the database, and increments the database value by the amount
 * specified in a single transaction.
 */
protected Long getAndIncrementNextValueInDB(long increment) {
  return dbClient
      .readWriteTransaction()
      .run(
          txn -> {
            Struct result =
                txn.readRow(
                    SEQUENCES_TABLE,
                    Key.of(sequenceName),
                    Collections.singletonList(NEXT_VALUE_COLUMN));
            if (result == null) {
              throw new NoSuchElementException(
                  "Sequence " + sequenceName + " not found in table " + SEQUENCES_TABLE);
            }
            long value = result.getLong(0);
            txn.buffer(
                Mutation.newUpdateBuilder(SEQUENCES_TABLE)
                    .set(SEQUENCE_NAME_COLUMN)
                    .to(sequenceName)
                    .set(NEXT_VALUE_COLUMN)
                    .to(value + increment)
                    .build());
            return value;
          });
}

Puoi utilizzare facilmente questa funzione per recuperare un singolo nuovo valore di sequenza, come mostrato nella seguente implementazione di una funzione getNext() asincrona:

/**
 * Returns the next value from this sequence.
 *
 * Uses a separate transaction so must be used <strong>outside</strong>any other transactions.
 * See {@link #getNextInBackground()} for an alternative version that uses a background thread
 */
public long getNext() throws SpannerException {
  return getAndIncrementNextValueInDB(1);
}

Il seguente codice di esempio mostra come utilizzare la funzione getNext() asincrona in una richiesta di due valori di sequenza:

// Async Sequence generator created outside transaction as a long-lived object.
private AsynchronousSequenceGenerator myAsyncSequence =
    new AsynchronousSequenceGenerator("my Sequence", dbClient);

public void usingAsynchronousSequenceGenerator() {
  // Get two sequence values
  final long key1 = myAsyncSequence.getNext();
  final long key2 = myAsyncSequence.getNext();
  dbClient
      .readWriteTransaction()
      .run(
          new TransactionCallable<Void>() {
            @Nullable
            @Override
            public Void run(TransactionContext txn) {
              // Use the 2 key values in the transaction
              // ...
              return null;
            }
          });
}

Nell'esempio di codice precedente, puoi vedere che i valori di 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 aspettando il risultato:

protected static final ExecutorService executor = Executors.newCachedThreadPool();

/**
 * Gets the next value using a background thread - to be used when inside a transaction to avoid
 * Nested Transaction errors.
 */
public long getNextInBackground() throws Exception {
  return executor.submit(this::getNext).get();
}

Generatore di sequenze batch

Puoi ottenere un miglioramento significativo delle prestazioni se elimini anche il requisito che i valori di sequenza debbano essere in ordine. In questo modo l'applicazione può prenotare un batch di valori di sequenza e emetterli internamente. Le singole istanze dell'applicazione hanno un proprio batch di valori separato, pertanto i valori emessi non sono in ordine. Inoltre, le istanze dell'applicazione che non utilizzano tutto il 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:

  • Mantieni uno stato interno per ogni sequenza che contenga il valore iniziale, le dimensioni del batch e il valore successivo disponibile.
  • Richiedi un valore di sequenza dal batch.
  • Se non sono presenti altri 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.
    • Aumenta la cella next_value nel database di un valore uguale alle dimensioni del batch.
    • Completa la transazione.
  • Restituisce il valore disponibile successivo 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 le dimensioni del batch, il rendimento può essere incrementato a qualsiasi livello, perché il fattore limitante diventa il numero di batch emessi al secondo.

Ad esempio, con un batch di 100 elementi, 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.

/**
 * Gets a new batch of sequence values from the database.
 *
 * <p>Reads next_value, increments it by batch size, then writes the updated next_value back.
 */
private synchronized void getBatch() throws SpannerException {
  if (next_value <= last_value_in_batch) {
    // already have some values left in the batch - maybe this has been refreshed by another
    // thread.
    return;
  }
  next_value = getAndIncrementNextValueInDB(batchSize);
  last_value_in_batch = next_value + batchSize - 1;
}

/**
 * Returns the next value from this sequence, getting a new batch of values if necessary.
 *
 * When getting a new batch, it creates a separate transaction, so this must be called
 * <strong>outside</strong> any other transactions. See {@link #getNextInBackground()} for an
 * alternative version that uses a background thread
 */

public synchronized long getNext() throws SpannerException {
  if (next_value > last_value_in_batch) {
    getBatch();
  }
  long value = next_value;
  next_value++;
  return value;
}

Il seguente codice di esempio mostra come utilizzare la funzione getNext() asincrona in una richiesta di due valori di sequenza:

// Batch Sequence generator created outside transaction, as a long-lived object.
private BatchSequenceGenerator myBatchSequence =
    new BatchSequenceGenerator("my Sequence", /* batchSize= */ 100, dbClient);

public void usingBatchSequenceGenerator() {
  // Get two sequence values
  final long key1 = myBatchSequence.getNext();
  final long key2 = myBatchSequence.getNext();
  dbClient
      .readWriteTransaction()
      .run(
          new TransactionCallable<Void>() {
            @Nullable
            @Override
            public Void run(TransactionContext txn) {
              // Use the 2 key values in the transaction
              // ...
              return null;
            }
          });
}

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 batch asincrone

Per le applicazioni ad alte prestazioni in cui un aumento della latenza non è accettabile, puoi migliorare il rendimento del generatore di batch precedente avendo a disposizione un nuovo batch di valori quando quello corrente è esaurito.

Puoi farlo impostando una soglia che indichi 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 lacune 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:

  • Mantieni 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 quanto segue 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 nella richiesta.
    • Memorizza questo valore internamente come valore iniziale del lottó successivo.
    • Aumenta la cella next_value nel database di un valore uguale alla dimensione del batch
    • Completa la transazione.
  • Se non ci sono valori rimanenti nel batch, recupera il valore iniziale del batch successivo dal thread in background (attendi 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 finiscano. In caso contrario, l'applicazione dovrà attendere il batch successivo e la latenza verrà aumentata. Pertanto, dovrai modificare le dimensioni del batch e la soglia bassa, a seconda della frequenza con cui vengono emessi i valori di sequenza.

Ad esempio, supponiamo un tempo di transazione di 20 ms per recuperare un nuovo batch di valori, un batch di 1000 elementi e una frequenza di emissione massima 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.

/**
 * Gets a new batch of sequence values from the database.
 *
 * <p>Reads nextValue, increments it by batch size, then writes the updated nextValue back.
 * Stores the resulting value in  nextBatchStartValue, ready for when the existing pool of values
 * is exhausted.
 */
private Long readNextBatchFromDB() {
  return getAndIncrementNextValueInDB(batchSize);
}

/**
 * Returns the next value from this sequence.
 *
 * If the number of remaining values is below the low watermark, this triggers a background
 * request for new batch of values if necessary. Once the current batch is exhausted, then a the
 * new batch is used.
 */
public synchronized long getNext() throws SpannerException {
  // Check if a batch refresh is required and is not already running.
  if (nextValue >= (lastValueInBatch - lowWaterMarkForRefresh) && pendingNextBatchStart == null) {
    // Request a new batch in the background.
    pendingNextBatchStart = executor.submit(this::readNextBatchFromDB);
  }

  if (nextValue > lastValueInBatch) {
    // batch is exhausted, we should have received a new batch by now.
    try {
      // This will block if the transaction to get the next value has not completed.
      long nextBatchStart = pendingNextBatchStart.get();
      lastValueInBatch = nextBatchStart + batchSize - 1;
      nextValue = nextBatchStart;
    } catch (InterruptedException | ExecutionException e) {
      if (e.getCause() instanceof SpannerException) {
        throw (SpannerException) e.getCause();
      }
      throw new RuntimeException("Failed to retrieve new batch in background", e);
    } finally {
      pendingNextBatchStart = null;
    }
  }
  // return next value.
  long value = nextValue;
  nextValue++;
  return value;
}

Il seguente codice di esempio mostra come viene utilizzata la funzione getNext()batch asincrona in una richiesta di due valori da utilizzare nella transazione:

// Async Batch Sequence generator created outside transaction, as a long-lived object.
private AsyncBatchSequenceGenerator myAsyncBatchSequence =
    new AsyncBatchSequenceGenerator("my Sequence", /* batchSize= */ 1000, 200, dbClient);

public void usingAsyncBatchSequenceGenerator() {
  dbClient
      .readWriteTransaction()
      .run(
          new TransactionCallable<Void>() {
            @Nullable
            @Override
            public Void run(TransactionContext txn) {
              // Get two sequence values
              final long key1 = myBatchSequence.getNext();
              final long key2 = myBatchSequence.getNext();
              // Use the 2 key values in the transaction
              // ...
              return null;
            }
          });
}

Tieni presente che in questo caso i valori possono essere richiesti all'interno della transazione, poiché il recupero di un nuovo batch di valori avviene in un thread in background.

Riepilogo

La tabella seguente mette a confronto le caratteristiche dei quattro tipi di generatori di sequenze:

Sincrona Asincrona Batch Batch asincrono
Valori univoci
Valori ordinati a livello globale No
Ma con un carico sufficientemente elevato e dimensioni dei batch sufficientemente ridotte, i valori si troveranno vicini
No
Ma con un carico sufficientemente elevato e dimensioni dei batch sufficientemente ridotte, i valori si troveranno vicini
Senza interruzioni No No No
Prestazioni 1/latenza transazione,
(circa 25 valori al secondo)
50-100 valori al secondo 50-100 lotti di valori al secondo 50-100 lotti di valori al secondo
Aumento della latenza > 10 ms
Molto più elevato con contesa elevata (quando una transazione richiede molto tempo)
10 ms per ogni transazione
Molto più elevato con competizione 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, rispettando al contempo i requisiti di rendimento globale.

Test delle prestazioni

Puoi utilizzare uno strumento di analisi/test delle prestazioni, che si trova nello stesso repository GitHub delle classi di generatori di sequenze precedenti, per testare ciascuno di questi generatori di sequenze e dimostrare le caratteristiche di prestazioni e latenza. Lo strumento simula una latenza delle transazioni 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 su cui eseguire il test perché viene modificata una sola riga.

Ad esempio, l'output seguente mostra un confronto del rendimento rispetto alla 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 ai percentile 50, 90 e 99:

Modalità e parametri Num thread Valori/sec Latenza al 50° percentile (ms) Latenza al 90° percentile (ms) Latenza
del 99° percentile (ms)
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
(dimensioni 200)
10 494 18 20 38
BATCH (dimensione del batch 200) 50 1195 27 55 168
ASYNC BATCH
(dimensione del batch 200, LT 50)
10 512 18 20 30
ASYNC BATCH
(dimensione del batch 200, LT 50)
50 1622 24 28 30

Puoi vedere che in modalità sincrona (SYNC), con l'aumento del numero di thread, aumenta la contesa. Ciò comporta un aumento significativo delle latenze delle transazioni.

In modalità asincrona (ASYNC), poiché la transazione per ottenere la sequenza è più piccola e separata dalla transazione dell'applicazione, le contese sono inferiori e la frequenza è più elevata. Tuttavia, possono verificarsi contese, con conseguente aumento delle latenze del 90° percentile.

In modalità batch (BATCH), la latenza viene ridotta in modo significativo, ad eccezione del 99o 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 con 50 thread ha una latenza più elevata perché le sequenze vengono emesse così velocemente che il fattore limitante è la potenza dell'istanza della macchina virtuale (VM) (in questo caso, una macchina con 4 vCPU è stata eseguita al 350% della CPU durante il test). L'utilizzo di più macchine e più processi mostrerebbe risultati complessivi simili a quelli della modalità batch a 10 thread.

In modalità ASYNC BATCH la variazione di latenza è minima e le prestazioni sono superiori, 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