Implementando Resiliência entre Microserviços com Dead-Letter e RabbitMQ

Quando falamos sobre arquitetura baseada em microserviços precisamos levar em consideração dois conceitos essenciais: escalabilidade e resiliência. É fato que cada vez mais trabalharemos com arquiteturas complexas e precisamos ter muito claro qual caminho seguir quando os serviços apresentam erros de comunicação entre si. É nesse momento que precisamos colocar em prática o conceito de Event Drive (arquitetura baseada em eventos) o que ajuda e deixar nossa arquitetura um pouco mais preparada para erros de comunicação sem que isso se torne um problema maior do que deve ser.

Para facilitar, vamos imaginar um cenário onde temos dois ou mais serviços que necessitam se comunicar entre si. Entretanto, as mensagens enviadas de um sistema para outro não podem se perder mesmo que um deles recuse o processamento da mensagem por algum motivo (podemos simular como uma conversa via carta entre duas pessoas), seja por algum tipo de indisponibilidade ou realmente por uma regra de negócio – é nesse momento que precisamos implementar um processo de retentativa de entrega da mensagem de forma eficiente para garantirmos a integridade de comunicação dos nossos microserviços. 
Neste artigo iremos exemplificar na prática como funciona a comunicação deste processo de comunicação assíncrona entre pequenos serviços através de um Message Broker utilizando o protocolo AMQP com ênfase no processo de retentativa de entrega da mensagem caso ela não seja processada com sucesso pelo sistema de destino, afinal, precisamos garantir a resiliência entre os serviços.

O que é um Message Broker?

Message Broker é um serviço responsável pela intermediação e entrega de uma mensagem que foi recebida com um destino já pré-definido. Ele se encarrega por todo o transporte da mensagem até o sistema destino sem que o sistema de origem tenha que se preocupar com a entrega e/ou integridade da mensagem enviada. Neste exemplo estamos utilizando RabbitMQ mas temos outros serviços, como Kafka ou AWS SQS por exemplo. Quando utilizamos um Message Broker o protocolo de comunicação utilizado é o AMQP ao invés de HTTP, por exemplo, o que geraria acoplamento, latência e outros pontos que não são muito interessantes para comunicação entre os serviços.

Comunicação assíncrona

Este tipo de comunicação é utilizado quando não há necessidade de se esperar uma resposta para um processo iniciado, uma requisição feita, por exemplo. O sistema de origem é apenas encarregado por postar a mensagem no Message Broker, isto é, não é de responsabilidade nem de conhecimento do sistema de origem saber se a mensagem foi entregue e/ou processada com sucesso para o sistema de destino. Sua responsabilidade vai até o momento em que a mensagem é enviada para o Message Broker e nada mais, ou seja, sua responsabilidade termina neste momento.

Como isso funciona na prática?

Antes de mais nada, precisamos esclarecer alguns conceitos muitos importantes que citaremos com bastante frequência no decorrer deste artigo, vamos lá:

  1. Publisher: Serviço responsável pelo envio da mensagem;
  2. Listener: Serviço responsável pelo recebimento da mensagem;
  3. Queue: Fila responsável pelo recebimento das mensagens;
  4. Exchange: Túnel de comunicação entre o sistema de origem e a Queue;
  5. RoutingKey: É o endereço que o Exchange irá utilizar para decidir a rota da mensagem até a Queue responsável por seu recebimento
  6. DeadLetter: Queue responsável pelo recebimento das mensagens não processadas com sucesso (mensagens que foram rejeitadas pela Queue principal);
  7. Time to Live (TTL): tempo em milissegundos de vida da mensagem antes do seu próximo processamento

Para exemplificar o cenário na prática utilizaremos NodeJS, RabbitMQ e Docker para podemos subir localmente a arquitetura necessária, cada um com sua devida responsabilidade:

  1. NodeJS: Para exemplificar o publisher e listener;
  2. RabbitMQ: Como Message Broker para gerenciamento das mensagens postadas;
  3. Docker: Para subirmos localmente a imagem do RabbitMQ;

Vamos primeiramente desenvolver o publisher que será responsável por postar as mensagens na fila que nomeamos de messages.queue. Repare que este serviço é responsável por preparar o ambiente no RabbitMQ caso ela ainda não exista (além de realizar o envio da mensagem), isto é, realizar a criação do Exchange e Queue bem como atrelar os parâmetros de Exchange e Routing-Key da Dead-Letter que serão utilizados caso de rejeição da mensagem:

exports.sendMessage = async (_message) =>
{
   amqp.connect(config.rabbirMQConnectionString, (err, conn) =>
   {
       conn.createChannel((err, ch) =>
       {
           //Configurations
           let queueOptions = {
               durable: false,
               arguments : {
                   "x-dead-letter-exchange": config.rabbitMQDeadLetterExchange,
                   "x-dead-letter-routing-key": config.rabbitMQDeadLetterRoutingKey
               }
           };
           ch.assertExchange(config.rabbitMQExchange, 'direct', { durable: false });
           ch.assertQueue(config.rabbirMQQueue, queueOptions, (error, success) =>
           {
               if (success)
               {
                   ch.bindQueue(
                       config.rabbirMQQueue,
                       config.rabbitMQExchange,
                       config.rabbitMQRoutingKey
                   );
               }
               ch.publish(
                   config.rabbitMQExchange,
                   config.rabbitMQRoutingKey,
                   new Buffer(JSON.stringify(_message)),
                   { persistent: true }
               );
               console.log("[pubisher] message published with success...")
           });
       });
       //Close connection
       setTimeout(() =>
       {
           conn.close();
       }, 500);
   });
   return "Message published with success";
}

