☰
RocketMQ百万消息堆积实战复盘:从告警到根因定位与根治方案
2026/10/20 8:04:30 网站建设 项目流程

晚上快十点,监控群里突然弹出一条告警:消费组 fulfillment-consumer 的积压消息数已经越过 50 万阈值,而且直线还在往上蹿。我打开控制台刷新了一下,累计堆积已经奔着 120 万去了。说实话,看到 RocketMQ 百万消息堆积这个数字时,第一反应不是慌,而是脑子快速过了一遍:这条链路上到底哪一环被卡死了?

这篇就把这次处理百万消息堆积的过程完整复盘一下。从告警触发到止血,从扩容无效到根因定位,再到最后的参数调优和代码改造,我会把每一步的排查思路、操作细节以及事后复盘都说清楚。如果你也负责消息中间件相关的业务链路,或者迟早要面对消费积压问题,这篇应该能帮你少走不少弯路。

1. 先还原现场:订单履约消息是怎么被一波流量打瘫的

先交代一下背景。我们这边有一套订单履约的异步链路,用户下单后,订单中心会发一条消息到 RocketMQ 的order_full_done这个 topic 里,下游消费端拿到消息后,要依次做库存预占、优惠券锁定、支付确认通知、物流单预创建等一连串动作。平时这条链路的日均消息量在百万级,消费端用集群模式跑着 8 个实例,消费 TPS 稳定在 2000 左右,一直没出过乱子。

出事这天是某场大促的晚上。流量峰值从平时的 2000 TPS 直接冲到了 6000 以上,消息生产速率快到 broker 端一点压力都没有,但消费端的表现却非常难看:消费 TPS 从 2000 掉到了 200 左右,积压曲线在短短一个小时内就突破了百万。

1.1 大促当天的消费链路全貌

先拆一下这条链路的数据流向,方便后面理解问题出在哪一环:

  • 订单中心发送消息到order_full_done,消息不带顺序要求,属于普通消息。
  • 消费者fulfillment-consumer以集群方式消费,8 个实例分布在两个可用区。
  • 每消费一条消息,需要同步调用三个外部服务:库存预占、优惠券锁定、物流单预创建。
  • 三个外部服务都走 HTTP 接口,客户端用的是连接池模式。

如果只是流量涨了三倍,理论上消费端应该也能跟上,因为 8 个实例的消费线程池加起来有 160 个线程,平时 CPU 和内存都还有很大余量。但那天的情况是:消费者实例的 CPU 并没有打满,内存也没爆,就是消费速度上不去,而且有越来越慢的趋势。

1.2 先把业务止血,再谈根因

面对百万级堆积,我第一反应不是去查代码,而是先让业务侧不继续受伤。积压的直接后果是用户下单后的库存预占和优惠券锁定被无限拉长,后端的超时重试会一次次叠加,情况只会更糟。

当时的应急动作有三个:

  1. 打开消费链路的降级开关,把"支付确认通知""短信提醒"这类非关键动作直接从消费主流程里摘掉。
  2. 把外部服务调用的超时时间从 6 秒临时改成 2 秒,宁可快速失败也不要让线程一直悬着等。
  3. 临时把消费者实例从 8 个扩容到 16 个,想通过加机器先把消费能力提上来。

结果呢?降级开关一开,非关键动作确实不执行了,但消费 TPS 只从 200 涨到了 400,依然远低于生产速率。扩容到 16 个实例后,积压下降的速度也没有我预想中那么明显。到这里我才意识到,问题不是资源不够,而是链路本身有东西卡住了。

2. 排查链路:从控制台积压曲线到 JVM 线程栈

面对消息堆积,最忌讳一上来就翻代码。正确的排查顺序应该是:先看监控曲线,再看消费者状态,最后才回到代码层面找原因。这套顺序能帮你快速缩小范围,避免在错误的方向上浪费时间。

2.1 先看趋势:积压是新发起的,还是已经歪了很久

