RabbitMQ 延迟消息怎么实现?TTL 与死信队列

前面我们已经讲过 RabbitMQ 的死信队列:

text 复制代码
正常 Queue
    ↓
消息无法继续处理
    ↓
Dead Letter Exchange
    ↓
Dead Letter Queue

当时主要用它处理:

text 复制代码
消费失败
重试耗尽
NACK + requeue=false

但死信机制还有一个非常经典的用途:

实现延迟消息。

比如商城系统中最常见的需求:

text 复制代码
用户创建订单
    ↓
等待 30 分钟
    ↓
仍然没有付款
    ↓
自动取消订单

问题来了:

创建订单的时候,我们显然不能:

java 复制代码
Thread.sleep(30 * 60 * 1000);

然后等半个小时再取消订单。

不仅线程会被白白占用,而且订单量一大,系统根本承受不了。

这时候就可以利用 RabbitMQ 的:

text 复制代码
TTL
 +
DLX

实现一个简单的延迟消息机制。


1. 什么是延迟消息?

普通 RabbitMQ 消息:

text 复制代码
Producer
    ↓
Queue
    ↓
Consumer

消息进入 Queue 后,很快就会被 Consumer 获取。

而延迟消息要求:

text 复制代码
Producer
    ↓
RabbitMQ
    ↓
等待一段时间
    ↓
Consumer

例如:

text 复制代码
12:00 创建订单

        ↓
      等待

12:30 检查订单
        ↓
如果未付款
        ↓
取消订单

也就是说:

消息发送以后,不是马上消费,而是在指定时间之后再进行处理。

这就是延迟消息。


2. RabbitMQ 怎么让消息等 30 分钟?

一个非常经典的实现方式就是:

text 复制代码
TTL + DLX

整个流程:

text 复制代码
Producer
    ↓
延迟 Queue
    ↓
等待 30 分钟
    ↓
消息 TTL 过期
    ↓
Dead Letter Exchange
    ↓
真正的业务 Queue
    ↓
Consumer

这里最关键的一步:

text 复制代码
消息 TTL 过期

RabbitMQ 会把符合死信条件的过期消息重新发布到配置好的 Dead Letter Exchange。消息因 TTL 过期正是 RabbitMQ 官方定义的死信产生条件之一。

因此我们可以故意让消息:

text 复制代码
先过期

然后利用:

text 复制代码
死信机制

把它送到真正的消费者那里。

这样看似在处理"死信",实际上实现的是:

text 复制代码
延迟执行

3. 什么是 TTL?

TTL:

text 复制代码
Time To Live

也就是:

text 复制代码
存活时间

RabbitMQ 可以限制消息在 Queue 中最多存活多长时间。

例如:

text 复制代码
TTL = 30000ms

代表消息最多可以在 Queue 中存在:

text 复制代码
30 秒

如果超过 30 秒仍然没有被消费:

text 复制代码
Message
   ↓
Queue 中等待
   ↓
30 秒过去
   ↓
Expired

消息就会:

text 复制代码
过期

RabbitMQ 官方支持 Queue 级消息 TTL 和单条消息 TTL,TTL 的单位都是毫秒。


4. 一个很容易混淆的问题:Message TTL 和 Queue TTL

RabbitMQ 中都叫 TTL,但实际上有两个不同概念。

Message TTL

表示:

消息最多可以在 Queue 中存活多长时间。

例如:

text 复制代码
消息进入 Queue
     ↓
等待 30 分钟
     ↓
过期

我们实现延迟消息主要用的就是:

text 复制代码
Message TTL

Queue TTL

表示:

一个 Queue 长时间没人使用以后,多久自动删除。

例如:

text 复制代码
Queue
 ↓
一直没有 Consumer
 ↓
也没有重新声明等活动
 ↓
达到 Queue TTL
 ↓
Queue 被删除

它解决的不是消息延迟问题。

RabbitMQ 中 Queue TTL 可以通过 x-expires 等方式设置,而 Message TTL 通常通过 x-message-ttl 或消息自身的 expiration 设置。

所以不要把:

text 复制代码
Queue TTL

和:

text 复制代码
Message TTL

混为一谈。

本文主要关注:

text 复制代码
Message TTL

5. 订单 30 分钟自动取消怎么设计?

假设用户创建订单:

text 复制代码
orderId = 10001

我们希望:

text 复制代码
创建订单
    ↓
30 分钟以后
    ↓
检查订单状态
    ↓
如果仍然 WAIT_PAY
    ↓
取消订单

可以设计两个 Queue:

text 复制代码
order.delay.queue

order.cancel.queue

其中:

order.delay.queue

负责:

text 复制代码
让消息等待 30 分钟

这个 Queue 不需要正常的业务 Consumer 去立即消费。


order.cancel.queue

负责:

text 复制代码
真正执行订单超时检查

最终结构:

text 复制代码
Producer
    ↓
order.delay.exchange
    ↓
order.delay.queue
    ↓
等待 30 分钟
    ↓
消息过期
    ↓
order.cancel.exchange
    ↓
order.cancel.queue
    ↓
CancelOrderConsumer

这里:

text 复制代码
order.cancel.exchange

实际上就是:

text 复制代码
DLX

6. 创建真正处理业务的 Queue

首先创建订单取消 Exchange:

java 复制代码
@Bean
public DirectExchange orderCancelExchange() {

    return new DirectExchange(
            "order.cancel.exchange"
    );
}

创建真正处理订单取消的 Queue:

java 复制代码
@Bean
public Queue orderCancelQueue() {

    return QueueBuilder
            .durable("order.cancel.queue")
            .build();
}

建立绑定:

java 复制代码
@Bean
public Binding orderCancelBinding() {

    return BindingBuilder
            .bind(orderCancelQueue())
            .to(orderCancelExchange())
            .with("order.cancel");
}

最终:

text 复制代码
order.cancel.exchange
        │
        │ order.cancel
        ↓
order.cancel.queue

注意:

text 复制代码
order.cancel.exchange

后面会作为延迟 Queue 的:

text 复制代码
Dead Letter Exchange

7. 创建延迟 Queue

接下来才是最关键的部分。

创建:

text 复制代码
order.delay.queue
java 复制代码
@Bean
public Queue orderDelayQueue() {

    return QueueBuilder
            .durable("order.delay.queue")

            .ttl(30 * 60 * 1000)

            .deadLetterExchange(
                    "order.cancel.exchange"
            )

            .deadLetterRoutingKey(
                    "order.cancel"
            )

            .build();
}

这里出现了三个非常重要的配置。


8. .ttl():让消息等待 30 分钟

java 复制代码
.ttl(30 * 60 * 1000)

计算:

text 复制代码
30 × 60 × 1000

= 1 800 000ms

也就是:

text 复制代码
30 分钟

相当于给这个 Queue 设置:

text 复制代码
x-message-ttl = 1800000

RabbitMQ 会限制消息在该 Queue 中能够保留的时间。

所以:

text 复制代码
message
   ↓
order.delay.queue
   ↓
等待 30 分钟
   ↓
过期

9. .deadLetterExchange():过期以后去哪?

然后:

java 复制代码
.deadLetterExchange(
        "order.cancel.exchange"
)

意思是:

order.delay.queue 中的消息成为死信以后,发送给 order.cancel.exchange

于是:

text 复制代码
order.delay.queue
       ↓
消息过期
       ↓
order.cancel.exchange

因为:

text 复制代码
TTL 过期

本身就是 RabbitMQ 的死信条件之一。


10. .deadLetterRoutingKey():进入哪个 Queue?

接下来:

java 复制代码
.deadLetterRoutingKey(
        "order.cancel"
)

表示消息进入 DLX 时:

text 复制代码
Routing Key = order.cancel

