Génération de séquences dans Spanner

Ce document décrit les méthodes permettant aux administrateurs de base de données et aux développeurs d'applications de générer des séquences de nombres uniques dans les applications qui utilisent Spanner.

Introduction

Il existe de nombreux cas dans lesquels une entreprise a besoin d'un identifiant numérique simple et unique, tel qu'un numéro d'employé ou un numéro de facture. Les bases de données relationnelles classiques incluent souvent une fonctionnalité permettant de générer des séquences de nombres uniques à croissance monotone. Ces séquences permettent de générer des identifiants uniques (clés de ligne) pour les objets stockés dans la base de données.

Toutefois, l'utilisation de valeurs à croissance (ou décroissance) monotone en tant que clés de ligne ne suit pas forcément les bonnes pratiques pour Spanner, car cela crée dans la base de données des hotspots conduisant à une baisse des des performances Ce document propose des mécanismes permettant de mettre en œuvre un générateur de séquence à l'aide d'une table de base de données Spanner et d'une logique de couche d'application.

Spanner intègre également un générateur de séquence par bit inversé. Pour en savoir plus sur le générateur de séquence Spanner, consultez la page Créer et gérer des séquences.

Conditions requises pour un générateur de séquence

Chaque générateur de séquence doit générer une valeur unique pour chaque transaction.

Selon le cas d'utilisation, un générateur de séquence peut également avoir besoin de créer des séquences présentant les caractéristiques suivantes :

  • Successivité : les valeurs inférieures de la séquence ne doivent pas être émises après les valeurs supérieures.
  • Absence d'intervalles : la séquence ne doit pas comporter d'intervalles.

Le générateur de séquence doit également générer des valeurs à la fréquence requise par l'application.

Il peut être difficile de répondre à toutes ces exigences, en particulier dans un système distribué. Si cela s'avère nécessaire pour atteindre vos objectifs de performances, vous pouvez renoncer aux exigences de successivité et d'absence d'intervalles.

D'autres moteurs de base de données offrent plusieurs façons de gérer ces exigences. Dans MySQL, par exemple, les séquences des colonnes PostgreSQL et AUTO_INCREMENT peuvent générer des valeurs uniques pour des transactions distinctes, mais ne peuvent pas générer de valeurs successives si les transactions sont annulées. Pour en savoir plus, consultez la section Notes dans la documentation PostgreSQL et les conséquences de l'incrémentation automatique dans MySQL.

Générateurs de séquence basés sur des lignes de table de base de données

Votre application peut mettre en œuvre un générateur de séquence en utilisant une table de base de données pour stocker les noms de séquences et la valeur suivante dans la séquence.

La lecture et l'incrémentation de la cellule next_value de la séquence dans une transaction de base de données génèrent des valeurs uniques, sans nécessiter de synchronisation supplémentaire entre les processus d'application.

Tout d'abord, définissez la table comme suit :

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

Vous pouvez créer des séquences en insérant une ligne dans la table portant le nom de la nouvelle séquence et la valeur de départ par exemple ("invoice_id", 1). Cependant, comme la cellule next_value est incrémentée pour chaque valeur de séquence générée, la fréquence de mise à jour de la ligne limite les performances.

Les bibliothèques clientes Spanner font appel à des transactions renouvelables pour résoudre les conflits. Si des cellules (valeurs de colonne) lues au cours d'une transaction en lecture/écriture sont modifiées ailleurs, la transaction est bloquée jusqu'à ce que l'autre transaction se termine, puis est annulée et renouvelée afin de lire les valeurs mises à jour. Cela permet de réduire la durée des verrous en écriture, mais signifie également qu'une transaction peut être renouvelée plusieurs fois avant son commit.

Étant donné qu'une seule transaction peut se produire sur une ligne à la fois, la fréquence maximale d'émission des valeurs séquentielles est inversement proportionnelle à la latence totale de la transaction.

La latence totale de la transaction dépend de plusieurs facteurs, tels que la latence entre l'application cliente et les nœuds Spanner ou la latence entre les nœuds Spanner et l'incertitude TrueTime. Par exemple, une configuration multirégionale présente une latence plus élevée, car pour se terminer, la transaction doit attendre de recevoir un quorum de confirmations d'écriture de la part des nœuds des différentes régions.

