Geração de sequências no Spanner

Este documento descreve métodos para que administradores de banco de dados e desenvolvedores de aplicativos possam gerar sequências numéricas únicas em aplicativos que usam o Spanner.

Introdução

Muitas vezes, uma empresa exige um código numérico simples e exclusivo, por exemplo, um número de funcionário ou um número de fatura. Geralmente, os bancos de dados relacionais incluem um recurso para gerar sequências de números únicos e monotonicamente crescentes. Essas sequências são usadas para gerar identificadores exclusivos (chaves de linha) para objetos armazenados no banco de dados.

No entanto, o uso de valores monotonicamente crescentes (ou decrescentes) como chaves de linha pode não seguir as práticas recomendadas no Spanner porque isso cria pontos de acesso no banco de dados, levando a uma de redução no desempenho. Este documento propõe mecanismos para implementar um gerador de sequências usando uma tabela de banco de dados do Spanner e a lógica da camada de aplicativo.

Como alternativa, o Spanner oferece suporte a um gerador de sequência reversa de bits integrado. Para mais informações sobre o gerador de sequências do Spanner, consulte Criar e gerenciar sequências.

Requisitos para um gerador de sequências

Cada gerador de sequências precisa gerar um valor único para cada transação.

Dependendo do caso de uso, um gerador de sequências também pode precisar criar sequências com as seguintes características:

  • Ordenado: os valores mais baixos na sequência não podem ser emitidos após valores mais altos.
  • Sem lacunas: não pode haver lacunas na sequência.

O gerador de sequências também precisa gerar valores na frequência exigida pelo aplicativo.

Pode ser difícil atender a todos esses requisitos, especialmente em um sistema distribuído. Para atender aos objetivos de desempenho, os requisitos de que a sequência seja ordenada e sem lacunas podem ser comprometidos, caso seja necessário.

Outros mecanismos de banco de dados têm algumas maneiras de lidar com esses requisitos. Por exemplo, as sequências nas colunas PostgreSQL e AUTO_INCREMENT no MySQL podem gerar valores únicos para transações separadas, mas não podem produzir valores sem lacunas, se as transações forem revertidas. Para saber mais, consulte Observações na documentação do PostgreSQL e implicações do AUTO_INCREMENT no MySQL.

Geradores de sequências usando linhas de tabela do banco de dados

O aplicativo pode implementar um gerador de sequências usando uma tabela de banco de dados para armazenar os nomes da sequência e o próximo valor na sequência.

Ler e incrementar a célula next_value da sequência dentro de uma transação de banco de dados gera valores únicos, sem exigir mais sincronização entre os processos do aplicativo.

Em primeiro lugar, defina a tabela da seguinte maneira:

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

É possível criar sequências inserindo uma linha na tabela com o novo nome de sequência e o valor inicial, por exemplo, ("invoice_id", 1). No entanto, como a célula next_value é incrementada para cada valor de sequência gerado, o desempenho é limitado pela frequência com que a linha pode ser atualizada.

As bibliotecas de cliente do Spanner usam transações repetíveis para resolver conflitos. Se uma célula (valores da coluna) lida durante uma transação de leitura/gravação for modificada em outro lugar, a transação será bloqueada até que a outra transação seja concluída e, então, será cancelada e repetida para que leia os valores atualizados. Isso minimiza a duração dos bloqueios de gravação, mas também significa que uma transação pode ser tentada várias vezes antes de ser confirmada.

Como uma transação pode ocorrer apenas em uma linha por vez, a frequência máxima de emissão de valores de sequência é inversamente proporcional à latência total da transação.

Essa latência da transação total depende de diversos fatores, como a latência entre o aplicativo cliente e os nós do Spanner e a latência entre os nós do Spanner e a incerteza TrueTime. Por exemplo, a configuração multirregional tem uma latência de transação maior porque precisa aguardar um quorum de confirmações de gravação dos nós em diferentes regiões para ser concluída.

Por exemplo, se uma transação de leitura/atualização em uma única célula (uma coluna em uma única linha) tiver uma latência de 10 milissegundos (ms), a frequência teórica máxima de emissão de valores sequenciais será de 100 por segundo. Esse valor máximo se aplica a todo o banco de dados, independentemente do número de instâncias do aplicativo cliente ou do número de nós no banco de dados. Isso ocorre porque uma única linha é sempre gerenciada por um único nó.

A seção a seguir descreve maneiras de contornar essa limitação.

Implementação do lado do aplicativo

O código do aplicativo precisa ler e atualizar a célula next_value no banco de dados. Há várias maneiras de fazer isso, cada uma com características de desempenho e desvantagens diferentes.

Gerador simples de sequências dentro da transação

A maneira mais simples de lidar com a geração de sequências é incrementar o valor da coluna dentro da transação sempre que o aplicativo precisar de um novo valor sequencial.

