RabbitMQ 队列堆积排查实录:那 2400 个延迟队列,和被我设错的 prefetch

🔑 关键词:RabbitMQ,消息堆积,prefetch,延迟队列,quorum queue

📖 摘要:一个订单系统里 2417 个队列是怎么来的,为什么 prefetch=1 反而更慢,以及 unacked 指标在排查积压时到底该怎么读。附实测数据和 rabbitmqctl 排查命令。

去年接了个订单系统,管理后台点 Queues 那个标签页要转三十多秒才出来。我一开始以为浏览器的问题,换了台机器、清了缓存,一样。ssh 上 broker 一跑 rabbitmqctl list_queues name | wc -l,2417 个队列。这台 8C16G 的机器扛的也就是每天几十万单的量,理论上一二十个队列绰绰有余。

图片

问题出在「订单 30 分钟未支付自动取消」这个需求上。前一位同学用 TTL + 死信交换机(DLX)做延迟,思路没错,但踩了 RabbitMQ 一个特别隐蔽的行为:消息的 TTL 是逐条判断的,可 broker 只从队头往下检查。也就是说,队列里同时躺着 TTL=30min 和 TTL=5min 的两条消息,5min 那条排在后面,它到点了也不会被投出去,得等前面那条先过期。这叫队头阻塞。官方文档 Time-To-Live 那一节最后几行提了一句,很不起眼,大多数人都是先从线上事故里学会的。

既然一个队列只能配一个统一的 TTL,那自然的结论就是——每种延迟时长建一个队列。这个思路一旦启动就停不下来:按分钟切,24 小时就是 1440 个;再加订单超时、优惠券过期、退款重试几个业务各自的队列,2400 出头就这么攒出来了。

2400 个队列的真实代价

这不是「能跑就行」的问题,而是一连串的连锁反应:

  1. classic queue 在 broker 侧对应独立的 Erlang 进程。2400 个队列,加上连接和 channel,这台节点上常年挂着两万多个进程。不是跑不动,是每个空队列也要占索引结构和内存,几百 MB 就这么没了。
  2. 管理插件的 /api/queues 要遍历所有队列抓指标,UI 卡是必然的,第三方监控(Prometheus 的 rabbitmq_exporter)也一起遭殃,抓一次要好几秒。
  3. 重启恢复慢。3.12 之前没有那套 lazy 加载的优化,broker 重启得逐个把队列索引从磁盘拉回来,2400 个队列的时间够我泡碗面。
  4. 最要命的是可观测性归零。出问题的时候监控面板上 2417 行,找哪个队列在堆,靠肉眼翻。

后来是这么收的场:延迟消息换成 rabbitmq_delayed_message_exchange 插件。它把消息暂存在交换机层,不经过队列的 FIFO,消息头上带一个 x-delay: 1800000,到点自己进队列。队列数从 2400 掉回三十来个。

图片

但这个插件的坑得说清楚。它不在核心发行版里,是社区维护的,版本更新经常比 broker 慢半拍,升级 broker 之前先去 GitHub 上确认有没有对应版本。另外它的延迟消息存在 Mnesia 表里,不走常规队列的内存统计,内存高水位的保护对它基本是失效的——堆了几百万条延迟消息的时候,OOM 来得很安静。单节点每天超过 500 万条延迟消息,我就建议换方案了。

真要做大规模延迟,我在另一个项目里用的是 Redis ZSet + 扫描协程:score 存到期时间戳,每秒 ZRANGEBYSCORE 捞一批。吞吐能做上去,代价是精度受扫描间隔限制,消息也不在 RabbitMQ 的持久化链路上,可靠性得自己兜。没有白吃的午餐。

prefetch 设成 1,吞吐掉到十分之一

测试机是 4C8G 单节点 RabbitMQ 3.13,消息 1KB 持久化,消费者干的事就是收下来写日志再 ack,单线程。下面这几个数是我自己压出来的:

prefetch 吞吐 消费者进程 RSS
0(不限制) 4.1 万 msg/s 220MB → 1.9GB
1 1800 msg/s 稳定 210MB
10 9200 msg/s 340MB
50 2.1 万 msg/s 620MB
100 2.4 万 msg/s 880MB
300 2.4 万 msg/s 1.3GB

图片

prefetch=1 那一行特别值得盯。很多人第一次配 RabbitMQ 时听到的建议是「设成 1 最安全,处理完一条再拿一条」,然后压测时发现吞吐只有预期的一成,来回翻文档找不到原因。原因其实很朴素:每处理一条都要等一次网络往返,broker 收到 ack 才会推下一条。局域网 RTT 0.5ms 看着不多,可 1800 次每秒就到顶了。

prefetch=0 是另一个极端。broker 会把队列里所有能发的消息一次全推给消费者,积压 10 万条的时候消费者内存直接炸。而这个值默认就是 0,我第一次看到这个默认值的时候愣了好一会儿。

