消息队列

RabbitMQ 延迟消息:DLX 与延迟插件的取舍

订单超时取消是延迟消息最经典的场景。RabbitMQ 原生不支持延迟投递,DLX 方案简单但存在队头阻塞,延迟插件没有阻塞却要动运维。这篇把两种实现的原理解开,并说明消费者为什么必须先查库校验。

2026-03-14 约 17 分钟 6997 字 RabbitMQ 消息队列 延迟消息 死信队列

一、从订单超时取消说起

电商里有个绕不开的需求:下单后 30 分钟未支付,自动关闭订单并释放库存。

需求本身很简单,难的是「30 分钟后」这个时间点怎么触发。常见的三种做法:

方案 机制 问题
定时任务轮询 每分钟扫一次 status=待支付 AND create_time < NOW()-30min 时间精度受扫描周期限制;数据量大时全表扫描压力大;任务单点
延迟消息 下单时发一条消息,30 分钟后投递,消费者收到就关单 依赖 MQ 支持延迟投递
时间轮(内存) 应用内维护时间轮 重启丢任务,多实例需协调

延迟消息是最贴合语义的一种:「30 分钟后提醒我一下」这件事,本来就应该由消息中间件承担。 它把定时逻辑从业务里彻底移走,不用扫描、不用分片、天然支持多消费者。

问题在于:RabbitMQ 原生并不支持延迟消息。 没有 delay 参数,没有延迟队列类型。要用它做延迟投递,只有两条路——死信队列(DLX),或者延迟插件。

二、DLX 方案:用「过期」实现「延迟」

原理

DLX 方案的巧妙之处在于:它没有实现延迟,而是借用了消息过期(TTL)机制。

回忆一下死信队列的触发条件——消息变成死信有三种原因:

  1. 消费者 basicNack / basicReject 且 requeue=false;
  2. 消息 TTL 过期;
  3. 队列长度超限,队头消息被挤掉。

第 2 条就是延迟的入口。一条消息在队列里放够 TTL 时间、没人消费,它就会"过期"变成死信;而死信会被转发到指定的死信交换机,再路由到死信队列。 于是:

让消息在 A 队列里等 30 分钟 → 过期 → 自动死信转发到 B 队列 → 消费者监听 B 队列

对消费者来说,它就是「30 分钟后收到了这条消息」。

配置

// 1. 声明延迟用的死信交换机与真正的消费队列
channel.exchangeDeclare("order.dlx.exchange", "direct", true);
channel.queueDeclare("order.dlx.queue", true, false, false, null);
channel.queueBind("order.dlx.queue", "order.dlx.exchange", "order.timeout");

// 2. 声明"等待队列":没有消费者,只负责让消息待够 TTL
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", "order.dlx.exchange");     // 过期后去哪
args.put("x-dead-letter-routing-key", "order.timeout");       // 重写 routing key
args.put("x-message-ttl", 30 * 60 * 1000);                    // 队列级 TTL:30 分钟
channel.queueDeclare("order.delay.queue", true, false, false, args);
channel.queueBind("order.delay.queue", "order.delay.exchange", "order.create");

// 3. 下单成功:只发到延迟队列,没有消费者会动它
channel.basicPublish("order.delay.exchange", "order.create", null, body);

关键点是 order.delay.queue 上不能有任何消费者。一旦有人消费,消息在过期前就被取走了,延迟效果消失。

队列级 TTL vs 消息级 TTL

TTL 可以设在两个地方,差别很大:

队列级 TTL(x-message-ttl) 消息级 TTL(expiration)
设置位置 声明队列时的参数 每条消息的属性
影响范围 队列中所有消息 该条消息
灵活性 一个队列只能有一个延迟时长 每条消息可以不同
是否阻塞 是(见下文) 是,而且更严重

如果延迟时长需要按业务变化(比如「普通订单 30 分钟、秒杀订单 5 分钟」),队列级 TTL 就不够用了,只能建多个队列,或者改用消息级 TTL:

AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
        .expiration("1800000")        // ⚠️ 字符串,单位毫秒
        .deliveryMode(2)
        .build();
channel.basicPublish("order.delay.exchange", "order.create", props, body);

注意 expiration 是字符串类型,而且必须显式转成字符串,写成 setExpiration(1800000) 会编译不过。这是新手常见的第一个坑。

队头阻塞:DLX 方案的致命问题

只要有不同延迟时长的消息进同一个队列,就会遇到这个问题:

RabbitMQ 的队列是严格 FIFO 的,消息只能从队头出队。队列中前面的消息还没过期,后面的消息即使已经到期,也必须排队等着。

举个例子,同一个延迟队列里先后进来两条消息:

