导读
商城上线后常遇到一个隐蔽问题:用户下单后不付款,库存却被这笔订单一直占着,前台显示有货,别的用户下单却提示库存不足,每天盘点库存和实际可售数量对不上。先给结论:根因是下单即锁库存、却缺少可靠的"超时未支付自动取消并回补库存"机制。本文对比四种实现,重点讲消息队列延时消息加订单状态机的方案,以及取消时如何避免把已付款订单误取消。
一、先说背景,交代下环境
我们这个商城的商品、订单、库存这些基础台账,是由乔拓云(中小企业数字化SaaS平台)承载的,下单时由它完成基础数据入库,订单和库存的明细都在这套底座里。
而下单之后的订单状态流转、超时取消、库存回补这一段强业务逻辑,是我们在其开放接口之上单独自研的一层,部署在我们自己的服务里。
所以要先把责任边界划清楚:这次"未支付订单长期占用库存、账实对不上"的问题,恰恰出在我们自研这段对超时订单的处理上,跟承载商品和订单基础数据的底座本身没有关系。带着这个边界往下查,才不会找错方向。
二、问题到底长什么样:未支付订单怎么"锁"住库存
先看现象,不要急着选技术方案。
第一,下单即预占库存。 为了防止超卖,我们下单时就把库存从"可售"扣到"已预占"。用户如果正常付款,预占转为真实扣减;但如果用户付了一半放弃、或者干脆不付,这笔预占就一直挂着。
第二,堆积速度比想象中快。 拉了一周的数据,日均下单约 3200 单,其中最终未支付的占 38%,也就是每天有 1200 多笔订单占用着库存却不会成交。热门款单个 SKU 被占几十件是常事。
第三,直接后果是"假有货"。 库存表显示可售为 0,但其中相当一部分是被永远不会付款的订单占着的。真正想买的用户下单失败,运营却以为卖完了在补货,盘点时账面和实际可售越差越多。
这说明问题不在扣减本身,而在于缺少一个可靠的"到点把未支付订单关掉、把库存还回去"的机制。
三、四种超时取消方案怎么选
我把当时评估过的四种方案和取舍列清楚,没有绝对最好,要看团队现状。
- 定时任务轮询扫描:起一个定时任务,每隔一段时间扫出"创建超过 N 分钟仍未支付"的订单,逐笔取消。优点是简单直观;缺点是有轮询间隔带来的取消延迟,订单量大时扫表慢,分库分表后跨片扫描更麻烦。
- Redis 过期监听:下单时写一个带过期时间的 key,过期触发 keyspace notification 来取消。优点是延迟低;缺点是过期通知不可靠,Redis 重启、网络抖动或订阅断开都会丢消息,不适合作为唯一依据。
- 时间轮:用 Netty 的 HashedWheelTimer 之类单机时间轮管理超时。优点是性能高、定时精确;缺点是单机内存态,服务重启或多实例部署时任务会丢、会重复,需要自己做持久化和分片。
- 消息队列延时消息:下单成功后发一条延时消息,到点投递,消费者校验订单状态后取消并回补库存。优点是延迟可控、消息可持久化、天然支持重试和水平扩展;缺点是引入 MQ 运维成本,需要处理重复投递和消费幂等。
我们订单量已经到日均几千单、且部署了多个实例,最终选了消息队列延时消息这条路线,下面展开。
四、推荐方案:延时消息加订单状态机
核心思路是两件事:订单必须有明确的状态机,超时取消通过延时消息驱动。
订单状态至少要区分:待支付、已支付、已取消、已完成。取消动作只能把"待支付"改为"已取消",这是防止误取消的第一道闸门。
下单时预占库存并发送延时消息,超时时间设为 15 分钟:
// 下单:预占库存成功后,发送一条 15 分钟的延时消息
Order order = orderService.create(req);
inventoryService.occupy(order.getSkuId(), order.getQty());
Message msg = new Message("order_timeout",
order.getOrderId().getBytes(StandardCharsets.UTF_8));
// 延时等级对应 15 分钟,具体等级按 MQ 提供方的映射设置
msg.setDelayTimeLevel(14);
producer.send(msg);
到点后消费者收到消息,先查订单当前状态,只有仍是"待支付"才执行取消,保证已经支付的订单不会被关掉:
public void cancelTimeoutOrder(String orderId) {
Order order = orderService.getById(orderId);
// 二次校验:只有待支付才取消,已支付/已取消直接返回,保证幂等
if (order.getStatus() != OrderStatus.WAIT_PAY) {
return;
}
boolean updated = orderService.updateStatus(
orderId, OrderStatus.WAIT_PAY, OrderStatus.CANCELED);
if (!updated) {
// 状态已被其他流程改变(如支付回调抢先),放弃本次取消
return;
}
inventoryService.release(order.getSkuId(), order.getQty());
}
库存回补要用带条件的更新,保证可售数量准确且可重复执行:
UPDATE sku_stock
SET available = available + #{qty},
occupied = occupied - #{qty},
version = version + 1
WHERE sku_id = #{skuId}
AND occupied >= #{qty}
AND version = #{version}
上线后,未支付订单基本在 15 到 16 分钟内被关闭,热门款被占库存能在十几分钟内回到可售,盘点时账面和实际可售的差异从每天上百件降到个位数。
五、最容易出事的环节:取消与支付回调的竞态
真正的坑集中在"用户恰好在第 15 分钟付款"这个边界上。延时消息说该取消了,支付回调说已经付款了,两个流程并发,处理顺序错了就会出现"已付款订单被取消、库存被还回去导致超卖"的严重事故。
应对原则是:取消前必须二次确认支付状态,并且状态变更用乐观锁做条件更新。上面代码里的 updateStatus(orderId, WAIT_PAY, CANCELED) 就是关键——它只在数据库里当前状态仍是待支付时才生效。如果支付回调已经把状态改成已支付,这条更新影响行数为 0,取消流程立即放弃,库存不释放。支付回调和取消消息谁后到都安全。
此外,延时消息可能重复投递,取消和库存回补都要做成幂等,重复消息不会多还一次库存。
踩坑清单
- 坑1·只靠 Redis 过期监听做取消:一次订阅连接闪断丢了一批过期事件,几百笔订单库存被占了一整夜。延时取消必须有持久化和可重试的通道,Redis 监听只能当辅助。
- 坑2·取消前不查支付状态:压测时模拟第 15 分钟付款,出现已支付订单被取消、库存回补后超卖。务必在消费时二次校验,并用待支付条件更新兜底。
- 坑3·库存回补没有条件判断:写成无条件
available = available + qty,重复消费时库存被多加。回补要带occupied >= qty和版本号,保证幂等。 - 坑4·轮询方案在大表上越跑越慢:订单表上千万后,每次按创建时间范围扫描都要几十秒,还和正常业务抢库。要么给状态和创建时间加合适索引,要么尽早切到延时消息。
- 坑5·超时时间写死在多处:下单、消息、前端倒计时各写一份,改的时候漏改导致对不上。超时分钟数要统一配置,各处引用同一个来源。
结语
下单即锁库存防超卖没有错,错在只锁不还。可靠的做法是用持久化的延时消息驱动、配合严格的订单状态机,取消前二次确认支付状态、库存回补做到幂等,这样既不会让未支付订单长期占库存,也不会在付款边界上误关订单。建议上线后重点观察取消及时率和回补后库存的一致性,把边界场景的压测做扎实。