Fernando Correia

Sequências com SQL Azure Federations

In Dicas on 13 mar 2012 at 22:25

sequencia_numerica

O particionamento horizontal dos dados, ao mesmo tempo em que favorece a escalabilidade e o desempenho, apresenta novos desafios. Um deles é como gerar códigos numéricos sequenciais. Neste artigo eu analiso diversos aspectos do problema e indico 5 alternativas de solução.

Federações auxiliam na escalabilidade

Federações no SQL Azure são um mecanismo para obter mais escalabilidade e melhor desempenho em uma aplicação através de particionamento horizontal dos dados, ou seja, pela distribuição dos dados em várias bases de dados relacionais.

Este recurso possui várias características interessantes, como o reparticionamento sem perda de disponibilidade, a filtragem implícita (útil para preservar o isolamento entre inquilinos) e o melhor aproveitamento do pool de conexões, evitando a fragmentação que ocorreria ao simplesmente utilizar-se diversas bases de dados sem o conceito de federação.

Particionamento impacta a estratégia de chaves

Para poder usufruir destas vantagens, é necessário abrir mão de alguns recursos. Uma destas restrições é que bases de dados que sejam membros de uma federação não suportam a propriedade IDENTITY, recurso do SQL Server e SQL Azure para geração automática de números sequenciais.

A razão disto é que para obter escalabilidade horizontal no armazenamento de dados, é necessário descentralizar o processamento e evitar gargalos. As bases de dados membros de uma federação fazem parte de um único grupo lógico. Além disso, elas podem ser particionadas e mescladas a qualquer momento. Isto significa que não poderiam haver identificadores duplicados entre as bases de dados. O conceito tradicional de uma única sequência numérica não é adequado para este cenário. A recomendação é que as chaves dos registros sejam identificadores únicos globais (UUIDs) e, portanto, que sejam chaves “surrogate” (não derivadas de dados de negócio).

Eu, adicionalmente, recomendo utilizar como chaves primárias GUIDs gerados segundo o mesmo algoritmo da função NEWSEQUENTIALID, para evitar a fragmentação do índice clusterizado.

Por outro lado, em muitos domínios de negócio é necessário ter chaves naturais. Por exemplo, um pedido pode necessitar um identificador que seja facilmente manipulável por humanos, já que UUIDs não são amigáveis. No SQL Azure Federations, os campos identificadores utilizados para este propósito podem ser definidos como uma chave alternativa, através da criação de uma chave única.

Alternativas para geração de identificadores sequenciais

Considerando estas restrições de ferramentas e arquitetura, um arquiteto de aplicação que necessite atender ao requisito de negócio de gerar identificadores sequenciais tem várias alternativas à disposição. Vamos avaliar algumas, considerando um cenário em que a a federação esteja particionada por cliente.

1. Uma tabela de sequências nos membros da federação, com um registro para cada valor de chave de federação

Digamos que cada cliente possa ter a sua própria sequência de número de pedido. Por exemplo, para o cliente 1234, pedido 28 o identificador poderia ser 001234000028.

Isto pode ser atendido por uma tabela de sequências, cuja chave seja a ID do cliente e o nome da sequência (“PEDIDO”). A chave de federação desta tabela seria o ID do cliente e, portanto, seria uma tabela distribuída entre os membros da federação.

Uma stored procedure com um comando UPDATE poderia ser utilizada para obter e retornar o próximo número sequencial para cada cliente. Seria importante chamar esta stored procedure fora da transação que estiver incluindo o pedido, para evitar contenção.

Este método é simples, flexível e tem escalabilidade razoável: embora seja sequencial para cada cliente, permite incluir pedidos de vários clientes de forma concorrente. Há a possibilidade da chamada “perda” de um número sequencial, se a sequência for incrementada mas o registro não chegar a ser gravado por algum problema. Isto poderia ser evitado englobando estas operações em uma só transação, ao custo de um aumento da contenção e consequente queda no desempenho de inclusões.

Um exemplo de código-fonte pode ser encontrado na opção 1 do artigo SQL Server Sequence Number.

Uma variação desta técnica seria a utilização do comando MERGE para evitar a necessidade de criar previamente o registro de sequência para cada cliente. Ver os exemplos em “UPSERT” Race Condition With MERGE e Developing Modifications that Survive Concurrency.

2. Atribuição assíncrona por um processo em segundo plano

Nesta abordagem, o registro do pedido seria inicialmente gravado sem o número do pedido (valor NULL). Um processo separado faria a atualização destes registros de forma assíncrona, gerando números sequenciais e atualizando os registros de pedido. Este processo iria atualizar os pedidos por ordem de data e hora de inclusão, para manter a sequência. O mecanismo pode ser tornar mais complexo caso seja necessário manter vários processos executando concorrentemente por questões de desempenho ou disponibilidade. Neste caso seria necessário algum mecanismo de coordenação entre estes processos.

