System Design: síncrono para assíncrono
Olá!
Este texto faz parte de uma série sobre System Design. Você pode encontrar os desafios anteriores nos links abaixo.
Desafio 0: separando banco de dados
Desafio 1: integração de arquivos
Desafio 2: processamento idempotente de arquivos
Desafio 3: Transformar um Processamento Síncrono de uma API em Assíncrono.
Como transformar uma API que tem um processamento síncrono em assíncrono? Pense em questões de status code de retorno, garantia de processamento, usabilidade da API, etc.
O grande problema desse tipo de problema é o entendimento do que é um processamento síncrono e o que é assíncrono. Para isso, imagine um fluxo de execução de um programa. Se ele vai até o fim, de forma direta e usando apenas uma thread de execução, ele é síncrono. Caso você opte por separar o processamento em múltiplos fluxos de execução, que dispara outras threads no processo, ele é assíncrono. Em uma linguagem visual, temos algo parecido com a imagem abaixo.
No caso do problema que iremos discutir, eu gostaria de separar a solução em dois cenários diferentes: o primeiro é transformar um fluxo de escrita de sync para async. O segundo é modificar uma leitura, fazendo a mesma transição.
Vamos analisar o caso da escrita primeiro, pois é algo mais simples.
Escrita Síncrona
Nesse cenário — talvez o mais simples de todos — temos uma escrita síncrona através de uma chamada de API. O fluxo básico aqui é: a cada request, uma nova conexão com o banco de dados é aberta (podendo ou não ser gerenciada por um connection pool), os dados fornecidos pelo cliente da API são sanitizados (ou ao menos deveriam ser, para evitar SQL injection) e inseridos no banco no final. Considerando casos comuns, o retorno da API é um HTTP 200 em casos de sucesso e HTTP 500 em caso de erro. O restante da complexidade encontrada nesse tipo de operação (como gestão de acessos, API Gateway e similares) não será tratada nesse artigo, para fins de brevidade.
Os pontos positivos desse método residem na simplicidade de entendimento e de execução de uma maneira geral (afinal, é mais simples manter um único componente do que múltiplos). Os grandes pontos negativos são que, em caso de uma necessidade de controle de vazão no acesso ao banco de dados ou uma necessidade de escalar a camada de API com mais instâncias, provavelmente transformará o banco de dados em gargalo no processamento.
Escrita Asíncrona
O jeito mais simples que consigo pensar para transformar esse fluxo em assíncrono, é o do diagrama abaixo:
Ao invés de escrever diretamente no banco de dados, a API enfileira uma mensagem com os dados a serem inseridos (isso pode ser feito usando ferramentas como Amazon SQS, Kafka ou até mesmo um RabbitMQ) e outro processo consumidor processa essas mensagens, inserindo-as no banco de dados.
Com esse método, é possível controlar a quantidade de consumidores gravando diretamente no banco de dados e, com isso, é possível reduzir um pouco o impacto do banco de dados se tornar um gargalo na arquitetura geral do sistema. Além disso, é possível escalar a camada de API mais facilmente usando essa estrutura. Explico.
Com a mesma quantidade de recursos, é provável que uma instância de API suporte mais requests por minuto fazendo operações baratas (como enfileirar uma mensagem) do que operações caras (como uma conexão ao banco de dados). Explico melhor os motivos aqui.
Além disso, sempre que um processo é feito via filas, há uma possibilidade de falha no consumo das mensagens em algum momento. Portanto, é recomendável utilizar duas técnicas defensivas para o processamento dessas mensagens: retentativa (retry) e uma outra técnica (com nome horrível em português): filas de letras mortas (em inglês fica melhor: dead letter queues, comumente chamadas de DLQ). A primeira é um mecanismo de tentar novamente caso uma mensagem não seja consumida corretamente. A segunda é uma fila especial na qual, após um número pré-estipulado de tentativas de consumo que falharam (geralmente 3 ou 5 vezes), as mensagens são armazenadas para serem processadas posteriormente após a volta da estabilidade sistêmica. Com esses mecanismos em operação, é possível garantir o sucesso da escrita no banco de dados com uma confiabilidade considerável.
No entanto, há pontos negativos a serem considerados. O principal deles é como a API é usada. Caso o cliente da API tenha expectativas de que a operação de escrita seja feita imediatamente, o processo assíncrono pode gerar confusão. Para evitar essa confusão, a API pode retornar um código de sucesso (como HTTP 200) e, mesmo assim, escrever no banco posteriormente. Para isso funcionar, basta adicionar controles que garantam que a operação de fato será efetuada. Essa técnica é chamada de atualização otimista (optimistic update). Geralmente, ela é utilizada em UIs: um exemplo clássico é o botão de “like” no Facebook, no qual a escrita da confirmação do “like” é assíncrona, mas aparece na interface do usuário que foi de fato efetuada imediatamente. A mesma ideia pode ser usada para esse caso, mesmo com as operações ocorrendo somente no back-end.
Lembrando que a alteração da forma de escrita pode gerar a necessidade de sincronização de dados em outros processos que dependam dessa base atualizada.
Leitura Síncrona
Esse cenário também é bastante direto e intuitivo, usando diagramas para representar o fluxo, temos:
Isso pode ser feito com ou sem o uso de ORMs, GraphQL ou algum outro mecanismo auxiliar relacionado à leitura de dados. O grande ponto de atenção aqui é: dependendo do mecanismo de persistência e camadas de abstração envolvidas na consulta a base de dados, por mais que a consulta tenha um processamento demorado, o resultado é obtido pelo cliente logo em seguida ao término da leitura.
Além disso, os mesmos problemas descritos na escrita podem ocorrer aqui: excesso de requests causando gargalo no banco de dados, por exemplo.
Leitura Assíncrona
O fluxo async para fornecer um resultado satisfatório para o cliente da API é algo semelhante ao abaixo:
A metáfora para explicar esse fluxo é basicamente a de um pedido em um balcão qualquer. Ou seja, alguém chega no balcão e solicita algo, recebendo um papel com um número que identifica o pedido. Após o tempo de preparo, o cliente é avisado que seu pedido ficou pronto, volta até o balcão, verifica se o conteúdo que pediu está correto. Caso esteja, leva o que pediu embora.
No fluxo sistêmico, ao solicitar algo (no caso do diagrama, a chamada para a rota /read da API de leitura, passando um id como parâmetro), é retornado sucesso (HTTP 200) e um transactionId para o solicitante. O transactionId é o identificador único daquela solicitação em si.
Após isso, a API enfileira as solicitações em um mecanismo qualquer de processamento de mensagens (podem ser os mesmos que foram discutidos na seção anterior) e um job é responsável por fazer essa leitura na base de dados de uma forma controlada, respeitando limites de requisições ao banco ou algo similar.
Após o sucesso na obtenção dos dados do mecanismo de persistência, os dados são compartilhados novamente com o solicitante através de um mecanismo de webhook. Então, o solicitante processa o webhook (na imagem, aquele transactionId é o elo entre os dados completos de uma transação, como nome, data e valor) e consegue o que precisa com os dados, finalizando o processo.
Caso você encontre algum erro ou informação incompleta neste artigo, por favor, avise-me que eu ajustarei o texto.
Obrigado por ler até aqui!
Até!
Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.