我们前面已经配置:

text 复制代码
order.cancel.exchange
        │
        │ order.cancel
        ↓
order.cancel.queue

所以消息最终:

text 复制代码
order.delay.queue
       ↓
30 分钟过期
       ↓
order.cancel.exchange
       ↓
order.cancel
       ↓
order.cancel.queue

到这里延迟链路就形成了。


11. 创建 Delay Exchange

为了让 Producer 把消息发到:

text 复制代码
order.delay.queue

还可以专门创建一个:

java 复制代码
@Bean
public DirectExchange orderDelayExchange() {

    return new DirectExchange(
            "order.delay.exchange"
    );
}

然后绑定:

java 复制代码
@Bean
public Binding orderDelayBinding() {

    return BindingBuilder
            .bind(orderDelayQueue())
            .to(orderDelayExchange())
            .with("order.delay");
}

结构:

text 复制代码
order.delay.exchange
        │
        │ order.delay
        ↓
order.delay.queue

12. Producer 发送延迟消息

创建订单以后:

java 复制代码
rabbitTemplate.convertAndSend(
        "order.delay.exchange",
        "order.delay",
        "10001"
);

假设:

text 复制代码
10001

就是订单 ID。

消息首先:

text 复制代码
Producer
    ↓
order.delay.exchange
    ↓
order.delay.queue

注意:

order.delay.queue 不应该有一个普通 Consumer 立即把消息拿走。

否则:

text 复制代码
消息刚进去
   ↓
Consumer 马上消费

那自然就没有:

text 复制代码
等待 30 分钟

这回事了。

它的作用就是:

text 复制代码
暂存消息
    ↓
等待过期

13. 30 分钟以后发生了什么?

假设:

text 复制代码
12:00

创建订单。

Producer:

text 复制代码
12:00

订单 10001
    ↓
order.delay.queue

然后:

text 复制代码
12:10

仍然在 Queue 中

继续等待。

text 复制代码
12:20

仍然在 Queue 中

到了:

text 复制代码
12:30

TTL 到期:

text 复制代码
订单消息
   ↓
Expired
   ↓
Dead Letter

然后 RabbitMQ:

text 复制代码
order.delay.queue
       ↓
DLX
       ↓
order.cancel.exchange
       ↓
order.cancel.queue

最终 Consumer 才收到消息:

text 复制代码
12:30

CancelOrderConsumer

这就实现了:

text 复制代码
延迟 30 分钟处理

14. Consumer 收到消息以后能直接取消订单吗?

不能。

这是实际业务中特别重要的一点。

假设用户:

text 复制代码
12:00 创建订单

12:10 完成付款

但是我们在:

text 复制代码
12:00

已经发送了一条延迟消息:

text 复制代码
30 分钟以后检查订单

所以到了:

text 复制代码
12:30

RabbitMQ 还是会把这条消息发送给 Consumer。

如果 Consumer 直接执行:

java 复制代码
cancelOrder(orderId);

就会把:

text 复制代码
已经付款的订单

错误取消。

所以 Consumer 收到消息以后,真正应该做的是:

text 复制代码
查询订单
   ↓
判断状态

例如:

java 复制代码
@RabbitListener(
        queues = "order.cancel.queue"
)
public void cancelOrder(Long orderId) {

    Order order = orderService.getById(orderId);

    if ("WAIT_PAY".equals(order.getStatus())) {

        orderService.cancel(orderId);
    }
}

流程:

text 复制代码
收到延迟消息
      ↓
查询订单状态
      ↓
     /   \
WAIT_PAY   PAID
   ↓        ↓
取消订单    不处理

因此延迟消息表达的更准确含义不是:

30 分钟以后一定取消订单。

而应该是:

30 分钟以后触发一次订单超时检查。

这是业务设计上非常重要的区别。


15. 为什么不用定时任务扫描订单?

其实订单超时还有一种非常常见的方案:

