Generación de secuencias en Spanner

En este documento se describen los métodos que pueden usar los administradores de bases de datos y los desarrolladores de aplicaciones para generar secuencias numéricas únicas en aplicaciones que usan Spanner.

Introducción

A menudo, las empresas necesitan un ID numérico único y sencillo, por ejemplo, un número de empleado o un número de factura. Las bases de datos relacionales convencionales suelen incluir una función para generar secuencias únicas de números que aumentan de forma monótona. Estas secuencias se usan para generar identificadores únicos (claves de fila) de los objetos almacenados en la base de datos.

Sin embargo, usar valores que aumenten (o disminuyan) de forma monótona como claves de fila puede no seguir las prácticas recomendadas de Spanner, ya que crea puntos de acceso en la base de datos, lo que provoca una reducción del rendimiento. En este documento se proponen mecanismos para implementar un generador de secuencias mediante una tabla de base de datos de Spanner y lógica de capa de aplicación.

También puedes usar un generador de secuencias invertidas de bits integrado en Spanner. Para obtener más información sobre el generador de secuencias de Spanner, consulta Crear y gestionar secuencias.

Requisitos de un generador de secuencias

Cada generador de secuencias debe generar un valor único para cada transacción.

Según el caso práctico, es posible que un generador de secuencias también tenga que crear secuencias con las siguientes características:

  • Ordenada: los valores más bajos de la secuencia no deben emitirse después de los valores más altos.
  • Sin espacios: no debe haber espacios en la secuencia.

El generador de secuencias también debe generar valores con la frecuencia que requiera la aplicación.

Puede ser difícil cumplir todos estos requisitos, especialmente en un sistema distribuido. Si es necesario para alcanzar tus objetivos de rendimiento, puedes hacer concesiones en los requisitos de que la secuencia esté ordenada y no tenga huecos.

Otros motores de bases de datos tienen formas de gestionar estos requisitos. Por ejemplo, las secuencias de PostgreSQL y las columnas AUTO_INCREMENT de MySQL pueden generar valores únicos para transacciones independientes, pero no pueden producir valores sin espacios si se revierten las transacciones. Para obtener más información, consulta las notas de la documentación de PostgreSQL y las implicaciones de AUTO_INCREMENT en MySQL.

Generadores de secuencias que usan filas de tablas de bases de datos

Tu aplicación puede implementar un generador de secuencias mediante una tabla de base de datos para almacenar los nombres de las secuencias y el siguiente valor de la secuencia.

Leer e incrementar la celda next_value de la secuencia dentro de una transacción de base de datos genera valores únicos sin necesidad de sincronizar los procesos de la aplicación.

Primero, define la tabla de la siguiente manera:

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

Para crear secuencias, inserta una fila en la tabla con el nombre de la nueva secuencia y el valor inicial (por ejemplo, ("invoice_id", 1)). Sin embargo, como la celda next_value se incrementa por cada valor de secuencia generado, el rendimiento se limita a la frecuencia con la que se puede actualizar la fila.

Las bibliotecas de cliente de Spanner usan transacciones reintentables para resolver conflictos. Si se modifican en otro lugar las celdas (valores de columna) que se leen durante una transacción de lectura y escritura, la transacción se bloqueará hasta que se complete la otra transacción. Después, se cancelará y se volverá a intentar para que lea los valores actualizados. De esta forma, se minimiza la duración de los bloqueos de escritura, pero también significa que se puede intentar realizar una transacción varias veces antes de que se confirme correctamente.

Como solo puede haber una transacción en una fila a la vez, la frecuencia máxima de emisión de valores de secuencia es inversamente proporcional a la latencia total de la transacción.

La latencia total de la transacción depende de varios factores, como la latencia entre la aplicación cliente y los nodos de Spanner, la latencia entre los nodos de Spanner y la incertidumbre de TrueTime. Por ejemplo, la configuración multirregional tiene una latencia de transacción mayor porque debe esperar a que se alcance el quórum de confirmaciones de escritura de los nodos de diferentes regiones para completarse.

