4.3 超时关单:定时与延迟任务

超时关单听起来简单:30 分钟没付款就关掉。但 deploy 到生产后我们踩了三个坑:定时任务把库存释放操作压垮 MySQL、延迟队列消息堆积导致关单延迟两小时、关单与支付回调整点撞车。这一节把「关单」这个看似简单的功能讲透。

场景:关单的两个时间维度

关单涉及两个独立的超时:订单超时(用户 30 分钟未付款则关闭)与库存释放超时(冻结的库存还回去)。两者理应同时发生,但实现时往往一个走定时任务、一个走延迟队列,分叉就出现了。理想状态是关单动作原子地完成两件事:状态机更新到已关闭,库存从 frozen 搬回 available。

方案:三种实现路线对比

我们评估了三种方案,最终选了组合:

方案 精度 吞吐 运维成本
定时扫表 分钟级 中 低
Redis 延迟队列 秒级 高 中
RocketMQ 延迟消息 秒级 高 高

最终选型是「定时扫表为主、延迟队列为辅」。理由是定时扫表实现简单、无需引入新中间件、扫的是分区表的单分区成本可控。秒级精度对我们是过度需求,订单超时是分钟级业务约定。

定时任务跑的频率与扫描范围:

参数 值 理由
扫描频率 1 分钟 关单精度够用
扫描窗口 创建 30 分钟以上、待支付 必要条件
批次大小 500 避免大事务
分片执行 按 userId 取模 多实例并行

分片执行是多实例部署后必须做的,否则 N 台机器跑同一个定时任务重复执行 N 遍。shard 通过配置下发,实例宕机时Consul 感知后重新分配。这个分片思路与 定时任务治理 的分布式调度是一个方案。

代码:分片扫表关单

@Scheduled(cron = "0 * * * * ?")
public void closeTimeoutOrders() {
    int shard = shardManager.currentShard();   // 分片号
    int total = shardManager.totalShards();
    List<Order> orders = orderDao.listTimeoutOrders(
        OrderStatus.PENDING_PAY,
        now.minusMinutes(30),
        shard, total, 500);                    // 500 条一批
    for (Order o : orders) {
        orderService.rollback(o.getOrderNo(), RollbackReason.TIMEOUT);
    }
}

注意这里调用的是统一的 rollback 入口,关单即回滚,与支付失败回滚共用逻辑。rollback 内部先走状态机条件更新(待支付 → 已关闭),再释放冻结库存,且有幂等保护,与 库存回滚 一节详述的实现一致。

踩坑:三个关单的坑

第一个是扫描范围随时间膨胀。初期扫描条件只按状态筛选,一年后待支付订单里掺着大量历史数据(其实当年多数已处理但状态漂移),扫描越来越慢。按创建时间加窗口条件后,扫描始终限定在小范围。时间是扫描条件的天然过滤,与分区表设计形成正反馈。

第二个是关单并发撞回调。关单与支付回调同时执行,回调把状态改为已支付,关单也想改为已关闭。状态机条件更新让关单失败(from 状态已不对),但有一版实现里关单成功而回调整型的库存扣减失败,出现「已关闭但库存没释放」。根因是关单与扣减不在同一个事务。解决:rollback 入口必须单个事务包裹状态变更与库存释放。

第三个是延迟队列的坑。我们短暂用 Redis 的 zset 做延迟队列,某次 zset 积压了 40 万条消息(任务积压的来源是一个 channel 接口超时导致消费速率跟不上),关单延迟两小时,期间库存都被占着,运营投诉。引入消息堆积监控之后才根治,堆积超过 1 万条就告警扩容消费者。

补一个小细节:关单后给用户的提示文案应是「订单已关闭」而非「订单已取消」。关闭是系统动作语义,取消是用户主动语义,混用会让客服与用户的沟通对不上。文案规范也是状态机的一部分。

进阶实战:关单与促销的联动

关单做到后来,遇到一个业务联动问题:关单时优惠券要不要还回。产品同学的直觉是要还,但券系统与订单系统的协议没定清楚,第一版是关单后直接异步调券系统还券。大促时出事了——50 万张秒杀券的关单还券请求把券接口打崩,券系统反馈我们压了他们 70% 的流量。复盘后定了两条规矩:关单消息进消息队列,券系统按自己的消费速率处理;超时关单产生的还券请求带反向限流。

联动系统 关单动作 频率控制
券系统 异步还券 队列解耦
积分系统 退回积分 批量处理
推送系统 给用户通知 合并发送
报表系统 更新统计 次日生效

关单文案在前面提过一次,这里补完整版:关单通知里要告诉用户原因是「超时未支付」还是「系统原因」,前者引导重新下单,后者附补偿券。文案的差别是客诉率的差别,A/B 实验中超时未支付的提醒加了一张重新下单入口的卡片,复购率提升了 8 个百分点。

关单时间的业务策略也值得单独讨论。30 分钟是参考值,不同品类其实不同:秒杀品 5 分钟(库存金贵),普通品 30 分钟,预售定金单 2 小时。把超时时长做成按品类可配置,运营自助调整,减少技术变更的频率。

速查清单

超时关单的落地配置:

项 推荐值
扫描频率 1 分钟
批次大小 500 条
分片执行 userId 取模
事务 关单与释放单事务
时长 按品类可配置

小结

超时关单的要点:定时扫表加分片、扫描窗口按时间收窄、关单与库存释放单事务、延迟队列必配堆积监控。所有关单动作都走统一的 rollback 入口,与下一章的库存回滚是同一件事的两面。

常见误解:超时关单的三个陷阱

第一个陷阱是「关单就是改状态」。关单牵动库存释放、优惠券返还、积分退回、通知下发,是一个跨多个子系统的业务动作。只改状态不做联动,留下的是一堆坏账。这也是为什么我们坚持统一 rollback 入口——入口统一,联动逻辑就有了唯一的收口处。

陷阱 后果
只改状态不联动 券与库存坏账
扫表无窗口限制 效率越来越低
关单与扣减分两事务 部分成功

第二个陷阱是「扫表不设时间窗口」。前面提过这个坑,补充一点:扫表窗口除了提高性能,还关掉了「脏扫描」。只扫近期待支付订单,历史遗留的异常单就不会被反复扫到并误处理。窗口给任务划定了清晰的职责边界。

第三个陷阱是「关单与扣减分两个事务执行」。关单事务提交成功、扣减事务提交失败,数据就此不一致。两个动作必须在同一事务,或者用本地消息表保证最终一致。我们的最终方案是单事务(都在单体内可行),拆服务之后走消息表方案。

团队协作:关单参数的自助化

关单时长这种参数,技术改一次发布一次,成本高且响应慢。我们把超时时长做成了运营后台可配置的开关,按品类维护。一次大促前某秒杀品需要从 30 分钟调到 5 分钟,运营自己点了配置立即生效,全程没有开发参与。配置化把决策权交回业务方,技术释放出来做更有价值的事。

配置项 谁配 生效时间
超时时长 运营 分钟级
扫描批次大小 技术 下次任务
品类级开关 运营 分钟级

配置化的前提是参数有明确业务语义且改错可控。超时时长错了影响销售节奏但不致命,适合自助;批次大小错了可能拖垮数据库,留在技术侧。每开放一个配置前都做风险评估,开放之后配操作日志,谁改的、改了几点几分的记录都留在运营后台。自由与秩序都需要,缺一不可。把配置权下放给运营的那天,技术同学第一直觉是担忧,三个月后再看,运营满意、技术解放,双赢。

延伸阅读