RabbitMQ 延迟队列三种方案实测:TTL+DLX 为什么把 30 秒的消息拖到了 47 分钟
先说结论,省得你看到一半发现不是你要的:如果延迟时间是单一固定值,TTL+DLX 够用;如果是混合延迟(同一个队列里既有 5 秒又有 30 分钟的),别碰它,去装插件;如果延迟普遍超过 1 小时、量还大,用 Redis ZSet 或者数据库轮询,别在 MQ 上硬扛。
我们这套是 RabbitMQ 3.12.4 + Erlang 25.3.2,3 台 4C8G 的机器,全是 Classic Queue + 镜像,没上 Quorum。配置确实寒酸,但恰恰因为寒酸,问题暴露得比线上大集群快。下面所有数字都来自我们自己的压测环境,你的硬件不一样数字肯定不一样,但量级关系是可以参考的。
一、TTL + DLX:官方文档里的「经典方案」,有个前提没人强调
配置大概长这样:
rabbitmqadmin declare queue name=order.delay durable=true arguments='{
"x-message-ttl": 5000,
"x-dead-letter-exchange": "dlx.exchange",
"x-dead-letter-routing-key": "order.timeout"
}'
逻辑是:消息在 order.delay 里躺 5 秒没人消费 → 过期 → 投到 DLX → 路由到真正的消费队列。看起来没毛病,我第一次写的时候也觉得挺优雅。
问题出在 RabbitMQ 检查过期的时机上。它不是给每条消息挂一个定时器,而是等消息排到队头、准备投递的那一刻才去读 expiration 属性。官方文档里那句原文是 "only expired messages at the head of the queue are discarded",很多人扫一眼就过去了,包括当时的我。
结果就是:队列本来就是严格 FIFO,队头压着一条 1800 秒的消息,后面 99999 条哪怕设的是 1 秒,也得排着。这就是「队头阻塞」。而且用 per-message TTL(把 expiration 塞进消息属性)也一样,判定规则没变,只是过期时间从队列级挪到了消息级,坑更隐蔽。
我们压测的参数:单队列投 10 万条消息,延迟在 1s~1800s 之间随机分布,prefetch_count=50,3 个消费者,手动 ack。跑出来的数字:
- 平均实际延迟比预期多 217 秒
- P99 实际延迟比预期多 43 分钟
- 最离谱的一条:设的 30 秒,实际 2860 秒(约 47 分钟)后才投出去
还有个更阴间的:如果 DLX 又路由回原队列(比如 routing key 配错,或者 exchange 是 fanout),消息会无限死信循环,把 CPU 直接打满。3.12 之后有死信循环检测,会报 dead-letter-cycle 相关的错误,但低版本就只能自己盯着。
二、delayed_message_exchange 插件:能用,但有三颗雷
装上很简单:
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
然后声明一个 x-delayed-message 类型的 exchange,用 x-delayed-type 指定底层路由方式:
rabbitmqadmin declare exchange name=delay.exchange type=x-delayed-message \
arguments='{"x-delayed-type":"direct"}'
发消息时在 header 里塞 x-delay,单位毫秒。原理是把消息暂存在插件内部的一张 Mnesia 表里,到期再由定时器投递,不占用队列本身,所以理论上没有队头阻塞。
雷一:x-delay 有上限。 这是个 32 位有符号整数,最大 2147483647 毫秒,约 24.8 天。超过这个值的延迟任务,你得自己想别的办法,或者拆成多段。文档里没怎么提这个,我们是压测到 30 天延迟时才发现数值绕回去了。
雷二:内存。 待投递的消息索引是放在内存里的,堆积越多涨得越快。我们堆到 80 万条待投递消息时,单节点内存从 1.2G 涨到 5.4G,直接顶到 vm_memory_high_watermark(默认 0.4),触发了全局流控,整个 vhost 上所有连接全部 block,连管理界面的 HTTP API 都开始超时。后来是用 rabbitmqctl set_vm_memory_high_watermark 0.6 临时顶过去,但这只是拖延。
雷三:跟 Quorum Queue 不搭。 插件不参与 Raft 复制,只有声明了 exchange 的那个节点持有定时器。那个节点重启或者宕机,上面还没到期的消息就没了,社区 issue 里搜 "lost" 一直有反馈。所以如果你选这条路,得接受「延迟消息可能丢」这个前提,重要度高的场景要在业务侧加对账。
延迟精度倒是还行,我们实测 ±50ms 左右,比 TTL+DLX 靠谱得多。
三、Redis ZSet:土办法,但在「延迟」这件事上更稳
思路特别朴素:ZADD delay:queue <到期时间戳> <消息JSON>,然后一个定时任务每秒 ZRANGEBYSCORE delay:queue 0 <now> LIMIT 0 1000,捞到期的扔回 RabbitMQ。删除和捞取要用 Lua 脚本保证原子性,不然多实例并发会重复投递。
看着很原始,但它解决了两个 MQ 方案都解决不了的问题:
第一,延迟任务可见。 ZCARD delay:queue 直接告诉你还有多少条在等,ZSCORE 能查到某条消息什么时候到期。TTL+DLX 和插件方案里,你只能翻管理界面那个数字,还看不到是哪条。这在排查「用户投诉说 10 分钟没收到短信」的时候是致命的。
第二,精度可控。 轮询间隔设 1 秒,精度就是 ±1 秒,延迟设 30 天也不会溢出。代价是多一跳网络往返。
内存占用上,10 万条平均 500 字节的消息,Redis 实测占 约 40MB,同样的量在插件方案里是 5.4G 内存。这个差距主要是 Mnesia 那套索引太重了。
缺点是运维复杂度上去了:Redis 得做持久化(AOF everysec 起步),得防脑裂,多实例抢任务得加锁。还有,Redis 主从切换的瞬间可能丢几十毫秒的数据,看你能不能接受。
四、三个方案摆一张表
| 维度 | TTL + DLX | delayed exchange | Redis ZSet |
|---|---|---|---|
| 混合延迟精度 | 极差(P99 偏差 43 分钟) | ±50ms | ±1s |
| 10 万条内存 | 低 | 5.4G(实测) | 约 40MB |
| 是否参与持久化 | 是 | 否(节点级) | 是 |
| 延迟任务可查询 | 否 | 否 | 是 |
| 最大延迟上限 | 无 | 24.8 天 | 无 |
| 运维复杂度 | 低 | 中(装插件) | 高(自己写) |
五、我的选择,以及一句不太好听的
我们最后是混合用:固定的 5 分钟订单超时关单,继续走 TTL+DLX,因为延迟单一,阻塞问题不存在,运维成本最低;用户自定义的提醒(1 分钟到 30 天不等),全部搬到 Redis ZSet;插件那套在压测完就卸了,主要是接受不了节点重启丢消息这件事,虽然它延迟精度最好。
说句不太好听的:很多团队上来就纠结「哪个方案性能最好」,但延迟队列这个场景里,性能从来不是瓶颈——消息量再大也大不过普通业务消息。真正的瓶颈是你对延迟精度的预期和方案实际能给的精度之间的差距。TTL+DLX 因为实现简单被大量教程推荐,而它恰恰在「混合延迟」这个最常见的需求上表现最差,这个错配我觉得挺讽刺的。
选之前先问自己一句:我要的延迟是固定的还是随机的?这个问题回答清楚了,方案基本也就定了。