订单30分钟未支付自动取消,这个需求在电商、外卖、预约、抢座这类系统里实在太太太常见了,常见到很多人第一眼看到它就直接开写定时任务。但如果你真把它当成一个“延时触发功能”来做,上线后大概率会被支付回调并发、库存没释放、消息丢失这种事追着跑。我前阵子在做一套带库存预约的交易系统时,也正儿八经把这条链路重新设计了一遍,从早期“定时扫库”改成了“延迟消息 + 数据库到期时间戳 + 兜底扫描”的组合方案。这篇文章不是教科书式讲理论,而是把那个项目里踩过的坑、做过的选型对比、最后落地的核心代码和边界处理都完整复盘出来。只要你的系统里出现过“超时未支付单需要自动关单”这几个字,这套思路基本可以直接迁移。
1. 先想清楚需求边界:这个“取消”不是一个定时器,而是一组状态流转
1.1 核心不在“30分钟”,而在“状态只能合法转换一次”
很多初版实现会把问题拆成两个动作:第一,下单后30分钟触发;第二,把订单状态改成“已取消”。这两个动作看起来是串行的,但实际上整个业务里同时存在多条链路在操作同一笔订单。
用户可能在手机上下单,订单状态是“待支付”;用户同时去支付,支付网关异步回调;另一个角落,取消任务的定时器到点触发。这三条链路之间没有任何天然的先后顺序,可能同时发生,也可能某个回调晚到了半分钟。假如取消任务只是简单执行“update set status='CANCELLED' where order_id=xxx”,完全不判断当前状态,那么它在支付回调到达之前先跑了,就会把一笔用户已经付了钱的订单给取消掉。这种问题一旦发生,就是妥妥的资损事故。
所以第一步设计的不是定时逻辑,而是订单状态机。最基本的一组状态是:
- PENDING:待支付
- PAID:已支付
- CANCELLED:已取消
- CLOSED:关闭或已完成
合法流转只有两条:PENDING 到 PAID,以及 PENDING 到 CANCELLED。PAID 不能再回到 CANCELLED,CANCELLED 也不能再被支付回调改成 PAID。数据库里的 status 字段必须作为事实的唯一来源,所有链路在改状态之前都要遵守“从 PENDING 才能迁出”的约束。
这个看似很简单的约束,才是整个自动取消方案最核心的不变量。后面所有的代码、SQL、幂等设计,本质上都是在守护这个不变量。
1.2 取消动作要释放的东西,往往比状态切换更重要
订单状态从 PENDING 改成 CANCELLED,只是完成了表面动作。真正的麻烦在于,创建订单时往往还预占了一批其他资源。
做电商,下单时锁定了库存;做预约,可能占用了某个时段的可约名额;做营销活动,可能冻结了一张优惠券;做会员体系,可能暂扣了积分或里程。这些资源在下单时已经被“预留”了,如果订单超时取消,而预留资源没有被释放,后续真实可售的库存就会越来越少,直到运营发现明明有库存却卖不出去。
我在项目里就遇到过类似问题:定时任务确实把订单状态更新成了已取消,但没有调库存释放接口。结果后台的待支付订单确实是关闭了,可那些名额依然被死死占着。排查了半天,才发现问题根本不在取消任务本身,而是取消流程里少了下游资源释放这一步。
因此在设计阶段,要把“订单取消”定义成一个完整的流程,而不是一条 SQL。流程至少包括:
- 校验订单当前状态,保证只处理 PENDING 且确实超时的订单;
- 更新订单状态为 CANCELLED,记录取消时间和原因;
- 释放库存、恢复优惠券、解冻积分等资源返还动作;
- 记录审计日志或操作流水。
如果库存、优惠券并不在同一个服务里,那这些释放动作还要做到幂等。最常用的办法是让释放接口接收一个业务幂等键,比如 orderId,下游保证同一个 orderId 的释放操作不管来几次都只有一次生效。这样才能支撑取消任务的重试和消息的重复投递。
1.3 边界 case 清单,拿来对齐需求
写代码之前,我习惯先列一个边界清单,拿给产品、后端、前端一起过。这份清单在我自己项目里基本长这样:
- 用户在第29分钟时发起了支付,支付扣款成功,但支付回调在订单被取消之后才到达,怎么处理?
- 取消任务执行到一半,应用重启了,重启后这个订单还会被继续取消吗?
- MQ消息重复投递,同一笔订单被取消两次,会不会释放两次库存?
- 消息队列消费积压,原来30分钟的取消延迟变成50分钟才触发,怎么兜底?
- 用户已经取消的订单,又收到支付成功的异步回调,是否要自动退款?
- 定时扫描兜底任务上线后,多台机器同时执行,会不会把同一笔订单重复取消?
这些问题不一定要全在第一个版本解决,但必须在一开始就知道哪些是靠设计规避的,哪些是靠状态机兜底的,哪些是靠对账手动修的。我见过太多团队把“30分钟未支付自动取消”估计成一个小需求,排期两三天,结果上线后每天都要人工处理异常单。提前把这份清单拿出来对齐,后面能少挨不少骂。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:从轮询扫表到延迟队列,我为什么选了“MQ+到期时间戳+兜底”组合
2.1 如果只想用定时任务扫表,会有什么问题
最朴素的做法是每隔一段时间用 XXL-Job 或 Spring Scheduled 扫描一次订单表,找出所有 status='PENDING' 且 create_time < now() - 30分钟 的订单,批量取消。这个方案实现起来确实最简单,短时间内也找不到什么毛病,所以被大量小规模系统采用。
但它的短板在你动辄几十万、上百万订单之后会暴露得很明显。首先,无条件扫描或者索引设计不合理会导致慢查询,每次扫描都扫到大量历史数据,数据库压力会越来越大。其次,任务的触发频率决定了取消的滞后程度,如果一分钟扫一次,最坏情况下用户在下单后30分59秒才会被取消,在时间敏感的业务里体验并不好。更危险的是,如果这台挂着定时任务的机器出问题、调度平台漏触发、或者那段时间任务队列堵了,那批应该被取消的订单就会一直悬在 PENDING 状态,没人处理。
说这些并不是说扫表方案不能用。如果你的业务量很小,用户支付超时晚一两分钟无感知,团队里也没有延迟队列设施,那它完全够用。但如果你对自动取消的实时性有要求,尤其还牵扯库存释放,那就不能让它当唯一手段,至少要做成“主要手段 + 兜底手段”里的那层兜底。
2.2 延迟方案逐个过一遍,讲清楚各自的适用边界
市面上聊得