Por ejemplo, si una transacción de lectura y actualización en una sola celda (una columna de una sola fila) tiene una latencia de 10 milisegundos (ms), la frecuencia teórica máxima de emisión de valores de secuencia es de 100 por segundo. Este máximo se aplica a toda la base de datos, independientemente del número de instancias de la aplicación cliente o del número de nodos de la base de datos. Esto se debe a que un solo nodo siempre gestiona una sola fila.

En la siguiente sección se describen formas de evitar esta limitación.

Implementación del lado de la aplicación

El código de la aplicación debe leer y actualizar la celda next_value de la base de datos. Hay varias formas de hacerlo, cada una con diferentes características de rendimiento e inconvenientes.

Generador de secuencias sencillo dentro de una transacción.

La forma más sencilla de gestionar la generación de secuencias es incrementar el valor de la columna dentro de la transacción cada vez que la aplicación necesite un nuevo valor secuencial.

En una sola transacción, la aplicación hace lo siguiente:

  • Lee la celda next_value para obtener el nombre de la secuencia que se va a usar en la aplicación.
  • Incrementa y actualiza la celda next_value del nombre de la secuencia.
  • Usa el valor obtenido para cualquier valor de columna que necesite la aplicación.
  • Completa el resto de la transacción de la aplicación.

Este proceso genera una secuencia ordenada y sin huecos. Si nada actualiza la celda next_value de la base de datos a un valor inferior, la secuencia también será única.

Como el valor de la secuencia se obtiene como parte de la transacción de la aplicación más amplia, la frecuencia máxima de generación de secuencias depende de la complejidad de la transacción de la aplicación en general. Una transacción compleja tendrá una latencia mayor y, por lo tanto, una frecuencia máxima posible menor.

En un sistema distribuido, se pueden intentar muchas transacciones al mismo tiempo, lo que provoca una gran contención en el valor de la secuencia. Como la celda next_value se actualiza en la transacción de la aplicación, cualquier otra transacción que intente incrementar la celda next_value al mismo tiempo se bloqueará por la primera transacción y se volverá a intentar. Esto provoca un aumento considerable del tiempo necesario para que la aplicación complete la transacción correctamente, lo que puede causar problemas de rendimiento.

El siguiente código proporciona un ejemplo de un generador de secuencias simple en una transacción que devuelve solo un valor de secuencia por transacción. Esta restricción se debe a que las escrituras de una transacción que usa la API Mutation no se pueden ver hasta que se confirma la transacción, ni siquiera en las lecturas de la misma transacción. Por lo tanto, si se llama a esta función varias veces en la misma transacción, siempre se devolverá el mismo valor de secuencia.

En el siguiente ejemplo de código se muestra cómo implementar una función getNext() síncrona:

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

El siguiente código de ejemplo muestra cómo se usa la función síncrona getNext() en una transacción:

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

Generador de secuencias síncronas y dentro de la transacción mejorado

Puedes modificar la abstracción anterior para generar varios valores en una sola transacción haciendo un seguimiento de los valores de secuencia emitidos en una transacción.

En una sola transacción, la aplicación hace lo siguiente:

  • Lee la celda next_value para obtener el nombre de la secuencia que se va a usar en la aplicación.
  • Almacena este valor como una variable internamente.
  • Cada vez que se solicita un nuevo valor de secuencia, se incrementa la variable next_value almacenada y se almacena en el búfer una escritura que define el valor de celda actualizado en la base de datos.
  • Completa el resto de la transacción de la aplicación.

Si usas una abstracción, el objeto de esta abstracción debe crearse en la transacción. El objeto realiza una sola lectura cuando se solicita el primer valor. El objeto registra internamente la celda next_value para que se pueda generar más de un valor.

Las mismas advertencias sobre latencia y contención que se aplicaban a la versión anterior también se aplican a esta versión.

En el siguiente ejemplo de código se muestra cómo implementar una función getNext() síncrona:

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

El siguiente código de ejemplo muestra cómo usar la función síncrona getNext() en una solicitud de dos valores de secuencia:

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

Generador de secuencias fuera de transacción (asíncrono)

