用户下单后不付款,库存被一直占着,其他想买的人却买不到——几乎每个电商系统都要解决"未支付订单超时自动取消并释放库存"的问题。看似一个定时任务就行,真做到不早关、不漏关、不重复关、大流量下不压垮数据库,里面有不少讲究。本文对比三种主流实现的取舍,并给出一套可落地、带幂等兜底的关单方案。
一、先把需求边界定清楚
动手前先明确几个容易被忽略的点,它们直接决定方案选择:
- 超时从什么时候算:以下单时刻为准,还是以"最后一次支付动作"为准?用户在第 29 分钟跳去支付,订单不该在第 30 分钟被关掉;
- 关单要联动什么:取消订单、释放占用库存、退还使用的优惠券、如果是积分/余额预占还要回滚,这些必须在一个可靠流程里;
- 时间精度要求:多数电商允许几十秒误差,没必要追求毫秒级,精度要求直接影响实现成本;
- 支付回调与关单会竞争:用户恰好在超时临界点支付成功,关单任务也在跑,两者必须有确定的胜负规则。
二、方案一:数据库定时扫描(最简单、有上限)
最朴素的做法是起一个定时任务,每隔一段时间扫描"已创建未支付且已超时"的订单:
SELECT id, order_no FROM orders
WHERE status = 'CREATED'
AND created_at < :threshold
LIMIT 500;
扫描出来后逐单执行关单。它的优点是实现简单、不依赖额外中间件、数据以数据库为准不会漏;缺点同样明显:
- 时间精度受扫描间隔限制,间隔越短越及时,但数据库压力越大;
- 订单量大时全表扫描成本高,需要在
(status, created_at)上建联合索引,并按主键分页、每批限量; - 单实例扫描有单点问题,多实例同时扫会重复处理,必须靠下面讲的幂等和抢占来解决。
它适合订单量不大、对及时性要求宽松的早期系统,也适合作为所有方案的最终兜底。
三、方案二:延迟消息 / 延迟队列(及时、解耦)
更主流的做法是下单成功后投递一条"延迟消息",延迟时间等于支付超时时间,消息到期被消费时检查订单状态、决定是否关单。以消息中间件的延迟能力为例:
// 下单事务提交成功后,再投递延迟消息(避免事务回滚却发了消息)
await orderRepo.create(order);
await mq.sendDelay("order.close.delay", {
orderNo: order.orderNo,
delaySeconds: 30 * 60 // 30 分钟后到期
});
消费端收到消息时并不直接关单,而是先查一次当前状态:
async function onDelayMessage(msg) {
const order = await orderRepo.findByNo(msg.orderNo);
if (!order) return; // 订单不存在,直接 ack
if (order.status !== "CREATED") return; // 已支付/已取消,无需处理
await closeOrder(order.orderNo); // 仍是待支付,执行关单
}
优点是及时性好、订单创建和关单逻辑解耦、吞吐高;代价是引入了消息中间件,且要处理消息丢失、消息重复、Broker 与数据库不一致这些分布式问题,因此数据库兜底扫描仍然不能省。
四、方案三:Redis 有序集合(轻量、需处理可靠性)
第三种常见做法是用 Redis ZSet,score 存"应关单时间戳",后台任务持续取出 score 小于当前时间的成员:
# 下单时加入待关单集合
ZADD order:close:zset <closeAtEpoch> <orderNo>
# 后台每秒取到期成员(不立即删除,处理成功再 ZREM)
ZRANGEBYSCORE order:close:zset 0 <nowEpoch> LIMIT 0 100
它实现轻量、定时精度高,适合高并发。但 Redis 不是订单的事实来源,存在内存数据丢失、与数据库状态不一致的风险,所以同样要以数据库状态为准、用扫描任务兜底,且关单结果要落库,不能只改 Redis。
五、关单本身必须幂等,且要和支付回调决胜
无论用哪种触发方式,"关单"动作都可能被重复执行:多实例扫描、消息重复投递、用户手动取消和定时关单撞车。因此关单必须用一条带状态条件的原子更新来"抢占":
UPDATE orders
SET status = 'CLOSED', closed_at = NOW()
WHERE order_no = :orderNo AND status = 'CREATED';
-- 影响行数为 1 才真正执行后续释放;为 0 说明已被支付或已关单,直接结束
只有抢占成功(影响行数=1)的那个执行者,才继续释放库存、退券、回滚预占。这样就天然解决了重复关单。
而关单与支付回调的竞争,规则应当是支付优先、以支付结果为准:
- 关单前的条件更新要求订单仍是
CREATED,若用户已支付(状态变为PAID),条件不成立,关单自动放弃; - 支付回调里若发现订单已被误关,需要有补偿通道(重新置为已支付或人工对账),避免"扣了钱却关了单";
- 建议用数据库事务或本地消息表保证"改订单状态 + 释放资源"要么都成功、要么可重试。
六、踩坑清单
- 先做释放、后改状态:若释放库存后进程崩溃而状态没改,重试时无法判断是否释放过,必须先抢占状态再联动;
- 延迟消息在下单事务提交前发送:事务回滚后出现一条找不到订单的关单消息(虽能被消费端挡掉,但属于脏流程);
- 扫描任务无索引或一次捞全表:订单量上来后拖垮主库,要联合索引 + 分批 + 限流;
- 多实例同时扫描不做抢占:同一单被关多次、库存被重复释放;
- 关单不退回优惠券/预占余额:用户资产莫名消失,投诉率很高;
- 只依赖延迟消息、没有兜底扫描:消息一旦丢失,订单永久占用库存;
- 临界支付被误关且无补偿:这是资金类问题,必须有支付优先规则与对账兜底。
七、工程落地建议
生产环境推荐"延迟消息做主路径 + 数据库扫描做兜底 + 条件更新保证幂等"的组合:99% 的订单由延迟消息及时关闭,极少数丢失或异常的订单由低频扫描兜底,所有关单都通过带状态条件的原子更新抢占,保证不重不漏。资源释放(库存、优惠券、预占资产)建议收敛在一个可重试的关单服务里,并记录每一步的处理流水,方便对账。中小团队若没有专职中间件运维,也可以在成型的电商系统(如乔拓云商城)上先使用其内置的订单超时与库存释放能力,把精力放在自身的退款、对账规则上;自研时务必第一版就把幂等抢占和兜底扫描做进去。
八、上线前复盘清单
- 超时起点是否明确,用户支付过程中订单会不会被提前关闭;
- 关单是否联动释放库存、优惠券、预占余额,且在可靠流程内;
- 关单更新是否带
status='CREATED'条件,重复执行是否安全; - 关单与支付回调并发时,是否做到支付优先、误关可补偿;
- 主路径之外是否有低频兜底扫描,索引与分批是否合理;
- 关单各步骤是否留痕,能否通过订单号追溯完整处理过程。
结语
订单超时取消的难点从来不是"定时",而是分布式环境下的一致性与幂等:选延迟队列还是定时扫描,本质是在及时性和简单性之间取舍,但无论怎么选,"以数据库状态为准、用条件更新抢占、用兜底扫描防丢、让支付在竞争中优先"这四条都不能省。把它们做扎实,订单在任何并发和异常场景下都只会被正确地关闭一次。