Em uma única transação, o aplicativo faz o seguinte:

  • Lê a célula next_value do nome da sequência que será usada no aplicativo.
  • Aumenta e atualiza a célula next_value para o nome da sequência.
  • Usa o valor recuperado para qualquer valor de coluna que o aplicativo precise.
  • Completa o restante da transação do aplicativo.

Esse processo gera uma sequência que está em ordem e sem lacunas. Se nada atualizar a célula next_value no banco de dados para um valor menor, a sequência também será única.

Como o valor da sequência é recuperado como parte de uma transação mais ampla do aplicativo, a frequência máxima de geração da sequências depende da complexidade da transação geral do aplicativo. Uma transação complexa terá uma latência maior e, portanto, a frequência máxima mais baixa possível.

Em um sistema distribuído, muitas transações podem ser tentadas ao mesmo tempo, levando a uma alta contenção no valor da sequência. Como a célula next_value é atualizada na transação do aplicativo, qualquer outra transação que tente incrementar a célula next_value ao mesmo tempo será bloqueada pela primeira transação e será executada novamente. Isso leva a um aumento significativo no tempo necessário para que o aplicativo conclua a transação, o que pode causar problemas de desempenho.

O código a seguir fornece um exemplo de um gerador de sequências simples em transação que retorna apenas um único valor de sequência por transação. Essa restrição existe porque as gravações em uma transação que usam a API Mutation não ficam visíveis até que a transação seja confirmada, mesmo para leituras na mesma transação. Portanto, chamar essa função várias vezes na mesma transação sempre retornará o mesmo valor de sequência.

O código de exemplo a seguir mostra como implementar uma função 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;
}

O código de exemplo a seguir mostra como a função getNext() síncrona é usada em uma transação:

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

Gerador de sequências síncronas aprimorado dentro da transação

É possível modificar a abstração anterior para produzir vários valores em uma única transação, monitorando os valores sequenciais emitidos em uma transação.

Em uma única transação, o aplicativo faz o seguinte:

  • Lê a célula next_value do nome da sequência que será usada no aplicativo.
  • Armazena internamente esse valor como uma variável.
  • Cada vez que um novo valor de sequência é solicitado, incrementa a variável next_value armazenada e armazena em buffer uma gravação que define o valor de célula atualizado no banco de dados.
  • Completa o restante da transação do aplicativo.

Se você estiver usando uma abstração, o objeto desta abstração precisa ser criado dentro da transação. O objeto realiza uma única leitura, quando o primeiro valor for solicitado. O objeto monitora internamente a célula next_value, para que mais de um valor possa ser gerado.

As mesmas advertências com relação à latência e à contenção que foram aplicadas à versão anterior também se aplicam a esta versão.

O código de exemplo a seguir mostra como implementar uma função 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;
}

O código de exemplo a seguir mostra como usar a função getNext() síncrona em uma solicitação de dois valores de sequência:

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

Gerador de sequências (assíncronas) fora da transação

Nas duas implementações anteriores, o desempenho do gerador depende da latência da transação do aplicativo. É possível melhorar a frequência máxima incrementando a sequência em uma transação separada. Porém, isso pode causar lacunas na sequência. Essa é a abordagem usada pelo PostgreSQL. É necessário recuperar os valores de sequência que serão usados primeiro, antes que o aplicativo inicie a transação.

O aplicativo faz o seguinte:

  • Cria uma primeira transação para receber e atualizar o valor da sequência:
    • Lê a célula next_value do nome da sequência que será usada no aplicativo.
    • Armazena esse valor como uma variável.
    • Incrementa e atualiza a célula next_value no banco de dados para o nome da sequência.
    • Completa a transação.
  • Usa o valor retornado em uma transação separada.

A latência dessa transação separada será próxima da latência mínima, com o desempenho se aproximando da frequência máxima teórica de 100 valores por segundo (presumindo uma latência de transação de 10 ms). Como os valores de sequência são recuperados separadamente, a latência da transação do aplicativo em si não é alterada e a contenção é minimizada.

No entanto, se um valor de sequência for solicitado e não for usado, uma lacuna será deixada na sequência, já que não será possível reverter os valores de sequência solicitados. Isso pode ocorrer se o aplicativo for cancelado ou falhar durante a transação depois de solicitar um valor de sequência.

O código de exemplo a seguir mostra como implementar uma função que recupera e incrementa a célula next_value no banco de dados:

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

É possível usar essa função facilmente para recuperar um único valor de sequência novo, conforme mostrado na seguinte implementação de uma função getNext() assí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);
}

O código de exemplo a seguir mostra como usar a função getNext() assíncrona em uma solicitação de dois valores de sequência:

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