En las dos implementaciones anteriores, el rendimiento del generador depende de la latencia de la transacción de la aplicación. Puedes mejorar la frecuencia máxima (a costa de tolerar huecos en la secuencia) incrementando la secuencia en una transacción independiente. Este es el enfoque que usa PostgreSQL. Debe recuperar los valores de secuencia que se van a usar primero, antes de que la aplicación inicie su transacción.

La aplicación hace lo siguiente:

  • Crea una primera transacción para obtener y actualizar el valor de la secuencia:
    • Lee la celda next_value para obtener el nombre de la secuencia que se va a usar en la aplicación.
    • Almacena este valor como una variable.
    • Incrementa y actualiza la celda next_value de la base de datos con el nombre de la secuencia.
    • Completa la transacción.
  • Usa el valor devuelto en una transacción independiente.

La latencia de esta transacción independiente será cercana a la latencia mínima, y el rendimiento se aproximará a la frecuencia teórica máxima de 100 valores por segundo (suponiendo una latencia de transacción de 10 ms). Como los valores de secuencia se obtienen por separado, la latencia de la transacción de la aplicación no cambia y la contención se minimiza.

Sin embargo, si se solicita un valor de secuencia y no se usa, se deja un hueco en la secuencia porque no es posible revertir los valores de secuencia solicitados. Esto puede ocurrir si la aplicación se interrumpe o falla durante la transacción después de solicitar un valor de secuencia.

En el siguiente ejemplo de código se muestra cómo implementar una función que obtiene e incrementa la celda next_value de la base de datos:

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

Puedes usar esta función fácilmente para obtener un único valor de secuencia nuevo, como se muestra en la siguiente implementación de una función getNext() asíncrona:

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

El siguiente código de ejemplo muestra cómo usar la función asíncrona getNext() en una solicitud de dos valores de secuencia:

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

En el ejemplo de código anterior, puede ver que los valores de secuencia se solicitan fuera de la transacción de la aplicación. Esto se debe a que Cloud Spanner no admite la ejecución de una transacción dentro de otra en el mismo subproceso (también conocidas como transacciones anidadas).

Puedes eludir esta restricción solicitando el valor de la secuencia mediante un hilo en segundo plano y esperando el resultado:

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

Generador de secuencias de lotes

Puedes mejorar significativamente el rendimiento si también eliminas el requisito de que los valores de la secuencia estén ordenados. De esta forma, la aplicación puede reservar un lote de valores de secuencia y emitirlos internamente. Las instancias de aplicaciones individuales tienen su propio lote de valores, por lo que los valores que se emiten no están ordenados. Además, las instancias de la aplicación que no usen todo su lote de valores (por ejemplo, si la instancia de la aplicación se cierra) dejarán huecos de valores no utilizados en la secuencia.

La aplicación hará lo siguiente:

  • Mantener un estado interno para cada secuencia que contenga el valor inicial, el tamaño del lote y el siguiente valor disponible.
  • Solicita un valor de secuencia del lote.
  • Si no quedan valores en el lote, haga lo siguiente:
    • Crea una transacción para leer y actualizar el valor de la secuencia.
    • Lee la celda next_value de la secuencia.
    • Almacena este valor internamente como el valor inicial del nuevo lote.
    • Incrementa la celda next_value de la base de datos en una cantidad igual al tamaño del lote.
    • Completa la transacción.
  • Devuelve el siguiente valor disponible e incrementa el estado interno.
  • Usa el valor devuelto en la transacción.

Con este método, las transacciones que usen un valor de secuencia experimentarán un aumento de la latencia solo cuando se tenga que reservar un nuevo lote de valores de secuencia.

La ventaja es que, al aumentar el tamaño del lote, el rendimiento se puede incrementar hasta cualquier nivel, ya que el factor limitante pasa a ser el número de lotes emitidos por segundo.

Por ejemplo, con un tamaño de lote de 100 (suponiendo una latencia de 10 ms para obtener un nuevo lote y, por lo tanto, un máximo de 100 lotes por segundo), se pueden emitir 10.000 valores de secuencia por segundo.