我做的第一件事,是看 RocketMQ 控制台上fulfillment-consumer这个消费组的积压趋势图和消费 TPS 曲线。

这里有个很关键的判断逻辑:如果积压是从几天前就开始缓慢上涨的,那大概率是消费端长期能力不足,或者消息模型设计有缺陷;如果积压是从某个时间点开始突然陡增的,那一般是生产端流量突增或者消费端发生故障。

从曲线看,积压是当天晚上 20 点之后开始陡增的,20 点之前消费 TPS 还能够得上生产 TPS,20 点之后消费 TPS 掉到了 200 附近,生产 TPS 却还在 4000 以上。每分钟净积压约 3800 条,半小时就是十万级,一个多小时到百万,完全对得上。

再对比 broker 维度的指标,写入 TPS、pageCache 命中率、broker 自身耗时都正常。这就说明一个很关键的事实:RocketMQ broker 本身没有任何压力,消息没丢,broker 也收得下,问题不在消息中间件这一层,而在消费者。

2.2 消费线程的状态才是最诚实的证据

下一步是看消费者实例的 JVM 线程栈。我挑了一个消费实例,用 jstack 抓了一份线程 dump,结果非常典型:

  • 有 30 多个线程处于WAITING状态,具体是阻塞在 HTTP 连接池获取连接的地方。
  • 有少量线程处于RUNNABLE,但也在等待外部接口的响应返回。
  • 真正在执行本地逻辑的线程几乎没有。

这说明什么?说明消费线程池虽然有一堆线程,但绝大多数线程都在等下游接口。消费线程池就像一个餐厅服务员团队,客人点完菜,服务员全都挤在出菜口等厨师上菜,那团队人数再多,翻台率也上不来。

再查消费日志,库存服务和优惠券服务的接口响应时间在那个时段已经涨到了 4 到 12 秒,有一部分请求直接超时。一旦消息消费失败,RocketMQ 默认会把它丢进重试队列,过几秒再投递一次,重试次数多了之后,消息在重试队列和主队列之间反复横跳,进一步消耗了本就紧张的消费线程资源。

这里要补一个 RocketMQ 的重试机制常识:消费失败后,消息不会原地消失,而是进入%RETRY%+ 消费组名的重试队列,延迟时间从 1 秒开始逐级翻倍,最多重试 16 次,16 次之后进入死信队列。如果下游接口一直不恢复,这些重试消息会反复占用消费名额,把本来就有限的消费能力进一步拖垮。

2.3 broker 和 nameserver 都正常,这本身就是重要线索

排查到这里,我已经可以排除掉一大半的可能性:

  • broker 端无异常,说明消息写入和存储没有问题。
  • nameserver 连接正常,说明客户端发现机制没问题。
  • 消费者实例都在线,说明负载均衡没有异常。
  • 消费线程大量阻塞在外部接口,说明瓶颈在下游依赖。

那结论就很清晰了:这不是 RocketMQ 本身的问题,而是消费端链路在下游服务上被打爆了。我们的消费者代码把一堆同步外部调用塞进消息处理逻辑里,平时 RT 低的时候没感觉,一旦下游扛不住,整个消费链路的吞吐量就被拖死了。

现在的问题是:根因找到了,但怎么解决?如果只是简单调大线程数,可能反而会让下游服务更快被打挂,这是一个需要小心的权衡点。

3. 为什么扩容消费实例没有效果:队列分配机制的决定性影响

很多人遇到消息堆积的第一反应就是加机器,我也不例外。但这次扩容的实际效果告诉我,加机器在 RocketMQ 的消息模型里不一定能解决问题,得先看队列和消费者的分配关系。

3.1 一个 topic 的队列数,限死了消费并行度

先讲清楚 RocketMQ 的一个核心机制。一个 topic 在创建时可以指定队列数,比如我们的order_full_donetopic 创建时设了 8 个读写队列。每条消息写入时,生产者会根据队列选择器把消息写到其中一个队列里;消费时,同一个消费组内的消费者实例会通过负载均衡算法分配队列,一个队列在同一时刻只会被一个消费者实例消费。

