System Design: integração com eventos via webhooks
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: síncrono para assíncrono
Desafio 4: métricas em tempo real
Desafio 5: integração com eventos via webhooks
Elabore uma solução de uma API que receba notificações de eventos por meio de webhooks. Considere tolerância à falhas, rapidez de processamento, etc. Questione se quem invoca o webhook faz retentativas, quais status codes são aceitos, etc.
O mecanismo básico que ocorre em sistemas que utilizam webhooks é o seguinte: inicia-se um processamento assíncrono para atender a uma solicitação qualquer. Após a recepção dessa solicitação por algum back-end, é retornado um tipo de identificador para o solicitante que permite a identificação posterior do resultado de processamento relacionado a essa requisição original, na forma de um evento. Comentei sobre uma solução semelhante a essa em um desafio anterior. Em resumo, é praticamente uma abstração de notificação.
Para ilustrar as responsabilidades de cada sistema, temos o desenho abaixo que representa o escopo geral do problema. Basicamente, o problema se resume a como fazer com que o evento com o resultado do processamento seja armazenado de forma segura, robusta e confiável no banco de dados destino. Não deixei explícito no desenho a questão dos códigos HTTP, pois acredito que sejam detalhes de implementação e são irrelevantes para a discussão proposta neste artigo.
O objetivo aqui é criar uma API que será chamada toda vez que o sistema fornecedor disparar um evento, informando que o processamento foi concluído. No exemplo dado, o destino será um database qualquer. Considerações mais aprofundadas sobre como expor a API e demais assuntos relacionados a segurança e disponibilidade serão abordados no próximo texto desta série.
Disponibilidade
O problema mais simples de se resolver é o da disponibilidade e tolerância a falhas. Como não é possível controlar a estabilidade sistêmica do back-end fornecedor, pelo menos podemos mitigar a chance do nosso consumidor/receptor de webhooks de ficar indisponível. O jeito mais simples de se fazer isso é com a clássica estrutura de load balancer e o uso de múltiplas instâncias.
Com isso, é possível é disponizar múltiplas instâncias que podem consumir os eventos em paralelo, caso o volume de eventos seja grande, mitigando as chances de uma interrupção no processamento devido ao consumidor não ser mais um único ponto de falha. Lembrando que nenhum arranjo dessa natureza resolve o problema da falha do fornecedor.
Unicidade no processamento
Há uma possibilidade do envio ser feito múltiplas vezes pela origem, o que pode resultar em processamento duplicado do lado do consumidor. A técnica que resolve esse problema é chamada de idempotência. Isso significa que, em cada webhook recebido, deverá ser executado uma validação que verifique se o evento em questão já foi processado ou não. A maneira mais simples de garantir isso é usando uma chave de idempotência, que deve ser composta pelos campos mínimos necessários para garantir a unicidade no processamento daquele evento. Os campos que compõem a chave e garantem a unicidade dependem do tipo de evento a ser processado e devem ser analisados caso a caso.
Um problema comum que pode ocorrer aqui é o banco de dados se tornar um gargalo devido ao alto volume de consultas de idempotência seguidas de inserção feitas ao mesmo componente. Para lidar com isso, podemos tomar duas ações: adicionar um mecanismo de enfileiramento para controlar o fluxo da escrita e efetuar a verificação da idempotência por meio de cache (usando ferramentas como Redis, Memcache e similares).
O registro da idempotência (chamada de Idempotency Registry no desenho abaixo) funcionaria da seguinte forma: a cada novo evento, é verificado se a chave de idempotência existe no cache. Se existir, é possível concluir que aquele evento já foi processado por outra instância e pode ser ignorado. Caso não exista, a chave é inserida no cache e o processamento ocorre normalmente. O controle de expiração da idempotência pode ser feito de forma temporal (exemplo: após uma quantidade de tempo) ou deliberadamente removendo a chave de idempotência no componente que insere os eventos no banco de dados (representado na imagem abaixo como Database Consumer), após um processamento bem-sucedido. O uso de cache ao invés de um banco de dados nesse caso é para que ocorra um desempenho adequado das verificações das chaves. Para fins de simplificação, o Idempotency Registry pode ser implementado como uma tabela no banco de dados de destino dos eventos, sacrificando um pouco da performance no processo.
A seguir está a solução final, considerando tudo o que foi discutido até agora:
Com esses componentes, é possível realizar o escalonamento horizontal do consumo dos eventos, obter um bom desempenho nas consultas de idempotência e evitar gargalos na escrita do banco de dados, mesmo em casos de alto volume de dados.
Se você encontrar algum erro ou informação incompleta neste texto, me mande uma mensagem que eu ajustarei.
Obrigado pela leitura!
Até!
Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.