做过库存型业务的大概都遇到过:用户把商品加进购物车、下了单却不付款,库存被一笔笔"待支付"订单占着,真正想买的人反而下单失败,活动高峰期尤其明显。要解决就得把"超时未支付的订单自动关掉、把预占的库存还回去"。听上去像加个定时器的事,真做下去全是竞态和一致性的坑。这篇把我们用过的三种方案、各自的边界,以及踩过的几个资损级问题讲清楚。
一笔待支付订单走到终点只有两条路:支付成功,或者超时关闭。关单这个动作要同时完成两件事——把订单状态改成已关闭、把当初预占的库存加回去,这两件事必须一致,少做一件都会留下烂账。
真正的难点是临界并发。假设超时时间是 15 分钟,第 14 分 59 秒用户刚好支付成功,而你的关单任务恰好在这一刻扫到了它。谁先落库?如果关单不管三七二十一先把订单关了、库存还了,另一边支付回调又成功了,就是"用户付了钱、订单却被关、库存还被多放出去一份"的资损。规模一大,第二个难点也来了:活动时几十万笔待支付单,不可能全表一把锁住去扫。
所以评判方案,看的不只是"能不能定时关",更是三件事:时间准不准、会不会漏单或重复关、扛不扛得住并发竞态。
最朴素的做法,起一个定时任务,分页捞出"状态为待支付、且创建时间早于当前时间减超时时长"的订单,逐批关闭。
-- 分页捞取到期的待支付订单,走索引、避免大事务
SELECT id, sku_id, quantity
FROM orders
WHERE status = 'UNPAID'
AND created_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE)
ORDER BY id
LIMIT 500;它的好处是简单、不引入额外中间件,而且关订单和释放库存在同一个本地事务里,天然一致:
def close_order(conn, order):
with conn.begin():
affected = conn.execute(
"UPDATE orders SET status='CLOSED' "
"WHERE id=%s AND status='UNPAID'", order.id)
if affected == 0:
return # 已被支付或已关闭,放弃
conn.execute(
"UPDATE stock SET locked=locked-%s "
"WHERE sku_id=%s", order.quantity, order.sku_id)短板也明确:关单时间精度取决于扫描间隔,间隔短了数据库压力大,长了关单慢、库存占着不放;单表变大后扫描越来越吃力;多实例部署时,几个节点会同时扫到同一批单,必须靠分片、选主或者把关单本身做成幂等来防重复。
下单时往 Redis 放一个带 TTL 的标记,到期触发关单,时间准、也不用扫表。但这里有个必须说清的坑:Redis 原生的键空间通知(keyspace notifications)并不可靠,订阅端断连、主从切换、过期消息不持久化,都可能让这条"到期事件"直接丢掉,丢了订单就永远占着库存。它适合做缓存层面的提醒,不适合当可靠任务队列。真要用 Redis 路线,应该上 Redisson 的 DelayedQueue 这类有持久化兜底的实现:
RBlockingQueue<Long> deadQueue = redisson.getBlockingQueue("orderTimeout");
RDelayedQueue<Long> delayed = redisson.getDelayedQueue(deadQueue);
// 下单成功后投递,15 分钟后才进入 deadQueue
delayed.offer(orderId, 15, TimeUnit.MINUTES);
// 消费者到点取出关单
Long id = deadQueue.take();
orderService.closeOrder(id);订单量上来后,主力切到消息队列:下单事务提交后发一条延迟消息,延迟时长等于支付超时,消费者到点收到再关单。RocketMQ 原生支持延迟级别,RabbitMQ 用消息 TTL 加死信交换机也能实现,自研的话时间轮也行。
def on_order_created(order):
db.insert(order)
# 事务提交后再发,避免"发了消息却回滚了订单"
mq.send_delay("order.close.delay", order.id, delay_seconds=900)
def consume_close(order_id):
order = db.get(order_id)
if not order or order.status != "UNPAID":
return # 不存在或已支付/已关,直接 ack
paid = pay_gateway.query_status(order_id) # 以渠道结果为准再确认一次
if paid:
order.mark_paid()
return
order.close_and_release_stock() # 内部带 CAS 与幂等
mq.ack()它的优点是削峰、消息持久化、可水平扩展,量大时最稳;代价是引入了 MQ 的复杂度,而且投递是"至少一次",消费者必须幂等,延迟期间用户完成支付也要能正确跳过。
无论用哪种方案,状态竞争都得在数据库层兜住,原则是状态只能单向流转,所有改动都带条件(CAS)。
关单时绝不能裸 update,要带上当前状态条件,影响行数为 0 就说明订单已经被支付或被关,立刻收手:
UPDATE orders
SET status = 'CLOSED', closed_at = NOW()
WHERE id = ? AND status = 'UNPAID';支付回调侧同样用条件更新,发现订单已经是关闭状态,不能简单"复活",而是要走退款或人工补偿流程。库存释放也得幂等——MQ 重复投递时,同一笔单不能把库存还两次,否则就是另一种超卖。我们的做法是落一条关单流水,用订单号做唯一约束,重复关单直接冲突跳过。
没有哪个单一方案能保证一条消息都不丢,所以最终一致还需要一张安全网:保留一个低频扫表任务,专门去捞那些"漏网"的、明显超时却仍是待支付的订单——MQ 丢了、Redis 抖了,最后都由它兜回来。主力方案负责快,扫表兜底负责全。
监控上重点盯四个量:待支付订单的堆积量、关单延迟的分布、关单与支付回调的冲突次数,以及"库存预占总量"和"待支付订单占用量"能不能对得上。超时时间也不是拍脑袋定的:太短,用户支付到一半被关;太长,库存被长期占死,应该结合支付页的平均支付时长来定,大促时还能动态调短。
订单超时关单看起来不起眼,实际上是"延迟调度 + 状态机 + 幂等 + 并发竞态"的一套组合拳。订单量小,定时扫表加本地事务完全够用,别一上来就堆中间件;量上来了,用 MQ 延迟消息做主力、低频扫表做兜底,再用条件更新和幂等流水把竞态守住,这套结构就比较稳了。如果你正在做类似功能,建议先问自己一句:如果延迟消息恰好丢了一条,我的系统多久能发现并补回来?答不上来,就说明还差那张安全网。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。