RocketMQ消息积压问题及解决方案

一、引言

在分布式系统中,消息中间件扮演着至关重要的角色,它如同系统的中枢神经,负责在各个服务之间高效、可靠地传递信息。然而,随着系统规模扩大和业务量激增,消息积压问题逐渐成为许多系统的梦魇。消息积压如同隐形杀手,严重影响着系统的健康,轻则导致系统响应迟缓,重则引发服务雪崩,最终造成业务损失。

二、消息堆积的场景

RocketMQ出现消息堆积大体是以下几个原因。

(1)消费速度客观上就是比较慢,而生产速度在某个时段确实比较大。典型的场景是日志系统的场景,一般情况下,日志存储需要批量处理,消费的耗时比较大,而日志的生产量在正常场景下可能看不出来什么延迟,一旦到了峰值就会发现有明显的生产消费速度差而导致的延迟。

(2)消费的处理能力出现了异常。有些时候生产的速率也没太大变化,但是突然就不断堆积了,这时候可能是消费端的处理能力出现了问题,如出现了某些异常的消息消费导致慢查询卡死了消费的线程,或者某些消费线程一直在等待锁。

(3)消费者状态异常。这是一种很异常的场景,如消费者启动出现了问题,导致不拉取消息了,又或者出现了某些异常的问题导致某条队列没有消费者获取,从而导致消息一直堆积没人消费。

三、消费堆积的危害

一般情况下,消息的堆积是没太大问题的,因为只要消息的消费在持续进行,最终总会把堆积的消息持续消费掉。但是消息的堆积意味着消息的消费是有延迟的,因为消息队列总是先进先出的,那么在堆积的情况下,最新到的消息是需要等待前面已经堆积的消息全部消费完才有机会消费的。这可能会导致以下两个问题。

(1)更重要的消息被迫需要等待。新来的消息可能更重要,但是必须要等前面的消息消费完才可以消费,从而产生了相互影响。例如一个工单的消息堆积了,付费用户的某个工单消息和免费用户的消息都使用一个主题一个队列,付费用户的新消息也会因为消息堆积而延迟处理。

(2)资源都被历史消息独占,服务无法恢复。这通常出现在一些对消息延迟较为敏感的场景,如购买成功后需要发送一条短信,如果发送短信的消息堆积了10w条,累积的延迟是2h那么新购买一个手机的时候,这条短信需要等非常久才能收到(消费速度慢的话,甚至要到第二天),这显得短信通知的功能像失效了一样。

四、消息积压扩容方式

当遇到消息堆积的时候,我们通常第一反应就是扩容,但有时候扩容却是无效的,这具体又是怎么回事呢?扩容的方式一般有5种

  1. 消费者服务扩容
  2. 消费者线程扩容
  3. Broker队列数扩容
  4. Broker扩容
  5. 下游服务扩容

4.1 消费者服务扩容

大部分情况下,我们首先想到的就是消费者服务扩容(增加消费者的实例数量)对堆积是有效果的。原因在于RocketMQ在设计上就是支持消费者的横向扩展的,所以整体的消费能力通常情况下会随着消费者数量的增加而增加。然而,这里有一个特殊的场景需要考虑,即队列数和消费者数量的比例。

如果扩容前,队列数比消费者数大或者队列数等于消费者实例数,那么扩容之后队列数可能小于消费者实例数。这样一来,多出来的消费者实际上是会空载的,也就是说这对消息的堆积丝毫没有帮助。

通过上图的对比我们也就知道了为什么我们有时候扩容没有一点效果了。原因就是每个队列只能分配给一个消费组里面的一个消费者,当每个队列都有各自的消费者时,我们即使在怎么扩容也是没有效果的,因为他就没有可以分配的队列。

4.2 消费者线程扩容

消费者线程扩容相比较于消费者服务扩容可能会更加有效,RocketMQ虽然一个消费者也是只能分配一条队列,但是这个消费者的并发数是可以大于1的,也就是说这个消费者实际上是多线程地在消费队列中的数据。

为什么说消费者线程扩容通常情况下应对堆积会是更有效的扩容方式呢?有以下两点原因。

