Geração de sequências no Spanner

Este documento descreve métodos para os administradores de bases de dados e os programadores de aplicações gerarem sequências numéricas únicas em aplicações que usam o Spanner.

Introdução

Existem frequentemente situações em que uma empresa requer um ID numérico simples e único, por exemplo, um número de funcionário ou um número de fatura. As bases de dados relacionais convencionais incluem frequentemente uma funcionalidade para gerar sequências únicas de números que aumentam monotonicamente. Estas sequências são usadas para gerar identificadores únicos (chaves de linhas) para objetos armazenados na base de dados.

No entanto, a utilização de valores que aumentam (ou diminuem) monotonicamente como chaves de linhas pode não seguir as práticas recomendadas no Spanner, uma vez que cria pontos críticos na base de dados, o que leva a uma redução no desempenho. Este documento propõe mecanismos para implementar um gerador de sequências através de uma tabela da base de dados do Spanner e da lógica da camada de aplicação.

Em alternativa, o Spanner suporta um gerador de sequência invertida de bits incorporado. Para mais informações sobre o gerador de sequências do Spanner, consulte o artigo Crie e faça a gestão de sequências.

Requisitos para um gerador de sequências

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

Consoante o exemplo de utilização, um gerador de sequências também pode ter de criar sequências com as seguintes caraterísticas:

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

O gerador de sequências também tem de gerar valores na frequência exigida pela aplicação.

Pode ser difícil cumprir todos estes requisitos, especialmente num sistema distribuído. Se for necessário para cumprir os seus objetivos de desempenho, pode fazer concessões nos requisitos de que a sequência seja ordenada e sem lacunas.

Outros motores de base de dados têm formas de processar estes requisitos. Por exemplo, as sequências no PostgreSQL e as colunas 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 mais informações, consulte as Notas na documentação do PostgreSQL) e as Implicações do AUTO_INCREMENT no MySQL).

Geradores de sequências com linhas de tabelas de base de dados

A sua aplicação pode implementar um gerador de sequências através de uma tabela de base de dados para armazenar os nomes das sequências e o valor seguinte na sequência.

A leitura e o incremento da célula next_value da sequência numa transação da base de dados geram valores únicos, sem necessidade de sincronização adicional entre os processos da aplicação.

Primeiro, defina a tabela da seguinte forma:

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

Pode criar sequências inserindo uma linha na tabela com o nome da nova sequência e o valor inicial, por exemplo, ("invoice_id", 1). No entanto, uma vez que 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 cliente do Spanner usam transações repetíveis para resolver conflitos. Se alguma célula (valores de coluna) lida durante uma transação de leitura/escrita for modificada noutro local, a transação é bloqueada até que a outra transação seja concluída. Em seguida, é anulada e repetida para que leia os valores atualizados. Isto minimiza a duração dos bloqueios de escrita, mas também significa que uma transação pode ser tentada várias vezes antes de ser confirmada com êxito.

Uma vez que só pode ocorrer uma transação numa linha de cada vez, a frequência máxima de emissão de valores de sequência é inversamente proporcional à latência total da transação.

Esta latência total da transação depende de vários fatores, como a latência entre a aplicação cliente e os nós do Spanner, a latência entre os nós do Spanner e a incerteza do TrueTime. Por exemplo, a configuração multirregional tem uma latência de transação mais elevada porque tem de aguardar um quórum de confirmações de escrita dos nós em diferentes regiões para ser concluída.

Por exemplo, se uma transação de leitura-atualização numa única célula (uma coluna numa única linha) tiver uma latência de 10 milissegundos (ms), a frequência teórica máxima de emissão de valores de sequência é de 100 por segundo. Este máximo aplica-se a toda a base de dados, independentemente do número de instâncias da aplicação cliente ou do número de nós na base de dados. Isto deve-se ao facto de uma única linha ser sempre gerida por um único nó.

A secção seguinte descreve formas de contornar esta limitação.

Implementação do lado da aplicação

O código da aplicação tem de ler e atualizar a célula next_value na base de dados. Existem várias formas de o fazer, cada uma com diferentes características de desempenho e desvantagens.

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

