前面我们已经讲过 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