Spring Cloud Stream + RabbitMQ: Mantendo a velocidade de entrega mesmo com mensagens ruins

Evitando lentidão e acumulo de mensagens em filas

Marlon Monçores
Bemobi
8 min readAug 23, 2019

--

Vetor criado por rawpixel.com (br.freepik.com)

Conectar uma aplicação Java utilizando Spring Cloud Stream no RabbitMQ é uma tarefa relativamente simples. Bastam passos simples:

  • Adicionar as dependências necessárias (exemplo utilizando maven):
Adicionar o spring-boot-starter-parent como parent do projeto
Adicionar a dependência do spring cloud starter stream rabbit
  • Anotar com @Input o médoto de uma interface:
Método anotado com @Input.
  • Criar uma classe e adicionar a anotação @EnableBinding referenciando a interface previamente criada. Adicionar um método e anotar com @StreamListener. Esse método será executado quando uma mensagem chegar na fila.
Classe com a anotação @EnableBinding e método com a anotação @StreamListener
  • Criar no arquivo application.yml a configuração de acesso ao RabbitMQ e da fila:
application.yml com as configurações

Em resumo, esses são os passos necessários para que a aplicação conecte no RabbitMQ e comece a consumir as mensagens.

O experimento

O objetivo do experimento é comparar a velocidade de consumo de uma fila em função da quantidade de mensagens com falhas. Para isso foi criado um publicador de mensagens, acessível via interface REST e um consumidor de mensagens.

O código fonte está disponível em: https://bitbucket.org/marlonmoncores/rabbitmessagesenderandconsumererrorbenchmark

Publicador de mensagens

O publicador recebe um JSON com 2 informações, a quantidade de mensagens a serem criadas e o percentual de mensagens com falhas que devem ser criadas. O publicador criará mensagens com falhas de forma aleatória, respeitando o percentual de falha.

Exemplo de payload

Consumidor de mensagens

Código do consumidor de mensagens

O consumidor jogará uma exceção se a mensagem contiver o status FAIL. Caso contrário a mensagem será consumida com sucesso.

Comparativo de desempenho

  • Cenário 1: 10.000 mensagens e 100% de sucesso
Velocidade obtida na entrega de 10.000 mensagens com sucesso com taxa de falha em 0%
  • Cenário 2: 10.000 mensagens com 1% de falha:
Velocidade obtida na entrega de 10.000 mensagens com sucesso com taxa de falha em 1%
Término do envio das mensagens

Percebe-se que embora a quantidade de mensagens com falhas seja pequena, apenas 1%, a velocidade de consumo cai drasticamente. No primeiro cenário foram consumidas cerca de 167 mensagens por segundo, enquanto que no segundo cenário apenas 33. Com isso, o tempo para o consumo das 10.000 mensagens foi de aproximadamente 15 segundos em um cenário para 5 minutos no outro.

  • Cenário 3: 10.000 mensagens com 10% de falha:
Velocidade obtida na entrega de 10.000 mensagens com sucesso com taxa de falha em 10%
Total de mensagens na fila após 10 minutos e velocidade obtida durante os 10 minutos

Aumentando-se o percentual de erros para 10%, percebe-se que a velocidade de consumo da fila despenca drasticamente para 1.9/s. Após 10 minutos, ainda existem cerca de 8.000 mensagens na fila. Nesse ritmo serão necessários cerca de 50 minutos para para que todas as 10.000 mensagens sejam consumidas (eu não esperei terminar).

Percebe-se que é impraticável a utilização dessa abordagem para sistemas produtivos, onde a taxa de erro pode ser elevada, mesmo que durante alguns momentos específicos, porque o atraso gerado pelas mensagens ruins é enorme.

Entendendo o problema

