[Retries e Dead Letter Queues] Falhando graciosamente com Apache Kafka

Victor Hugo
10 min readMar 19, 2021

--

Hello! Estou mais uma vez por aqui e irei falar um pouco sobre uma característica intrínseca no desenvolvimento de sistemas distribuídos, as funções de tentar novamente, o famoso retry. Iremos abordar algumas estratégias para sua simples utilização e como estendê-la para domínios um pouco mais complexos, exercitando soluções numa abordagem event driven, fazendo uso do Apache Kafka. Por fim, apresentamos exemplos de código de uma das estratégias abordadas aqui, caso este seja seu foco, pule para lá. Então vamos que hoje as coisas serão um pouco mais interessantes!👻

Falhar é inevitável, mas o modo como falhamos faz toda diferença

No mundo distribuído, devemos sempre encarar as coisas partindo do pressuposto de que algo irá dar errado, nossas aplicações poderão falhar, os containers podem morrer repentinamente ou mesmo a comunicação entre partes do ecossistema podem simplesmente falhar devido a problemas de rede, como bem lembrado no artigo Fallacies of Distributed Computing. Assim sendo, devemos construir nossas aplicações de forma que eles sejam tolerantes a falhas (fault-tolerant). Mecanismos de retry ajudam nessa tarefa, sendo considerados obrigatórios na maioria dos casos que utilizam esse tipo de arquitetura. Normalmente, esse tipo de mecanismo envolve o método alvo com um proxy, que observa sua execução e em caso de exceção, ele irá realizar novamente a execução do método. Um exemplo desse fluxo ocorre por exemplo no projeto spring-retry.

Parece algo simples certo? Podemos ter um serviço A que tenta se comunicar com um serviço B via chamada HTTP, caso ocorra algum problema na comunicação, o retry-proxy no serviço A irá detectar a falha e tentar novamente a comunicação até que a mesma seja bem sucedida, ou no pior dos casos, onde todas as tentativas falham, poderíamos chamar um handler para decidir o que fazer (backoff policy). Mas, e se estivermos lidando com um sistema numa escala um pouco maior, numa arquitetura event-driven, como poderíamos lidar com falhas de processamento de mensagens?

A partir de agora, vamos analisar estas e outras considerações aplicadas ao contexto do Apache Kafka, que é uma plataforma distribuída de transmissão (streaming) de eventos, muito utilizada em sistemas distribuídos, dentre outros fatores, devido à sua performance, capacidade e facilidade de escalar.

Baseando-me num exemplo semelhante ao do meu artigo sobre o Outbox Pattern, imagine que temos um sistema de catálogo de filmes, composto basicamente por três aggregates: Person, Catalog e Recommendations. Onde Person é responsável pelos usuários do sistema, Catalog pelos filmes disponíveis e Recommendations por gerir a recomendação de filmes ao usuário de acordo com seus critérios diversos. Cada qual com seu próprio serviço instanciado, com um diferencial em Recommendations, onde teremos dois serviços compondo o aggregate: recommendations-service, o serviço principal onde o ator do nosso caso de uso interage com o sistema, e temos também o recommendations-processor-verticle que seria uma instância dedicada para processamento de recomendações, ele que aplicará a lógica que definirá qual filme será recomendado ao usuário. Esta divisão foi feita a fim de simular algo mais próximo do mundo real, além de agregar mais conteúdo à obra.

Quando um novo usuário é registrado em Person, um evento comunicando sobre o fato que ocorreu no sistema é publicado (PersonRegisteredEvent), e o serviço recommendations-service reage a esse evento, começando a traçar o perfil do usuário para recomendar filmes. Neste ponto já podemos prever algumas das falhas naturais da arquitetura, como por exemplo, falha de comunicação com o recommendations-processor-verticle, devido a instabilidades na rede, morte do container por motivos diversos como OOM, etc. Da mesma forma, por este exemplo não fazer uso de um Event-Carried State Transfer (tema do próximo artigo?), o serviço de recomendações ainda terá de se comunicar com o catalog-service afim de recuperar os filmes que farão parte do algoritmo de recomendações, estando então suscetível às mesmas falhas mencionadas anteriormente. Dado esse panorama, que tal explorarmos algumas soluções para esse problema?

Esta não é a solução ideal para o sistema, porém é funcional e nos dá um background interessante para os exemplos a seguir! 😉

Simple Retry 🤕

À primeira vista, podemos pensar em adicionar um mecanismo simples de retry, para que sempre que ocorra uma falha na solicitação à um dos serviços, ele tente novamente até que seja bem sucedido ou caia numa condição de parada e execute seu backoff strategy. Mas porque essa não é a melhor das soluções?

