Garantindo transações distribuidas entre microsserviços


Introdução
Garantir que informações espalhadas em vários locais estejam consistentes é um dos desafios enfrentados quando utilizamos uma arquitetura de microsserviços.
Apesar deste tipo de arquitetura permitir definir estrategias ágeis de desenvolvimento, implantação, escalonamento, manutenção e comunicação, além de tornar flexível a escolha de linguagens e itens de infra para cada serviço, erros sempre podem acontecer nesse "meio de campo" e precisam de tratamento adequado para garantir a consistência dos dados de cada serviço.
É nossa responsabilidade como desenvolvedores assegurar que, mesmo com os recursos distribuídos, o resultado produzido por eles possua atomicidade, e que os dados não fiquem inconsistentes em casos de falha. Esse é o grande desafio que tentaremos resolver neste artigo utilizando algumas técnicas.
Conceitos
Antes de começar, vamos definir alguns conceitos que utilizaremos ao longo desta jornada:
- Transação: Conjunto de uma ou mais operações que alteram o estado de uma entidade.
- Operação: Ação que altera efetivamente algum atributo de uma determinada entidade.
- Entidade: Dependendo do contexto pode ser um objeto em memória, uma tabela do banco de dados, um arquivo, entre outros.
Caso de uso
O sistema proposto aqui, será de uma hipotética companhia aérea, que utiliza de vários microsserviços para fazer a reserva e o pagamento de suas passagens. O fluxo de compra de passagens feito nessa companhia segue os seguintes passos:
1 - O usuário, via interface seleciona o destino.
2 - Uma API que faz o controle das passagens aéreas altera o status de uma reserva para "pendente"
3 - Uma consulta é feita numa API externa que faz o controle de assentos para determinada seção do avião.
4 - Caso o assento esteja disponível, a API altera seu status para reservado.
5 - De volta a API de passagens, o status da mesma agora é alterado para "reservada"
6 - Agora o usuário informa os dados do cartão, para pagamento da passagem
7 - Uma API externa de pagamentos processa esses dados, e valida ou não a operação
8 - O pagamento estando validado e aprovado, o usuário recebe a confirmação no frontend
Neste fluxo hipotético, temos 2 pontos onde a transação pode falhar, são eles:
- Verificação de disponibilidade de assentos.
- Validação do pagamento.
Repare que, se alguma dessas operações falhar devemos realizar uma ação que desfaz o passo anterior.
- Caso não exista assento disponíveis, é preciso alterar o status da passagem de "pendente" para "cancelado" e retorna uma mesagem para o usuário.
- Caso o pagamento seja recusado, tanto a reserva do assento, quanto a reserva da passagem devem ser canceladas e uma mensagem deve ser enviada para o usuário.
Fluxograma
Para termos uma visão mais clara sobre o fluxo, segue diagrama
Solução proposta
Usaremos um exemplo bastante simples para mostrar como organizar a coreografia entre essas operações para garantir que todas produzam o resultado esperado, ou que nada seja alterado em casos de falha.
Vamos falar um pouco sobre SAGA.
Exemplo
O sitema está divido em 4 microsserviços:
- Formulário
- API de Passagens
- API de Assentos
- API de Pagamentos
Os trechos de código abaixo estão na ordem que são executados.
Para facilitar a explicação:
- Todas as classes e funções foram escritas em português;
- Algumas boas práticas foram deixadas de lado;
- Não usamos bibliotecas externas;
- Não usamos banco de dados.
O código da aplicação está nesse repositório.
Tudo começa em: formulario/main.py
def main():
while True:
destino = escolhe_um_destino()
passagem_reservada = solicita_reserva_de_passagem_para(destino)
referencia_da_passagem = pega_referencia_da_passagem(passagem_reservada)
solicita_pagamento(referencia_da_passagem)
1 - O serviço de formulário solicita que o usuário escolha um destino
destino = escolhe_um_destino()
2 - Solicita uma reserva de passagem para o serviço de passagens
passagem_reservada = solicita_reserva_de_passagem_para(destino)
No serviço de passagens em: passagens/main.py,
class Handler(BaseHTTPRequestHandler):
passagens = []
def envia_de_volta(self, resposta):
self.wfile.write(bytes(json.dumps(resposta), "utf-8"))
def do_POST(self):
if self.path == "/passagens/cancela":
manipulador = ManipuladorDeConteudo(self)
conteudo = manipulador.pega_conteudo()
banco = BancoDeDadosDeMentira(self)
referencia_da_passagem = banco.marca_como_cancelada(conteudo)
solicitador = SolicitadorDeAssentosDeMentira(self)
referencia_do_assento = solicitador.solicita_o_cancelamento_do_assento_com(
referencia_da_passagem
)
resposta = manipulador.monta_resposta(
referencia_da_passagem, referencia_do_assento
)
self.envia_de_volta(resposta)
if self.path == "/passagens":
manipulador = ManipuladorDeConteudo(self)
conteudo = manipulador.pega_conteudo()
banco = BancoDeDadosDeMentira(self)
referencia_da_passagem = banco.marca_com_pendente(conteudo)
solicitador = SolicitadorDeAssentosDeMentira(self)
referencia_do_assento = solicitador.solicita_reserva_com(
{"destino": conteudo["destino"], **referencia_da_passagem}
)
if referencia_do_assento["status_do_assento"] == "reservado":
referencia_da_passagem = banco.marca_como_reservada(
referencia_do_assento
)
self.envia_de_volta(referencia_da_passagem)
else:
referencia_da_passagem = banco.marca_como_cancelada(
referencia_do_assento
)
self.envia_de_volta(referencia_da_passagem)
3 - Trata minimamente o conteúdo
manipulador = ManipuladorDeConteudo(self)
conteudo = manipulador.pega_conteudo()
4 - Cria uma passagem com status pendente
banco = BancoDeDadosDeMentira(self)
referencia_da_passagem = banco.marca_com_pendente(conteudo)
5 - Solicita um assento para o serviço de assentos
solicitador = SolicitadorDeAssentosDeMentira(self)
referencia_do_assento = solicitador.solicita_reserva_com(
{"destino": conteudo["destino"], **referencia_da_passagem}
)
No serviço de assentos em: assentos/main.py
class Handler(BaseHTTPRequestHandler):
assentos = []
def envia_de_volta(self, resposta):
self.wfile.write(bytes(json.dumps(resposta), "utf-8"))
def do_POST(self):
if self.path == "/assentos/cancela":
manipulador = ManipuladorDeConteudoDeMentira(self)
referencia_da_passagem = manipulador.pega_conteudo()
banco = BancoDeDadosDeMentira(self)
referencia_do_assento = banco.marca_como_cancelado(referencia_da_passagem)
self.envia_de_volta(referencia_do_assento)
if self.path == "/assentos":
banco = BancoDeDadosDeMentira(self)
manipulador = ManipuladorDeConteudoDeMentira(self)
conteudo = manipulador.pega_conteudo()
verificador = VerificadorDeAssentoDeMentira()
existe_assento_disponivel = verificador.verifica(conteudo)
if existe_assento_disponivel:
referencia_do_assento = banco.marca_como_reservado(conteudo)
self.envia_de_volta(referencia_do_assento)
else:
referencia_do_assento = banco.marca_como_cancelado(conteudo)
self.envia_de_volta(referencia_do_assento)
6 - Abre uma conexão com o banco de dados fictício
banco = BancoDeDadosDeMentira(self)
7 - Trata minimamente o conteúdo
manipulador = ManipuladorDeConteudoDeMentira(self)
conteudo = manipulador.pega_conteudo()
8 - Verifica se existe assento disponível para o destino escolhido
verificador = VerificadorDeAssentoDeMentira()
existe_assento_disponivel = verificador.verifica(conteudo)
9 - Saga em ação. Primeiro ponto de falha:
Depois de verificar a disponibilidade de assentos, o serviço precisa decidir qual fluxo deve seguir:
Fluxo normal:
- Criar um assento marcado com reservado e devolver a referência dele para o serviço de passagens.
Fluxo de compensação:
- Criar um assento marcado com cancelado e devolver a referência dele para o serviço de passagens.
if existe_assento_disponivel:
referencia_do_assento = banco.marca_como_reservado(conteudo)
self.envia_de_volta(referencia_do_assento)
else:
referencia_do_assento = banco.marca_como_cancelado(conteudo)
self.envia_de_volta(referencia_do_assento)
Repare que, do ponto de vista de código, é apenas uma estrutura de decisão, no entanto, é exatamente essa decisão que garante que a transação ou siga em frente e produza um resultado consistente, ou retorne desfazendo tudo que já tinha feito.
De volta para o serviço de passagens em: passagens/main.py
10 - Saga em ação. Segundo ponto de falha:
Com a resposta do serviço de assentos, o serviço de passagens verifica se o assento foi reservado ou não, de acordo com a resposta, ele pode seguir:
Fluxo normal:
- Alterar o status da passagem para reservada e devolver a referência dela para o serviço de formulário.
Fluxo de compensação
- Alterar o status da passagem para cancelada e devolver a referência dela para o serviço de formulário.
if referencia_do_assento["status_do_assento"] == "reservado":
referencia_da_passagem = banco.marca_como_reservada(
referencia_do_assento
)
self.envia_de_volta(referencia_da_passagem)
else:
referencia_da_passagem = banco.marca_como_cancelada(
referencia_do_assento
)
self.envia_de_volta(referencia_da_passagem)
De volta para o serviço de formulário em: formulario/main.py
11 - Pega uma referência da passagem e solicita o pagamento
referencia_da_passagem = pega_referencia_da_passagem(passagem_reservada)
solicita_pagamento(referencia_da_passagem)
No serviço de pagamentos em: pagamentos/main.py
class Handler(BaseHTTPRequestHandler):
pagamentos = []
def envia_de_volta(self, resposta):
self.wfile.write(bytes(json.dumps(resposta), "utf-8"))
def do_POST(self):
if self.path == "/pagamentos":
gerador = GeradorDePagamentoDeMentira()
banco = BancoDeDadosDeMentira(self)
manipulador = ManipuladorDeConteudoDeMentira(self)
conteudo = manipulador.pega_conteudo()
verificador = VerificadorDePagamentoDeMentira()
pagamento_esta_valido = verificador.verifica_pagamento(conteudo)
if pagamento_esta_valido:
pagamento_valido = gerador.gera_pagamento_valido(conteudo)
referencia_do_pagamento = banco.salva(pagamento_valido)
self.envia_de_volta(referencia_do_pagamento)
else:
referencia_da_passagem = manipulador.pega_referencia_da_passagem(
conteudo
)
solicitador = SolicitadorDePassagemDeMentira(self)
referencia_da_passagem = (
solicitador.solicita_o_cancelamento_da_passagem_com(
referencia_da_passagem
)
)
pagamento_invalido = gerador.gera_pagamento_invalido(conteudo)
referencia_do_pagamento = banco.salva(pagamento_invalido)
self.envia_de_volta(referencia_do_pagamento)
12 - Abre uma conexão com o banco fictício, e outra com o gerador de pagamentos
gerador = GeradorDePagamentoDeMentira()
banco = BancoDeDadosDeMentira(self)
13 - Trata mimimamente o conteúdo
manipulador = ManipuladorDeConteudoDeMentira(self)
conteudo = manipulador.pega_conteudo()
14 - Verifica se o pagamento é válido
verificador = VerificadorDePagamentoDeMentira()
pagamento_esta_valido = verificador.verifica_pagamento(conteudo)
15 - Saga em ação. Terceiro ponto de falha.
Mas uma vez, só que agora no serviço de pagamentos, chegamos em um ponto de decisão na transação, onde mais vez, a sequência de operações pode seguir:
Fluxo normal:
- Gerar um pagamento válido, criar um pagamento com status pago devolver a referência dele para o serviço de formulário.
Fluxo de compensação:
- Gerar um pagamento inválido, criar um pagamento com status cancelado e devolver a referência dele para o serviço de formulário.
if pagamento_esta_valido:
pagamento_valido = gerador.gera_pagamento_valido(conteudo)
referencia_do_pagamento = banco.salva(pagamento_valido)
self.envia_de_volta(referencia_do_pagamento)
else:
referencia_da_passagem = manipulador.pega_referencia_da_passagem(
conteudo
)
solicitador = SolicitadorDePassagemDeMentira(self)
referencia_da_passagem = (
solicitador.solicita_o_cancelamento_da_passagem_com(
referencia_da_passagem
)
)
pagamento_invalido = gerador.gera_pagamento_invalido(conteudo)
referencia_do_pagamento = banco.salva(pagamento_invalido)
self.envia_de_volta(referencia_do_pagamento)
Transação completa
Por fim, depois de processar o pagamento, a execução volta para o serviço de formulário, que mostra uma mensagem de sucesso ou erro dependendo do resultado das operações dentro da transação.
- Se tudo correu bem, uma passagem e um assento foram marcado como reservados e um pagamento foi registrado como pago.
- Se não havia assento disponível para um determinado destino, tanto a passagem quanto o assento foram marcados como cancelados e nenhum pagamento foi solicitado.
- Se as informações de pagamento estiverem inválidas, passagem, assento e pagamento serão marcados como cancelados.
Conclusão
Conforme visto, garantir a atomicidade de transações distribuídas pode se mostrar um desafio e tanto, mas é possível utilizando o padrão de projeto correto.
É importante se atentar a todo possível ponto de falha, e escrever um fluxo de rollback para cada um destes pontos.
Novamente, o código utilizado neste exemplo você encontra aqui. Tentamos manter nosso exemplo o mais simples possível, e livre de dependências externas, para focar no padrão SAGA em si.
Referências
https://docs.python.org/3/library/http.server.html
https://docs.python.org/3/library/http.client.html