No exemplo de código anterior, é possível ver que os valores de sequência são solicitados fora da transação do aplicativo. Isso ocorre porque o Cloud Spanner não é compatível com a execução de uma transação dentro de outra transação na mesma linha de execução (elas são também conhecidos como transações aninhadas).

É possível contornar essa restrição solicitando o valor da sequência usando uma linha de execução em segundo plano e aguardando o 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();
}

Gerador de sequências em lote

É possível ter uma melhoria de desempenho significativa ao eliminar o requisito de que os valores de sequência precisam estar em ordem. Isso permite que o aplicativo reserve um lote de valores sequenciais e os emita internamente. As instâncias individuais do aplicativo têm seus próprios lotes de valores separados. Portanto, os valores emitidos não estão em ordem. Além disso, as instâncias do aplicativo que não usam todo o lote de valores (por exemplo, se a instância do aplicativo for encerrada) deixarão lacunas de valores não utilizados na sequência.

O aplicativo vai:

  • manter um estado interno para cada sequência que contém o valor inicial, o tamanho do lote e o próximo valor disponível;
  • solicitar um valor de sequência do lote.
  • Se não houver valores restantes no lote, faça o seguinte:
    • Crie uma transação para ler e atualizar o valor da sequência.
    • Leia a célula next_value para a sequência.
    • Armazene esse valor internamente como o valor inicial do novo lote.
    • Incremente a célula next_value no banco de dados com um valor igual ao tamanho do lote.
    • Conclua a transação.
  • Retorne o próximo valor disponível e incremente o estado interno.
  • Use o valor retornado na transação.

Com esse método, as transações que usam um valor de sequência terão um aumento na latência somente quando um novo lote de valores de sequência precisar ser reservado.

A vantagem é que, ao aumentar o tamanho do lote, o desempenho pode ser aumentado para qualquer nível, porque o fator limitante se torna o número de lotes emitidos por segundo.

Por exemplo, com um tamanho de lote de 100 podem ser emitidos 10 mil valores de sequência por segundo, supondo que haja uma latência de 10 ms para receber um novo lote e, portanto, um máximo de 100 lotes por segundo.

O código de exemplo a seguir mostra como implementar uma função getNext() usando lotes. Observe que o código reutiliza a função getAndIncrementNextValueInDB() definida anteriormente para recuperar novos lotes de valores de sequência do banco de dados.

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

O código de exemplo a seguir mostra como usar a função getNext() assíncrona em uma solicitação de dois valores de sequência:

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

Novamente, os valores precisam ser solicitados fora da transação (ou usando uma linha de execução em segundo plano) porque o Spanner não é compatível transações aninhadas.

Gerador de sequências em lote assíncrono

Para aplicativos com alto desempenho, em que qualquer aumento na latência não é aceitável, é possível melhorar o desempenho do gerador de lotes anterior. Para isso, um novo lote de valores precisa estar pronto quando o lote atual de valores estiver esgotado.

Para conseguir esse resultado, defina um limite que indique quando o número de valores de sequência restante em um lote está muito baixo. Quando o limite é atingido, o gerador da sequência começa a solicitar um novo lote de valores em uma linha de execução de segundo plano.

Assim como a versão anterior, os valores não são emitidos em ordem e haverá lacunas nos valores não utilizados na sequência, se as transações falharem ou se as instâncias do aplicativo forem encerradas.

O aplicativo vai:

  • manter um estado interno para cada sequência que contém o valor inicial do lote e o próximo valor disponível;
  • solicitar um valor de sequência do lote.
  • Se os valores restantes no lote forem menores que o limite, faça o seguinte em uma linha de execução em segundo plano:
    • Crie uma transação para ler e atualizar o valor da sequência.
    • Leia a célula next_value do nome da sequência que será usada no aplicativo.
    • Armazene esse valor internamente como o valor inicial do próximo lote.
    • Incremente a célula next_value no banco de dados com um valor igual ao tamanho do lote.
    • Conclua a transação.
  • Se não houver valores restantes no lote, recupere o valor inicial do próximo lote da linha de execução de segundo plano (aguardando a conclusão, se necessário) e crie um novo lote usando o valor inicial recuperado como o próximo valor.
  • Retorne o próximo valor e incremente o estado interno.
  • Use o valor retornado na transação.

Para um desempenho ideal, a linha de execução de segundo plano precisa ser iniciada e concluída antes que os valores de sequência no lote atual terminem. Caso contrário, o aplicativo precisará aguardar o próximo lote e a latência vai aumentar. Portanto, é necessário ajustar o tamanho do lote e o limite mínimo, dependendo da frequência dos valores de sequência emitidos.