A velocidade de consumo da fila é afetada porque o consumidor, ao receber uma mensagem com falha, por padrão, efetua mais duas tentativas de consumi-la. Também é adicionado um tempo de espera entre cada uma das tentativas. Assim, o consumidor fica literalmente parado esperando o momento de tentar consumir a mensagem que apresentou falha novamente. Após as 3 tentativas, enfim, a mensagem é encaminhada para a DLQ (DLQ é uma outra fila que armazena mensagens que não foram consumidas com sucesso) e o consumidor segue para a próxima mensagem.

O problema pode ser mitigado, aumentando-se o número de consumidores de uma fila, assim, se uma mensagem falhar, apenas esse consumidor será afetado. Contudo, o problema não estará resolvido. A velocidade será impactada visto que um consumidor está mais lento que os demais. E pode acontecer de chegarem mais falhas e outros consumidores também serão impactados, eventualmente, todos.

Solucionando

A solução para o problema está descrita na própria documentação do Spring Cloud Stream.

A estratégia é simples: Ao invés de fazer o consumidor parar e esperar para tentar consumir novamente a mesma mensagem, deve-se tirar a opção de retentativa do consumidor. Dessa forma, a mensagem será encaminhada a DLQ (se estiver habilitada, caso contrário será descartada) após a primeira falha e o consumidor estará liberado para consumir a próxima mensagem.

Basta adicionar essa configuração, substituindo input pelo nome utilizado no momento do bind (valor da anotação @Input(…) na interface).

#desativa a retentativa
spring.cloud.stream.bindings.input.consumer.max-attempts: 1

E se a retentativa fizer sentido?

Se a retentativa for desejável, a solução é pegar a mensagem que falhou, joga-lá na DLQ por um tempo específico (chamado dlq-ttl) e após, recolocá-la na fila novamente. Dessa forma, o consumidor não ficará ocioso e a mensagem terá a oportunidade de ser consumida novamente. Só é possível utilizar essa técnica se a ordem das mensagens for irrelevante.

Esse cenário é alcançável com as seguintes configurações, lembrando de substituir input pelo nome utilizado no momento do bind.

#desativa a retentativa
spring.cloud.stream.bindings.input.consumer.max-attempts: 1
#ativa a dlx/dlq
spring.cloud.stream.rabbit.bindings.input.consumer.auto-bind-dlq: true
#ativa o dlq-ttl
spring.cloud.stream.rabbit.bindings.input.consumer.dlq-ttl: 5000
#Faz com que as mensagem passem da dlq (dlx) para a fila original
spring.cloud.stream.rabbit.bindings.input.consumer.dlq-dead-letter-exchange:

ATENÇÃO: Nesse cenário a mensagem com falha ficará sendo enviada para a DLQ e depois para fila indefinidamente, até que seja consumida com sucesso.

E como desistir de uma mensagem nesse cenário?

Felizmente, existe um header que faz a contagem da quantidade de vezes que uma mensagem passou pela DLQ. Resumindo:

@StreamListener(target = "nome_do_binder")
public void consomeMensagem(String mensagem, @Header(name = "x-death", required = false) Map<?,?> death) {
if (death != null && death.get("count").equals(3L)) {
throw new ImmediateAcknowledgeAmqpException("Desistindo");
}
}

Utiliza-se o header x-death, que é um mapa. Lê-se a propriedade count para saber quantas vezes a mensagem passou pela DLQ. No momento de desistir da mensagem, no exemplo após 3 vezes, basta lançar uma ImmediateAcknowledgeAmqpException. Assim, a mensagem será considerada como entregue (Ack), ou seja, não será enviada novamente para a DLQ.

Comparando as soluçoes

A imagem a seguir exibe as 3 filas criadas para comparação e suas respectivas DLQs.

Filas criadas automaticamente após a inicialização do app

A fila defaultChannel.basicQueueNoRetry é uma fila com retentativa desabilitada. As mensagens são encaminhadas diretamente para a DLQ quando não são consumidas com sucesso

A fila defaultChannel.basicQueueNoRetryAndDlqTtlAndMaxRetries é uma fila com o fluxo de retentativas utilizando a DLQ como “estacionamento temporário” para as mensagens.