这里有一个非常容易被忽视的点:消费并行度的上限由队列数决定,而不是由消费者实例数决定。

order_full_done只有 8 个队列,那我即便扩容到 16 个消费者实例,在正常情况下也只有 8 个实例能分配到队列并消费消息,剩下 8 个实例会处于"空转"状态。这就像共享单车只有 8 辆车,你叫来 16 个人,同一时刻也只有 8 个人在骑车。

在 RocketMQ 默认的AllocateMessageQueueAveragely分配策略下,如果消费者数量大于队列数,多出来的消费者会分不到任何队列。所以从 8 个实例扩到 16 个,有效消费实例并不会翻倍。

这就解释了为什么我扩容后积压下降不明显:队列数没变,实际的并行消费能力上限就没变。大部分新增的实例只是白白占着资源,并没有真正分担消费压力。

3.2 8 个队列、160 个消费线程,为什么还是跑不满

你可能会问:队列数是 8,但每个消费者实例有 20 个消费线程,8 个实例一共有 160 个消费线程在跑,并行度看起来并不低啊?

问题在于,RocketMQ 的消费线程并不是每时每刻都在拉消息。消费者内部的拉取流程大致是这样的:

  1. 每个消费者实例维护一个 PullRequest,不断向 broker 拉取该队列的消息。
  2. 拉回来的消息进入本地的一个处理队列,由消费线程池执行。
  3. 如果消费线程池里的线程都在等待外部接口响应,处理队列就会满,拉取工作也会随之停下来。

也就是说,消费线程池的吞吐量上限完全取决于每个线程处理一条消息需要多久。假如每条消息要花 4 秒调用外部接口,那一个线程每分钟最多只能处理 15 条消息。160 个线程看起来多,但假设 80% 的线程都阻塞在等待外部接口返回上,有效吞吐可能只有每天 3 万到 5 万条,远低于生产速率。

这也是消费端代码里最隐蔽的坑:你看到的线程数、实例数都够,但实际吞吐量被外部依赖的响应时间锁死了。这种情况加线程数不但没用,还会让下游服务的负载进一步升高,形成恶性循环。

3.3 我顺着代码看了一遍:一条消息要经过四个外部调用

在定位到外部接口之后,我顺手拉了一遍消费端的核心代码,把一条消息从投递到消费完成的路径捋了一遍。这里有必要把这段逻辑抽出来,因为它直接影响了后面要怎么改。

当时的消费流程大致是:

  1. 收到order_full_done消息,解析出订单号。
  2. 调用库存服务预占库存。
  3. 调用优惠券服务锁定优惠券。
  4. 调用支付服务确认支付状态。
  5. 调用物流服务预创建物流单。
  6. 全部成功后才返回成功,任意一个失败则整个消息消费失败。

看到这里我心里基本有数了:这条链路把五个动作全部串在一起,任意一个下游服务抖动,都会让整条消息消费失败并进入重试,而重试又是从头再来一遍。库存服务一旦打满,后面四个服务全都跟着遭殃。

而且还有一个隐蔽的问题:如果第 3 步成功、第 4 步失败,消息进入重试后,第 2 步和第 3 步会再执行一遍。也就是说,消费者代码天然不幂等,重试次数一多,对下游服务的重复调用量会呈倍数放大。这在平时没问题,但大促流量高峰时就是雪上加霜。

4. 根治方案:先调参数止血,再从代码层面切断阻塞

根因清楚了,接下来就是怎么解决。我当时把解决方案分成三个层次:参数调整止血、代码逻辑改造、队列模型扩容。三个动作并行推进,前两个当天晚上就上了,第三个花了一点时间。

4.1 参数组合拳:消费线程数、连接池、超时时间怎么配

首先是消费端参数的调整。这里要说明一下,调参不是无脑往大调,而是要根据瓶颈位置来决定。

当时我们遇到的问题是消费线程大量阻塞在 HTTP 连接池获取连接上,所以第一步不是加消费线程,而是先放大 HTTP 客户端连接池,并压缩调用超时时间。