A forma mais simples de processar a geração de sequências é incrementar o valor da coluna na transação sempre que a aplicação precisar de um novo valor sequencial.

Numa única transação, a aplicação faz o seguinte:

  • Lê a célula next_value para o nome da sequência a usar na aplicação.
  • Incrementa e atualiza a célula next_value para o nome da sequência.
  • Usa o valor obtido para qualquer valor de coluna de que a aplicação necessite.
  • Conclui o resto da transação da aplicação.

Este processo gera uma sequência ordenada e sem lacunas. Se nada atualizar a célula next_value na base de dados para um valor inferior, a sequência também é única.

Uma vez que o valor da sequência é obtido como parte da transação da aplicação mais ampla, a frequência máxima de geração de sequências depende da complexidade da transação geral da aplicação. Uma transação complexa tem uma latência mais elevada e, por conseguinte, uma frequência máxima possível mais baixa.

Num sistema distribuído, podem ser tentadas muitas transações ao mesmo tempo, o que leva a uma elevada contenção no valor da sequência. Uma vez que a célula next_value é atualizada na transação da aplicação, quaisquer outras transações que tentem incrementar a célula next_value ao mesmo tempo são bloqueadas pela primeira transação e são repetidas. Isto leva a grandes aumentos no tempo necessário para a aplicação concluir com êxito a transação, o que pode causar problemas de desempenho.

O código seguinte fornece um exemplo de um gerador de sequência na transação simples que devolve apenas um único valor de sequência por transação. Esta restrição existe porque as escritas numa transação que usa a API Mutation não são visíveis até a transação ser confirmada, mesmo para leituras na mesma transação. Por conseguinte, chamar esta função várias vezes na mesma transação devolve sempre o mesmo valor da sequência.

O exemplo de código seguinte mostra como implementar uma função síncrona:getNext()