text 复制代码
定时任务

例如每分钟执行:

sql 复制代码
SELECT *
FROM orders
WHERE status = 'WAIT_PAY'
AND create_time < NOW() - INTERVAL 30 MINUTE;

然后:

text 复制代码
找到超时订单
    ↓
批量取消

这种方式当然也能实现。


但是它存在一个问题:

假设:

text 复制代码
订单数量非常大

那么定时任务就需要不断:

text 复制代码
扫描数据库

例如:

text 复制代码
每分钟
   ↓
查询一次
   ↓
再查询一次
   ↓
再查询一次

会给数据库带来额外压力。

而消息方式:

text 复制代码
每创建一个订单
      ↓
产生一个延迟检查任务

可以减少这种大范围周期扫描。

不过这并不意味着:

text 复制代码
RabbitMQ 一定比定时任务好

对于:

text 复制代码
订单量很小
业务简单
时间要求不高

定时任务反而:

text 复制代码
更加简单

实际项目应该根据业务规模和复杂度选择。


16. Queue 级 TTL 和 Message 级 TTL

前面的代码:

java 复制代码
.ttl(30 * 60 * 1000)

相当于给整个 Queue 中的消息统一规定:

text 复制代码
TTL = 30 分钟

这叫:

text 复制代码
Queue 级 Message TTL

也就是说:

text 复制代码
进入这个 Queue 的消息
        ↓
全部最多等待 30 分钟

RabbitMQ 还支持:

text 复制代码
Per-message TTL

也就是:

每一条消息单独设置过期时间。

例如 Spring AMQP 中可以给消息设置 expiration。

思路类似:

text 复制代码
消息 A
 ↓
5 分钟

消息 B
 ↓
30 分钟

消息 C
 ↓
1 小时

RabbitMQ 官方说明,如果 Queue TTL 和单条 Message TTL 同时存在,会采用两者中较小的值。


17. 单条消息设置不同 TTL 有一个坑

乍一看,我们是不是可以建立:

text 复制代码
一个 delay.queue

然后:

text 复制代码
消息 A → TTL 30 分钟

消息 B → TTL 5 分钟

这样所有延迟任务都扔进一个 Queue?

需要小心。

假设顺序:

text 复制代码
Queue:

A:30 分钟
B:5 分钟

B 虽然:

text 复制代码
5 分钟

就已经过期了,

但在 Classic Queue 等场景下,过期消息可能要等到它到达 Queue 头部时才真正被移除并进行死信处理。RabbitMQ 官方专门说明,使用 per-message TTL 时,已经过期的消息可能排在尚未过期的消息后面,因此不会立即释放,也可能影响实际死信时间。

于是可能出现:

text 复制代码
A:还没到时间
    ↓
挡在前面

B:其实已经过期
    ↓
但还排在后面

这会造成:

text 复制代码
B 本来想延迟 5 分钟

实际却可能:

text 复制代码
更晚才进入 DLX

所以:

TTL + DLX 很适合固定延迟时间的场景,但如果存在大量不同延迟时间,就需要更加谨慎地设计。

这也是这种方案的一个重要限制。


18. 为什么死信队列能实现延迟?

现在回过头来看,其实 RabbitMQ 并没有神奇地:

text 复制代码
睡眠 30 分钟

真正发生的是:

text 复制代码
Producer
    ↓
消息进入延迟 Queue
    ↓
Queue 暂时保存消息
    ↓
Message TTL 到期
    ↓
消息成为 Dead Letter
    ↓
DLX 重新路由
    ↓
业务 Queue
    ↓
Consumer

所以:

text 复制代码
TTL

负责:

text 复制代码
什么时候到期

而:

text 复制代码
DLX

负责:

text 复制代码
到期以后去哪里

两者结合起来:

text 复制代码
TTL
  ↓
等待

DLX
  ↓
转发

最终形成:

text 复制代码
延迟消息

