Skip to content

让你实现一个订单超时取消功能,怎么设计? ​

  • 标签:后端、系统设计、场景题

🏛 业务架构师 ​

订单超时取消是设计类高频题,我的回答核心是按订单量级分档选方案,并且把业务权衡(超时时间定多长)讲出来——这题技术人容易只答技术。

方案分档

第一档:单量小(日均几万单内)——定时轮询 每分钟扫一次 WHERE status='待支付' AND create_time < now()-30min,命中索引(status + create_time)。简单可靠,五分钟内取消完全够用。小业务用延迟消息属于过度设计。

第二档:中等量级——延迟消息(主流方案)

  • RocketMQ 延迟消息(原生延迟级别,30 分钟对应固定档位)或死信队列改造成的延迟队列
  • 下单时发一条 30 分钟后投递的消息,消费时检查订单状态,未支付则取消并释放库存
  • 优点:精度高(秒级)、无扫表开销;注意消费幂等和"取消时用户刚好在支付"的竞态(条件更新订单状态,见付款回调流程)

第三档:海量订单——时间轮/分片扫表

  • Kafka 无原生延迟,可用时间轮(如 Netty HashedWheelTimer)或按分钟分片的扫表任务(每节点负责一段时间片)
  • 真正的巨头(淘宝量级)用"时间轮 + 订单冷热分层"的定制方案

业务架构视角(差异点)

  1. 超时时长是业务指标不是技术参数:设 15 分钟,库存周转快但砍掉犹豫用户;设 2 小时,转化率高但爆款被占死。要按品类差异化(秒杀 5 分钟,家具 24 小时)并 A/B 验证
  2. 取消前提醒是转化工具:超时前 5 分钟 push"订单即将取消",能挽回相当比例的支付——技术上只是多一个延迟节点,业务上是纯增量
  3. 取消瞬间在支付的竞态:条件更新 + 自动退款兜底
  4. 释放的库存/券要实时生效,否则大促期形成"幽灵缺货"

🏗 系统架构师 ​

订单超时取消我按精度要求与量级给三档方案,这也是我实际经历的演进路径。

方案一:被动取消 + 定时扫表(兜底必备) 定时任务(如每 1 分钟)扫 status=待支付 AND create_time < now()-30min 的订单做取消、释放库存。用 expire_time 索引 + 游标分页,一次千条。缺点:精度受扫描周期限制、全表式扫描浪费;但逻辑集中可审计,我一定保留它做兜底。

方案二:延迟消息(主流方案) 下单成功投递 30 分钟延迟消息(RocketMQ 定时消息原生支持任意时间;Kafka 无原生延迟就用时间轮或延迟 topic 中转)。消费时校验订单仍待支付才取消。

  • 关键点:消费时必须做状态二次校验(消息到点前用户可能刚好支付),取消动作用条件更新 UPDATE ... WHERE status=待支付 保证幂等与并发安全
  • 下单与发消息的一致性用事务消息(RocketMQ half 消息)或本地消息表,避免"订单建了没消息"导致永不取消

方案三:Redis 过期监听 / ZSet 轮询 ZSet 以 expire_time 为 score 存订单号,扫描器每秒 ZRANGEBYSCORE 取到期单。注意 key过期通知(keyspace notification)不可靠——过期事件不保证及时投递且会丢,只能用 ZSet 主动扫。

我的生产组合:延迟消息为主(秒级精度、水平扩展)+ 每小时扫表兜底对账(消息丢失、时钟漂移的保险),取消操作幂等,释放库存走逆向消息。量级上百万订单/天时延迟消息方案无压力。

💻 全栈开发专家 ​

订单超时取消我做过三版,直接给结论:中小规模用定时扫表,一致性要求高用延迟消息,单机小场景用 Redisson 时间轮。

方案对比

方案精度依赖缺点
定时扫表分钟级无量大时扫描压力高
RocketMQ 延迟消息秒级MQ依赖 MQ 可用性
Redis 过期监听不可控Redis通知可能丢,绝不能当核心逻辑

生产方案:延迟消息(主力)+ 扫表(兜底)

java
// 下单时
Message msg = new Message("ORDER_TIMEOUT", orderNo.getBytes());
msg.setDeliverTimeMs(System.currentTimeMillis() + 30 * 60 * 1000); // RocketMQ 5.x 任意延迟
producer.send(msg);

// 消费时:条件更新,幂等 + 防误杀已支付单
public void onTimeout(String orderNo) {
    int n = orderMapper.closeExpired(orderNo); // UPDATE orders SET status='CLOSED'
                                               // WHERE order_no=? AND status='PENDING' AND create_time < ?
    if (n == 1) stockService.restore(orderNo); // 回库存,发关单事件
}

关键细节

  • 关单前必须查支付状态(或依赖条件更新的 status 校验),防止"用户已付款但消息迟到"误关单——两边用同一把 Redisson 锁(orderNo)串行化更稳。
  • 消费幂等:MQ 至少投递一次,重复关单靠条件更新天然吸收。
  • 兜底 Job:每 10 分钟扫 status=PENDING AND create_time < now-31min 的漏网单,处理 MQ 丢失场景。