Aviso: Esse artigo é baseado em fatos reais 😬.
Nesse artigo eu vou te apresentar o problema da escrita dupla ("dual write") e as consequência desse problema em um sistema real que eu trabalhei. Eu não espero te apresentar uma resposta definitiva sobre como lidar com o problema mas eu espero que essa introdução faça você entender o problema, pensar sobre ele, saber identificá-lo e evitá-lo.
Problema
Vamos começar descrevendo o nosso problema. Nós tínhamos um sistema que fazia o seguinte:
- Usuário cria uma nova review
- O reviewService cria a review no repositório como pendente.
- O reviewService invoca o approvalService enviando uma mensagem para que o sistema de moderação aprove ou não a review. Esse processamente ocorre assíncronamente.
- O sistema responde a requisição com o ID da review pendente para que o usuário possa verificar o status depois, se ela foi aprovada ou rejeitada.
Em código o nosso caso de uso ficaria assim:
package com.hugomarques;
public class ReviewController {
private final ReviewRepository reviewsRepository;
private final ApprovalWorkflow approvalService;
ReviewController(ReviewRepository repository) {
this.repository = repository;
}
@PostMapping("/reviews")
@Transactional
Review newReview(Review newReview) {
var pendingReview = reviewsRepository.save(newReview);
approvalService.send(pendingReview);
return pendingReview;
}
}
Anos atrás, em meados de 2009-2010, esse código provavelmente seria implantado em um servidor JBoss, o repository seria um MySQL ou PostgresSQL e o approvalService estaria por detrás de uma fila JMS. E por que esses detalhes são importantes? Essas tecnologias em conjunto garantiam o controle transacional, ou seja, se enviar a mensagem para fila falha, a transação seria desfeita no banco de dados.
Hoje em dia, esse mesmo sistema (por vários motivos) poderia ser implementado sem o JBoss como servidor de aplicação, o repository poderia usar o AWS DynamoDB e o messageria poderia utilizar o AWS SQS. Qual o problema que isso nos trás? Nós não temos mais o controle transacional. Se a nossa mensagem não for enviada, o DynamoDB não tem como fazer rollback 😱.
Uma história real
Um dos times que eu trabalhei se deparou com esse problema um tempo atrás. Um belo dia a equipe começou a receber tickets que algumas reviews estavam ficando bloqueadas em status pendente
.
Eu era o engenheiro on-call e decidi fazer uma análise. Eu verifiquei que toda vez que a review estava pendente o nosso código emitia uma exception de "Falha ao enviar mensagem para a fila".
Eu fui analisar o código e vi algo parecido com o exemplo desse artigo. Pra fazer as coisas ainda piores o código que enviava a mensagem estava contido dentro de um bloco try/catch
, o catch logava o erro e não fazia mais nada. Ou seja, a operação era retornada com sucesso para o usuário!
Se você não entendeu o problema, em vez do fluxo ideal que temos no exemplo esse fluxo de erro era o seguinte:
- Usuário cria uma nova review
- O reviewService cria a review no repositório como pendente.
- O reviewService invoca o approvalService enviando uma mensagem para que o sistema de moderação aprove ou não a review. Esse processamente ocorre assíncronamente.
- A mensagem por qualquer motivo que seja falha ao ser enviada.
- O reviewService loga uma mensagem "Falha ao enviar mensagem para a fila".
- O reviewService retorna a review pendente ao usuário.
- Como a review não foi enviada para o fluxo de aprovação, ela ficará pendente para SEMPRE!.
Uma solução "ingênua"
Depois que achamos o problema, nós decidimos aplicar a solução mais simples possível: Tratar o erro diretamente no código, capturando o erro/exception e executando uma chamada de rollback para o serviço 1. O nosso novo fluxo fica da seguinte forma:
Vamos ver a implementação:
package com.hugomarques;
public class ReviewController {
private final ReviewRepository repository;
private final ApprovalWorkflow approvalWorkflowService;
ReviewController(ReviewRepository repository) {
this.repository = repository;
}
@PostMapping("/reviews")
Review newReview(Review newReview) {
var pendingReview = repository.save(newReview);
try {
var approvedReview
= approvalWorkflowService.start(pendingReview);
return approvedReview;
catch (Exception e) {
repository.delete(pendingReview);
}
}
}
Notem que eu chamei a solução de ingênua. Por quê? O que acontece se o seu serviço tiver um problema de erro justo no rollback?
Observe no diagrama acima, quando tentamos fazer o rollback chamando repository.delete
o DynamoDB pode nos retornar um erro 400. Logo, nesse cenário a nossa review continuará pendente.
Embora ingênua, a solução reduziu o número de inconsistências em mais de 90%. Por ser uma solução barata e simples, ela foi suficiente para "estancar o sangramento".
Para uma solução mais robusta o time teria que rearquitetar algumas partes do sistema e usar alguns padrões de microserviços como SAGAs e/ou "Transactional Outbox" mas isso são cenas para os próximos capítulos.
Conclusão
Recapitulando o que aprendemos até aqui:
- Cuidado ao invocar sistemas distribuídos em sequência sem contexto transacional. Isso é receita para problemas de escrita-dupla e inconsistência dos dados.
- Algumas vezes uma solução simples pode ser tudo que você precisa dependendo da sua escala.
- Embora não seja o foco aqui, você agora sabe que existem outros padrões para lidar com esse tipo de erro.
Eu espero que você tenha curtido. Se gostou, não deixe de acompanhar as minhas dicas no twitter @hugaomarques.
Agradecimentos especiais ao @rponte e ao @zanfranceschi por revisarem o artigo e colaborarem com idéias.
Quer saber mais?
A discussão segue bem interessante no twitter com referências à outras soluções e padrões que lidam com esse problema.
Top comments (7)
Hugo, ótimo artigo. Fiquei com curiosidade agora: o que seria uma solução menos ingênua, mais robusta? Seria colocar todos os que falharam ao entrar no approvalservice em uma fila pra processar mais tarde? Talvez com flag de pendente de ir pro approvalservice? Ou uma política de retry? Ou ambos?
Fui dar uma pesquisada nos padrões que ele comentou: Transactional Outbox e Sagas. Tem as informações aqui:
Boa André!
O transactional outbox tem um custo bem maior em termos técnicos mas ele acaba sendo uma solução mais robusta quando você tem esse encadeamento de chamadas que precisam ser feitas de 1x.
SAGAs funciona pero no much. SAGAs são ótimas pra fazer compensation quando uma chamada de negócio falha. Por exemplo, tentou fazer a compra no cartão, não deu certo, faz a compensation pra chamar o inventório e cancelar a compra. Porém, SAGAs não resolvem problemas técnicos. Afinal, mesmo na Compensation call ainda podem haver falhas.
Wow, o texto ficou muito melhor desde a última vez que li, parabéns 👏🏻👏🏻
No primeiro código, acho que faltou o
@Transactional
para explicitar o escopo transacional ☺️E em ambos o código faltou passar o service como dependência. Mas esse aqui não eh nada demais 👊🏻
hahaha eu tava querendo manter o mais simples possível então eu ignorei a maioria das annotations mas o @Transactional era bom ter incluído sim pra deixar explícito a transação.
Excelente post Hugo! No projeto que estou trabalhando atualmente tivemos esse cenário da escrita dupla, e resolvemos ele com o Transactional Outbox. Mas é exatamente como você disse pro André Brandão, esse padrão realmente tem um custo técnico alto
Muito bom seu artigo Hugo! Quando comecei, só atuava em monolitos com transações. Quando caí de paraquedas nos microsserviços, esse tipo de cenário começou a acontecer com certa frequência comigo.