19. TTL + DLX 是真正的延迟队列吗?

从使用效果来看:

text 复制代码

我们确实实现了:

text 复制代码
消息延迟一段时间以后再消费

但是从底层机制来看:

text 复制代码
RabbitMQ 并不是专门创建了一个定时器

而是巧妙利用了:

text 复制代码
消息过期
    +
死信重新路由

因此更准确地理解:

TTL + DLX 是利用 RabbitMQ 已有的消息过期和死信机制实现延迟效果。

这也解释了为什么它会受到 Queue 中消息顺序、TTL 设置方式等因素影响。


20. 一张图看完整订单超时流程

现在把整个案例串起来:

text 复制代码
用户创建订单
       ↓
Order Service
       ↓
发送 orderId
       ↓
order.delay.exchange
       ↓
order.delay.queue
       ↓
   TTL = 30min
       ↓
     过期
       ↓
      DLX
       ↓
order.cancel.exchange
       ↓
order.cancel.queue
       ↓
CancelOrderConsumer
       ↓
查询订单状态
       ↓
  ┌───────────────┐
  ↓               ↓
WAIT_PAY          PAID
  ↓               ↓
取消订单          不处理

这就是利用 RabbitMQ 实现订单超时关闭的一套基本思路。


21. TTL + DLX 还能用在哪?

除了:

text 复制代码
订单 30 分钟未付款自动取消

还有很多类似场景。

例如:

优惠券到期提醒

text 复制代码
领取优惠券
    ↓
等待一定时间
    ↓
到期前提醒用户

延迟发送通知

text 复制代码
创建任务
    ↓
等待 10 分钟
    ↓
发送提醒

失败任务延迟重试

text 复制代码
调用第三方服务失败
      ↓
不立即重新调用
      ↓
延迟 30 秒
      ↓
重新执行

这其实和前面讲过的:

text 复制代码
Retry

也能串起来。

例如:

text 复制代码
第一次失败
   ↓
延迟 10 秒
   ↓
第二次执行
   ↓
仍然失败
   ↓
延迟 30 秒
   ↓
第三次执行

可以避免:

text 复制代码
失败以后瞬间连续重试

给下游服务造成更大压力。


22. 生产环境还有一个细节:优先考虑 Policy

为了方便学习,本文使用:

java 复制代码
.deadLetterExchange(...)
.deadLetterRoutingKey(...)

直接把 DLX 配置写在 Queue 声明中。

这样非常直观。

不过 RabbitMQ 官方对于 DLX 更推荐使用:

text 复制代码
Policy

进行配置,因为 Policy 可以动态修改,而硬编码在 Queue 声明中的 x-arguments 往往需要删除并重新声明 Queue 才方便调整。

所以:

text 复制代码
学习 Demo
   
Java QueueBuilder 配置

而:

text 复制代码
生产环境
    
可以进一步考虑 Policy
相关推荐
元界metalite1 小时前
Java通用枚举驱动下拉框-元数据接口与前端契约
后端
Csvn1 小时前
🐍 Day 10: 依赖管理 — 从 requirements.txt 到 pyproject.toml
后端·python
学长毕业设计1 小时前
基于SpringBoot的校园二手物品交易系统(源码+文档+讲解视频)
java·spring boot·后端
Darling噜啦啦1 小时前
从 SSE 到 LLM 流式输出:搞懂前端实时通信的两种姿势
前端·后端·llm
wangfpp1 小时前
原生NodeJS维护Agent Memory实践
后端·agent·全栈
星月日2 小时前
前端上手后端起手式
前端·后端
掘金挖土2 小时前
前端手摸手跑路之 AI 应用开发(一)
前端·后端
Lyy2 小时前
DevOps平台 — 第九篇:配置中心的设计与实现
后端·devops
吃饱了得干活2 小时前
MySQL 进阶:锁与事务、执行计划、内存管理、高可用架构及 8.0 新特性
后端·mysql