调这个值的经验公式:prefetch ≈ 消费者线程数 × 单条平均处理耗时 ÷ 网络 RTT。处理 5ms、RTT 0.5ms、8 个线程,算出来 80 上下,再用压测微调。宁可略大,别设 1。

堆积排查:先看 unacked,不是 ready

「消费好像停了,但队列上明明有消费者」——这问题我遇到过三次,每次的表象都不一样。先记一条命令:

rabbitmqctl list_queues name messages messages_ready messages_unacknowledged consumers

图片

四个数就够用了。messages_ready 是等着被推的,messages_unacknowledged 是推出去但还没 ack 的,consumers 是消费者数量。组合起来看:

  • ready 高、unacked 低、consumers > 0:消费者在,但推不动。通常是 prefetch 满了,或者消费端线程池堵死了。
  • unacked 持续涨、ready 不动:有消费者拿到消息不 ack。高频原因是异常路径上忘了 ack,手动 ack 模式下业务代码抛异常直接把那行跳过去了。
  • consumers = 0、ready 高:消费者全挂了。去看应用日志,还有到 broker 的网络。

想再往下钻一层,用 channel 维度的:

rabbitmqctl list_channels connection number consumer_count messages_unacknowledged prefetch_count

这个能直接定位到哪个连接、哪个 channel 在卡。我用它查过一次事故:某台消费者机器的 GC 停顿了二十来秒,那期间它手里的 200 条 unacked 消息一直挂着(因为 TCP 连接没断),整个队列的消费看起来就像停了,可管理界面上 consumers 显示 1,一切「正常」。这个场景特别迷惑人,因为没人会想到去翻单机的 GC 日志。

要记住:只有 channel 关闭或者连接断开,unacked 的消息才会重新入队。一个假死的消费者能一直占着位置,比真正挂掉还难缠。

图片

我的判断:RabbitMQ 的价值是路由,不是队列

聊点个人看法。RabbitMQ 最有价值的地方不是「队列」,是「路由」。AMQP 0-9-1 里 exchange、binding、routing key 这一套,允许你把分发规则放在 broker 侧而不是应用侧。一个 topic exchange 上,order.*.created、order.paid.# 这类通配符能把同一条消息送进十几个不同语言的消费者手里,Python 发的 Go 收,Java 发的 Node 收,谁都不需要知道对方存在。Kafka 的 topic 是扁平的,想做类似的事得在消费端写 if-else,或者干脆多发几个 topic,存储成本直接翻倍。

所以我的选型标准很简单:场景是「多个异构系统之间按规则分发事件」,RabbitMQ 是省事的那个;场景是「我自己一个系统内部的异步任务」,RabbitMQ 大概率是过度设计,Celery、BullMQ 在重试、延迟、可观测性上给你的东西更顺手;需要高吞吐、需要回放、多个消费者组各自独立读全量——那是 Kafka 的地盘,别硬拿 RabbitMQ 顶。

一个反常识的点:RabbitMQ 是 push 模型,消息 ack 之后就没了,天生不适合「重放」。有人用多个队列绑同一个 exchange 来实现多个消费者组各读全量,能跑通,但存储成本是 ×N,而且没有 offset 这种东西,出问题了你没法从某个位置重来。

quorum queue,别只看它慢

图片

quorum queue 从 3.8 引进,到 3.13 / 4.0 已经是默认推荐。4.0 直接砍掉了 classic mirrored queue,老集群升级时如果还在用镜像队列,得先迁到 quorum。

性能上要有心理准备。同样单队列、持久化、1KB 消息,我实测 classic 的生产端 confirm 大概 3.5 万 msg/s,quorum 三副本 9000~12000,差三倍左右。换来的是一条消息落到多数派才返回,以及 x-delivery-limit(3.10 起)这类能防毒消息重复投递的东西。想抵消这个差距,常用手段是批量 confirm,把网络往返摊薄,我实测能追回四成左右。

还有一个被低估的开销:quorum queue 的 Raft 日志要落盘 fsync,磁盘的 fdatasync 延迟直接决定你的吞吐。用云上那种标称 2000 IOPS 的网络盘跑 quorum queue,你会看到延迟毛刺,而且一开始找不到原因。这不是 RabbitMQ 的锅,但它确实不会主动告诉你。

最后几条实在建议

  • 延迟消息别用 TTL+DLX 按队列切,队列数超过 200 就该警觉。
  • prefetch 别设 1,也别留 0。
  • 排查堆积先看 messages_unacknowledged,它比 ready 更能说明问题。
  • 3.13 之后的新集群,直接用 quorum queue,别犹豫。
  • 监控至少盯住这四个:messages_unacknowledged、consumers、连接数、内存高水位(默认 0.4,超了会 block 住生产者,表现是 socket 卡住)。

这些坑大部分是我拿真实事故换来的,代价不算低。要是能让你少踩一个,那这篇就没白写。

🏷️ 标签: