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 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, 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 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:
O código de exemplo a seguir mostra como a função getNext()
síncrona é usada em uma transação:
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:
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:
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.
- Lê a célula
- 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:
É 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:
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:
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:
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.
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:
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.
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:
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
- Saiba mais sobre as práticas recomendadas para o design de esquemas no Spanner.
- Leia sobre como escolher chaves e índices para tabelas do Spanner.
- Confira arquiteturas de referência, diagramas, tutoriais e práticas recomendadas do Google Cloud. Confira o Centro de arquitetura do Cloud.