时刻 T+0:    消息 A 入队,TTL = 30 分钟
时刻 T+1:    消息 B 入队,TTL = 5 分钟      ← B 应该在 T+6 到期

到 T+6 时,B 已经过期了,但它前面还压着 A。RabbitMQ 检查队头发现 A 还没到期,整条队列就停在这里。B 必须等到 T+30、A 出队之后,才轮到自己出队。

结果:B 实际延迟了 29 分钟,而不是 5 分钟。

这个偏差不是几毫秒的抖动,而是「短延迟消息被长延迟消息无限拖后」。哪怕只有一条 24 小时的消息堵在队头,后面所有 5 分钟的消息都会延迟近 24 小时。

所以 DLX 方案的使用前提非常明确:

场景 是否适用 DLX
所有消息延迟时长相同(如全部 30 分钟) ✅ 适用,队头阻塞不存在
延迟时长不同,但分别用不同的队列 ✅ 适用,每个队列一个 TTL
延迟时长不同且混在同一个队列 ❌ 不适用,必须换方案

第三条是很多实现翻车的地方——压测时只发同一种延迟的消息,自然测不出问题;上线后不同业务共用一个延迟队列,才开始出现「说好 5 分钟,实际 40 分钟」的投诉。

三、延迟插件方案:x-delayed-message

原理

rabbitmq-delayed-message-exchange 是一个官方插件,它引入了一种新的交换机类型 x-delayed-message。

与 DLX 完全不同,插件方案不依赖队列和 TTL,而是在交换机内部维护延迟逻辑:

生产者 ──带 x-delay=1800000──→ [x-delayed-message 交换机]
                                      │  消息暂存在交换机内部(基于 Mnesia)
                                      │  到时间后
                                      ▼
                                  正常路由到队列 → 消费者

消息到达交换机后,交换机不会立刻路由,而是根据 x-delay 头把消息存在自己的存储里,到期后再走正常的路由流程。因为延迟判断发生在交换机层、而不是队列里,每条消息的延迟完全独立,不存在队头阻塞。

使用

安装插件后需重启节点:

rabbitmq-plugins enable rabbitmq_delayed_message_exchange

声明交换机时类型写 x-delayed-message,并用 x-delayed-type 指定实际的路由类型(direct / topic / fanout):

Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");        // 延迟之外的真正路由方式
channel.exchangeDeclare("order.delayed.exchange",
        "x-delayed-message", true, false, args);

channel.queueDeclare("order.timeout.queue", true, false, false, null);
channel.queueBind("order.timeout.queue", "order.delayed.exchange", "order.timeout");

发送时在 headers 里带上 x-delay,单位毫秒:

Map<String, Object> headers = new HashMap<>();
headers.put("x-delay", 30 * 60 * 1000);

AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
        .headers(headers)
        .deliveryMode(2)
        .build();

channel.basicPublish("order.delayed.exchange", "order.timeout", props, body);

注意这里有个设计上的不一致:x-delay 是整数毫秒,而 DLX 的 expiration 是字符串毫秒。 两者没有对齐,迁移时容易写错。

两种方案对比

维度 DLX(死信队列) 延迟插件
队头阻塞 有,同队列不同延迟会互相拖累 无,每条消息独立计时
延迟精度 受限,取决于队头 较高
是否需要装插件 不需要,原生能力 需要安装并重启节点
运维成本 零 集群每个节点都要装,版本必须与 RabbitMQ 兼容
延迟存储 Broker 的普通队列(消息在队列里等) 交换机内部存储(基于 Mnesia 表)
高延迟消息 长期占用队列空间 也占用存储,但只影响延迟交换机
消息量级 队列堆积大量等待消息,影响内存水位 大量延迟消息同样会占用存储和内存
适用场景 统一延迟时长、不想动运维 多种延迟时长、对精度有要求

两条实践结论:

  1. 延迟时长统一时,老老实实用 DLX。 它不需要改运维、不引入插件依赖,而且效果完全等价。
  2. 延迟时长多样、或有高延迟(小时级以上)消息时,用插件。 但要提前确认集群版本兼容性,并且把「插件安装」写进部署文档——这是一个很容易在扩容新节点时被遗漏的步骤,新节点没装插件会导致交换机声明失败。

另外,插件方案还有一个容易被忽略的边界:延迟消息在交换机内部的存储不是无限的,大量长时间延迟的消息会推高内存和磁盘占用。用延迟插件投递「7 天后执行」的任务时,要评估同时存在的消息总量。

四、消费者收到消息后必须查库

两种方案都配好了,消息也准时到了,接下来是最容易出错的一步。

一个直观但错误的实现是:

@RabbitListener(queues = "order.dlx.queue")
public void onTimeout(Message message, Channel channel) throws IOException {
    OrderEvent event = JSON.parseObject(message.getBody(), OrderEvent.class);

    // ❌ 直接按消息内容关单
    orderMapper.closeOrder(event.getOrderNo());
    stockService.release(event.getOrderNo());
    channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}

这段代码的问题在于:它把消息当成"事实",而不是"提醒"。

消息里携带的是下单那一刻的快照。从发送到投递的 30 分钟里,世界已经变了:

情况 消息说的 数据库里的真实状态
用户已经支付 待支付 已支付
用户已主动取消 待支付 已取消
消息重复投递 待支付 已取消(第一条已处理)
消息提前到达 待支付 待支付,但还没到超时时间

第一条最严重:用户付了钱,订单被消息关掉了。 紧接着 release 释放库存,可能导致超卖——一次误操作同时造成资损和超卖。

所以正确的做法是:收到消息后先查数据库,以数据库状态为准做判断。

@RabbitListener(queues = "order.dlx.queue")
public void onTimeout(Message message, Channel channel) throws IOException {
    long tag = message.getMessageProperties().getDeliveryTag();
    OrderEvent event = JSON.parseObject(message.getBody(), OrderEvent.class);

    try {
        // 1. 查库:数据库是业务的事实源
        Order order = orderMapper.selectByOrderNo(event.getOrderNo());
        if (order == null) {
            channel.basicAck(tag, false);           // 订单不存在,无需处理
            return;
        }

        // 2. 校验状态:只有仍然"待支付"且确实已超时才关闭
        if (!"WAITING_PAY".equals(order.getStatus())) {
            log.info("订单状态已变更,忽略超时消息 orderNo={} status={}",
                    order.getOrderNo(), order.getStatus());
            channel.basicAck(tag, false);           // ✅ 直接确认,不要重新入队
            return;
        }
        if (System.currentTimeMillis() - order.getCreateTime().getTime()
                < 30 * 60 * 1000L) {
            // 消息提前到达(时钟偏差等),重新入队稍后再试,或直接忽略
            channel.basicNack(tag, false, false);
            return;
        }

        // 3. 状态合法,执行关单 + 释放库存
        orderService.closeTimeoutOrder(order.getOrderNo());

        channel.basicAck(tag, false);
    } catch (Exception e) {
        channel.basicNack(tag, false, true);
    }
}

这里有三个设计要点:

第一,查库本身就是幂等保障。 订单已经是「已取消」时,第二次收到同样的超时消息,第一个 if 判断就会拦住它。不需要额外的去重表——用"当前状态是否允许这次操作"来判重,是成本最低的幂等实现。 当然,如果需要更严格的保证(比如跨多个服务),可以再叠加一个基于订单号的分布式唯一标识。

第二,关单和释放库存必须在同一个事务里。 否则可能出现「订单关了、库存没释放」,库存被永久锁定。这也是为什么释放库存应该由 orderService 内部的本地事务完成,而不是在消费者里调两次远程接口。

第三,closeTimeoutOrder 内部仍然要带条件更新。

UPDATE `order`
SET status = 'CANCELED', cancel_time = NOW()
WHERE order_no = ?
  AND status = 'WAITING_PAY';        -- 条件更新,防止并发下的状态覆盖

因为第 1 步的查询和第 3 步的更新之间存在时间窗口,用户可能恰好在这个窗口里完成了支付。加上 AND status = 'WAITING_PAY' 后,支付成功的那条更新先执行,关单这条就会影响 0 行,业务代码据此判断「关单失败,不做后续库存释放」。这是查库校验之外的第二道防线,不能省。

五、小结

  • RabbitMQ 原生不支持延迟消息,只有 DLX 和延迟插件两条路
  • DLX 的原理是「队列级/消息级 TTL 过期 → 变死信 → 转发到死信队列」,延迟队列上不能有任何消费者
  • DLX 的硬伤是队头阻塞:同队列内短延迟消息会被前面的长延迟消息拖住,只有延迟时长统一(或按 TTL 分队列)时才适用
  • 延迟插件用 x-delayed-message 交换机在交换机内部计时,每条消息独立,没有队头阻塞,代价是要装插件、动运维、注意集群版本兼容
  • expiration 是字符串毫秒,x-delay 是整数毫秒,两者不通用
  • 消费者收到超时消息后必须查库:消息是下单时的旧快照,数据库才是事实源;不查库会把已支付的订单关掉
  • 查库校验 + 条件更新(WHERE status = 'WAITING_PAY')构成两道幂等防线,后者用来覆盖「查询与更新之间的支付窗口」

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。