Par exemple, si une transaction en lecture/mise à jour sur une seule cellule (une colonne sur une seule ligne) présente une latence de 10 millisecondes (ms), la fréquence théorique maximale d'émission des valeurs séquentielles est de 100 par seconde. Cette fréquence maximale s'applique à l'ensemble de la base de données, quel que soit le nombre d'instances de l'application cliente ou le nombre de nœuds dans la base de données. En effet, une seule ligne est toujours gérée par un seul nœud.

La section suivante décrit comment contourner cette limite.

Mise en œuvre côté application

Le code de l'application doit lire et mettre à jour la cellule next_value dans la base de données. Pour cela, il existe plusieurs façons de procéder, chacune présentant des caractéristiques de performance et des inconvénients différents.

Générateur de séquence simple au sein d'une transaction

Le moyen le plus simple de gérer la génération de séquence consiste à incrémenter la valeur de la colonne au sein de la transaction chaque fois que l'application a besoin d'une nouvelle valeur séquentielle.

Au cours d'une seule transaction, l'application effectue les opérations suivantes :

  • Lecture de la cellule next_value correspondant au nom de la séquence à utiliser dans l'application
  • Incrémentation et mise à jour de la cellule next_value correspondant au nom de la séquence
  • Utilisation de la valeur récupérée de n'importe quelle colonne comportant la valeur dont l'application a besoin
  • Achèvement du reste de la transaction de l'application

Ce processus génère une séquence de valeurs successives et sans intervalles. Si rien ne met à jour la cellule next_value de la base de données avec une valeur inférieure, la séquence reste unique.

Comme la valeur séquentielle est extraite dans le cadre de la transaction globale de l'application, la fréquence maximale de génération de séquence dépend de la complexité de la transaction globale de l'application. Une transaction complexe présente une latence plus élevée et, par conséquent, une fréquence maximale possible inférieure.

Dans un système distribué, de nombreuses transactions peuvent être renouvelées simultanément, ce qui entraîne un conflit important sur la valeur séquentielle. Comme la cellule next_value est mise à jour dans la transaction de l'application, toute autre transaction tentant d'incrémenter la cellule next_value en même temps sera bloquée par la première transaction, puis renouvelée. Cela entraîne une augmentation importante du temps nécessaire à l'application pour terminer la transaction, ce qui peut occasionner des problèmes de performances.

Le code suivant fournit un exemple de générateur de séquence simple au sein de la transaction, qui ne renvoie qu'une seule valeur séquentielle par transaction. Cette restriction existe, car les écritures au sein d'une transaction à l'aide de l'API Mutation ne sont visibles qu'après le commit de la transaction, tout comme les lectures au sein de la même transaction. Par conséquent, l'appel de cette fonction plusieurs fois dans la même transaction renvoie toujours la même valeur séquentielle.

L'exemple de code suivant montre comment mettre en œuvre une fonction getNext() synchrone :

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

L'exemple de code suivant montre comment utiliser la fonction getNext() synchrone dans une transaction :

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

Générateur de séquence synchrone amélioré au sein de la transaction

Vous pouvez modifier l'extrait précédent afin de produire plusieurs valeurs au sein d'une même transaction en effectuant le suivi des valeurs séquentielles émises.

Au cours d'une seule transaction, l'application effectue les opérations suivantes :

  • Lecture de la cellule next_value correspondant au nom de la séquence à utiliser dans l'application
  • Stockage de cette valeur en interne en tant que variable
  • À chaque demande de nouvelle valeur séquentielle, incrémentation de la variable next_value stockée et mise en mémoire tampon d'une écriture qui définit la valeur de la cellule mise à jour dans la base de données
  • Achèvement du reste de la transaction de l'application

Si vous utilisez un extrait, vous devez créer l'objet de cet extrait au sein de la transaction. L'objet effectue une seule lecture lorsque la première valeur est demandée. L'objet effectue un suivi interne de la cellule next_value, ce qui permet de générer plusieurs valeurs.

Les mêmes mises en garde concernant la latence et les conflits applicables à la version précédente s'appliquent également à cette version.

L'exemple de code suivant montre comment mettre en œuvre une fonction getNext() synchrone :

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

L'exemple de code suivant montre comment utiliser la fonction getNext() synchrone dans une requête afin d'obtenir deux valeurs séquentielles :

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

