RabbitMQ 怎么实现延迟消息?

#中间件#面试八股

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 秒。规避办法有三种:

  1. 按延迟档位拆队列(delay.5s.q、delay.30s.q、delay.1h.q),每队列固定 TTL;
  2. 使用延迟插件,按到期时间排序;
  3. 用"消息级 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 + 死信的核心缺陷是队头阻塞,按档位拆队列可以规避。
  • 延迟插件按时间排序、精度高,代价是内存占用与版本耦合。
  • 延迟消息同样要持久化与幂等,否则"到点不投"和"重复投递"都会出现。
笔记加载中…