Meu post anterior desta série abordou os problemas de confiabilidade que muitos sistemas baseados em mensageria enfrentam e como resolvê-los implementando o outbox pattern. Neste post, o foco muda da publicação para o processamento de mensagens publicadas pelos vários componentes de um sistema. Como antes, vou começar definindo o problema que precisamos enfrentar.
O Problema
Vamos revisitar o outbox pattern mais uma vez. Ele resolve um problema comum: o de publicar mensagens depois que a transação de negócio já foi confirmada — porque os eventos podem simplesmente não chegar a ser publicados. Ao implementar esse padrão, você garante que todos os eventos serão publicados — pelo menos uma vez. Deixa eu explicar essa última parte.

Figura 1: O outbox pattern
O relay de publicação busca os eventos no banco de dados operacional e, depois que são publicados, eles são confirmados — seja removendo os registros, seja marcando-os como publicados. Mas e se o processo falhar justamente entre publicar o evento e marcá-lo como publicado? Aí o evento vai ser publicado de novo na próxima tentativa, e os assinantes vão receber a mesma mensagem mais de uma vez. Isso é um problemão? Nem de longe.
Entrega At-Least-Once
Se você usa o AWS SQS para processar eventos aos quais um serviço está inscrito (e deveria mesmo), saiba que o serviço já oferece, de qualquer forma, garantias de " entrega at-least-once ":
"O Amazon SQS armazena cópias das suas mensagens em vários servidores, para garantir redundância e alta disponibilidade. Em casos raros, um dos servidores que guarda uma cópia da mensagem pode estar indisponível no momento em que você recebe ou exclui a mensagem… Quando isso acontece, é possível que essa cópia volte a aparecer ao consumir mensagens."
Isso vale não só para o AWS SQS, mas para qualquer barramento de mensagens distribuído (que seja honesto). A esta altura, você deve estar se perguntando: e quanto às SQS FIFO Queues? Segundo a documentação, as filas FIFO do SQS deveriam resolver isso:
"Diferentemente das filas padrão, as filas FIFO não introduzem mensagens duplicadas. As filas FIFO ajudam a evitar o envio de duplicatas para uma fila. Se você fizer um retry da ação SendMessage dentro do intervalo de deduplicação de 5 minutos, o Amazon SQS não introduz duplicatas na fila."
Mas isso resolve só parte do problema. De fato, se o outbox publicar a mesma mensagem mais de uma vez (em até 5 minutos), uma fila FIFO vai identificar a duplicata e ignorá-la. Mas vamos olhar para o lado do consumidor. Processar mensagens de uma fila distribuída envolve estes três passos:
- Buscar a próxima mensagem disponível.
- Processar a mensagem.
- Confirmar a mensagem, marcando-a como processada ou excluindo-a da fila, no caso do SQS.
Agora, vista o chapéu de pessimista extremo e considere as três perguntas que já fiz no post anterior:
- O que pode dar errado aqui?
- Quais são as implicações disso?
- Dá pra dizer que esse fluxo de processamento de mensagens é confiável?
Pause e pense um pouco antes de seguir lendo.
Beleza, vamos comparar nossas respostas.
Bom, se algo der errado entre processar uma mensagem e confirmá-la, ela será buscada de novo na próxima execução — ou seja, será processada mais de uma vez. Mesmo que as filas FIFO prometam "processamento exactly-once", essa garantia diz respeito ao processamento de mensagens do lado do SQS, não da sua aplicação. Qual delas é mais importante para você? A segunda, claro. Por isso, para projetar um sistema distribuído confiável, você precisa partir do princípio de que qualquer mensagem pode ser entregue aos assinantes mais de uma vez.
A Solução
Voltando à página da documentação do SQS que reconhece a possibilidade de entrega duplicada, ela também indica como lidar com a questão:
Projete suas aplicações para serem idempotentes (elas não devem ser afetadas negativamente ao processar a mesma mensagem mais de uma vez).
Ótimo conselho. Se passar o mesmo evento ou comando para a sua lógica de aplicação mais de uma vez resultar no mesmo desfecho, você não precisa se preocupar com casos extremos que gerem mensagens duplicadas.
Implementar uma lógica de processamento idempotente que seja confiável e à prova de balas é fácil? De jeito nenhum! Mas, antes de falar sobre como fazer, quero comentar como não fazer.
Processamento Idempotente de Eventos: O Jeito Errado e Fácil
Muita gente tenta alcançar o processamento idempotente implementando o padrão idempotent consumer. Não há nada de errado com a ideia por trás do padrão, mas a maioria das implementações erra justamente o motivo pelo qual ele existe. Veja como costuma ser feito:
Primeiro, cada comando/evento processado pela aplicação deve receber um identificador único. Segundo, é preciso ter um "idempotency store" — um banco de dados chave/valor que mapeia IDs de requisição para seus resultados (por exemplo, Redis, DynamoDB, mapas em memória, etc.).
Com esses dois requisitos no lugar, processar uma requisição recebida segue uma lógica simples:
Quando uma requisição chega, primeiro verifique se o ID dela já consta no idempotency store. Se já estiver lá, retorne o resultado persistido na tabela; nenhum processamento é necessário. Se o ID ainda não existir no idempotency store:
- Execute a requisição.
- Persista o ID e o resultado no idempotency store.
Coloque o chapéu de pessimista de novo e me diga: o que pode dar errado aqui?
Claro: se, por qualquer motivo, o processo falhar logo depois que o processamento terminou, mas antes de o resultado ser gravado no idempotency store, a operação vai ser executada mais de uma vez.
Você pode duvidar que uma solução tão ingênua esteja sendo usada por aí. Pois então dá uma olhada neste blog sobre AWS Lambda Powertools.
Existe uma versão um pouco mais avançada desse algoritmo, que primeiro marca a requisição como "em processamento" e expira esse estado após certo intervalo, mas ela sofre da mesma desvantagem: se o processo falhar depois de concluir o processamento, mas antes de atualizar o idempotency store, a requisição vai ser processada de novo.
De forma geral, enquanto a operação usar um banco de dados que não participe da mesma transação que o idempotency store, sempre vai existir a possibilidade de uma requisição ser processada mais de uma vez.
Aqui você pode questionar: "esse caso extremo parece tão raro, por que eu deveria me preocupar com isso?". Bom, o mesmo se aplica ao problema original que estamos tentando resolver. Talvez seja aceitável para o seu sistema específico. Se for, por que adicionar peças móveis extras à arquitetura? De qualquer forma, decidir se o processamento duplicado é aceitável ou não é uma decisão de design que precisa ser tomada de forma consciente.
Então, como implementar uma lógica de processamento de mensagens idempotente, confiável e à prova de balas?
Processamento Idempotente de Eventos: O Jeito Confiável e Difícil
Como no caso do outbox pattern, uma implementação confiável do padrão idempotent consumer exige uma transação atômica que abranja tanto a lógica de negócio quanto o rastreamento dos eventos processados. E, também como no outbox, a implementação depende do stack tecnológico.
Idempotência com Transações Multi-Registro
Se o seu banco de dados operacional suporta transações multi-registro, crie uma tabela para chaves de idempotência — IDs das requisições recebidas (eu prefiro chamar essa tabela de "inbox"). Ao terminar de executar uma requisição, atualize os dados operacionais e insira a chave de idempotência em uma única transação atômica. Mas atenção: a inserção da chave precisa ter uma condição — só deve dar certo se esse valor ainda não existir na tabela. Se já existir, a operação deve falhar; quando isso acontecer, você sabe que a requisição já foi processada.
...
response = dynamodb.transact_write_items(
TransactItems=[\
{\
'Put': {\
'TableName': 'operational_data',\
'Item': {\
...\
}\
}\
},\
{\
'Put': {\
'TableName': 'inbox',\
'Item': {\
'request_id': {'S': event_id},\
},\
'ConditionExpression': 'attribute_not_exists(request_id)'\
}\
}\
]
)
...
Como otimização adicional, você pode verificar a existência da chave de idempotência antes mesmo de executar a lógica de processamento.
Idempotência com Controle de Concorrência Otimista
Se o banco de dados que você usa não suporta transações multi-registro, ainda dá para implementar processamento idempotente confiável usando controle de concorrência otimista:
- Cada operação envolve a modificação de um único registro na tabela (por exemplo, um documento JSON).
- Cada registro tem um campo de versão para controlar exceções de concorrência.
- Toda atualização de um registro incrementa o valor da sua versão.
- Na atualização, o banco de dados precisa garantir que a versão do registro sobrescrito coincida com a versão lida inicialmente.
Com o controle de concorrência otimista no lugar, o registro gerenciado pode ganhar um campo adicional: processed_events (array). Antes de executar uma requisição, verifique se o ID dela já existe no array processed_events. Se não existir, prossiga com o tratamento do evento e adicione o ID ao array.
Posts da Série
- Event-Driven Architecture on AWS, Part I: The Basics
- Event-Driven Architecture on AWS, Part II: The Advanced Basics
- Event-Driven Architecture on AWS, Part III: The Hard Basics (Post atual)
Publicado originalmente em https://vladikk.com .
Este post encerra minha série de três artigos sobre os fundamentos do design de sistemas baseados em arquitetura orientada a eventos na AWS:
- No primeiro post, você aprendeu a aproveitar os serviços gerenciados que a AWS oferece para separar, no nível arquitetural, as responsabilidades de publicar e assinar eventos.
- O segundo post explorou problemas comuns que podem surgir na publicação de eventos e como resolvê-los com o outbox pattern.
- Por fim, este post mostrou como tornar seu sistema mais confiável aproveitando a idempotência na lógica de processamento de eventos.
Resumindo: ao projetar um sistema distribuído, lembre-se não só das falácias da computação distribuída, mas também da Lei de Murphy. Sempre parta do princípio de que tudo o que pode dar errado, vai dar. Faça disso parte da arquitetura do seu sistema. Endereçar todos os cenários de falha possíveis vai deixar seus fins de semana e feriados muito mais tranquilos ;)