Sequenzgenerierung in Spanner

In diesem Dokument werden Methoden für Datenbankadministratoren und Anwendungsentwickler beschrieben, mit denen eindeutige numerische Sequenzen in Anwendungen, die Spanner verwenden, generiert werden können.

Einführung

Es gibt oft Situationen, in denen ein Unternehmen eine einfache, eindeutige numerische ID benötigt, z. B. eine Mitarbeiternummer oder eine Rechnungsnummer. Konventionelle relationale Datenbanken enthalten oft ein Feature zum Generieren eindeutiger, monoton ansteigender Nummernsequenzen. Diese Sequenzen werden verwendet, um eindeutige Kennungen (Zeilenschlüssel) für Objekte zu generieren, die in der Datenbank gespeichert sind.

Die Verwendung monoton ansteigender (oder abnehmender) Werte als Zeilenschlüssel entspricht möglicherweise nicht den Best Practices in Spanner, da dies Hotspots in der Datenbank erzeugt und zu einer reduzierten Leistung führt. In diesem Dokument werden Mechanismen zur Implementierung eines Sequenzgenerators mithilfe einer Spanner-Datenbanktabelle und Logik auf Anwendung vorgeschlagen.

Alternativ unterstützt Spanner einen integrierten Bit-Umkehr-Sequenzgenerator. Weitere Informationen zum Spanner-Sequenzgenerator finden Sie unter Sequenzen erstellen und verwalten.

Anforderungen für einen Sequenzgenerator

Jeder Sequenzgenerator muss für jede Transaktion einen eindeutigen Wert generieren.

Je nach Anwendungsfall muss ein Sequenzgenerator möglicherweise auch Sequenzen mit den folgenden Eigenschaften erstellen:

  • Geordnet: Niedrigere Werte in der Sequenz dürfen nicht nach höheren Werten ausgegeben werden.
  • Lückenlos: In der Sequenz darf es keine Lücken geben.

Der Sequenzgenerator muss außerdem Werte mit der für die Anwendung erforderlichen Häufigkeit generieren.

All diese Anforderungen zu erfüllen, kann sich als schwierig erweisen, insbesondere in einem verteilten System. Sie können aber Kompromisse bei den Anforderungen an eine geordnete und lückenlose Sequenz eingehen, wenn dies zum Erreichen Ihrer Leistungsziele erforderlich ist.

Andere Datenbank-Engines bieten Möglichkeiten, um diese Anforderungen zu erfüllen. Beispielsweise können Sequenzen in PostgreSQL-Spalten und AUTO_INCREMENT-Spalten in MySQL eindeutige Werte für separate Transaktionen generieren, allerdings keine lückenlosen Werte, wenn für Transaktionen ein Rollback durchgeführt wird. Weitere Informationen finden Sie in den Hinweisen in der PostgreSQL-Dokumentation und im Abschnitt zu den AUTO_INCREMENT-Auswirkungen in MySQL.

Sequenzgeneratoren, die Zeilen von Datenbanktabellen verwenden

Ihre Anwendung kann einen Sequenzgenerator mithilfe einer Datenbanktabelle implementieren, in der die Sequenznamen und der nächste Wert in der Sequenz gespeichert werden.

Beim Lesen und Erhöhen der Zelle next_value einer Sequenz in einer Datenbanktransaktion werden eindeutige Werte generiert, ohne dass eine weitere Synchronisierung zwischen Anwendungsprozessen erforderlich ist.

Definieren Sie zuerst die Tabelle so:

CREATE TABLE sequences (
    name STRING(64) NOT NULL,
    next_value INT64 NOT NULL,
) PRIMARY KEY (name)

Zum Erstellen von Sequenzen können Sie eine Zeile mit dem neuen Sequenznamen und dem Startwert in die Tabelle einfügen, z. B. ("invoice_id", 1). Da jedoch die Zelle next_value für jeden generierten Sequenzwert erhöht wird, ist die Leistung durch die Häufigkeit begrenzt, mit der die Zeile aktualisiert werden kann.

Spanner-Clientbibliotheken verwenden wiederholbare Transaktionen, um Konflikte zu lösen. Wenn Zellen (Spaltenwerte), die während einer Lese-/Schreibtransaktion gelesen werden, an anderer Stelle geändert werden, wird die Transaktion blockiert, bis die andere Transaktion abgeschlossen ist. Dann wird die Transaktion abgebrochen und wiederholt, sodass die aktualisierten Werte gelesen werden. Dies minimiert die Dauer von Schreibsperren, bedeutet aber auch, dass eine Transaktion mehrmals wiederholt werden kann, bevor ein Commit erfolgreich durchgeführt wird.

Da für eine Zeile jeweils nur eine Transaktion stattfinden kann, ist die maximale Häufigkeit der Ausgabe von Sequenzwerten umgekehrt proportional zur Gesamtlatenz der Transaktion.

Diese Gesamtlatenz der Transaktion hängt von mehreren Faktoren ab, z. B. der Latenz zwischen der Clientanwendung und den Spanner-Knoten, der Latenz zwischen den Spanner-Knoten und der TrueTime-Unsicherheit. Eine multiregionale Konfiguration hat beispielsweise eine höhere Transaktionslatenz, da sie auf ein Quorum von Schreibbestätigungen von den Knoten in verschiedenen Regionen warten muss, um den Vorgang abzuschließen.

Beispiel: Wenn eine Lese-/Aktualisierungstransaktion für eine einzelne Zelle (eine Spalte in einer einzelnen Zeile) eine Latenz von 10 Millisekunden (ms) hat, beträgt die maximale theoretische Häufigkeit der Ausgabe von Sequenzwerten 100 pro Sekunde. Dieser Höchstwert gilt für die gesamte Datenbank, unabhängig von der Anzahl der Clientanwendungsinstanzen oder der Anzahl der Knoten in der Datenbank. Dies liegt daran, dass eine einzelne Zeile immer von einem einzelnen Knoten verwaltet wird.

Im folgenden Abschnitt wird beschrieben, wie Sie diese Einschränkung umgehen können.

Anwendungsseitige Implementierung

Der Anwendungscode muss die Zelle next_value in der Datenbank lesen und aktualisieren. Dafür gibt es mehrere Möglichkeiten, die jeweils unterschiedliche Leistungsmerkmale und Nachteile haben.

Einfacher Sequenzgenerator innerhalb von Transaktionen

Die einfachste Möglichkeit zur Verarbeitung der Sequenzgenerierung besteht darin, den Spaltenwert innerhalb der Transaktion zu erhöhen, wenn die Anwendung einen neuen sequenziellen Wert benötigt.

In einer einzelnen Transaktion führt die Anwendung Folgendes aus:

  • Liest die Zelle next_value, um den in der Anwendung zu verwendenden Sequenznamen zu ermitteln.
  • Erhöht und aktualisiert die Zelle next_value für den Sequenznamen.
  • Verwendet den abgerufenen Wert für den Spaltenwert, den die Anwendung benötigt.
  • Führt den Rest der Anwendungstransaktion aus.

Durch diesen Prozess wird eine Sequenz generiert, die geordnet und lückenlos ist. Wenn die Zelle next_value in der Datenbank durch keine Aktion auf einen niedrigeren Wert aktualisiert wird, ist die Sequenz außerdem eindeutig.

Da der Sequenzwert als Teil der größeren Anwendungstransaktion abgerufen wird, hängt die maximale Häufigkeit der Sequenzgenerierung von der Komplexität der gesamten Anwendungstransaktion ab. Eine komplexe Transaktion hat eine höhere Latenz und daher eine geringere maximal mögliche Häufigkeit.

In einem verteilten System können viele Transaktionsversuche gleichzeitig erfolgen, was zu einer großen Anzahl von Sequenzwertkonflikten führt. Da die Zelle next_value innerhalb der Anwendungstransaktion aktualisiert wird, werden alle anderen Transaktionen, die gleichzeitig die Zelle next_value erhöhen möchten, von der ersten Transaktion blockiert und wiederholt. Dies kann die Zeit, die die Anwendung zur erfolgreichen Ausführung der Transaktion benötigt, erheblich erhöhen und zu Leistungsproblemen führen.

Der folgende Code ist ein Beispiel für einen einfachen Sequenzgenerator innerhalb einer Transaktion, der pro Transaktion nur einen einzigen Sequenzwert zurückgibt. Diese Einschränkung ergibt sich daraus, dass Schreibvorgänge innerhalb einer mit der Mutation API ausgeführten Transaktion erst nach dem Commit der Transaktion sichtbar sind, auch für Lesevorgänge in derselben Transaktion. Daher wird immer derselbe Sequenzwert zurückgegeben, wenn Sie diese Funktion mehrmals in derselben Transaktion aufrufen.