Nesse tipo de situação, estamos sujeitos a falhas sequenciais da mesma mensagem, o que pode ocasionar na obstrução do nosso fluxo de consumo e processamento. Digamos que o problema na falha da requisição seja devido a um bug que ocorre em certos parâmetros de requisição, isso quer dizer que todas as retentativas irão falhar sempre, nossa fila ficará presa por bastante tempo e consequentemente gastando muito recurso. Se a mensagem atual falhou, não quer dizer que a próxima mensagem irá falhar, então porque ficar preso, insistindo em uma mensagem específica?

Outro ponto, é que caso a mensagem consiga ser processada em uma das tentativas, fica difícil de obtermos dados sobre o problema, como por exemplo o número de tentativas executadas, em que momento o problema ocorreu, qual consumer group, etc.

Spring’s Error Handler 😐

Como sempre, nosso querido Spring faz algumas “mágicas” para facilitar a vida dos desenvolvedores, mas é importante entendermos realmente o que essa magia significa, e se é dela que realmente precisamos.

Em alguns tutoriais, inclusive da própria Confluent, encontramos a recomendação do uso das classes SeekToCurrentErrorHandler e DeadLetterPublishingRecoverer para serem responsáveis pela operação de retry durante o consumo de mensagens. Quando o processamento de uma mensagem falha, o spring faz com que o Kafka Consumer procure novamente pelos offsets que não foram processados (sem commit), e na próxima execução do método poll as mensagens que falharam serão novamente entregues para processamento. Caso esse ciclo falhe dez vezes seguidas, por padrão, a mensagem é enviada para um tópico DLQ (Dead Letter Queue).

Note que na prática, não é tão diferente da primeira solução, ainda estamos obstruindo nossa fila de processamento com a mensagem problemática. Também não conseguimos uma boa observabilidade sobre o que está acontecendo em nossa aplicação.

O grande valor dessa estratégia, em detrimento da anterior, é o uso explícito de DLQ, que nada mais é do que tópicos que carregam mensagens que tiveram sua condição de parada atingida, isto é, o algoritmo percebeu que ele por si só não seria capaz de completar o seu processamento e a encaminhou para uma fila sem consumidores, onde algum engenheiro de software poderá consultar essa fila, verificar as mensagens que lá estão, testar, descobrir e corrigir o problema, e por fim, republicar a mesma ao tópico principal, onde será processada novamente com provável êxito.

Republicação da mensagem 🙂

Uma outra estratégia também conhecida, é a de republicar a mensagem que falhou em um tópico especifico para reprocessamento. Acontece da seguinte maneira, quando uma mensagem falha, assumimos que a mesma foi consumida com sucesso, realizamos o commit do offset do Kafka Consumer, liberando assim a fila de processamento que irá para a próxima mensagem, porém, ainda republicamos a mensagem problemática em um novo tópico, para que ela possa ser processada novamente. Caso a condição de parada estabelecida seja alcançada, seria feita a republicação da mensagem problemática em uma DLQ.

A política de criação dos tópicos de retry varia por implementação, algumas criam um tópico por consumer group 1:N (1 tópico de origem, para N outros de falha, onde N é a quantidade de consumer groups consumindo aquele tópico), outras apenas um tópico de retry por tópico principal, 1:1. Na estratégia 1:1, como se pode esperar, iremos economizar na quantidade de tópicos gerenciados pelo kafka broker, porém todos os consumer groups terão de se registrar neste único tópico, portanto para cada falha de um consumer group especifico, os demais serão notificados sobre a mensagem de retry e terão de executar sua verificação/operação de idempotência. Outro problema, é que um único tópico DLQ para todos os consumer groups pode dificultar a identificação da falha, e talvez, acabar nos levando a adicionar meta-dados na mensagem durante o processo de republicação. Por outro lado, caso optemos pelo modelo 1:N, podemos ter um aumento considerável na quantidade de tópicos gerenciados pelo broker, porém a identificação das falhas que ocorreram se tornaria uma tarefa menos onerosa além de que os consumers notificados seriam apenas os que estão de fato, interessados na mensagem.

Como toda escolha, ela varia de acordo com nossa equipe e domínio, o quão uma estratégia seria mais custosa (organização, infraestrutura, engenharia, manutenção) em relação à outra?

Outro fator interessante nessa abordagem, é que a mesma mensagem pode ficar sendo republicada no mesmo tópico, isto é, poderíamos adicionar meta-dados à mensagem onde rastrearíamos quantas tentativas foram executadas, e republicaríamos a mensagem no mesmo tópico de retry até que fosse processada com sucesso, ou a condição de parada fosse atingida, nesse caso, enviando-a para DLQ.

Devido a essa característica de republicação da mesma mensagem diversas vezes no mesmo tópico de retry, algumas implementações costumam fazer uso de compacted topics, dessa forma o Kafka irá manter somente a versão mais atualizada da mensagem em seus registros, e não uma cópia para cada tentativa.