(1)存量堆积数据的消费加速。对于已经堆积到队列的数据,因为队列无法同时分配给多个消费者,假设研发人员采取消费者实例数扩容,最终总会到达这样的一个状态:消费者数量=队列数量。到达这个状态之后,再扩容消费者实例已经没有意义了。但是队列里面堆积的数据是没办法迁移的,如果要加速这个队列的消费速度,最终只能提升单个消费者的消费度来加速堆积数据的处理。而消费者的线程数是可以继续扩容的,而且扩线程数就是扩并发数。也就是说只要扩容了线程数,在不考虑资源消耗(多线程的上下文切换、内存开销等)的情况下,对于整体的消费速度总会线性提升的。    

(2)线程池参数优化。很多情况下,开发者对于线程池的前期评估是不准的,大多数情况下都是按照一些简单的经验做的保守设置,如16。但是现阶段很多服务器的机器资源是很充足的,CPU的利用率远远不够,适当提升线程数是低成本实现消费能力扩展的很好手段。还有一种情况下,很多研发人员对于线程池参数设置是错误的。默认情况下,消费者线程数是20(consumeThreadMin和consumeThreadMax都是20),也就是说默认情况下一个队列的并发量最大可以达到 20。但是由于很多研发人员对于线程池的原理理解有误,经常设置类似下面这样的参数。

consumeThreadMin =1
consumeThreadMax=64

有的研发人员误以为设置的意思是这样的:

平时低峰的时候保留一个线程,如果处理不过来,就一直扩充线程直到64个。

然而这理解是错误的,在队列无限长的情况下(默认 RocketMQ 的消费者线程池的队列长度是 Integer.MAX_VALUE),线程池的线程数会一直保持在最小设置的数量上,也就是说上面的设置等同于设置成单线程消费。所以,除非单机负载真的比较高了,否则通常情况下增加消费者实例数能应对的堆积问题,改为设置更合理的线程数会是更优更低廉的扩容手段。

4.3 Broker队列数扩容

有些时候我们看到队列大量堆积了,就病急乱投医,直接考虑扩充队列数去解决堆积问题,实际上这种扩容方式大部分情况下是没有效果的。原因在于 RocketMQ 的队列发生数量变化的时候,并不会做数据的搬迁。假设现有4个队列,每个队列都堆积了 10万条消息,那么当研发人员把队列数量扩容到8条的时候,原来的4条队列每条还是堆积了 10万条消息,而新创建的队列则没有堆积的消息。

同时,对于消费能力来说,也没有帮助。因为消费能力实际上是消费者本身的能力决定的,在堆积的场景下(消费能力追不上生产能力),每个队列基本都是堆积的,从而导致消费者的线程池一直是满的,也就是说原本的队列已经消费不过来了,再分配更多的队列只会增加负载,并不会提升其消费能力。

当然了这种方式也并不是一无是处,在某些情况下,队列数扩容确实能解决一些别的问题。

其中一个场景就是如果队列数大于等于消费者实例数,那么要扩展消费者实例,前提是队列数的扩容,同时扩展后的消费者数要小于等于队列数,要不然我们有的消费者就没有相对应的队列。

还有另外一个场景是可以缓解新消息的处理延迟问题。堆积会导致新消息需要一直等待老消息的消费结束才能消费。例如4个队列中的每个队列都堆积了10万条消息假设有个消费者分别以 100/s 的速度消费,那么消费完10万条消息需要1000s,即大约 16min的时间。如果这个消息是一个类似会议提醒之类的通知,那么这时候突然来了一个会议需要提醒的话,则消费者需要延迟 16min 才会收到提醒,而且是所有新的提醒都会有这个延迟。

很多时候,我们希望发生故障的时候能尽可能不影响新的请求。也就是说堆积的消息已经受影响了,可能没什么办法,但是我们是希望新的消息能在这些影响中得到隔离。这时候扩容队列数对这个问题会有缓解作用。还是以前面的例子为例,4个队列中的每个队列都堆积10万条消息,假设把队列扩到8条,那么就会有4个新的、空的队列,而这4个队列也会分配给现在的消费者。因为消息生产默认是轮询的,那么每新发送8条新消息,就会有4条消息落到没有堆积的新队列中,所以这4条消息就能有机会更快地被消费,而不是前面说的等待 16min。