A fila defaultChannel.basicQueueWithDlq é uma fila simples, apenas com sua DLQ. Essa fila foi utilizada no comparativo de desempenho inicial.

Resultados

  • Teste com 10.000 mensagens e falha de 1%
Quantidade de mensagens e a velocidade de consumo na fila que não possui retentativa
Quantidade de mensagens e a velocidade de consumo na fila que possui a retentativa com a mensagem passando pela DLQ

Percebe-se pelas imagens que o desempenho de ambas as filas foi próximo. Ambas conseguiram velocidade de consumo iguais ou superiores a velocidade obtida quando a fila possuía apenas mensagens boas, ou seja, 167/s. A fila com retentativas aparentemente alcançou um desempenho maior, mais este provavelmente foi obtido pelo fato do aplicativo utilizado para publicar as mensagens estar compartilhando o mesmo hardware do consumidor. Acontece que durante as retentativas o aplicativo que faz a publicação já estava ocioso, assim como as mensagens publicas na exchange já não estavam mais sendo replicadas nas filas.

Quantidade de mensagens na dql da fila que não possui retentativa
Quantidade de mensagens na DLQ da fila que possui retentativa

Também observa-se que a quantidade de mensagens na DLQ da fila que não faz retentativa aumenta até ficarem 101 mensagens, ou seja, 1% do total de 10.000 mensagens. Enquanto que na outra DLQ o aumento é mais gradual. Isso se justifica porque ao mesmo tempo que mensagens estão chegando na DLQ, outras já ficaram o tempo máximo saem desta para serem reprocessadas. Ao final o tamanho da fila vai a 0 visto que após 3 tentativas sem sucesso, as mensagens são descartadas.

  • Teste com 10.000 mensagens e falha de 10%
Quantidade de mensagens e a velocidade de consumo na fila que não possui retentativa
Quantidade de mensagens e a velocidade de consumo na fila que possui a retentativa com a mensagem passando pela DLQ

O experimento foi repetido com 10% de mensagens ruins. Percebe-se que a velocidade de consumo não foi afetada com o aumento da quantidade de erros, novamente, o valor obtido foi igual ou superior aos 167/s.

Quantidade de mensagens e a velocidade de consumo na fila que não possui retentativa
Quantidade de mensagens e a velocidade de consumo na fila que possui a retentativa com a mensagem passando pela DLQ— inicio
Quantidade de mensagens e a velocidade de consumo na fila que possui a retentativa com a mensagem passando pela DLQ — fim

Por sua vez a quantidade de mensagens em ambas as DLQs também se mostra consistente com o cenário apresentado. Em uma ficaram 1.033 mensagens, ou seja, aproximadamente 10% do total de mensagens, e na outra, onde há retentativa, a quantidade de mensagens oscilou, mas no final, não ficaram mensagens na DLQ. Demonstrando que ocorreram publicações de mensagens ao mesmo tempo que as mais antigas estavam sendo removidas para serem retentadas, ou descartadas após atingirem o limite de retentativa.

Conclusão

Os experimentos mostraram que a configuração padrão mais básica entre o Spring Cloud e o RabbitMQ provoca uma redução na velocidade de consumo de mensagens quando mensagens ruins estão na fila. Em um cenário perfeito cerca de 167 mensagens são consumidas por segundo. Porém em um cenário com 10% de falhas a velocidade é reduzida para menos de 2 mensagens por segundo.

Também foi demonstrado que com uma correta configuração, é possível manter a velocidade de consumo da fila, independentemente da quantidade de mensagens ruins e da quantidade de retentativas de cada mensagem.

Dessa forma é imprescindível a correta aplicação das configurações em filas onde o volume de mensagens é alto e falhas podem ocorrer.

Novamente, caso tenha interesse de experimentar, ou contribuir, o código fonte está disponível em: https://bitbucket.org/marlonmoncores/rabbitmessagesenderandconsumererrorbenchmark.

--

--