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 分钟,运营自己点了配置立即生效,全程没有开发参与。配置化把决策权交回业务方,技术释放出来做更有价值的事。
| 配置项 | 谁配 | 生效时间 |
|---|---|---|
| 超时时长 | 运营 | 分钟级 |
| 扫描批次大小 | 技术 | 下次任务 |
| 品类级开关 | 运营 | 分钟级 |
配置化的前提是参数有明确业务语义且改错可控。超时时长错了影响销售节奏但不致命,适合自助;批次大小错了可能拖垮数据库,留在技术侧。每开放一个配置前都做风险评估,开放之后配操作日志,谁改的、改了几点几分的记录都留在运营后台。自由与秩序都需要,缺一不可。把配置权下放给运营的那天,技术同学第一直觉是担忧,三个月后再看,运营满意、技术解放,双赢。