4.4 Broker扩容

有些时候,研发人员会认为消息堆积是和 Broker 的投递性能有关的,所以考虑扩容更多的 Broker,以获得更好的堆积消息处理吞吐量。这几乎是无用功。首先,RocketMQ的消费投递的性能极高,因为有大量的零拷贝、PageCache、批处理、长轮询等设计,RocketMQ无论是消息查找,还是消息拉取方面性能都是极高的,几乎不存在瓶颈。如果非要说有瓶颈,那可能就是网卡的瓶颈。然而生产上不太可能消费端的处理速度快到让瓶颈出现在网卡上的情况,所以 Broker 扩容对于消息堆积是没有意义的。即便真的是网卡出现了问题,该扩的也是该 Broker 所在的机器上面的网卡,而不是扩容新的 Broker。实际上,Broker 的扩容在消息堆积场景下起到的作用和队列数扩容的效果是一样的,所以如果仅仅只是为了让新的消息能更及时地被消费到,在原 Broker 上扩容队列数也能达到一样的效果。

4.5 下游服务扩容

通常情况下,排除 Broker 投递上遇到的瓶颈(网卡),堆积肯定是消费性能跟不上导致的。而消费性能一般又分为自身资源不足和下游服务性能不足导致的。如果是自身资源不启的情况,通过对消费者服务的扩容或者对消费者线程的扩容都是可以起到不错的效果的。但是如果瓶颈出现在下游的服务、存储上的话,那么扩容消费者本身的线程或者实例都只能是加重下游的负担,对于加速堆积消息的消费反而是有害的。所以当定位到瓶颈出现在依赖的服务、存储的时候,需要做的不是消费者本身的扩容,而是依赖的服务、存储的扩容。千万不要盲目扩容,这样可能会使得下游压力加大而雪崩,最后堆积情况反而加重了。

那么如何判断瓶颈到底是服务本身还是下游呢?在生产遇到故障的时候,已经很少有时间给我们分析代码,或者在机器上看线程状态做具体的分析了。一个成本比较低、响应比较快的方式是先快速扩容少量的实例数,或者调整某几台消费者的线程数,然后观察整体的消费速度是否有上升,如果有上升,证明当前的瓶颈还没到下游,而是并发能力限制的消费能力。如果这样操作后没有得到消费能力的上升,那么应该尽快分析链路上的强依赖路径看耗时主要消耗在哪里,找到该路径的服务、存储进行对应的扩容处理。

五、消息堆积的定位

5.1 定位消费瓶颈

堆积最大的可能是消费速度确实跟不上生产的速度了,所以一直堆积。这可能是程序的问题,也可能是下游服务的问题。该怎样定位呢?

消费者消费的时候实际上是用一个线程池消费的。而这个线程池的线程在 RocketMQ 里面都是有专门名称的,且都是以 ConsumeMessageThread_开头。在最新的版本中,这个线程名还会带上消费者组的组名,但无论如何都肯定是以ConsumeMessageThread_开头的。所以当研发人员在消费者服务上进行jstack 操作打出线程堆栈时,可以尝试搜索Con-sumeMessageThread_关键字。通过结果可以知道当前消费线程池中正在运行的线程数,也就是并发数。如果发现这个数很不正常的话,则需要回头看看参数配置是否正确。最典型的情况就是像前文所说的,线程数其实只有1个,所以导致并发能力很差。也有可能是线程数量过大,导致性能反而下降。这两种情况都是可以通过调整线程数量来提高消费能力的。

如果线程的数量看起来是健康的,接下来则可以看看堆栈中的线程正在做什么。通常情况下,如果消费的瓶颈来自于某个慢的代码,那么进程中的那些线程在一瞬间(进行jstack 的时候)大概率都在执行相同的代码,所以它们的堆会非常相像。

5.2 定位数据倾斜

