本文翻译自https://www.alphasights.com/news/exponential-backoff-with-rabbitmq?locale=en。
在AlphaSights公司,RabbitMQ是我们event-drivien架构的核心元素。它使得我们的服务之间相互解耦,并且一个新的应用开始消费需要的events也非常容易。
不过,有时候也会出错,导致消费者不能处理消息。通常,有两种原因:或者是我们引入了一个bug,使得工作线程本身不工作;或者是工作线程依赖的第三方服务在当时暂时不available。
一般来说有三种方法来处理RabbitMQ中消息处理失败:丢弃消息;把消息重新放回队列(requeuing);或者是把消息发送到一个死信交换机(dead-letter exchange)。
假定我们收到的所有消息都很重要,我们不能直接丢弃掉,因此我们还剩下两种选项。
The Problem in Requeueing
这是我们最初始的方法,每次当一条消息处理失败,我直接把消息重新放回队列(requeue),接着我们可以尝试再次处理它。
尽管对于简单场景,这是一个有效的方案,但是对于我们的案例,相比于解决的问题,它引入了更多的问题。
在工作线程出问题的情况下,仅仅是再次处理同一条消息不会有任何帮助,它只会反复的再次失败(并且会在你的监控工具上生成很多杂音)。尽管如此,最坏的问题是,我们会使另外一个服务超负荷运行。如果这个服务因此不available,发送上千次请求并不是一个好的注意。
The Problem in Dead-Lettering
第二种方法就是把处理失败的消息发送到一个死信交换机,它进而把消息路由到一个空置的队列,然后我们需要手动处理。当问题确认解决之后,我们可以把消息放回工作队列去重新处理,或者如果没有必要再消费这条消息的话,直接把这条消息reject掉,这种方法永远不会使另外一个服务超负荷运行。
现在的问题是,我们有一个手动的步骤。大多数时候失败是由于一些间歇性的问题引起的,比如说超时,而且如果延后几秒钟或者几分钟重新处理这条消息,常常就可以解决问题。
Enters the Exponential Backoff Strategy
针对我们遇到的这些问题,解决方案是采取一个指数回退算法策略。这样不会使其它服务超负荷,并且对于间歇性的错误,这些消息会自动重试,避免了没有必要的人工介入。但是,实现这个策略并不是像我们想象的那么简单。
我们开始研究其他人是怎么做的,好像通用的方案是使用一个重试交换机,加上一个基于消息级别的TTL。它如下所示工作:
一旦你理解了RabbitMQ怎么处理TTL和死信交换机,实现就很简单:
The Problem
你可能已经猜到,这种方法也有一个问题,它跟RabbitMQ如何处理过期消息有关。具体可参考官方文档:
While consumers never see expired messages, only when expired messages reach the head of a queue will they actually be discarded (or dead-lettered).
When setting a per-queue TTL this is not a problem, since expired messages are always at the head of the queue. When setting per-message TTL however, expired messages can queue up behind non-expired ones until the latter are consumed or expired.
意思是说只有当一条消息到达队列头的时候,它才可能会被死信,所以,如果我们有一条消息的TTL是5分钟,另外一条消息的TTL是1秒钟,第一条消息就会阻塞队列中其余的消息,第二条消息只有等第一条消息过期之后,才会变成死信(进而再次被消费)。
原因是RabbitMQ队列的原则总是“先进先出”,因此TTL会告诉Rabbit MQ是否需要把消息发送给消费者,或者是否需要reject这条消息。因为重试队列没有任何消费者,消息会一直保持在那直到它可以被安全的reject。
这使得这个方案行不通,因为更高TTL的消息会阻塞失败之后需要更快执行的消息。因此继续探索。
And, Finally, Our Solution
为了解决这个问题,我们想到了一个类似的解决方案,即是针对不同的TTL值,动态创建新的队列。
这里关键的不同是针对不同的TLL创建新的队列。这解决了阻塞消息的问题,因为现在每条到达queue.5000的消息,都是TTL为5000ms的消息,因此队列中的第一条消息总是接下来即将过期的消息。其它的队列也是类似。
为了避免所有的消息被消费之后,出现一堆空的队列,我们用一个x-expires参数来定义动态创建的队列,意味着队列的最后一条消息被消费之后,队列本身也会被删掉。
Show Me the Code
如果你对到目前为止所看到的都是一些图很失望的话,这里即是我们使用的代码(参考文末链接)。对于每次消息处理失败,主要是Sneakers handler完成了神奇的逻辑。
尽管实现非常细节,但是只要你了解整体架构,应用到其它语言应该就会很简单。
Useful Links