Files
zhuangang/topics/backend/mq/scenarios/delayed-order-cancel.md
T

100 lines
4.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: backend.mq.scenario.delayed-order-cancel
title: 订单超时自动取消:延迟消息、幂等和兜底扫描
type: scenario
domain: backend
stack: [mq, redis, mysql]
tags: [延迟消息, 死信, 订单超时, 幂等, 定时任务]
level: intermediate
prerequisites: [backend.mq.rabbitmq]
est_hours: 2
related: [backend.mq.reliability, backend.redis.scenario.stock-reservation, backend.mq.interview]
week: [14]
---
# 订单超时自动取消:延迟消息、幂等和兜底扫描
## 业务背景与问题
下单后 30 分钟未支付要自动取消并释放库存。最初的做法是每分钟一个定时任务扫描 `WHERE status='pending' AND created_at < now() - 30 分钟`。订单量上来后:扫描越来越慢;取消时间最多晚 1 分钟;多实例部署时定时任务同时执行,重复取消、重复回补库存。
## 约束
- 取消时间误差在秒级即可。
- 绝不能取消已支付的订单(支付回调和超时取消可能同时发生)。
- 库存回补只能做一次。
- 已有 RabbitMQ。
## 方案设计
```mermaid
sequenceDiagram
participant O as 订单服务
participant W as 等待队列(TTL=30 分钟,无消费者)
participant D as 死信交换机
participant C as 取消消费者
participant DB as 数据库
O->>DB: 创建订单(pending)+ outbox 消息(同一事务)
O->>W: 发布 {order_id}
Note over W: 30 分钟后消息过期
W->>D: 转为死信
D->>C: 投递到处理队列
C->>DB: UPDATE ... SET status='cancelled' WHERE id=? AND status='pending'
alt 影响 1 行
C->>DB: 同一事务回补库存
else 影响 0 行(已支付 / 已取消)
C-->>C: 什么都不做,直接 ack
end
```
1. **延迟消息**:TTL + 死信交换机。一个等待队列只放同一种延迟时间(30 分钟)。
2. **幂等取消**:用条件更新 `WHERE status='pending'`,和支付回调的 `WHERE status='pending'` 天然互斥,只有一个会成功;库存回补放在同一事务里,只有取消成功才回补。
3. **兜底扫描**:消息仍可能因为故障丢失,保留一个低频(如每 10 分钟)的扫描任务,处理超过 35 分钟还是 pending 的订单;扫描任务用分布式锁保证只有一个实例执行,并走同一个幂等取消函数。
4. **订单量非常大或延迟时间很多种**:考虑延迟消息插件、Redis ZSET(分数=到期时间)或专门的延迟任务服务。
## 关键代码
完整可运行示例:[examples/delay-cancel/delay_cancel.py](../examples/delay-cancel/delay_cancel.py)(Python + aio-pika,延迟 1 秒演示):
```python
async def declare_wait_queue(ch, dlx, suffix: str):
return await ch.declare_queue(f"order.wait.{suffix}", durable=True, arguments={
"x-message-ttl": DELAY_MS, # 过期时间
"x-dead-letter-exchange": dlx.name, # 过期后转到死信交换机
"x-dead-letter-routing-key": "cancel",
})
async def consume(work_q, store):
async with work_q.iterator() as it:
async for msg in it:
async with msg.process(): # 正常结束 ack,异常 nack
oid = json.loads(msg.body)["order_id"]
store.cancel_if_unpaid(oid) # 等价于条件 UPDATE,重复消息无副作用
```
示例验证了:消息在延迟之后才被处理;超时前支付的订单不受影响;同一订单的重复消息只取消一次。
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| 定时扫描数据库 | 简单、可靠 | 有延迟;数据量大时扫描压力大;多实例要加锁 |
| **TTL + 死信(本方案)** | 不依赖插件、精度到秒 | 每种延迟时间一个队列;消息丢失需要兜底 |
| 延迟消息插件 | 任意延迟时间 | 需要安装插件,大量延迟消息时有性能和可靠性限制(以官方说明为准) |
| Redis ZSET 轮询 | 灵活、轻量 | 要自己处理可靠性(取出后崩溃) |
## 易错点
- 取消时不加 `status='pending'` 条件:把刚支付的订单取消了。
- 先回补库存再更新订单状态,且不在同一事务:重复消息导致库存多次回补。
- 一个队列混放 15 分钟和 30 分钟的消息:短的被长的挡住,到期也不出队。
- 完全依赖延迟消息不做兜底:消息丢了订单就一直 pending、库存一直被占。
- 消费者自动 ack:处理失败消息就没了。
## 面试怎么讲
> “订单超时取消原来是定时扫表,数据量大后又慢又不准,多实例还会重复执行。我改成 RabbitMQ 的 TTL 加死信队列做延迟消息,消费者用 `WHERE status='pending'` 的条件更新做取消,和支付回调天然互斥,库存回补放在同一事务里,所以重复消息也只会取消一次。另外保留了一个低频的兜底扫描,防止消息丢失。”
追问准备:为什么不用延迟插件?支付回调在取消之后才到怎么办(订单已取消 → 走自动退款流程)?