Por exemplo, suponha um tempo de transação de 20 ms para recuperar um novo lote de valores, um tamanho de lote de 1.000 e uma frequência máxima de emissão de sequência de 500 valores por segundo (um valor a cada 2 ms). Durante os 20 ms em que um novo lote de valores é emitido, 10 valores de sequência são emitidos. Portanto, o limite para o número de valores de sequência restantes deve ser maior que 10, para que o próximo lote esteja disponível, quando necessário.

O código de exemplo a seguir mostra como implementar uma função getNext() usando lotes. Observe que o código usa a função getAndIncrementNextValueInDB() definida anteriormente para recuperar um lote de valores de sequência usando uma linha de execução em 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;
}

O código de exemplo a seguir mostra como a função getNext() em lote assíncrona é usada em uma solicitação de dois valores na transação:

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

Nesse caso, os valores podem ser solicitados dentro da transação, porque a recuperação de um novo lote de valores ocorre em uma linha de execução em segundo plano.

Resumo

A tabela a seguir compara as características dos quatro tipos de geradores de sequências:

Síncrona Assíncrona Lote Lote assíncrono
Valores exclusivos Sim Sim Sim Sim
Valores ordenados globalmente Sim Sim Não.
Porém, com uma carga alta o suficiente e um tamanho de lote pequeno o suficiente, os valores ficarão próximos uns dos outros
Não.
Porém, com uma carga alta o suficiente e um tamanho de lote pequeno o suficiente, os valores ficarão próximos uns dos outros
Sem lacunas Sim Não Não Não
Desempenho 1/latência da transação,
(~ 25 valores por segundo)
50 a 100 valores por segundo 50 a 100 lotes de valores por segundo 50 a 100 lotes de valores por segundo
Aumento da latência > 10 ms
. Significativamente mais alto e com alta contenção (quando uma transação demora de forma considerável)
10 ms em cada transação
Significativamente maior e com alta contenção
10 ms, mas somente quando um novo lote de valores for recuperado Zero, se o tamanho do lote e o limite mínimo estiverem definidos com os valores apropriados

A tabela anterior também ilustra o fato de que talvez seja necessário comprometer os requisitos de valores ordenados globalmente e séries de valores sem lacunas para gerar valores únicos, atendendo aos requisitos gerais de desempenho.

Teste de desempenho

É possível usar uma ferramenta de teste/análise de desempenho, localizada no mesmo repositório do GitHub que as classes geradoras de sequências anteriores, para testar cada um desses geradores de sequências e comprovar as características de desempenho e latência. A ferramenta simula uma latência de transação de aplicativo de 10 ms e executa várias linhas de execução simultaneamente que solicitam valores de sequência.

Os testes de desempenho precisam apenas de uma instância de nó único do Spanner para testar, porque apenas uma linha será modificada.

Por exemplo, a saída a seguir mostra uma comparação entre desempenho e latência no modo síncrono com 10 linhas de execução:

$ 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

A tabela a seguir compara os resultados de vários modos e números de sequências paralelas, incluindo o número de valores que podem ser emitidos por segundo e a latência nos percentis 50º, 90º e 99º:

Modo e parâmetros Número de linhas de execução Valores/segundo Latência do 50° percentil (ms) Latência do 90° percentil (ms) Latência do 99° percentil
(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
(tamanho 200)
10 494 18 20 38
BATCH (tamanho do lote 200) 50 1195 27 55 168
ASYNC BATCH
(tamanho do lote 200, LT 50)
10 512 18 20 30
ASYNC BATCH
(tamanho do lote 200, LT 50)
50 1622 24 28 30

É possível ver que no modo síncrono (SYNC), o aumento do número de linhas de execução causa aumento da contenção. Isso leva a latências de transação significativamente altas.

No modo assíncrono (ASYNC), como a transação para receber a sequência é menor e separada da transação do aplicativo, há menos contenção e a frequência é maior. No entanto, a contenção ainda pode ocorrer, levando a maiores latências do 90° percentil.

No modo em lote (BATCH), a latência é reduzida significativamente, exceto para o 99º percentil, que corresponde ao momento em que o gerador precisa solicitar outro lote de valores de sequência do banco de dados. O desempenho é muitas vezes maior no modo BATCH do que no modo ASYNC.

O modo de lote com 50 linhas de execução tem maior latência porque as sequências são emitidas tão rapidamente que o fator limitante é a potência da instância de máquina virtual (VM, na sigla em inglês). Nesse caso, durante o teste, uma máquina com 4 vCPUs estava sendo executada com 350% de CPU. O uso de várias máquinas e vários processos mostraria resultados gerais semelhantes ao modo de lote com 10 linhas de execução.

No modo BATCH ASYNC, a variação na latência é mínima e o desempenho é maior, mesmo com um grande número de linhas de execução, porque a latência de solicitação de um novo lote do banco de dados é completamente independente da transação do aplicativo.

A seguir