RabbitMQ 怎么实现延迟消息?
结论:RabbitMQ 原生没有延迟队列,常用两条路——用"消息 TTL + 死信队列(DLX)"拼出来,或者安装官方的延迟消息插件(
rabbitmq_delayed_message_exchange)。前者零依赖但有队头阻塞(Head-of-Line Blocking)问题,后者按到期时间排序投递,是生产上的首选。
一、方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| TTL + 死信 | 消息在队列中过期后转入死信交换机 | 无需插件,兼容所有版本 | 队列 FIFO 导致队头阻塞,延迟精度受限于过期扫描 |
| 延迟插件 | 自定义交换机类型 x-delayed-message,消息带 x-delay 头 | 按时间排序,无队头阻塞,精度毫秒级 | 需安装插件,消息在交换机内部存储,量大时占内存 |
| 外部调度 | 业务写延迟表 + 定时任务扫描 | 可控、可查、可补偿 | 需要自己实现调度与分片 |
二、TTL + 死信的结构
Publisher ──(x-message-ttl=30s)──> delay.queue ──过期──> DLX "delay.dlx"
│
routing: order.timeout
▼
order.timeout.q ──> Consumer
// 延迟队列:统一 30 秒过期,过期后转死信
Map<String, Object> delayArgs = new HashMap<>();
delayArgs.put("x-message-ttl", 30_000);
delayArgs.put("x-dead-letter-exchange", "delay.dlx");
delayArgs.put("x-dead-letter-routing-key", "order.timeout");
channel.queueDeclare("delay.30s.q", true, false, false, delayArgs);
// 死信交换机与真正的消费队列
channel.exchangeDeclare("delay.dlx", BuiltinExchangeType.DIRECT, true);
channel.queueDeclare("order.timeout.q", true, false, false, null);
channel.queueBind("order.timeout.q", "delay.dlx", "order.timeout");
三、队头阻塞是怎么发生的
delay.queue(FIFO,只有队头会被判断是否过期)
┌──────────────┬──────────────┬──────────────┐
│ msg A TTL=30s│ msg B TTL=5s │ msg C TTL=10s│
└──────┬───────┴──────────────┴──────────────┘
│ 队头 A 还没到期
└─> B、C 即使已到期也不能先出队,只能等 A 过期
这就是"同一条延迟队列里混用不同 TTL"的坑:B 明明 5 秒就该触发,却被 A 挡了 30 秒。规避办法有三种:
- 按延迟档位拆队列(
delay.5s.q、delay.30s.q、delay.1h.q),每队列固定 TTL; - 使用延迟插件,按到期时间排序;
- 用"消息级 TTL"时保证同一队列内后进入的消息 TTL 不小于先进入的(即单调递增)。
四、延迟插件用法
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
Map<String, Object> args = Map.of(
"x-delayed-type", "direct"); // 内部实际使用的路由类型
channel.exchangeDeclare("delay.ex", "x-delayed-message", true, false, args);
channel.queueDeclare("order.timeout.q", true, false, false, null);
channel.queueBind("order.timeout.q", "delay.ex", "order.timeout");
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
.deliveryMode(2)
.header("x-delay", 30_000) // 毫秒,30 秒后投递
.build();
channel.basicPublish("delay.ex", "order.timeout", props, body);
插件内部把消息存在 Mnesia 表里,靠 Erlang 定时器到期后重新投递。它解决了排序问题,但消息量大时内存占用会明显上升,且插件版本必须与 Broker 版本严格匹配,升级时要重点验证。
五、常见追问
- 延迟消息重启会丢吗? TTL + 死信模式下消息持久化就不会丢;插件模式下 3.9 之后支持持久化,但插件升级、集群重启期间仍需验证恢复行为。
- 延迟精度有多高? TTL + 死信依赖过期扫描,精度在秒级且有延迟;插件是毫秒级定时器,但受 Broker 负载影响,不要用它做秒杀级别的精确控制。
- 为什么不用定时任务扫表? 扫表在千万级延迟任务时会有严重性能问题,需要分片与索引优化;RabbitMQ 方案把调度压力交给中间件,但会消耗 Broker 内存。
- 延迟队列和死信队列是一回事吗? 不是。死信队列是"处理失败或过期消息的兜底容器",延迟队列是"到点才投递";用 TTL + 死信实现延迟,只是借用了死信的转移能力。
小结
- RabbitMQ 原生不支持延迟,TTL + 死信是零依赖方案,延迟插件是生产首选。
- TTL + 死信的核心缺陷是队头阻塞,按档位拆队列可以规避。
- 延迟插件按时间排序、精度高,代价是内存占用与版本耦合。
- 延迟消息同样要持久化与幂等,否则"到点不投"和"重复投递"都会出现。