Générateur de séquence hors transaction (asynchrone)

Dans les deux mises en œuvre précédentes, les performances du générateur dépendent de la latence de la transaction de l'application. Vous pouvez améliorer la fréquence maximale, ce qui implique toutefois de tolérer les intervalles, en incrémentant la séquence dans une transaction distincte. (Cette approche est utilisée par PostgreSQL.) Vous devez tout d'abord récupérer les valeurs séquentielles à utiliser avant que l'application ne commence la transaction.

L'application effectue les opérations suivantes :

  • Création d'une première transaction pour obtenir et mettre à jour la valeur séquentielle :
    • Lecture de la cellule next_value correspondant au nom de la séquence à utiliser dans l'application
    • Stockage de cette valeur en tant que variable
    • Incrémentation et mise à jour de la cellule next_value de la base de données correspondant au nom de la séquence
    • Achèvement de la transaction
  • Utilisation de la valeur renvoyée dans une transaction distincte

Cette transaction distincte présente une latence proche de la latence minimale et des performances avoisinant la fréquence maximale de 100 valeurs par seconde (en supposant une latence de transaction de 10 ms). Comme les valeurs séquentielles sont récupérées séparément, la latence de la transaction de l'application elle-même demeure intacte et les conflits sont minimisés.

Cependant, si une valeur séquentielle est demandée mais non utilisée, un intervalle se crée dans la séquence parce qu'il n'est pas possible d'annuler les valeurs séquentielles demandées. Cela peut se produire après avoir demandé une valeur séquentielle si l'application abandonne la transaction ou échoue pendant son exécution.

L'exemple de code suivant montre comment mettre en œuvre une fonction qui récupère et incrémente la valeur de la cellule next_value dans la base de données :

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

Vous pouvez facilement utiliser cette fonction pour récupérer une nouvelle valeur séquentielle unique, comme indiqué dans la mise en œuvre suivante d'une fonction getNext() asynchrone :

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

L'exemple de code suivant montre comment utiliser la fonction getNext() asynchrone dans une requête afin d'obtenir deux valeurs séquentielles :

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

Dans l'exemple de code précédent, vous pouvez constater que les valeurs séquentielles sont demandées en dehors de la transaction de l'application. En effet, Cloud Spanner ne permet pas d'exécuter une transaction au sein d'une autre transaction d'un même thread (que l'on appelle "transaction imbriquée").

Vous pouvez contourner cette restriction en exécutant un thread d'arrière-plan pour demander la valeur séquentielle et attendre le résultat :

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

Générateur de séquence par lot

Vous pouvez améliorer considérablement les performances si vous renoncez également à l'exigence de successivité des valeurs séquentielles. Cela permet ainsi à l'application de réserver un lot de valeurs séquentielles et de les émettre en interne. Les instances d'application individuelles disposent de leur propre lot de valeurs distinctes. Par conséquent, les valeurs émises ne sont pas successives. En outre, les instances d'application qui n'utilisent pas l'intégralité de leur lot de valeurs (par exemple, en cas d'arrêt d'une instance d'application) laissent des intervalles de valeurs inutilisées dans la séquence.

L'application effectue les opérations suivantes :

  • Maintien d'un état interne pour chaque séquence contenant la valeur de départ et la taille du lot, ainsi que la valeur suivante disponible
  • Demande d'une valeur séquentielle faisant partie du lot
  • S'il ne reste aucune valeur dans le lot, procédez comme suit :
    • Créez une transaction de lecture et de mise à jour de valeur séquentielle.
    • Lisez la cellule next_value de la séquence.
    • Stockez cette valeur en interne en tant que valeur de départ du nouveau lot.
    • Incrémentez la cellule next_value de la base de données d'une valeur égale à la taille du lot.
    • Terminez la transaction.
  • Renvoyez la valeur disponible suivante et incrémentez l'état interne.
  • Utilisez la valeur renvoyée dans la transaction.

Avec cette méthode, les transactions qui utilisent une valeur séquentielle n'augmentent la latence que lorsqu'un nouveau lot de valeurs séquentielles doit être réservé.

L'avantage est qu'en augmentant la taille du lot, vous pouvez augmenter les performances à n'importe quel niveau parce que le facteur limitant est désormais le nombre de lots émis par seconde.

Par exemple, avec une taille de lot de 100, en supposant une latence de 10 ms pour l'obtention d'un nouveau lot, soit un maximum de 100 lots par seconde, il est possible d'émettre 10 000 valeurs séquentielles par seconde.

L'exemple de code suivant montre comment mettre en œuvre une fonction getNext() en utilisant des lots. Vous remarquerez que le code réutilise la fonction getAndIncrementNextValueInDB() définie précédemment pour récupérer de nouveaux lots de valeurs séquentielles dans la base de données.

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

L'exemple de code suivant montre comment utiliser la fonction getNext() asynchrone dans une requête afin d'obtenir deux valeurs séquentielles :

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

Là encore, les valeurs doivent être demandées en dehors de la transaction (ou à l'aide d'un thread d'arrière-plan) parce que Spanner ne permet pas d'exécuter des transactions imbriquées.

Générateur de séquence par lot asynchrone

Dans le cas d'applications à hautes performances pour lesquelles l'augmentation de la latence n'est pas acceptable, vous pouvez améliorer les performances du générateur de lots précédent en générant un nouveau lot prêt au moment où le lot de valeurs actuel sera épuisé.

Pour ce faire, vous pouvez définir un seuil indiquant que le nombre de valeurs séquentielles restant dans un lot est insuffisant. Une fois ce seuil atteint, le générateur de séquence commence à demander un nouveau lot de valeurs via un thread d'arrière-plan.

Comme dans la version précédente, les valeurs émises ne sont pas successives et la séquence comportera des intervalles de valeurs inutilisées si les transactions échouent ou si les instances d'application sont arrêtées.

L'application effectue les opérations suivantes :

  • Maintien d'un état interne pour chaque séquence contenant la valeur de départ du lot, ainsi que la valeur suivante disponible
  • Demande d'une valeur séquentielle faisant partie du lot
  • Si le nombre de valeurs restantes dans le lot est inférieur au seuil, exécutez les opérations suivantes dans un thread d'arrière-plan :
    • Créez une transaction de lecture et de mise à jour de valeur séquentielle.
    • Lisez la cellule next_value correspondant au nom de la séquence à utiliser dans l'application.
    • Stockez cette valeur en interne en tant que valeur de départ du lot suivant.
    • Incrémentez la cellule next_value de la base de données d'une valeur égale à la taille du lot.
    • Terminez la transaction.
  • S'il ne reste plus de valeurs dans le lot, récupérez la valeur de départ du lot suivant en exécutant le thread d'arrière-plan (attendez qu'il se termine si nécessaire) et créez un lot en utilisant la valeur de départ récupérée en tant que valeur suivante.
  • Renvoyez la valeur suivante et incrémentez l'état interne.
  • Utilisez la valeur renvoyée dans la transaction.

Pour des performances optimales, le thread d'arrière-plan doit démarrer et se terminer avant l'épuisement des valeurs séquentielles du lot actuel. Sinon, l'application devra attendre le lot suivant, ce qui aura pour effet d'augmenter la latence. Par conséquent, vous devez ajuster la taille du lot et réduire le seuil en fonction de la fréquence d'émission des valeurs séquentielles.

Par exemple, supposons une transaction d'une durée de 20 ms pour récupérer un nouveau lot de valeurs, une taille de lot de 1 000 et une fréquence maximale d'émission de 500 valeurs par seconde (soit une valeur toutes les 2 ms). Au cours des 20 ms pendant lesquelles le nouveau lot de valeurs est généré, 10 valeurs séquentielles sont émises. Par conséquent, le seuil de valeurs séquentielles restantes doit être supérieur à 10 pour garantir la disponibilité du lot suivant en cas de besoin.

L'exemple de code suivant montre comment mettre en œuvre une fonction getNext() en utilisant des lots. Vous remarquerez que le code utilise la fonction getAndIncrementNextValueInDB() définie précédemment pour récupérer un lot de valeurs séquentielles à l'aide d'un thread d'arrière-plan.

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

L'exemple de code suivant montre comment utiliser la fonction getNext() asynchrone par lot dans une requête afin d'obtenir deux valeurs à utiliser dans la transaction :

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

Vous remarquerez que dans ce cas, les valeurs peuvent être demandées au sein de la transaction, car la récupération d'un nouveau lot de valeurs se produit dans un thread d'arrière-plan.

Résumé

Le tableau suivant compare les caractéristiques des quatre types de générateurs de séquence :

Synchrone Asynchrone Lot Par lot asynchrone
Valeurs uniques Oui Oui Oui Oui
Valeurs globalement successives Oui Oui Non
Mais avec une charge suffisamment élevée et une taille de lot suffisamment réduite, les valeurs se rapprochent les unes des autres.
Non
Mais avec une charge suffisamment élevée et une taille de lot suffisamment réduite, les valeurs se rapprochent les unes des autres.
Sans intervalles Oui Non Non Non
Performances 1/latence de transaction,
(~ 25 valeurs par seconde)
50 à 100 valeurs par seconde 50 à 100 lots de valeurs par seconde 50 à 100 lots de valeurs par seconde
Augmentation de la latence Plus de 10 ms
Beaucoup plus importante avec une contention élevée (lorsqu'une transaction prend beaucoup de temps)
10 ms pour chaque transaction
Beaucoup plus importante avec une contention élevée
10 ms, mais uniquement lorsqu'un nouveau lot de valeurs est récupéré Zéro si la taille du lot et le seuil inférieur sont définis sur des valeurs appropriées

Le tableau ci-dessus montre également pourquoi vous devrez peut-être renoncer aux exigences de successivité globale des valeurs et d'absence d'intervalles dans les séries afin de générer des valeurs uniques tout en répondant aux besoins de performances généraux.

Tests de performances

Vous pouvez tester chacun de ces générateurs de séquence et mettre en évidence les caractéristiques de performances et de latence à l'aide d'un outil de test/analyse des performances situé dans le même dépôt GitHub que les classes de générateur de séquence précédentes. L'outil simule une latence de transaction d'application de 10 ms et exécute plusieurs threads simultanément pour demander des valeurs séquentielles.

Les tests de performances ne nécessitent qu'une instance Spanner à nœud unique sur laquelle effectuer le test, car une seule ligne est modifiée.

Par exemple, le résultat suivant montre une comparaison des performances par rapport à la latence lorsque le mode synchrone est utilisé avec 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

Le tableau suivant compare les résultats obtenus avec différents modes et nombres de threads parallèles. Il indique également la fréquence d'émission des valeurs par seconde et la latence aux 50e, 90e et 99e centiles :

Mode et paramètres Nombre de threads Valeurs/s 50e centile de latence (ms) 90e centile de latence (ms) 99e centile
de latence (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
LOT
(taille 200)
10 494 18 20 38
LOT (taille 200) 50 1195 27 55 168
LOT ASYNC
(taille 200, LT 50)
10 512 18 20 30
LOT ASYNC
(taille 200, LT 50)
50 1622 24 28 30

Comme vous pouvez le constater, le mode synchrone (SYNC) a pour effet d'augmenter le nombre de threads, ce qui entraîne des latences de transaction beaucoup plus élevées.

En mode asynchrone (ASYNC), comme la transaction d'obtention de séquence est plus petite et distincte de la transaction de l'application, il y a moins de conflits et la fréquence est plus élevée. Cependant, des conflits peuvent toujours se produire et entraîner des latences plus élevées au 90e centile.

En mode par lot (LOT), la latence est considérablement réduite, sauf pour le 99e centile, qui correspond au moment où le générateur doit demander de manière synchrone un autre lot de valeurs séquentielles à la base de données. Les performances sont bien plus élevées en mode LOT qu'en mode ASYNC.

Le mode par lot avec 50 threads présente une latence plus élevée, car les séquences sont émises si rapidement que le facteur limitant est la puissance de l'instance de machine virtuelle (VM). Dans le cas présent, il s'agit d'une machine à quatre processeurs virtuels qui utilisait 350 % du processeur. L'utilisation de plusieurs machines et processus permet d'obtenir des résultats généraux semblables à ceux du mode par lot avec 10 threads.

En mode LOT ASYNC, la variation de latence est infime et les performances sont plus élevées, même avec un grand nombre de threads, car les demandes de nouveau lot à la base de données présentent une latence entièrement indépendante de la transaction de l'application.

Étape suivante