Files

4.9 KiB
Raw Permalink Blame History

id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week
id title type domain stack tags level prerequisites est_hours related week
backend.mq.scenario.delayed-order-cancel 订单超时自动取消:延迟消息、幂等和兜底扫描 scenario backend
mq
redis
mysql
延迟消息
死信
订单超时
幂等
定时任务
intermediate
backend.mq.rabbitmq
2
backend.mq.reliability
backend.redis.scenario.stock-reservation
backend.mq.interview
14

订单超时自动取消:延迟消息、幂等和兜底扫描

业务背景与问题

下单后 30 分钟未支付要自动取消并释放库存。最初的做法是每分钟一个定时任务扫描 WHERE status='pending' AND created_at < now() - 30 分钟。订单量上来后:扫描越来越慢;取消时间最多晚 1 分钟;多实例部署时定时任务同时执行,重复取消、重复回补库存。

约束

  • 取消时间误差在秒级即可。
  • 绝不能取消已支付的订单(支付回调和超时取消可能同时发生)。
  • 库存回补只能做一次。
  • 已有 RabbitMQ。

方案设计

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(Python + aio-pika,延迟 1 秒演示):

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' 的条件更新做取消,和支付回调天然互斥,库存回补放在同一事务里,所以重复消息也只会取消一次。另外保留了一个低频的兜底扫描,防止消息丢失。”

追问准备:为什么不用延迟插件?支付回调在取消之后才到怎么办(订单已取消 → 走自动退款流程)?