Apesar da maior complexidade, esta abordagem teria impacto zero durante a inclusão dos pedidos, evitando perdas de desempenho devido a contenções. O mecanismo também pode ser projetado para evitar a possibilidade de deixar de aproveitar (“perder”) um número sequencial. Pode ser uma solução adequada para sistemas orientados a mensagens ou desenvolvidos no conceito de separação de responsabilidade de comandos e consultas (CQRS).

3. Concorrência otimista

Reza o Zen do Python que “simples é melhor do que complexo”. Uma estratégia bem simples e que pode ser suficiente é simplesmente utilizar concorrência otimista.

Neste modelo, é declarada uma restrição de unicidade (UNIQUE constraint) nos campos que compõem a chave alternativa (por exemplo, ID do cliente e número sequencial do pedido). A inclusão do pedido é protegida por um bloco de tratamento de exceções e tenta gerar o próximo número sequencial simplesmente obtendo o valor máximo atual e adicionando 1. Se a inclusão falhar (porque outro pedido do mesmo cliente estava sendo incluído concorrentemente), o processo se repetirá até que o pedido seja gravado com um número único.

Esta abordagem terá um desempenho satisfatório em cenários em que não se espera a inclusão de um grande número de pedidos para o mesmo cliente concorrentemente. Se, por outro lado, as inclusões concorrentes para o mesmo agrupador (no exemplo, o cliente) forem muito frequentes, a contenção e o excesso de tentativas inválidas irão causar um desempenho inaceitável.

4. Abusando de uma fila de mensagens

Essa é uma idéia extrema e radical, coisa de gênio, louco, ou ambos. Basicamente têm-se um mecanismo em que uma fila de mensagens é alimentada com números sequenciais por um lado e usada como fonte de números sequenciais do outro.

O artigo Generate Identity / Sequence Values from Azure Storage explica este conceito usando como base o Azure Queue Service, mas como ele não garante a sequência FIFO das mensagens, o Service Bus seria uma alternativa melhor.

Apesar da relativa simplicidade, o fato é que ainda existe um gargalo de performance, uma vez que tanto o Queue Service como o Service Bus têm um limite de requisições por segundo. Isto poderia ser contornado usando várias filas simultaneamente, mas me parece que isto começa a acrescentar tanta complexidade que torna a idéia inviável.

5. Sistema distribuído de numeração

Se abraçarmos o conceito de que na nuvem os dados são distribuídos e aceitarmos que a geração de identificadores também seja distribuída, abrindo mão de alguns requisitos, podemos alcançar um outro nível de escalabilidade. Esta é a abordagem adotada, por exemplo, pelo Twitter, com o snowflake. Um método que atende o Twitter também poderá atender, por exemplo, um negócio de comércio eletrônico de grande porte.

No Windows Azure, um projeto que implementa este mecanismo é o SnowMaker. Trata-se de um gerador de identificadores inteiros únicos. Ele funciona mantendo um registro centralizado das sequências, porém descentralizando a geração destas sequências. Ou seja, cada servidor de aplicação obtém um lote de números sequenciais e aloca estes números, sequencialmente, aos registros que for incluindo. Se o tamanho do lote for 1.000, por exemplo, isto significa que cada servidor somente irá se comunicar com o repositório centralizado uma vez a cada 1.000 registros incluídos, o que dilui o custo desta centralização. Assim, garante-se a geração de identificadores únicos e inteiros de forma extremamente veloz.

Estes identificadores inteiros serão sequenciais para cada servidor, mas não serão incluídos sequencialmente considerando o conjunto de todos os servidores, porque cada servidor está de forma concorrente alocando os números do lote recebido. Também haverá “perda”, ou seja, deixarão de ser aproveitados números, quando os servidores forem reiniciados ou falharem.

Para geração de identificadores únicos, por exemplo para identificar internamente pedidos dentro de um processo de negócio, esta pode ser a solução mais eficaz. Por outro lado não será adequada para geração de identificadores que por força de lei precisarem de garantias de sequência (como números de notas fiscais) ou quando os usuários insistirem no conceito de que os identificadores únicos dos registros são uma fonte de métricas de negócio (por exemplo, de número de pedidos recebidos por dia).

Conclusão

A utilização do particionamento horizontal com o SQL Azure Federations e a falta de suporte a SEQUENCE e IDENTITY não significa que seja impossível a numeração sequencial de registros.

Existem diversas alternativas que os arquitetos de aplicação podem selecionar com base nos requisitos de desempenho, escalabilidade e garantias de sequência da aplicação em questão.

Você conhece mais alguma estratégia para gerar sequências neste cenário? Qual lhe parece mais indicada? Continue a discussão nos comentários abaixo ou pelo Twitter.