首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >下单不付款白白占着库存:订单超时自动关闭的三种方案与踩过的竞态坑

下单不付款白白占着库存:订单超时自动关闭的三种方案与踩过的竞态坑

原创
作者头像
数字化落地笔记
发布2026-09-15 14:14:54
发布2026-09-15 14:14:54
20
举报

导读

做过库存型业务的大概都遇到过:用户把商品加进购物车、下了单却不付款,库存被一笔笔"待支付"订单占着,真正想买的人反而下单失败,活动高峰期尤其明显。要解决就得把"超时未支付的订单自动关掉、把预占的库存还回去"。听上去像加个定时器的事,真做下去全是竞态和一致性的坑。这篇把我们用过的三种方案、各自的边界,以及踩过的几个资损级问题讲清楚。

一、先看清这件事难在哪

一笔待支付订单走到终点只有两条路:支付成功,或者超时关闭。关单这个动作要同时完成两件事——把订单状态改成已关闭、把当初预占的库存加回去,这两件事必须一致,少做一件都会留下烂账。

真正的难点是临界并发。假设超时时间是 15 分钟,第 14 分 59 秒用户刚好支付成功,而你的关单任务恰好在这一刻扫到了它。谁先落库?如果关单不管三七二十一先把订单关了、库存还了,另一边支付回调又成功了,就是"用户付了钱、订单却被关、库存还被多放出去一份"的资损。规模一大,第二个难点也来了:活动时几十万笔待支付单,不可能全表一把锁住去扫。

所以评判方案,看的不只是"能不能定时关",更是三件事:时间准不准、会不会漏单或重复关、扛不扛得住并发竞态。

二、三种方案,逐个说透

方案一:数据库定时任务扫表

最朴素的做法,起一个定时任务,分页捞出"状态为待支付、且创建时间早于当前时间减超时时长"的订单,逐批关闭。

代码语言:sql
复制
-- 分页捞取到期的待支付订单,走索引、避免大事务
SELECT id, sku_id, quantity
FROM orders
WHERE status = 'UNPAID'
  AND created_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE)
ORDER BY id
LIMIT 500;

它的好处是简单、不引入额外中间件,而且关订单和释放库存在同一个本地事务里,天然一致:

代码语言:python
复制
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 过期通知 / 延迟队列

下单时往 Redis 放一个带 TTL 的标记,到期触发关单,时间准、也不用扫表。但这里有个必须说清的坑:Redis 原生的键空间通知(keyspace notifications)并不可靠,订阅端断连、主从切换、过期消息不持久化,都可能让这条"到期事件"直接丢掉,丢了订单就永远占着库存。它适合做缓存层面的提醒,不适合当可靠任务队列。真要用 Redis 路线,应该上 Redisson 的 DelayedQueue 这类有持久化兜底的实现:

代码语言:java
复制
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);

方案三:MQ 延迟消息 / 死信队列(我们最终的主力)

订单量上来后,主力切到消息队列:下单事务提交后发一条延迟消息,延迟时长等于支付超时,消费者到点收到再关单。RocketMQ 原生支持延迟级别,RabbitMQ 用消息 TTL 加死信交换机也能实现,自研的话时间轮也行。

代码语言:python
复制
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 就说明订单已经被支付或被关,立刻收手:

代码语言:sql
复制
UPDATE orders
SET status = 'CLOSED', closed_at = NOW()
WHERE id = ? AND status = 'UNPAID';

支付回调侧同样用条件更新,发现订单已经是关闭状态,不能简单"复活",而是要走退款或人工补偿流程。库存释放也得幂等——MQ 重复投递时,同一笔单不能把库存还两次,否则就是另一种超卖。我们的做法是落一条关单流水,用订单号做唯一约束,重复关单直接冲突跳过。

四、再稳也要有兜底和监控

没有哪个单一方案能保证一条消息都不丢,所以最终一致还需要一张安全网:保留一个低频扫表任务,专门去捞那些"漏网"的、明显超时却仍是待支付的订单——MQ 丢了、Redis 抖了,最后都由它兜回来。主力方案负责快,扫表兜底负责全。

监控上重点盯四个量:待支付订单的堆积量、关单延迟的分布、关单与支付回调的冲突次数,以及"库存预占总量"和"待支付订单占用量"能不能对得上。超时时间也不是拍脑袋定的:太短,用户支付到一半被关;太长,库存被长期占死,应该结合支付页的平均支付时长来定,大促时还能动态调短。

踩坑清单

  • 坑1:多实例一起扫表互相打架。 定时任务在每个节点都跑,同一批单被重复关。要么分片选主,要么把关单做成幂等,二者至少占一个。
  • 坑2:把 Redis 键空间通知当可靠队列。 主从切换或订阅断连丢了过期事件,订单永久占库存。它只能做提醒,可靠延迟任务用 DelayedQueue 或 MQ。
  • 坑3:关订单和释放库存没放在一个事务里。 一个成功一个失败,库存和订单从此对不上。本地事务内一起改,跨资源就上对账。
  • 坑4:关单不做 CAS、不复查渠道结果。 在临界点把已支付订单关了,直接构成资损。更新必须带状态条件,关之前再查一次支付渠道。
  • 坑5:MQ 消费不幂等。 重复投递把同一笔库存释放两次,治超时反而治出了超卖。关单流水加唯一约束兜底。

结语

订单超时关单看起来不起眼,实际上是"延迟调度 + 状态机 + 幂等 + 并发竞态"的一套组合拳。订单量小,定时扫表加本地事务完全够用,别一上来就堆中间件;量上来了,用 MQ 延迟消息做主力、低频扫表做兜底,再用条件更新和幂等流水把竞态守住,这套结构就比较稳了。如果你正在做类似功能,建议先问自己一句:如果延迟消息恰好丢了一条,我的系统多久能发现并补回来?答不上来,就说明还差那张安全网。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 导读
  • 一、先看清这件事难在哪
  • 二、三种方案,逐个说透
    • 方案一:数据库定时任务扫表
    • 方案二:Redis 过期通知 / 延迟队列
    • 方案三:MQ 延迟消息 / 死信队列(我们最终的主力)
  • 三、绕不开的竞态:关单和支付回调谁先谁后
  • 四、再稳也要有兜底和监控
  • 踩坑清单
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档