En el siguiente ejemplo de código se muestra cómo implementar una función getNext() mediante lotes. Ten en cuenta que el código reutiliza la getAndIncrementNextValueInDB()función definida anteriormente para obtener nuevos lotes de valores de secuencia de la base de datos.

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

El siguiente código de ejemplo muestra cómo usar la función asíncrona getNext() en una solicitud de dos valores de secuencia:

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

De nuevo, los valores deben solicitarse fuera de la transacción (o mediante un hilo en segundo plano), ya que Spanner no admite transacciones anidadas.

Generador de secuencias de lotes asíncronas

En las aplicaciones de alto rendimiento en las que no se puede aceptar ningún aumento de la latencia, puedes mejorar el rendimiento del generador de lotes anterior teniendo preparado un nuevo lote de valores para cuando se agote el lote de valores actual.

Para ello, puede definir un umbral que indique cuándo es demasiado bajo el número de valores de secuencia que quedan en un lote. Cuando se alcanza el umbral, el generador de secuencias empieza a solicitar un nuevo lote de valores en un subproceso en segundo plano.

Al igual que en la versión anterior, los valores no se emiten en orden y habrá huecos de valores no utilizados en la secuencia si las transacciones fallan o si se cierran las instancias de la aplicación.

La aplicación hará lo siguiente:

  • Mantener un estado interno para cada secuencia, que contenga el valor inicial del lote y el siguiente valor disponible.
  • Solicita un valor de secuencia del lote.
  • Si los valores restantes del lote son inferiores al umbral, haz lo siguiente en un subproceso en segundo plano:
    • Crea una transacción para leer y actualizar el valor de la secuencia.
    • Lee la celda next_value para ver el nombre de la secuencia que se va a usar en la aplicación.
    • Almacena este valor internamente como el valor inicial del siguiente lote.
    • Incrementa en la base de datos la celda next_value en una cantidad igual al tamaño del lote.
    • Completa la transacción.
  • Si no quedan valores en el lote, recupera el valor inicial del siguiente lote del subproceso en segundo plano (esperando a que se complete si es necesario) y crea un nuevo lote con el valor inicial recuperado como siguiente valor.
  • Devuelve el siguiente valor e incrementa el estado interno.
  • Usa el valor devuelto en la transacción.

Para que el rendimiento sea óptimo, el subproceso en segundo plano debe iniciarse y completarse antes de que se agoten los valores de secuencia del lote actual. De lo contrario, la aplicación tendrá que esperar al siguiente lote y la latencia aumentará. Por lo tanto, tendrás que ajustar el tamaño del lote y el umbral bajo en función de la frecuencia con la que se emitan los valores de la secuencia.

Por ejemplo, supongamos que el tiempo de transacción para obtener un nuevo lote de valores es de 20 ms, el tamaño del lote es de 1000 y la frecuencia máxima de emisión de secuencias es de 500 valores por segundo (un valor cada 2 ms). Durante los 20 ms en los que se emite un nuevo lote de valores, se emitirán 10 valores de secuencia. Por lo tanto, el umbral del número de valores de secuencia restantes debe ser superior a 10 para que el siguiente lote esté disponible cuando sea necesario.

En el siguiente ejemplo de código se muestra cómo implementar una función getNext() mediante lotes. Ten en cuenta que el código usa la función getAndIncrementNextValueInDB() definida anteriormente para obtener un lote de valores de secuencia mediante un subproceso en segundo plano.

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

El siguiente código de ejemplo muestra cómo se usa la función asíncrona por lotes getNext() en una solicitud de dos valores que se van a usar en la transacción:

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

Ten en cuenta que, en este caso, los valores se pueden solicitar dentro de la transacción, ya que la recuperación de un nuevo lote de valores se produce en un subproceso en segundo plano.

Resumen

En la siguiente tabla se comparan las características de los cuatro tipos de generadores de secuencias:

