DEV Community

Hugo Marques
Hugo Marques

Posted on • Updated on

O problema da "escrita-dupla"

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:

Image description

  1. Usuário cria uma nova review
  2. O reviewService cria a review no repositório como pendente.
  3. 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.
  4. 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;
    }
} 
Enter fullscreen mode Exit fullscreen mode

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:

Image description

  1. Usuário cria uma nova review
  2. O reviewService cria a review no repositório como pendente.
  3. 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.
  4. A mensagem por qualquer motivo que seja falha ao ser enviada.
  5. O reviewService loga uma mensagem "Falha ao enviar mensagem para a fila".
  6. O reviewService retorna a review pendente ao usuário.
  7. 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:

Image description

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);
        }
    }
} 
Enter fullscreen mode Exit fullscreen mode

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?

Image description

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:

  1. Cuidado ao invocar sistemas distribuídos em sequência sem contexto transacional. Isso é receita para problemas de escrita-dupla e inconsistência dos dados.
  2. Algumas vezes uma solução simples pode ser tudo que você precisa dependendo da sua escala.
  3. 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.

Discussion (4)

Collapse
rponte profile image
Rafael Ponte

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 👊🏻

Collapse
hugaomarques profile image
Hugo Marques Author

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.

Collapse
mrbrunelli profile image
Matheus Ricardo Brunelli

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.

Collapse
vitorrubio profile image
vitor rubio

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?