Der folgende Beispielcode zeigt, wie eine synchrone getNext()-Funktion implementiert wird:

/**
 * 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;
}

Der folgende Beispielcode zeigt, wie die synchrone getNext()-Funktion in einer Transaktion verwendet wird:

// 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;
            }
          });
}

Verbesserter synchroner Sequenzgenerator innerhalb von Transaktionen

Sie können die vorherige Abstraktion so ändern, dass mehrere Werte innerhalb einer einzelnen Transaktion erzeugt werden. Dazu verfolgen Sie die Sequenzwerte, die innerhalb einer Transaktion ausgegeben werden.

In einer einzelnen Transaktion führt die Anwendung Folgendes aus:

  • Liest die Zelle next_value, um den in der Anwendung zu verwendenden Sequenznamen zu ermitteln.
  • Speichert diesen Wert intern als Variable.
  • Erhöht jedes Mal, wenn ein neuer Sequenzwert angefordert wird, die gespeicherte Variable next_value und speichert einen Schreibvorgang zwischen, der den aktualisierten Zellenwert in der Datenbank festlegt.
  • Führt den Rest der Anwendungstransaktion aus.

Wenn Sie eine Abstraktion verwenden, muss das Objekt für diese Abstraktion innerhalb der Transaktion erstellt werden. Das Objekt führt einen einzelnen Lesevorgang aus, wenn der erste Wert angefordert wird. Das Objekt verfolgt die Zelle next_value intern, sodass mehr als ein Wert generiert werden kann.

Für diese Version gelten dieselben Einschränkungen in Bezug auf Latenz und Konflikte wie bei der vorherigen Version.

Der folgende Beispielcode zeigt, wie eine synchrone getNext()-Funktion implementiert wird:

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;
}

Der folgende Beispielcode zeigt, wie die synchrone getNext()-Funktion in einer Anfrage für zwei Sequenzwerte verwendet wird:

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;
            }
          });
}

(Asynchroner) Sequenzgenerator außerhalb von Transaktionen

In den vorherigen beiden Implementierungen hängt die Leistung des Generators von der Latenz der Anwendungstransaktion ab. Wenn Sie die Sequenz in einer separaten Transaktion erhöhen, können Sie die maximale Häufigkeit verbessern, müssen dann allerdings Lücken in der Sequenz in Kauf nehmen. Dies ist der von PostgreSQL verwendete Ansatz. Bevor die Anwendung die Transaktion startet, sollten Sie zuerst die zu verwendenden Sequenzwerte abrufen.

Die Anwendung führt Folgendes aus:

  • Erstellt zuerst eine Transaktion zum Abrufen und Aktualisieren des Sequenzwerts:
    • Liest die Zelle next_value, um den in der Anwendung zu verwendenden Sequenznamen zu ermitteln.
    • Speichert diesen Wert als Variable.
    • Erhöht und aktualisiert die Zelle next_value in der Datenbank für den Sequenznamen.
    • Schließt die Transaktion ab.
  • Verwendet den zurückgegebenen Wert in einer separaten Transaktion.

Die Latenz dieser separaten Transaktion liegt nahe an der minimalen Latenz, wobei sich die Leistung der maximalen theoretischen Häufigkeit von 100 Werten pro Sekunde nähert (bei einer angenommenen Transaktionslatenz von 10 ms). Da die Sequenzwerte separat abgerufen werden, ändert sich die Latenz der Anwendungstransaktion selbst nicht und Konflikte werden minimiert.

Wenn jedoch ein Sequenzwert angefordert und nicht verwendet wird, bleibt in der Sequenz eine Lücke, da kein Rollback für angeforderte Sequenzwerte durchgeführt werden kann. Dies kann auftreten, wenn die Anwendung während der Transaktion nach dem Anfordern eines Sequenzwerts abgebrochen wird oder fehlschlägt.

Der folgende Beispielcode zeigt, wie eine Funktion implementiert wird, die die Zelle next_value in der Datenbank abruft und erhöht:

/**
 * 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;
          });
}

Mit dieser Funktion können Sie ganz einfach einen einzelnen neuen Sequenzwert abrufen, wie in der folgenden Implementierung einer asynchronen getNext()-Funktion gezeigt:

/**
 * 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);
}

Der folgende Beispielcode zeigt, wie die asynchrone getNext()-Funktion in einer Anfrage für zwei Sequenzwerte verwendet wird:

// 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;
            }
          });
}

Im vorherigen Codebeispiel sehen Sie, dass die Sequenzwerte außerhalb der Anwendungstransaktion angefordert werden. Dies liegt daran, dass Cloud Spanner die Ausführung einer Transaktion innerhalb einer anderen Transaktion im selben Thread (auch als verschachtelte Transaktionen bezeichnet) nicht unterstützt.

Sie können diese Einschränkung umgehen, wenn Sie den Sequenzwert mithilfe eines Hintergrundthreads anfordern und auf das Ergebnis warten:

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();
}

Batchsequenzgenerator

Sie können eine erhebliche Leistungsverbesserung erzielen, wenn Sie auch auf die Anforderung verzichten, dass Sequenzwerte geordnet sein müssen. Dadurch kann die Anwendung einen Batch von Sequenzwerten reservieren und intern ausgeben. Einzelne Anwendungsinstanzen haben einen eigenen separaten Batch von Werten, sodass die Werte nicht der Reihe nach ausgegeben werden. Außerdem hinterlassen Anwendungsinstanzen, die nicht ihren gesamten Batch von Werten verwenden (beispielsweise beim Herunterfahren der Anwendungsinstanz), Lücken durch nicht verwendete Werte in der Sequenz.

Die Anwendung führt Folgendes aus:

  • Behält für jede Sequenz einen internen Status bei, der den Startwert und die Größe des Batches sowie den nächsten verfügbaren Wert enthält.
  • Fordert einen Sequenzwert aus dem Batch an.
  • Führt Folgendes aus, wenn im Batch keine Werte mehr vorhanden sind:
    • Erstellt eine Transaktion, um den Sequenzwert zu lesen und zu aktualisieren.
    • Liest die Zelle next_value für die Sequenz.
    • Speichert diesen Wert intern als Startwert des neuen Batches.
    • Erhöht die Zelle next_value in der Datenbank um einen Betrag, der der Batchgröße entspricht.
    • Schließt die Transaktion ab.
  • Gibt den nächsten verfügbaren Wert zurück und erhöht den internen Status.
  • Verwendet den zurückgegebenen Wert in der Transaktion.

Bei dieser Methode kommt es bei Transaktionen, die einen Sequenzwert verwenden, nur dann zu einer erhöhten Latenz, wenn ein neuer Batch von Sequenzwerten reserviert werden muss.

Der Vorteil besteht darin, dass durch Erhöhen der Batchgröße die Leistung auf ein beliebiges Niveau gesteigert werden kann, da der begrenzende Faktor die Anzahl der pro Sekunde ausgegebenen Batches ist.

Bei einer Batchgröße von 100 – einer angenommenen Latenz von 10 ms zum Abrufen eines neuen Batches und somit maximal 100 Batches pro Sekunde – können beispielsweise 10.000 Sequenzwerte pro Sekunde ausgegeben werden.

Der folgende Beispielcode zeigt, wie eine getNext()-Funktion mithilfe von Batches implementiert wird. Beachten Sie, dass der Code die zuvor definierte getAndIncrementNextValueInDB()-Funktion wiederverwendet, um neue Batches von Sequenzwerten aus der Datenbank abzurufen.

/**
 * 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;
}

Der folgende Beispielcode zeigt, wie die asynchrone getNext()-Funktion in einer Anfrage für zwei Sequenzwerte verwendet wird:

// 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;
            }
          });
}

Auch hier müssen die Werte außerhalb der Transaktion (oder mithilfe eines Hintergrundthreads) angefordert werden, da Spanner keine verschachtelten Transaktionen unterstützt.

Asynchroner Batchsequenzgenerator

Sie können die Leistung des vorherigen Batchgenerators für Hochleistungsanwendungen verbessern, bei denen eine erhöhte Latenz nicht akzeptabel ist. In diesem Fall müssen Sie dafür sorgen, dass ein neuer Batch von Werten bereitsteht, wenn der aktuelle Batch von Werten aufgebraucht ist.

Dazu legen Sie einen Schwellenwert fest, der angibt, wann die Anzahl der verbliebenen Sequenzwerte in einem Batch zu niedrig ist. Wenn der Schwellenwert erreicht ist, fordert der Sequenzgenerator einen neuen Batch von Werten in einem Hintergrundthread an.

Wie bei der vorherigen Version werden die Werte nicht der Reihe nach ausgegeben. Wenn Transaktionen fehlschlagen oder Anwendungsinstanzen heruntergefahren werden, entstehen außerdem Lücken durch die nicht verwendeten Werte in der Sequenz.

Die Anwendung führt Folgendes aus:

  • Behält für jede Sequenz einen internen Status bei, der den Startwert des Batches sowie den nächsten verfügbaren Wert enthält.
  • Fordert einen Sequenzwert aus dem Batch an.
  • Führt Folgendes in einem Hintergrundthread aus, wenn die Anzahl der verbliebenen Werte im Batch kleiner als der Schwellenwert ist:
    • Erstellt eine Transaktion, um den Sequenzwert zu lesen und zu aktualisieren.
    • Liest die Zelle next_value, um den in der Anwendung zu verwendenden Sequenznamen zu ermitteln.
    • Speichert diesen Wert intern als Startwert des nächsten Batches.
    • Erhöht die Zelle next_value in der Datenbank um einen Betrag, der der Batchgröße entspricht.
    • Schließt die Transaktion ab.
  • Ruft den Startwert des nächsten Batches aus dem Hintergrundthread ab (und wartet gegebenenfalls auf dessen Abschluss), wenn im Batch keine Werte mehr vorhanden sind, und erstellt einen neuen Batch mit dem abgerufenen Startwert als nächsten Wert.
  • Gibt den nächsten Wert zurück und erhöht den internen Status.
  • Verwendet den zurückgegebenen Wert in der Transaktion.

Damit Sie eine optimale Leistung erzielen, sollte der Hintergrundthread gestartet und abgeschlossen werden, bevor die Sequenzwerte im aktuellen Batch aufgebraucht sind. Andernfalls muss die Anwendung auf den nächsten Batch warten, was die Latenz erhöht. Daher müssen Sie die Batchgröße und den unteren Schwellenwert abhängig von der Häufigkeit der ausgegebenen Sequenzwerte anpassen.

Gehen Sie beispielsweise von einer Transaktionszeit von 20 ms zum Abrufen eines neuen Batches von Werten, einer Batchgröße von 1.000 und einer maximalen Sequenzausgabehäufigkeit von 500 Werten pro Sekunde (ein Wert alle 2 ms) aus. In diesem Fall werden während der 20 ms, in denen ein neuer Batch von Werten ausgegeben wird, 10 Sequenzwerte ausgegeben. Daher sollte der Schwellenwert für die Anzahl der verbliebenen Sequenzwerte größer als 10 sein, damit der nächste Batch bei Bedarf verfügbar ist.

Der folgende Beispielcode zeigt, wie eine getNext()-Funktion mithilfe von Batches implementiert wird. Beachten Sie, dass der Code die zuvor definierte getAndIncrementNextValueInDB()-Funktion verwendet, um einen Batch von Sequenzwerten mithilfe eines Hintergrundthreads abzurufen.

/**
 * 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;
}

Der folgende Beispielcode zeigt, wie die asynchrone getNext()-Batchfunktion in einer Anfrage für zwei Werte zur Verwendung in der Transaktion eingesetzt wird:

// 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;
            }
          });
}

In diesem Fall können die Werte innerhalb der Transaktion angefordert werden, da der Abruf eines neuen Batches von Werten in einem Hintergrundthread erfolgt.

Fazit

In der folgenden Tabelle werden die Eigenschaften der vier Typen von Sequenzgeneratoren verglichen:

Synchron Asynchron Batch Asynchroner Batch
Eindeutige Werte Ja Ja Ja Ja
Global geordnete Werte Ja Ja Nein
Bei einer ausreichend hohen Last und einer ausreichend kleinen Batchgröße liegen die Werte jedoch nahe beieinander.
Nein
Bei einer ausreichend hohen Last und einer ausreichend kleinen Batchgröße liegen die Werte jedoch nahe beieinander.
Lückenlos Ja Nein Nein Nein
Leistung 1/Transaktionslatenz,
(ungefähr 25 Werte pro Sekunde)
50 bis 100 Werte pro Sekunde 50 bis 100 Batches von Werten pro Sekunde 50 bis 100 Batches von Werten pro Sekunde
Latenzanstieg Mehr als 10 ms
Erheblich höher mit großer Anzahl von Konflikten (wenn eine Transaktion sehr lange dauert)
10 ms bei jeder Transaktion
Erheblich höher mit großer Anzahl von Konflikten
10 ms, aber nur, wenn ein neuer Batch von Werten abgerufen wird Null, wenn die Batchgröße und der untere Schwellenwert auf geeignete Werte festgelegt sind

Die obige Tabelle zeigt auch, dass Sie bei den Anforderungen an global geordnete Werte und lückenlose Wertereihen unter Umständen Kompromisse eingehen müssen, um eindeutige Werte zu generieren und gleichzeitig allgemeine Leistungsanforderungen zu erfüllen.

Leistungstests

Sie können ein Leistungstest-/Analysetool verwenden, das sich im selben GitHub-Repository wie die vorherigen Sequenzgeneratorklassen befindet, um jeden dieser Sequenzgeneratoren zu testen und Leistungs- und Latenzmerkmale zu demonstrieren. Das Tool simuliert eine Anwendungstransaktionslatenz von 10 ms und führt mehrere Threads gleichzeitig aus, die Sequenzwerte anfordern.

Für Leistungstests wird nur eine Spanner-Instanz mit einem einzigen Knoten benötigt, da lediglich eine einzige Zeile geändert wird.

Die folgende Ausgabe zeigt beispielsweise einen Vergleich zwischen Leistung und Latenz im synchronen Modus mit 10 Threads:

$ 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

In der folgenden Tabelle werden die Ergebnisse für verschiedene Modi und Anzahlen von parallelen Threads verglichen, einschließlich der Anzahl der Werte, die pro Sekunde ausgegeben werden kann, und der Latenz beim 50., 90. und 99. Perzentil:

Modus und Parameter Anzahl der Threads Werte/Sek. Latenz beim 50. Perzentil (ms) Latenz beim 90. Perzentil (ms) Latenz beim
99. Perzentil (ms)
SYNC 10 34 27 1189 2703
SYNC 50 30,6 1191 3513 5982
ASYNC 10 66,5 28 611 1460
ASYNC 50 78,1 29 1695 3442
BATCH
(Größe 200)
10 494 18 20 38
BATCH (Batchgröße 200) 50 1195 27 55 168
ASYNC BATCH
(Batchgröße 200, LT 50)
10 512 18 20 30
ASYNC BATCH
(Batchgröße 200, LT 50)
50 1622 24 28 30

Sie sehen, dass im synchronen Modus (SYNC) mit der höheren Anzahl von Threads mehr Konflikte auftreten. Dies führt zu deutlich höheren Transaktionslatenzen.

Im asynchronen Modus (ASYNC) gibt es weniger Konflikte und die Häufigkeit ist höher, da die Transaktion zum Abrufen der Sequenz kleiner und von der Anwendungstransaktion getrennt ist. Es kann jedoch trotzdem zu Konflikten kommen, was zu höheren Latenzen beim 90. Perzentil führt.

Im Batchmodus (BATCH) wird die Latenz erheblich reduziert, mit Ausnahme des 99. Perzentils, da der Generator dort einen weiteren Batch von Sequenzwerten aus der Datenbank synchron anfordern muss. Die Leistung ist im BATCH-Modus um ein Vielfaches höher als im ASYNC-Modus.

Der Batchmodus mit 50 Threads hat eine höhere Latenz, da Sequenzen so schnell ausgegeben werden, dass der begrenzende Faktor die Leistung der VM-Instanz ist (in diesem Fall lief während des Tests eine Maschine mit 4 vCPUs bei 350 % CPU). Wenn Sie mehrere Maschinen und mehrere Prozesse verwenden, erhalten Sie ähnliche Gesamtergebnisse wie beim Batchmodus mit 10 Threads.

Im ASYNC BATCH-Modus ist die Latenzschwankung minimal und die Leistung höher – selbst bei einer großen Anzahl von Threads –, da die Latenz beim Anfordern eines neuen Batches aus der Datenbank völlig unabhängig von der Anwendungstransaktion ist.

Nächste Schritte