阶段2(上):后端专题 Go/Gin/PostgreSQL/MySQL/Redis/MQ/gRPC 的课程、企业场景、面试题和可运行示例
This commit is contained in:
1 parent
0bae9916a9
commit
5eb67c5d08
65 files changed
+6457
-77
No files matched your search
@@ -0,0 +1,99 @@
|
||||
---
|
||||
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'` 的条件更新做取消,和支付回调天然互斥,库存回补放在同一事务里,所以重复消息也只会取消一次。另外保留了一个低频的兜底扫描,防止消息丢失。”
|
||||
|
||||
追问准备:为什么不用延迟插件?支付回调在取消之后才到怎么办(订单已取消 → 走自动退款流程)?
|
||||
Reference in new issue
Block a user