RocketMQ 的消费线程数在客户端代码里可以这样设置:

DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("fulfillment-consumer"); consumer.setConsumeThreadMin(20); consumer.setConsumeThreadMax(64);

在调整前,消费线程池是 20 到 40 之间,我把最大值调到了 64,但强调一点:线程数不是越大越好。如果瓶颈在外部服务 RT,线程数再大也只是让更多线程堆积在等待队列里。我们调整线程数的前提是先解决了外部连接池的问题,否则这个参数改完效果会很有限。

HTTP 连接池这块,原来是每个实例 50 个连接,我临时调到了 200。一次消费消息需要并发调多个服务,连接池太小的话,即使消费线程再多,也只能排队等连接。

同时把超时时间从 6 秒调到 2 秒。在没有明确兜底逻辑前,快速失败比长时间等待更健康。消息消费失败可以重试,但线程不能被一个慢接口无限挂住。

再提一个很多人容易忽略的参数:批量消费。如果消息结构允许的话,可以在消费者里设置批处理大小:

consumer.setConsumeMessageBatchMaxSize(16);

批量消费的意思是,一次回调把 16 条消息一起交给ConsumeMessageListener处理。注意,这要求监听器里的逻辑能接受List<MessageExt>,而不是单条消息。批量消费可以显著减少锁开销和线程切换,把消费吞吐量提上来一个档次。但这个参数对我们的场景来说不是核心,因为下游接口调用还是单条粒度。

4.2 把消费逻辑"瘦身":降级、快速失败、异步补偿

参数调整只是止血,真正的根治必须改代码。核心思路只有一句话:把外部依赖从消费主链路里摘出去,或者改成非阻塞的快速失败模式。

具体我们做了三件事。

第一件,给消费逻辑加了一层"动作优先级"配置。消息处理不再是无脑执行五个步骤,而是先执行关键动作(库存预占、优惠券锁定),再根据降级开关决定是否执行非关键动作(支付确认通知、短信提醒、物流单预创建)。大促期间非关键动作可以直接跳过,等流量过去之后再从补偿任务里补。

第二件,把多个外部调用从"串行全部调用"改成了"快速失败 + 延迟重试"。比如调用库存服务时,如果 2 秒内没有返回,就直接返回失败状态,把消息标记为稍后重新消费,而不是同步等待整个小区间。这样可以避免一个慢接口拖垮整条链路。

第三件,给消费逻辑里嵌入了幂等控制。我们在消费端加了一个基于业务唯一键的去重判断,核心代码如下:

String dedupKey = "order_done:" + orderId + ":" + actionType; boolean firstTime = redis.setIfAbsent(dedupKey, "1", Duration.ofHours(24)); if (!firstTime) { // 已经处理过,直接返回成功 return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; }

为什么幂等很重要?因为 RocketMQ 本身是"最少一次"投递语义,消息可能因为各种原因被重复消费。在积压回补场景下,消息从重试队列再次投递时,业务数据的重复处理概率会大幅上升。没有幂等保护,你根本不敢放心地提升消费速度。

这里有个小细节:不要用消息 IDmsgId做幂等键。因为消息进入重试队列后,RocketMQ 会生成新的消息 ID,你拿到的msgId和第一次消费时已经不是同一个了。一定要用业务侧真正唯一的字段,比如订单号加动作类型。

4.3 队列扩容的正确姿势:为什么建议平滑迁移而不是原地扩展

前面说过,order_full_donetopic 只有 8 个队列,这限制了消费并行度的上限。所以根治方案里必须包括扩大队列数。但这里有一个实操层面的坑:topic 的队列数在 RocketMQ 里不能简单地在生产环境原地调整,因为涉及生产者的路由缓存和消费者的队列重新分配。

我记得当时评估了两种方案。

方案一是直接通过管理工具把order_full_done的读写队列数从 8 调到 32。技术上可行,但有几个隐患:生产端客户端对 topic 的路由信息有缓存,你改了 broker 端配置后,客户端不一定会立刻感知,可能会出现消息继续往旧队列写入、新队列长期空闲的情况;消费者重新平衡期间,会造成一段时间的消费中断。

方案二是新建一个同构 topicorder_full_done_v2,队列数设置为 32,然后把生产者按比例灰度切换过去,等新 topic 稳定后全量切换,旧 topic 的存量积压由旧消费者继续慢慢消化。

我们最终选的是方案二。虽然要多做一步迁移操作,但胜在平滑、可控、可回滚。切换发消息的逻辑也很简单,加一个路由开关:

/** * 根据开关决定消息发到新 topic 还是旧 topic */ String targetTopic = dynamicConfig.isUseNewOrderTopic() ? "order_full_done_v2" : "order_full_done"; Message msg = new Message(targetTopic, payload); producer.send(msg);

先把 10% 的流量切到新 topic,观察半小时,确认新 topic 的消费者实例都能分配到队列、消费 TPS 正常,再把开关逐渐放大到 50%、100%。

这里也给个经验值:队列数不要盲目贪大。队列太多会让每个消费者实例频繁在队列之间做拉取切换,增加 broker 端顺序读的碎片化开销。我们的场景 32 个队列已经非常宽裕,8 个消费者实例平均每个分到 4 个队列,并行度是原来的 4 倍。

5. 从百万到清零:验证过程与数据复盘

改造上线后的验证过程,比我预想的要顺利一些,但中间也踩了几个小坑。把这段写出来,是想让大家知道:堆积清零不是一个节点,而是一条曲线,而且清零之后还要防反弹。

5.1 消费 TPS 恢复到多少,积压曲线才会掉头

参数调整和代码改造陆续上线后,我盯了几个关键指标:消费组fulfillment-consumer的消费 TPS、生产 TPS、积压消息数。

改造前,消费 TPS 大概在 200 到 400 之间波动,生产 TPS 在 4000 左右,积压每分钟都在涨。改造后,消费 TPS 在几分钟内爬升到了 2800 以上,最高冲到过 3200。虽然还是赶不上峰值的生产速率 6000,但大促流量此刻已经在回落,生产 TPS 降到了 2500 左右,消费速度第一次反超了生产速度。

积压曲线的拐点就是这样出现的:消费 TPS 大于生产 TPS 时,积压开始下降。我们当时的峰值积压约 116 万条,按照每分钟净消费约 500 条的速度,理论上要将近 4 小时才能清完。但因为生产速率在持续下降,实际清空时间大约用了 2.5 小时。

这个过程中还发生了一个值得一提的细节:消费 TPS 的提升不是线性的,而是先快速爬升,然后在一个平台上稳定一阵,再突然跳升。原因在于,旧 topic 的消费线程还在处理大量重试队列里"翻来覆去"的失败消息,这些消息占用了不少线程资源。等我们打开降级开关、把失败消息的处理路径改短之后,这部分无效占用才真正释放出来。

5.2 回补期间最容易踩的坑:重复消费和乱序

积压回补阶段,最危险的不是消费不快,而是消费太快导致业务出问题。

我们当时就踩了一个真实案例:优惠券锁定服务接到了一笔订单的两次锁定请求。原因是消息第一次消费时,库存预占成功、优惠券锁定成功,但支付确认调用超时,整条消息被判定为消费失败,进了重试队列。等重试的时候,库存预占和优惠券锁定又跑了一遍。第一次锁定和第二次锁定之间没有做防重处理,优惠券就被重复扣减了。

好在我们已经把幂等去重逻辑提前写进消费端了,这个问题在监控上暴露之后,很快通过补单脚本修复。这里再多说一句:任何消费端代码上线前,都要回答一个问题——如果这条消息被重复消费一次、乱序到达一次、延迟一小时到达,业务会不会出问题?如果会,就该把幂等、顺序、时效的保护写进代码里。

5.3 我的验证标准:不光看清零,还要看是否反弹

积压归零不等于事情结束了。我当时还做了一轮完整的回归验证,标准只有两条:清零之后不反弹,外部依赖恢复后不引发新堆积。

具体做法是把关键指标在 Grafana 上拉了一个整晚的视图,从晚上 10 点到第二天早上 8 点,逐小时确认:

  • fulfillment-consumer消费组积压数维持在个位数。
  • 重试队列里的消息数量没有异常上涨。
  • 外部服务 RT 恢复后,消费 TPS 没有出现剧烈波动。
  • 死信队列条目没有新增明显异常。

另外,我把新 topicorder_full_done_v2的消费链路整体梳理了一遍,确认了降级开关、幂等键、重试策略都已经生效。这样才算真正把这次的坑填平,而不是只把表面数字压下去。

6. 下次再遇上百万消息堆积,我会先做这三件事

处理完这次事件之后,我们团队把整套排查和预防机制固化了下来。这里挑三件我觉得最具通用价值的经验分享出来。

6.1 提前埋好三个信号,不要等百万堆积才报警

百万积压这个级别,发现问题时其实已经很晚了。更好的做法是把告警阈值设到业务还能自我修复的范围,同时让值班人有足够的信息进行判断。

我现在的建议是至少埋三个信号:

  1. 消费积压条数:超过 5 万就报警,超过 20 万就分页升级。这个阈值要根据业务高峰期生产速率动态调整,不能拍脑袋写死。
  2. 消费 TPS 与生产 TPS 的比值:如果消费能力连续 5 分钟低于生产速率的五分之一,不需要等积压量上来就直接告警。
  3. 消费线程活跃度:消费线程池的活跃线程占比连续 5 分钟超过 70%,自动抓一份 jstack 快照并通知到人。有了线程快照,排查效率能提升好几倍。

这套信号体系的好处是,在积压还没滚成雪球之前就能发现问题。而且有了消费线程快照,不用每次都在出问题时才临时去抓,很多问题的根因当场就能看出来。

6.2 处理消息堆积的固定排查清单

再给一份排查清单,这是我在这次实践中反复调整后沉淀下来的一套动作:

  • 第一步:看积压曲线是陡增还是缓涨,判断是新发故障还是长期隐患。
  • 第二步:对比生产 TPS 和消费 TPS,确定是生产端流量暴涨还是消费端吞吐下降。
  • 第三步:看消费者实例的线程状态,找到阻塞在什么位置。
  • 第四步:看下游依赖的 RT 和错误率,确认是不是外部服务被打爆。
  • 第五步:看重试队列和死信队列的量,判断失败消息是否在占用消费资源。
  • 第六步:确认幂等逻辑是否存在,没有就先补上再加速消费。

按照这个顺序走,绝大多数堆积问题的根因都能在两轮排查内定位到。切忌一上来就翻代码,更不要盲目加机器,否则很可能像我们这次一样白折腾一圈。

6.3 事后复盘的一条铁规矩

最后再分享一条我们团队在复盘后立下的规矩:任何消费端代码合入前,必须说明消息被重复消费、乱序到达、延迟一小时处理时,业务会发生什么。写不清楚,代码就不允许合入。

这不是形式主义。这次百万堆积的爆发,表面上是大促流量超预期、下游服务 RT 升高导致的,但深层次原因是消费端代码把外部强依赖、非幂等操作、同步串行调用全部塞进了主链路,把一个消息中间件应该承担的削峰填谷功能,变成了一条碰一下就断的串联链路。

把外部重逻辑做成异步化或可降级,给所有消费逻辑配上幂等键,在关键节点埋好监控信号,这三件事做扎实了,再遇到流量洪峰时你会踏实很多。

这次经历给我最大的收获是:消息堆积不可怕,可怕的是在错误的方向上用蛮力。"加机器"和"调大线程数"看起来是努力,但真正有效的努力永远是先搞清楚卡点在哪。希望这篇复盘能帮你下次从告警到定位的时间缩短一半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询