/**
 * 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 exemplo de código seguinte mostra como a função getNext() síncrona é usada numa 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 melhorado na transação

Pode modificar a abstração anterior para produzir vários valores numa única transação acompanhando os valores de sequência emitidos numa transação.

Numa única transação, a aplicação faz o seguinte:

  • Lê a célula next_value para o nome da sequência a usar na aplicação.
  • Armazena este valor como uma variável internamente.
  • Sempre que é pedido um novo valor de sequência, incrementa a variável next_value armazenada e armazena em buffer uma gravação que define o valor da célula atualizado na base de dados.
  • Conclui o resto da transação da aplicação.

Se estiver a usar uma abstração, o objeto desta abstração tem de ser criado na transação. O objeto executa uma única leitura quando o primeiro valor é pedido. O objeto monitoriza internamente a célula next_value, para que seja possível gerar mais do que um valor.

As mesmas ressalvas relativas à latência e à contenção que se aplicavam à versão anterior também se aplicam a esta versão.

O exemplo de código seguinte mostra como implementar uma função síncrona:getNext()

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 exemplo de código seguinte mostra como usar a função síncrona getNext() num pedido 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 fora da transação (assíncrono)

Nas duas implementações anteriores, o desempenho do gerador depende da latência da transação da aplicação. Pode melhorar a frequência máxima, à custa de tolerar lacunas na sequência, incrementando a sequência numa transação separada. (Esta é a abordagem usada pelo PostgreSQL.) Deve obter primeiro os valores da sequência a usar antes de a aplicação iniciar a respetiva transação.

A aplicação faz o seguinte:

  • Cria uma primeira transação para obter e atualizar o valor da sequência:
    • Lê a célula next_value para o nome da sequência a usar na aplicação.
    • Armazena este valor como uma variável.
    • Incrementa e atualiza a célula next_value na base de dados para o nome da sequência.
    • Conclui a transação.
  • Usa o valor devolvido numa transação separada.

A latência desta transação separada vai ser próxima da latência mínima, com o desempenho a aproximar-se da frequência teórica máxima de 100 valores por segundo (partindo do princípio de uma latência de transação de 10 ms). Uma vez que os valores da sequência são obtidos separadamente, a latência da transação da aplicação em si não é alterada e a contenção é minimizada.

No entanto, se for pedido um valor de sequência e não for usado, fica uma lacuna na sequência porque não é possível reverter os valores de sequência pedidos. Isto pode ocorrer se a aplicação for anulada ou falhar durante a transação após pedir um valor de sequência.

O exemplo de código seguinte mostra como implementar uma função que obtém e incrementa a célula next_value na base 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;
          });
}

Pode usar facilmente esta função para obter um único novo valor de sequência, como 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 exemplo de código seguinte mostra como usar a função getNext() assíncrona num pedido 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, pode ver que os valores da sequência são pedidos fora da transação da aplicação. Isto deve-se ao facto de o Cloud Spanner não suportar a execução de uma transação dentro de outra transação no mesmo segmento (também conhecido como transações aninhadas).

Pode contornar esta restrição pedindo o valor da sequência através de um processo 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 de lotes

Pode obter uma melhoria significativa do desempenho se também eliminar o requisito de que os valores da sequência têm de estar por ordem. Isto permite que a aplicação reserve um lote de valores de sequência e os emita internamente. As instâncias de aplicações individuais têm o seu próprio lote de valores separado, pelo que os valores emitidos não estão por ordem. Além disso, as instâncias da aplicação que não usam o lote completo de valores (por exemplo, se a instância da aplicação for encerrada) deixam lacunas de valores não usados na sequência.

A aplicação vai fazer o seguinte:

  • Manter um estado interno para cada sequência que contenha o valor inicial, o tamanho do lote e o próximo valor disponível.
  • Pedir um valor de sequência do lote.
  • Se não existirem 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 este valor internamente como o valor inicial do novo lote.
    • Incrementar a célula next_value na base de dados por um valor igual ao tamanho do lote.
    • Conclua a transação.
  • Devolve o próximo valor disponível e incrementa o estado interno.
  • Use o valor devolvido na transação.

Com este método, as transações que usam um valor de sequência vão sofrer um aumento na latência apenas quando for necessário reservar um novo lote de valores de sequência.

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

Por exemplo, com um tamanho do lote de 100, partindo do princípio de uma latência de 10 ms para obter um novo lote e, por conseguinte, um máximo de 100 lotes por segundo, podem ser emitidos 10 000 valores de sequência por segundo.

O exemplo de código seguinte mostra como implementar uma função getNext() usando lotes. Repare que o código reutiliza a função definida anteriormente para obter novos lotes de valores de sequência da base de dados.getAndIncrementNextValueInDB()

/**
 * 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 exemplo de código seguinte mostra como usar a função getNext() assíncrona num pedido 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;
            }
          });
}

Mais uma vez, os valores têm de ser pedidos fora da transação (ou através de um processo em segundo plano) porque o Spanner não suporta transações aninhadas.

Gerador de sequência de lotes assíncrono

Para aplicações de elevado desempenho em que qualquer aumento na latência não é aceitável, pode melhorar o desempenho do gerador de lotes anterior tendo um novo lote de valores pronto para quando o lote de valores atual estiver esgotado.

Pode fazê-lo definindo um limite que indica quando o número de valores de sequência restantes num lote é demasiado baixo. Quando o limite é atingido, o gerador de sequências começa a pedir um novo lote de valores num processo em segundo plano.

Tal como na versão anterior, os valores não são emitidos por ordem, e existem lacunas de valores não usados na sequência se as transações falharem ou se as instâncias da aplicação forem encerradas.

A aplicação vai fazer o seguinte:

  • Manter um estado interno para cada sequência, que contém o valor inicial do lote e o valor seguinte disponível.
  • Pedir um valor de sequência do lote.
  • Se os valores restantes no lote forem inferiores ao limite, faça o seguinte num processo em segundo plano:
    • Crie uma transação para ler e atualizar o valor da sequência.
    • Leia a célula next_value para o nome da sequência a usar na aplicação.
    • Armazene este valor internamente como o valor inicial do lote seguinte.
    • Incrementar a célula next_value na base de dados por um valor igual ao tamanho do lote
    • Conclua a transação.
  • Se não existirem valores restantes no lote, obtenha o valor inicial do lote seguinte a partir do processo em segundo plano (aguardando a respetiva conclusão, se necessário) e crie um novo lote com o valor inicial obtido como o valor seguinte.
  • Devolve o valor seguinte e incrementa o estado interno.
  • Use o valor devolvido na transação.

Para um desempenho ideal, o processo em segundo plano deve ser iniciado e concluído antes de esgotar os valores de sequência no lote atual. Caso contrário, a aplicação tem de aguardar o lote seguinte e a latência aumenta. Por conseguinte, tem de ajustar o tamanho do lote e o limite inferior, consoante a frequência dos valores da sequência emitidos.

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

O exemplo de código seguinte mostra como implementar uma função getNext() usando lotes. Repare que o código usa a função getAndIncrementNextValueInDB() definida anteriormente para obter um lote de valores de sequência através de um processo 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 exemplo de código seguinte mostra como a função getNext()assíncrona em lote é usada num pedido de dois valores a usar 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;
            }
          });
}

Tenha em atenção que, neste caso, os valores podem ser pedidos dentro da transação, porque a obtenção de um novo lote de valores ocorre num segmento em segundo plano.

Resumo

A tabela seguinte compara as caraterísticas dos quatro tipos de geradores de sequências:

Síncrono Assíncrono Lote Lote assíncrono
Valores únicos Sim Sim Sim Sim
Valores ordenados globalmente Sim Sim Não
Mas, com uma carga suficientemente elevada e um tamanho do lote suficientemente pequeno, os valores vão estar próximos uns dos outros
Não
Mas, com uma carga suficientemente elevada e um tamanho do lote suficientemente pequeno, os valores vão estar próximos uns dos outros
Sem intervalos Sim Não Não Não
Desempenho Latência de 1/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 elevado com elevada contenção (quando uma transação demora um período significativo)
10 ms em todas as transações
Significativamente mais elevado com elevada concorrência
10 ms, mas apenas quando é obtido um novo lote de valores Zero, se o tamanho do lote e o limite inferior estiverem definidos para valores adequados

A tabela anterior também ilustra o facto de que pode ter de comprometer os requisitos para valores ordenados globalmente e séries de valores sem lacunas para gerar valores únicos, ao mesmo tempo que cumpre os requisitos de desempenho gerais.

Testes de desempenho

Pode usar uma ferramenta de teste/análise de desempenho, que se encontra no mesmo repositório do GitHub que as classes de geradores de sequências anteriores, para testar cada um destes geradores de sequências e demonstrar as características de desempenho e latência. A ferramenta simula uma latência de transação de aplicação de 10 ms e executa vários threads em simultâneo que pedem valores de sequência.

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

Por exemplo, o resultado seguinte mostra uma comparação do desempenho com a latência no modo síncrono com 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

A tabela seguinte compara os resultados de vários modos e números de processos paralelos, 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 Num threads Valores/seg Latência do percentil 50 (ms) Latência do percentil 90 (ms) Latência do percentil 99
(ms)
SINCRONIZADO 10 34 27 1189 2703
SINCRONIZADO 50 30,6 1191 3513 5982
ASYNC 10 66,5 28 611 1460
ASYNC 50 78,1 29 1695 3442
BATCH
(size 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

Pode ver que, no modo síncrono (SYNC), com o aumento do número de threads, existe um aumento da contenção. Isto leva a latências de transação significativamente mais elevadas.

No modo assíncrono (ASYNC), uma vez que a transação para obter a sequência é mais pequena e separada da transação da aplicação, existe menos contenção e a frequência é mais elevada. No entanto, a contenção ainda pode ocorrer, o que leva a latências do percentil 90 mais elevadas.

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

O modo de lote de 50 threads tem uma latência mais elevada porque as sequências são emitidas tão rapidamente que o fator limitativo é a potência da instância da máquina virtual (VM) (neste caso, uma máquina de 4 vCPUs estava a ser executada a 350% de CPU durante o teste). A utilização de várias máquinas e vários processos mostraria resultados gerais semelhantes aos do modo de lote de 10 threads.

No modo ASYNC BATCH, a variação na latência é mínima e o desempenho é superior, mesmo com um grande número de threads, porque a latência de pedir um novo lote à base de dados é completamente independente da transação da aplicação.

O que se segue?