Consistência de dados e padrão Outbox

  • arquitetura
  • outbox pattern
  • consistência
  • microsserviços
Consistência de dados e padrão Outbox

Um dos pontos mais importantes em todas aplicações que trabalha com escrita de dados é garantir a consistência dos dados.

Um exemplo básico é a criação de um pedido que possui itens. Considerando um banco relacional, os dados são armazenados em duas tabelas Orders e OrderItems. Imagine que a aplicação realize um INSERT no banco na tabela Orders e recebe um erro na tentativa de fazer o INSERT na tabela OrderItems. Dessa forma o sistema ficou inconsistente, o usuário tem um pedido sem itens e isso pode, inclusive, impedir que ele consiga repetir o pedido.

Aplicação de Pedidos que armazena no banco de dados

O desejado é que o pedido seja salvo completamente ou que seja nada salvo. Nos bancos relacionais a solução é o uso de transações.

BEGIN TRANSACTION
  INSERT INTO TABLE Orders...
  INSERT INTO TABLE OrderItems...
COMMIT

Caso o segundo INSERT não seja concluído deve ser realizado o rollback da transação mantendo o comportamento de “tudo ou nada”.

O problema de escrita dupla (dual write)

Agora vamos ampliar nosso cenário, um microsserviço de Pedidos é responsável por manter os dados de pedidos e notificar os outros sistemas sobre pedidos feitos. Para se comunicar com os outros serviços será utilizado uma solução de mensageria.

A aplicação salva pedidos no banco de dados e envia uma mensagem para um sistema de mensageria

Dessa forma, ao receber um novo pedido deve ser feita uma escrita na tabela Orders no banco de dados e enviar uma mensagem order.created para uma fila. Como manter a consistência entre o banco de dados e o sistema de mensageria? Se, após inserir um registro no banco de dados, o envio da mensagem para a fila não tiver sucesso as aplicações que esperam a mensagem ficam inconsistentes. Invertendo a lógica e enviando a mensagem antes de salvar no banco de dados produz um problema maior pois as aplicações seguintes podem receber mensagens de operações que nunca foram registradas na aplicação que é dona desse dado.

Manter a consistência dos dados em sistemas distribuídos é um grande desafio e diversas abordagens tem diferentes trade-offs. Comumente, é aceitável garantir que o sistema esteja consistente em algum ponto no futuro. Essa abordagem é chamada de Consistência Posterior (eventual consistency).

Outbox Pattern

A ideia do outbox pattern é usar o banco de dados para garantir que a mensagem será enviada. As mensagens que serão enviadas para o sistema de mensageria devem ser armazenadas em uma tabela junto do status de envio. Ao inserir um registro da tabela Orders também deve-se inserir a mensagem, tudo dentro de uma transação.

BEGIN TRANSACTION
  INSERT INTO Orders...
  INSERT INTO OutboxMessages(message, status)...
COMMIT

Essa tabela OutboxMessages deve ser verificada periodicamente em busca de mensagens com o status pendente para que sejam enviadas para a fila. Dessa forma tem-se a garantia que a mensagem será enviada ao menos uma vez. Por que ao menos uma vez? Porque o processo de envio da mensagem e a atualização do status de envio do registro do banco é uma escrita dupla e está sujeita a falhas. A operação de atualização do status como enviado pode falhar e portanto durante a próxima busca de mensagens pendentes essa mensagem seria enviada novamente.

Idempotência

Considerando o problema anterior é possível que uma mesma mensagem seja enviada repetidamente para a fila. Assim, é necessário que os sistemas que processam essas mensagens sejam idempotentes, ou seja, a execução de operações repetidas não podem afetar o resultado da primeira operação. Algumas operações são idempotentes por natureza, como atribuir um determinado valor para um campo de um registro de uma tabela. Se executada múltiplas vezes o resultado final é o mesmo de se executar apenas uma vez. Em outras situações não garantir a idempotência pode ser catastrófico. Expandindo nosso exemplo, considere uma aplicação que recebe mensagens de pedidos realizados e decrementa o estoque dos produtos vendidos. Se a mensagem do pedido for duplicada, o pedido abateria do estoque o dobro de produtos comprados.

O serviço de Produtos processa a mensagem de pedidos realizados e atualiza no seu banco de dados

Uma maneira de implementar idempotência para esses casos é utilizar novamente o banco de dados. A aplicação que consome as mensagens deve possuir uma tabela para armazenar o id das mensagens processadas. Ao executar a operação no banco de dados, no nosso exemplo um UPDATE na tabela Products, deve-se inserir o id da mensagem na tabela de mensagens processadas. As duas operações devem ser realizadas dentro de uma transação pois se a restrição de chave primária da tabela ProccessedMessages retornar erro (ou seja, a mensagem já foi processada) a atualização do produto deve ser desfeita.

BEGIN TRANSACTION
  UPDATE Produtcs...
  INSERT INTO ProccessedMessages(Id) ...
COMMIT
  

Implementação

Usando C# existem algumas opções de bibliotecas que implementam o padrão Outbox como CAP e Mass Transit. Para idempotência eu criei o Ziggurat que implementa a solução descrita nesse texto. É possível ver um exemplo de implementação de CAP com Ziggurat aqui.

No python existe a biblioteca django-outbox-pattern que implementa o padrão outbox e também garante idempotência nos consumidores.

← Todos os posts