Usando Saga para garantir consistência de dados em ambientes distribuídos

Sidharta Rezende
7 min readJan 20, 2020

Existem alguns ótimos artigos e vídeos flutuando na internet sobre como o padrão Saga pode ajudar a garantir consistência de dados em ambientes distribuídos. Meu objetivo com o presente artigo é falar um pouco sobre a minha experiência pessoal implementando esse padrão, e qual foi a abordagem que, na minha experiência, resolve melhor os problemas que a própria solução pode trazer.

Um pouco de contexto para começar

Convido o leitor a viajar comigo de volta ao final da década de 1980 e descobrir como um padrão de mais de 30 anos teve um revival e tornou-se essencial para a integração entre diferentes microserviços.

O padrão Saga foi primeiramente descrito em1987, em um artigo acadêmico escrito por Hector Garcia-Molina e Kenneth Salem, na época doutorandos da Universidade de Princeton. O trabalho original pode ser encontrado aqui.

Em seu trabalho original, os autores propuseram Saga como uma maneira de garantir que recursos de bancos de dados fossem corretamente liberados no caso de transações duradouras (long lived transactions). Em resumo, a transação poderia se estender por múltiplas tabelas e bancos de dados, garantindo que no caso de alguma falha todos os recursos previamente alocados seriam liberados e qualquer mudança desfeita. Estamos falando de transações que englobavam múltiplos databases, e um simples commit/rollback não daria conta.

The rise of micro-services

Fast forward para os 2010’s. A arquitetura em micro-serviços é uma realidade. Trouxe consigo inúmeros benefícios em manutenção, entregas baseadas em valor e escalabilidade. Ao mesmo tempo introduziu novos desafios, sobretudo em como manter consistência de dados e, um ambiente onde cada micro-serviço possui seu próprio banco de dados.

A arquitetura em micro-serviços surgiu em oposição aos sistemas monolíticos que vinham sendo desenvolvidos desde o início da internet comercial no final da década de 1990. Em um sistema monolítico o banco de dados tende a ser uma entidade super-poderosa, que se extende por todo o domínio do sistema, englobando o todo do negócio. Coisas que hoje consideramos anti-padrões, como espalhar a lógica do negócio dentro do banco de dados acaba sendo algo comum nesse tipo de arquitetura.

Já os micro-serviços invertem a relação de dominância da lógica de negócio entre serviço/database. Nessa abordagem o domínio do negócio está no serviço, e cada micro-serviço se preocupa apenas com a parte dessa lógica que lhe convém. O database, quando necessário, é apenas um recurso de persistência de dados dentro do escopo daquele micro-serviço. Ele deixa de ser uma entidade super poderosa e passa a ser um recurso.

Hora de viajar

Pelo restante deste artigo, vamos utilizar como exemplo o sistema imaginário de uma fictícia agência de viagens. Este exemplo não é original meu, existem outros artigos sobre Saga que utilizam o mesmo exemplo, mas acredito ser o mais didático para explicar os detalhes de como o padrão Saga pode nos ajudar.

Na nossa agência de viagens imaginária, agendar uma viagem consiste dos seguinte passos:

1-Reservar um hotel

2-Alugar um carro

3-Comprar passagem de avião para o destino.

Uma abordagem monolítica, bem simplificada, seria mais ou menos essa:

O processo book_travel se espalha por todas as tabelas do onipresente database. Usando um banco de dados transacional, lock nas tabelas e estratégia de commit/rollback asseguram que não acontecerá concorrência de acessos ou race condition.

Já se tratando de micro-serviços, onde cada serviço tem sua responsabilidade e escopo bem definidos, poderia ser algo assim:

Consistência de Dados em micro-serviços

Esse modelo distribuído trás inúmeros benefícios, já abordados, porém também trás desafios. Como manter integridade de dados através de diferentes serviços?
Voltando ao exemplo da agência de viagens, no caso de haver feito com sucesso o aluguel de carro e a reserva do hotel, mas falhando em encontrar um vôo adequado, como fazer para garantir que os dados referentes aos serviços que já haviam sido consumidos retornem a seu estado original?

No modelo monolítico, isso era resolvido usando commit/rollback no banco de dados. Mas agora temos múltiplos serviços, podendo, cada um, ter de diferentes bancos de dados.

É aí que entra o Saga. Em 1987 o padrão foi pensada para resolver a consistência de dados e de liberação de recursos em long lived transactions. A mesma abordagem funciona para transações distribuídas.

O padrão Saga determina que toda ação tenha uma ação compensatória correspondente. Assim sendo, ao ocorrer uma falha, as ações compensatórias de cada ação previamente executada com sucesso deve ser também executada.

No nosso exemplo da agência de viagens, teríamos as seguintes ações:

