阶段2(上):后端专题 Go/Gin/PostgreSQL/MySQL/Redis/MQ/gRPC 的课程、企业场景、面试题和可运行示例

This commit is contained in:
Shikui Wang committed 2026-10-23 15:24:57 +08:00
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'` 的条件更新做取消,和支付回调天然互斥,库存回补放在同一事务里,所以重复消息也只会取消一次。另外保留了一个低频的兜底扫描,防止消息丢失。”
追问准备:为什么不用延迟插件?支付回调在取消之后才到怎么办(订单已取消 → 走自动退款流程)?