有些时候研发人员可能会发现同一个主题下,大部分的队列可能没有堆积,只有某个队列特别慢、堆积特别多。

这种情况下,有两种可能:

(1)这个队列分配到的消费者特别慢。这可能是消费逻辑的问题,可以用前面讲的jstack的手段定位。还有另一种比较常见的情况是部署上的问题,例如这个队列所属的消费者是另外一个机房的。

(2)还有一种情况是消费者可能并没有太多问题,问题出现在消息的分配策略上。例如4个消费者的处理速度都是10条/s,这4个消费者都得到一条队列。如果说消息以40 条/s的速度生产,在平均负载的情况下消息是不应该有堆积的。但是假设有三个队列的消费速度是1条/s,剩下的一个队列是 47 条/s,那么就有一条队列以 37 条/s 的速度在堆积了。这很可能是产生了一些热点问题。最典型的情况就是开发人员在消息生产的时候采用了selector 的策略以控制了消息生产的负载均衡。例如做直播业务的时候,开发人员以直播间的房间id作为分片键去计算应该投递的队列。当出现了一个超级大热门的直播间的时候,某个队列可能就会因为数据严重倾斜导致堆积。这时候定位上需要特别关注这个堆积的队列里消费的消息是否具备特殊的特征,从而回头审视 selector 的逻辑是否需要优化。

六、消息堆积问题的解决方案

6.1 快速扩容

遇到消息堆积,通常情况下需要第一时间快速扩容。扩容方式参照前面内容

6.2 快速恢复新消息的消费

扩容之后,研发团队还需要考虑让新消息能有机会被消费到。前面说过,扩容即使可以提升消费能力,但还是无法做到让新消息插队。

这时候以下几个方法是可以参考的。

1)更换消息主题。研发人员可以让生产者更换一个主题,让消息能进到另外一个主题中.这时候让消费者组也更换新的消费主题,从而实现新消息能及时分配资源。

2)重置消费进度。研发人员也可以通过控制台或者运营命令行重置这个消费者组的消费进度,使其跳过历史的消息而只消费新消息。

3)更换消费者组名,策略设置为CONSUME_FROM_LAST_OFFSET。这样这个新消费者组会立刻从队列的末尾开始消费。

6.3 恢复历史消息的消费

这之后,就可以做到新消息能立刻被消费。但是可能会导致这期间堆积的消息如果这些消息无关紧要或者和时间强相关,那么到这一步就可以了。
而如果这些消息要求不能丢失,借助 RockeMQ,研发团队也可以补教。最简单的资是再启动一个新的消费者组,设置一个合理的消费位点去回放这些堆积的消息。

七、预防消息堆积

7.1 合理地规划消费并行度

首先,消费的并行能力是最容易导致消费瓶颈的,而这其中特别需要关注以下三个指标,

(1)消费者的实例数量。

(2)每个消费者能分配到的队列数。

(3)消费者线程池的线程数。

7.2 避免热点数据

某些场景下,程序需要做一些消息分区的动作,这时候一定要注意选择好分片键,因为可能会出现热点问题。例如刚刚所说的直播间功能,如果按照直播间房间id做消息分区的话,大间的场景下就会导致某个分区堆积,而如果选择观众的账号id去分区的话,就能避免。同样问题可能出现在商品上,一些商品可能会突然产生大量浏览记录,如果按照商品id去做消分区的话,这里浏览记录的主题可能也会出现热点数据的问题,导致某个队列的数据特别多。

7.3 适当丢弃过老的消息

为了避免消息的堆积而大量影响新消息的消费,程序可以在处理消费逻辑的时候适度丢弃老的消息,从而让新消息能更快地被消费。
有两个手段去判断消息是否过老。第一种可以依赖消息的生产时间。另外一种手段可以看堆积的数量,如果堆积太多了,那么就舍弃一批。

8 小结

本文主要介绍了消息堆积发生的原因,同时探讨了各种扩容手段的效果,从而了解了如何进行正确的扩容。同时还介绍了定位消息堆积瓶颈的手段。最后,介绍了处理消息堆积的一些方法,以及预防消息堆积需要提前考虑的点。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值