Tentamos alugar um carro. Caso tenhamos sucesso, seguimos com a reserva do hotel e em seguida a compra da passagem. Em caso de falha em qualquer desses passos, desfazemos os passos anteriores.

Coreografar ou orquestrar

Existem duas abordagens clássicas para implementar Saga: Coreografia ou Orquestração.

Na coreografia, cada nó do Saga sabe qual o próximo nó a ser chamado, seja seguindo na ação ou na compensação.

Modelo coreografado, saga executada com sucesso
modelo coreografado, saga entrando em ação compensatória

Já na orquestração, existe uma entidade, o Orquestrador, que é responsável por saber qual o próximo passo a ser executado.

Saga orquestrada em fluxo de sucesso
Saga orquestrada em fluxo de compensação

Cada uma das duas abordagens tem suas vantagens e suas desvantagens. O trade off é substituir um ponto único de falha (orquestrador) por uma infra-estrutura mais pesada no caso da coreografia, como abordaremos a seguir.

Como implementar uma Saga

Como vimos acima, uma saga é um workflow, onde cada passo tem a opção de seguir adiante ou voltar na execução cancelando os passos já realizados.

Existem várias formas de se implementar um motor de workflow. Para fins da Saga, acredito que a melhor forma é aderindo ao uso de filas.

Filas são elementos de infra-estrutura únicos e de alta performance. O alto throughput permite a execução de um grande número de passos por segundo, com o peso sendo praticamente o da execução de cada nó. Além disso já tem built in diversas funcionalidades que simplificam desenvolvimentos de features importantes para uma Saga, como controle de re-tentativas e confirmação da execução.

Para uma solução orquestrada, uma única fila de processamento é suficiente. O orquestrador consome, processa e reenvia para a fila, em ciclo até o fim do processamento.

Esquema de uso da fila em uma Saga orquestrada

Já para uma Saga coreografada, uma abordagem comum costuma ser expor uma fila para cada nó, sendo que determinado nó detém o conhecimento de qual fila precisa postar em caso de sucesso ou erro em sua execução.

esquema de uma saga coreografada utilizando filas

Detalhes sobre a solução orquestrada

Enquanto na solução coreografada cada nó tem conhecimento do próximo nó a ser executado através das filas para as quais pode produzir mensagens, a solução orquestrada depende de outros mecanismos para saber o próximo passo a ser executado.

Uma abordagem popular é manter uma máquina de estado, na qual antes de produzir a mensagem novamente para a fila, o nó atualiza uma base de dados com informação de qual o próximo nó a ser chamado. Pode-se utilizar um cache de alta performance, como o Redis, para alcançar esse objetivo sem trazer overhead significativo para a execução.

Pessoalmente não gosto dessa abordagem por depender de mais um item de infra-estrutura, que trás mais uma complexidade para seu serviço.
Uma outra abordagem que eu prefiro é fazer o nó informar, na própria mensagem qual o próximo nó a ser executado.
Essa abordagem híbrida entre orquestração e coreografia trás, na minha opinião, o melhor dos dois mundos. O nó ainda detém conhecimento de qual é o próximo nó a ser executado, reduzindo o peso do orquestrador como ponto de falha, e simplifica a infra-estrutura, não requerendo filas adicionais, como no modelo coreografado clássico, nem um recurso de persistência adicional para controlar o estado.

Desafios adicionais que o padrão Saga trás

Na minha experiência utilizando Sagas, alguns pontos de atenção surgiram:
1-Caso seja decidido implementar a saga em cima de filas, como sugerido nesse artigo, é preciso se preocupar e se proteger contra perda ou duplicação de mensagens.

2-Alguns serviços consumidos podem não ser naturalmente idempotentes. Nesse caso, em especial no que tange re-tentativas, é necessário um controle adicional para saber se determinada chamada a um serviço externo já foi ou não realizada.

3-Controles de re-tentativa e idempotência devem residir em propriedades das mensagens e das filas, e não nas instâncias do orquestrador ou dos nós, pois pode ser decidido escalar a solução, criando novas instâncias.

Conclusão

O padrão Saga é ideal para manter consistência de dados em ambiente de micro-serviços. Pode-se utilizar abordagens orquestrada ou coreografado, e cada uma tem suas vantagens e desvantagens, ou buscar uma solução híbrida.

Independentemente da solução adotada, sugere-se o uso de filas e mensagens. Novos desafios podem surgir dependendo da solução adotada.

Espero que o artigo presente sejá útil para implementar esse padrão. Aguardo comentários. Qualquer dúvida ou comentário pode também ser encaminhada para sidharta.rezende@gmail.com

--

--

Sidharta Rezende

Skatista de downhill, amante da vida e Engenheiro de Software