Esta estratégia é superior às demais apresentadas até então, pelo fato de ocorrer uma menor obstrução da nossa fila de processamento principal, pois a cada erro encontrado o offset do Kafka Consumer avança para a próxima mensagem. Tem-se também uma observabilidade um pouco melhor, já que podemos colher meta-dados do tópico específico para retry.

Apesar dos pontos positivos, a própria fila de retry pode ser obstruída caso ocorram muitos erros consecutivos nas mensagens, fazendo com que aquelas que poderiam ser processadas sem problemas tenham de aguardar bastante até chegar sua vez. Outra observação importante, é que a mesma fila irá executar o reprocessamento de todas as mensagens, então, uma mensagem que está por exemplo, em sua quarta falha consecutiva, irá ter a mesma prioridade de processamento que outra que falhou apenas uma vez, então será que vale a pena investir igualmente em mensagens que falharam tanto?

Reprocessamento em filas separadas 😄

Esta particularmente é a minha solução preferida dentre as citadas para o nosso problema, e também a que implementei como material de apoio deste artigo. Nessa estratégia, assim como na anterior, quando uma mensagem falha, assume-se que a mesma foi consumida com sucesso, realizando o commit do offset do Kafka Consumer, dessa forma liberando a fila de processamento que irá para a próxima mensagem. A republicação da mensagem problemática em um novo tópico ainda é realizada, porém desta vez, tem-se um tópico específico para cada tentativa de processamento, então as mensagens irão circular de um tópico para o outro até que sejam processadas corretamente ou a condição de parada for atingida e a mesma seja direcionada para DLQ.

Supondo que tem-se o tópico principal person-registered-topic, e que é necessário um mecanismo de retry no processamento das mensagens deste tópico, onde houvessem duas tentativas de processamento, e ao exaustá-las encaminha-se a mensagem problemática para uma DLQ. Poderíamos ter os seguintes tópicos criados: recommendations-person-registered-topic-RETRY-1, recommendations-person-registered-topic-RETRY-2 e por fim, recommendations-person-registered-topic-DLQ. Como pode ter percebido, esses seriam tópicos de retry e DLQ específicos para o consumer grouprecommendations”.

Uma medida interessante para este cenário, que melhora ainda mais nosso tratamento, é que podemos adicionar um delay de processamento a cada nível de fila (literalmente um Thread.sleep, caso esteja programando em Java), de forma que cada nível de processamento tenha uma velocidade de consumo reduzida em relação ao seu nível superior, desse modo, conseguimos alguns benefícios como prevenir que uma falha temporária que ocorreu na fila principal cascateie nas nossas filas de retry, também conseguimos dar prioridade às mensagens com maior probabilidade de sucesso (as que menos falharam) além de reduzir a quantidade de spam de solicitações potencialmente incorretas nos demais serviços, respeitando assim o throughput suportado por eles.

Note que neste modelo, controlando via delay a taxa de fluxo entre serviços, estamos seguindo algo semelhante ao que diz o leaky bucket pattern, onde nossa taxa de processamento é ajustada via delay, não deixando nosso bucket transbordar.

Outro ponto positivo desta abordagem que vale a pena ressaltar, é que em nenhum momento, em nenhuma das filas, houve a interrupção do consumo de mensagens, cada mensagem que falhou teve seu offset cometido e foi deslocada para seu próprio canal designado para reprocessamento, dessa forma conseguimos aumentar o throughput da nossa aplicação. Também ganhamos em observabilidade, conseguindo facilmente rastrear todo o fluxo que a mensagem teve de percorrer, quando e quantas falhas ocorreram, além de podermos monitorar, ao se comparar as métricas do tópico principal com as de retry, o quanto um erro impacta na performance do sistema.

Em contrapartida, esse tipo de estratégia é mais complexa, pois a quantidade de tópicos geridos pelo sistema pode crescer agressivamente, também aumentam as quantidades de consumers por tópico, que se reflete na forma de consumo de recursos computacionais.

Apesar de preferências pessoais, cada caso é um caso e podem ser pedidas abordagens diferentes dependendo de nosso contexto. O interessante é termos cada vez mais noção das possibilidades de solução que temos em mãos, para que nossa tomada de decisão seja cada vez mais assertiva. Lembrando que as soluções aqui propostas tem como base o exemplo fornecido no início do artigo.

Encerramos por aqui a exposição para que o trabalho não fique ainda mais extenso, porém ainda existem mais estratégias e técnicas de retry, como identificação de erros recuperáveis e irrecuperáveis (para evitar reprocessamento que está fadado a falhar sempre), etc. Talvez voltemos no futuro para falar mais sobre elas.

Caso se interesse por um exemplo prático, confira esta implementação utilizando Vert.x, RxJava e claro, o Apache Kafka! Vlw e até a próxima!

Se você gostou do artigo, pressione o botão👏 (palmas) para incentivar o autor e tornar o artigo mais acessível ao público

--

--