Da mesma forma vamos desenvolver o listener que é o responsável pelo recebimento e processamento das mensagens e, eventualmente, o direcionamento da mensagem da Queue principal para a Queue de Dead-Letter caso a mesma não seja processada com sucesso, que é exatamente o cenário que estamos abordando neste artigo.

amqp.connect(config.rabbitMQConnectionString, function (err, conn)
{
   conn.createChannel(function (err, ch)
   {
       //Dead Letter Exchange
       let queueDeadLetterOptions = {
           durable: true,
           arguments : {
               "x-dead-letter-exchange": "message.exchange",
               "x-dead-letter-routing-key": "message",
               "x-queue-type": "classic",
               "x-message-ttl": 15000
           }
       };
       ch.assertExchange(config.rabbitMQDeadLetterExchange, 'direct', { durable: false });
       ch.assertQueue(config.rabbitMQDeadLetterQueue, queueDeadLetterOptions, (error, success) =>
       {
           if (success)
           {
               ch.bindQueue(config.rabbitMQDeadLetterQueue, config.rabbitMQDeadLetterExchange, config.rabbitMQDeadLetterRoutingKey)
           }
       });
       let queueOptions = {
           durable: false,
           arguments : {
               "x-dead-letter-exchange": config.rabbitMQDeadLetterExchange,
               "x-dead-letter-routing-key": config.rabbitMQDeadLetterRoutingKey
           }
       };
       ch.assertQueue(config.rabbitMQQueue, queueOptions);
       ch.prefetch(1);
       ch.consume(config.rabbitMQQueue, function (msg)
       {
           if (!msg.properties.headers["x-death"])
           {
               console.log("[listener][0] try process message...");
               ch.nack(msg, false, false);
           }
           else
           {
               let counter = msg.properties.headers["x-death"][0].count;
               console.log("[listener]["+counter+"] try process message...");
               if (counter == 4)
               {
                   console.log("[listener] retry limit reached...");
                   ch.ack(msg);
               }
               else
               {
                   ch.nack(msg, false, false);
               }
           }
       });
   });
});

Neste exemplo, ainda abordando sobre o listener implementamos uma rejeição inicial da mensagem de 3 vezes seguidas (para simular alguma indisponibilidade ou erro por parte do listener) para exemplificar como ocorre a implementação da retentativa automática fornecida pelo nosso Message Broker, sem que seja necessário a implementação de um serviço ou código adicional para isto – este é o grande segredo.

Com o publisher e listener sendo executados é possível colocar em prática o envio da mensagem e observar o que acontece quando o listener recusa o processamento da mesma:

ch.consume(config.rabbitMQQueue, function (msg)
       {
           if (!msg.properties.headers["x-death"])
           {
               console.log("[listener][0] try process message...");
               ch.nack(msg, false, false);
           }
           else
           {
               let counter = msg.properties.headers["x-death"][0].count;
               console.log("[listener]["+counter+"] try process message...");
               if (counter == 4)
               {
                   console.log("[listener] retry limit reached...");
                   ch.ack(msg);
               }
               else
               {
                   ch.nack(msg, false, false);
               }
           }
       });

Repare que o listener recebe o processamento da mensagem e realiza a rejeição da mesma. Se analisarmos diretamente no RabbitMQ é possível identificar que a mensagem foi movida de Queue, nesse momento ela se encontra na Queue de Dead-Letter que possui um TTL de 15000 milissegundos, isso significa que dentro de 15 segundos a mensagem será movida novamente para a Queue message.queue, o que desencadeará uma nova tentativa de processamento desta mensagem por parte do listener

Analisando o processamento a retentativa de entrega da mensagem acontece por 3 vezes seguidas e, após a terceira tentativa a mensagem é ignorada de forma permanente:

Contextualizando para um cenário mais real imagine um cenário onde nosso listener dependente de um banco de dados para processamento da mensagem e, por algum motivo, este banco de dados está indisponível. Nesse mesmo cenário a mensagem seria direcionada para a Queue de Dead-Letter com um TTL configurado para realizar novas tentativas em um espaço de tempo até que o banco de dados se torne operacional novamente, sem gerar nenhuma perda desta mensagens e garantindo que em algum momento ela seria processada com sucesso – este é o grande benefício de se trabalhar com Message Broker para uma arquitetura Event Drive

Considerações Adicionais

Neste artigo tentei abordar um pouco mais de forma teórica e técnica, mas caso sinta falta de linhas de código você pode clicar aqui para acessar o repositório do GitHub com toda a implementação deste projeto e, caso deseje subir localmente o RabbitMQ na sua máquina utilizando Docker você pode fazer através deste comando:

docker run -d --hostname my-rabbit --name rabbit001 -p 8080:15672 -p 5672:5672 -p 25676:25676 rabbitmq:3-management

Se tiver dúvidas sobre a documentação do RabbitMQ você também pode conferir clicando aqui.

Obrigado e até a próxima =D

Leave a comment