Síncrona Asíncrono Lotes Lote asíncrono
Valores únicos
Valores ordenados globalmente No
Pero con una carga lo suficientemente alta y un tamaño de lote lo suficientemente pequeño, los valores estarán cerca entre sí
No
Pero con una carga lo suficientemente alta y un tamaño de lote lo suficientemente pequeño, los valores estarán cerca entre sí
Sin pausas No No No
Rendimiento Latencia de 1 transacción,
(unas 25 transacciones por segundo)
Entre 50 y 100 valores por segundo Entre 50 y 100 lotes de valores por segundo Entre 50 y 100 lotes de valores por segundo
Aumento de la latencia > 10 ms
Significativamente más alto con una alta contención (cuando una transacción tarda mucho tiempo)
10 ms en cada transacción
Significativamente más alto con una alta contención
10 ms, pero solo cuando se obtiene un nuevo lote de valores Cero, si el tamaño del lote y el umbral inferior se han definido con valores adecuados

La tabla anterior también ilustra el hecho de que es posible que tengas que hacer concesiones en los requisitos de los valores ordenados globalmente y las series de valores sin huecos para generar valores únicos y, al mismo tiempo, cumplir los requisitos generales de rendimiento.

Pruebas de rendimiento

Puedes usar una herramienta de prueba o análisis de rendimiento, que se encuentra en el mismo repositorio de GitHub que las clases de generador de secuencias anteriores, para probar cada uno de estos generadores de secuencias y demostrar las características de rendimiento y latencia. La herramienta simula una latencia de transacción de aplicación de 10 ms y ejecuta varios hilos simultáneamente que solicitan valores de secuencia.

Las pruebas de rendimiento solo necesitan una instancia de Spanner de un solo nodo para realizar las pruebas, ya que solo se modifica una fila.

Por ejemplo, el siguiente resultado muestra una comparación del rendimiento y la latencia en el modo síncrono con 10 hilos:

$ 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

En la siguiente tabla se comparan los resultados de varios modos y números de hilos paralelos, incluido el número de valores que se pueden emitir por segundo y la latencia en los percentiles 50, 90 y 99:

Modo y parámetros Número de cadenas Valores/s Latencia del percentil 50 (ms) Latencia del percentil 90 (ms) Latencia del percentil 99
(ms)
SINCRONIZAR 10 34 27 1189 2703
SINCRONIZAR 50 30,6 1191 3513 5982
ASYNC 10 66,5 28 611 1460
ASYNC 50 78,1 29 1695 3442
LOTE
(tamaño 200)
10 494 18 20 38
LOTE (tamaño del lote 200) 50 1195 27 55 168
LOTE ASÍNCRONO
(tamaño del lote 200, LT 50)
10 512 18 20 30
LOTE ASÍNCRONO
(tamaño del lote 200, LT 50)
50 1622 24 28 30

Como puedes ver, en el modo síncrono (SYNC), al aumentar el número de hilos, se produce un aumento de la contención. Esto provoca latencias de transacción significativamente más altas.

En el modo asíncrono (ASYNC), como la transacción para obtener la secuencia es más pequeña y está separada de la transacción de la aplicación, hay menos contención y la frecuencia es mayor. Sin embargo, aún puede producirse una contención, lo que provoca latencias más altas en el percentil 90.

En el modo por lotes (BATCH), la latencia se reduce significativamente, excepto en el percentil 99, que corresponde a cuando el generador necesita solicitar de forma síncrona otro lote de valores de secuencia de la base de datos. El rendimiento es muchas veces mayor en el modo BATCH que en el modo ASYNC.

El modo por lotes de 50 hilos tiene una latencia mayor porque las secuencias se emiten tan rápido que el factor limitante es la potencia de la instancia de máquina virtual (en este caso, una máquina de 4 vCPUs funcionaba al 350% de CPU durante la prueba). Si se usan varias máquinas y varios procesos, los resultados generales serían similares a los del modo por lotes de 10 hilos.

En el modo ASYNC BATCH, la variación de la latencia es mínima y el rendimiento es mayor, incluso con un gran número de subprocesos, porque la latencia de solicitar un nuevo lote a la base de datos es completamente independiente de la transacción de la aplicación.

Siguientes pasos