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