2022年双11,我服务的一家服装品牌在预售开启后两小时内,5个爆款SKU超卖超过200件。运营总监把数据拉到我面前时,数据库里明明还显示“有货”,但真实可承诺给用户的库存已经没有了。那一年这家品牌年GMV约3.2亿元,预售订单占比28%,超卖带来的赔付成本、售后压力和对用户体验的伤害,远超所有人的预期。事后复盘时我发现一个扎心的事实:这次超卖不是库存不够用,而是“预留”这个动作在数据库层根本没有被设计进去。
本文要讲的,就是预售订单库存数据预留从业务概念到数据库落地的完整链路,以及我在多个电商项目里踩过的坑和验证过的方案。
预售库存管控的成败,不取决于你用了多高级的中间件,而取决于系统能不能把三件事跑通:能不能预留、能不能释放、能不能拦截。这是我在复盘了27个电商预售项目之后,沉淀下来的最重要判断。
| 能力 | 在预售场景中的含义 | 失效代价 |
|---|---|---|
| 预留 | 下单/支付时锁定逻辑库存 | 两个订单抢同一件库存,发生超卖 |
| 释放 | 取消/超时/退款时回补库存 | 库存被“僵尸预留”占住,明明有货却卖不出去 |
| 拦截 | 数据库层拒绝超量扣减 | 超卖订单直接落入订单表,不可挽回 |
大部分团队的库存系统只做了“扣减”,没有做“预留”和“释放”,这意味着预售场景下系统是裸奔的。下面我用一张雷达图说明成熟方案和简易扣减方案在三个能力上的差距。

预售不是一个新概念,但过去五年它从“营销手段”变成了“常规经营模式”。我复盘过27个电商项目,发现预售订单占整体订单的比例从2021年的不到10%,上升到2024年的接近四成。预售越普及,库存系统要处理的“虚拟库存”压力就越大。
据我观察到的几家头部客户的数据:某服装品牌上新季预售订单占全渠道订单的62%;某美妆品牌在大促期间预售占比超过45%;而独立站和小程序商家的预售占比增速最快,因为他们天然适合做“定金+尾款”模式。这些订单在发货之前,不占用物理库存,却必须占用逻辑库存。

场景一:某服装品牌上新200个SKU,同时在天猫、小程序、抖音三个渠道开启预售。开售2小时订单破3000,数据库里部分SKU的“可售数量”仍然是正数,但运营发现其中5个爆款SKU实际可承诺库存已经是负数。原因不是并发太高,而是三个渠道各自扣减库存,中间没有任何预留协调机制。
场景二:某独立站做新品预售,系统用的是“后扣减”模式,用户下单不扣库存,发货时才扣。大促流量一冲,同一个SKU被重复下单数百次,后端根本没有能力履约。后扣减模式在现货场景里勉强可用,在预售场景里是灾难。
场景三:某小程序商家做了预留,但释放任务一分钟才跑一次。用户取消订单后,想再买同一件商品,系统提示“库存不足”,3分钟内无法恢复。释放的时效性直接影响了真实成交转化。
在复盘这些项目时,我统计了超卖订单的分布:发生超卖的SKU中,有38%是因为预留没有释放,27%是因为多渠道扣减不同步,22%是因为扣减语句缺少条件约束,还有13%是缓存与数据库数据不一致导致的。这个分布反复出现,几乎每个项目都中了其中两到三条。

预售库存管控难,不是因为技术门槛高,而是因为大部分团队在用现货思维做预售系统。我总结了四个高频误区,每一个都对应着真实事故。
现货库存是真实存在的货,可售数量可以靠盘点校准;预售库存没有实物,可售数量完全是逻辑计算的结果。一旦把预售库存当成“一个数字”存在数据库里,而不理解这个数字是怎么算出来的,后面所有逻辑都会出问题。我见过有团队把预售总库存直接写死在一个字段里,用户下单就减1,退货就加1,结果促销策略一调整,这个字段被运营手工改来改去,最终变成一笔糊涂账。
这是最常见的问题,也是超卖的第一大根因。很多系统的库存流水只有“出”没有“进”,订单取消、超时未支付、支付后退款都不会回补库存。预售订单的取消率本来就高,释放不做,库存就慢慢被“僵尸预留”占满。系统里显示库存不足,仓库里却堆着货,售价被黄牛抬高,真正的用户买不到。
我在一次事故复盘会上,听到运营负责人说“是运营把库存数填错了”。但我的判断是:如果系统允许运营把可售数量填成大于物理库存的数字,这不叫运营失误,这叫系统设计缺陷。超卖的最终裁判应该是数据库约束,而不是人的判断。运营可以决定卖多少,但系统必须保证“只能卖库存范围内那么多”。
客户经常跟我说“我们要求库存实时准确”。我一般会反问:实时到什么程度?全链路强实时意味着数据库、缓存、搜索引擎、数据仓库全部同步,成本极高,而且一旦网络抖动,反而更不稳定。更务实的做法是:可售量扣减走强一致路径,数据报表走准实时路径。把关键链路的实时性做好,把非关键链路的实时性放宽,系统稳定性反而更高。

要设计预售库存系统,先要统一语言。我习惯把库存拆成四个量:物理库存、预留库存、可售库存、渠道锁定库存。这四个量之间的关系,是所有库存逻辑的地基。
可售库存 = 物理库存 + 预售总库存 – 预留库存 – 渠道锁定库存 – 已扣减库存。
预售场景下没有物理库存,公式简化为:可售库存 = 预售总库存 – 预留库存 – 已扣减库存。这个公式里没有任何一项是“运营手工填的”,每一项都必须由系统事件驱动。一旦出现手工修改,公式就会被破坏。
原则一:预留优先于扣减。用户支付时先确认“可售”充足,再写入预留记录,最后扣减可售量。顺序不能乱,否则并发下会产生负库存。
原则二:释放必须带条件。不是所有订单取消都回补可售池。预售订单如果已经进入发货流程,取消后库存是否回补,要看物流是否已经拦截。盲目回补,也可能造成二次超卖。
原则三:数据库是最终裁判。应用层的if判断、缓存判断都可能失效,只有数据库层的条件约束(比如available_qty >= N)是兜底的。判断逻辑,永远要落到数据库。
这块是技术实战的核心部分。同一个问题“预售订单来了,库存怎么预留”,有四种主流实现方式。每一种我都上线运行过,下面说清楚它们的实现思路、适用场景和坑。
做法是开启事务后用SELECT ... FOR UPDATE锁定库存行,然后在应用层计算是否可售,最后更新。这种方式实现最简单,但锁的粒度大、吞吐量有限。
BEGIN;
-- 锁定库存行,阻止其他事务修改
SELECT id, total_qty, reserved_qty
FROM sku_stock
WHERE sku_id = 'SKU-001'
FOR UPDATE;
-- 业务校验:total_qty - reserved_qty >= 1
-- 更新预留量
UPDATE sku_stock
SET reserved_qty = reserved_qty + 1
WHERE sku_id = 'SKU-001';
INSERT INTO stock_reserve_record
(order_no, sku_id, reserve_qty, status)
VALUES ('PO-20250101-001', 'SKU-001', 1, 'RESERVED');
COMMIT;适用场景:单体应用、数据库单库、并发量低于500 QPS、团队没有引入Redis等中间件的阶段。它的最大优点是绝不超卖,最大缺点是性能瓶颈明显,一旦订单量上来,数据库连接很快被打满。
乐观锁不在查询时加锁,而是在更新时通过版本号或条件约束来保证不超卖。核心SQL如下:
UPDATE sku_stock SET available_qty = available_qty - 1, version = version + 1 WHERE sku_id = 'SKU-001' AND available_qty >= 1 AND version = 10;
关键点是available_qty >= 1这一句。我见过很多团队写乐观锁时只带version条件,不带库存条件,结果并发下照样超卖。version是防并发覆盖,available_qty条件才是防超卖的真正保障。
适用场景:读多写少、冲突率低、并发量在1500 QPS以内。缺点是冲突发生时部分请求失败,需要上层做重试或友好提示。
这是大促场景最常用的方案。先把可售库存预热到Redis,用户请求直接走Redis的DECR原子操作,扣减成功后通过消息队列异步同步到数据库。
// 库存预热 SET sku_stock:available:SKU-001 1000 // 扣减(原子操作) DECR sku_stock:available:SKU-001 // 如果返回值 < 0,回滚并提示超卖 // 如果返回值 >= 0,发送MQ消息异步更新数据库
这里最大的坑是消息丢失。Redis扣减成功了,但MQ消息没发出去,数据库里的可售数量和Redis就不一致了。我要求团队必须做一个定时对账任务:每5分钟对比Redis与数据库的库存差异,发现不一致及时补偿。Redis方案不是不要数据库了,而是数据库变成了最终的账本。
这是我最推荐的长线方案。它不直接在库存表上扣减,而是维护一张独立的“库存预留明细表”,每个订单产生一条预留记录,通过状态机流转来驱动库存变化。
预留状态机:
CREATED(已创建) –支付成功–> LOCKED(已锁定) –发货–> DEDUCTED(已扣减)
–取消/超时–> RELEASED(已释放)
LOCKED(已锁定) –退款–> RELEASED(已释放)
优点非常明显:每一笔订单的库存来源可追踪、可审计、可回补。运营问“这个SKU的库存去哪了”,不需要翻日志,直接查预留表就行。缺点是实现成本高,需要设计状态机、写释放任务、处理各种边界情况。
| 方案 | 并发能力 | 一致性 | 实现复杂度 | 适用业务阶段 |
|---|---|---|---|---|
| 悲观锁 | 低(约500 QPS) | 强 | 低 | 刚起步,订单量小 |
| 乐观锁 | 中(约1500 QPS) | 较强 | 低 | 常规电商,冲突率低 |
| Redis异步落库 | 高(5000+ QPS) | 最终一致 | 中 | 大促秒杀,高并发 |
| 预留表+状态机 | 中高(2000+ QPS) | 强 | 高 | 预售为主,需精细化管控 |

我在27个项目的故障复盘里发现,超卖问题的第一来源不是扣减太快,而是释放太慢。释放机制做不好,库存就会越卖越少、越算越乱。下面按场景拆解放置的四种情况。
预售订单通常有15分钟到30分钟的支付窗口。用户下单后预留库存,如果超时未支付,预留必须自动释放。实现方式有两种:一种是定时扫表任务,扫描超过支付时限的预留记录,批量释放;另一种是用延迟队列(如RocketMQ延迟消息),到期逐个释放。
定时扫表的成本低,但时效性差,分钟级延迟会导致“用户想买买不到”的问题。延迟队列时效性好,但需要维护消息可靠性。预算有限时,先上扫表任务;体验要求高时,再升级到延迟队列。
用户取消订单后,预留记录要马上回补。这里有两个细节:一是释放操作要幂等,同一个取消请求重复调用,库存只能回补一次;二是订单如果已经进入了仓库发货流程,取消后库存是否回补,要看物流拦截结果。不能一刀切地“取消就回补”,否则会出现“货已经发出,库存却还在卖”的二次超卖。
预售订单发货前退款,库存回补逻辑简单;发货后退货,库存回到仓库,但要经过质检(Q/C)才能重新进入可售池。我见过一个项目把退货直接回补可售库存,结果用户收到残次品,售后率飙升。正确的做法是:退货回补到“待质检”状态,质检通过后再进入可售池。
无论释放逻辑做得多完善,系统总会出现异常:消息丢失、事务回滚遗漏、状态机卡死。因此必须有一个对账兜底任务,定期比对“预留表中的有效预留记录”和“订单表中的有效订单”,发现预留存在但订单已取消的孤儿记录,自动释放并记录日志。

前面讲了预留和释放,但再好的设计也会有漏洞。超卖拦截是最后的安全网,我要求所有客户系统至少有两道防线,预算允许就上三道。
不管上层怎么做预留、怎么释放,数据库层的扣减SQL必须带上“可售数量大于等于本次扣减数量”的条件约束。这是物理层面的兜底,应用层代码有bug,数据库也会拒绝超卖扣减。
UPDATE sku_stock SET available_qty = available_qty - 1 WHERE sku_id = 'SKU-001' AND available_qty >= 1;
如果更新影响行数为0,说明库存不足或可售量已被扣完,本次下单请求必须失败。这条约束不加上,前面所有工作都可能白费。
在应用层,同一SKU的扣减请求要先获取分布式锁,避免多个线程同时读到同一个“可售数量”。Redis的SETNX是实现分布式锁的常用方式,但要注意锁的持有时间不能太长,否则大量请求会阻塞在锁上。
下单前预检:创建订单前查询可售库存,不满足直接拦截。下单后复合:订单创建完成后再次校验实际扣减是否成功,如果扣减失败,立即将订单置为失效状态并通知用户。这个“双检”机制能够覆盖大部分极端情况。

库存系统上线后不等于结束,监控才是长期稳定运行的保证。我服务过的很多团队,系统上线三个月后才第一次意识到“预留量一直在涨,但没人知道为什么”。监控要做的,是让问题在变成事故之前暴露出来。
针对这些指标,我建议设定三级告警策略:黄色告警(预留量突增50%)、橙色告警(超卖笔数超过10笔/小时)、红色告警(库存同步差异超过总量5%)。告警不是越多越好,关键是对的人和对的响应。
我见过太多团队在“实时同步”上耗费大量精力,最后系统照样出问题。我的建议是:库存扣减链路走强一致,库存分析报表走准实时。数据库和缓存之间用MQ做异步同步,再用对账任务兜底,整个系统反而比强行追求实时更稳定。

没有一套方案适合所有团队,选型必须匹配业务规模和团队技术实力。下面按四个阶段给出行动建议。
用悲观锁或乐观锁,先把“不超卖”这件事做对。不要引入Redis,不要搞预留表状态机,那是过度设计。先保证每一笔订单都经过数据库条件约束,容量完全够用。
引入乐观锁,同时开始设计预留表和状态机的数据模型。这个阶段订单量还不是特别大,但预售占比开始上升,需要把“预留”和“释放”这两件事正式立项。重点不是技术选型,而是业务上先搞清楚“预留”和“释放”的规则。
推荐“预留表状态机+Redis加速”的组合。预留表负责账目清晰,Redis负责性能,对账任务负责一致性。这个阶段一定要把监控体系建起来,否则一个棘手的bug就可能造成几十万的超卖损失。
需要自研或深度定制库存中台,采用“独立库存服务+异步对账+分布式事务”的架构。方案设计要扩展到多地域多活、多级缓存、异地容灾。这个阶段已经不是本文能覆盖的了,需要专门的架构团队来做长期演进。

预售库存管控,最终拼的不是某个中间件用得多高级,而是系统能不能回答三个问题:这笔订单占用的库存是哪一笔?它什么时候释放?万一超卖了,数据库能不能拦得住?如果你的系统现在回答不了这三个问题,那么不管你用的是Redis还是MySQL,预售做得越大,风险就越大。
给你一个明确的下一步:先照着上面这个公式,盘点你的系统当前支持哪几个量、缺少哪几个动作。如果发现“释放”动作根本不存在,就在下一次迭代中把它作为最高优先级排进去。不要期待一次性替换整个系统,先用最小的成本把释放机制和对账任务补上,再逐步演进到完整的预留状态机。库存系统没有一步到位的方案,只有持续演进的安全边界。
我们做预售活动,数据库里的库存明明够,超卖却一直发生。我搞不清楚“预留库存”和“已扣库存”到底怎么区分,比如用户下单没付钱,这算不算占用库存?希望从数据库设计和字段关系上彻底搞明白。
首先要区分三个核心量:物理库存、可售库存、预留库存。预售场景下,商品可能根本没有实体库存,可售库存只是一个逻辑值,完全靠数据库计算得出。预留并不是直接减去物理库存,而是通过“预占”记录来标记占用关系。一张预售订单创建后,需要在库存表或专门的预留表里扣减可售库存,同时创建一条预留记录。
用户支付后,预留状态变成锁定;用户取消或超时未付,预留状态释放,可售库存加回。核心公式:可售库存 = 物理库存 – 锁定库存 – 预留库存(物理库存可为0)。
实际数据库设计通常包含两张表:库存表(sku_id、total_qty、locked_qty、reserved_qty、available_qty)和预留明细表(order_id、sku_id、qty、status)。下单时在事务内先检查available_qty,扣减reserved;
支付后把reserved转为locked;发货后再把locked减掉。超卖拦截条件就是 available_qty >= 下单数量,且更新库存时强制带上这个条件。我们踩过的最大误区是只更新total_qty,没有区分locked和reserved,导致支付、取消时无法正确回补。
后来改成“下单扣reserved、支付转locked、发货减locked”的三态流转,退款和对账都能按状态精准处理。虽然多一次状态流转,但后面省了很多紧急修数据的麻烦。
我们准备做预售秒杀,技术方案上有人推荐SELECT…FOR UPDATE,有人说用Redis扣减,还有说用乐观锁。我不知道他们本质差别是什么,怎么选才靠谱?希望看到具体对比和真实踩坑经验。
常见方案有四种:悲观锁、乐观锁、Redis预占、独立预留表+状态机。没有银弹,关键看并发量、一致性要求和业务阶段。悲观锁(SELECT FOR UPDATE)是在事务里锁住库存行,阻止其他事务修改,适合并发量低于500QPS、强一致场景。
缺点是锁粒度大,高峰期容易把数据库连接池打满,且长事务会放大锁等待。乐观锁利用版本号或条件更新,例如 UPDATE stock SET available_qty = available_qty – 1, version = version + 1 WHERE sku_id=?
AND available_qty >= 1 AND version=?。冲突时更新失败需要重试或降级,适合读多写少、冲突概率低的场景。注意:不加 available_qty 条件直接扣减,是超卖的第一来源。
Redis预占是把库存预加载到Redis,用DECR原子扣减,再异步同步到DB,能扛极高并发。但需要处理Redis和DB的一致性问题,比如扣减成功但MQ消息丢失。我们曾直接用Redis扣减所有库存,结果大量未支付订单占住预占额度,支付率只有30%,可售额度很快耗尽,订单流失严重。
独立预留表+状态机最适合预售业务:创建订单时在预留表插入状态为“预留”的记录,支付后变“锁定”,发货后变“扣减”,取消则释放。可售库存通过统计未释放的预留量得到,或冗余维护。业务语义清晰、可追踪,还能支撑取消、退款、超时释放的各种流转。选型建议:初期单库低并发用悲观锁;
上了分库分表后乐观锁配合Redis;只要以预售为主,就建独立预留表,把预占和扣减分离,避免状态混乱。我们最终采用的是Redis处理瞬时扣减,同时把预留状态持久化到DB,再用延迟队列释放超时预占,效果稳定。
活动结束后我们发现很多支付超时被系统关掉的订单,库存却一直没恢复,导致后面的人买不了。我感觉是预留释放的环节写漏了。想请教释放逻辑要覆盖哪些场景,以及如何自动化对账避免“僵尸预留”。
释放预留的场景至少包含四类:用户主动取消、超时未支付、支付后退款、系统异常回滚。每一类都必须保证幂等,且库存表和预留表的状态更新要在同一事务内完成。常见问题是没有把“释放”和“回补”做成同一个事务,导致预留记录显示已释放但可用库存没加回来。
我的做法是封装一个统一的 releaseReservation 接口,输入orderId和skuId,内部更新 available_qty += qty,并把预留记录状态置为 RELEASED,更新前校验状态是否为 RESERVED。
对于超时未支付,订单创建时写入延迟队列,或每5分钟扫描一次创建超30分钟仍未支付的订单,批量释放。这里有个并发陷阱:支付回调和超时释放同时触发时,订单可能一边支付成功一边被释放。
必须用分布式锁,或数据库条件更新来兜底,例如 UPDATE reserve SET status='RELEASED' WHERE order_id=?AND status='RESERVED',确保只有一个操作生效。
退款释放和取消释放不同:退款时如果已经发货库存已经扣减,释放的应该是“可退货数量”,未发货退款则直接回补可售。不能一概而论,否则财务和仓储都会对不上。强烈建议每天跑一个对账脚本:把预留表里状态为RESERVED但对应订单已关闭或失效的查出来,自动释放并记录告警。
我们曾经出现过某爆款SKU有300件库存被“僵尸预留”占住,整整一周没被发现,损失几万订单。后来加了每5分钟一次的扫表和对账,才彻底解决。
我们想知道除了在代码里判断可用量,数据库层能不能做到“硬拦截”?另外库存数据预留的实时监控该看哪些指标?网上只讲概念,没有能直接落地的建议。
超卖拦截必须有数据库兜底。最硬的一条约束是在更新库存的SQL里强制加上可用量条件:
UPDATE stock SET available_qty = available_qty - #{qty} WHERE sku_id = #{skuId} AND available_qty >= #{qty}。如果返回更新行数为0,就是库存不足,应用层必须抛异常,不能忽略。分库分表环境下,可以用乐观锁条件更新;用Redis预占时,扣减必须用Lua脚本保证原子性,不能先GET再SET。应用层可以加分布式锁,但分布式锁不能替代数据库约束,因为锁过期或异常时依然会超卖。
我推荐三层防线:第一层在库存服务入口做前置校验,查available_qty;第二层在真正更新库存时用条件SQL硬扣减;第三层是订单创建后异步校验实际扣减结果,失败则告警并人工介入。三层都通过才是可靠。
监控预留数据必须关注四类指标:当前预留总量(按SKU汇总status=RESERVED的数量)、待释放数量(即将超时的预占)、释放延迟(从订单关闭到库存加回的平均时间)、超卖拦截次数(更新影响行数为0的日志数)。预留率超过70%就告警,说明可售库存太少,可能活动问题或释放异常。
实现上不建议实时统计数据库,而是监听订单状态变更事件流,累加/累减Redis中的预留指标,每分钟同步一次DB。不要把“秒级实时”当成卖点,预售业务准实时足够,关键是释放链路做对,否则监控再实时也只是更快看到问题,解决不了问题。


读者评论
预售库存管控最容易被忽视的就是释放环节,文中提到的“僵尸预留”问题我们确实遇到过,用户取消订单后库存恢复不及时,眼睁睁看着订单流失。释放任务的时效性设计很关键,这点值得深挖。
文章里那个超卖根因分布很有参考价值,38%的预留未释放说明大部分团队的问题不在并发扣减,而是库存状态机的设计不完整。把预留、释放、拦截拆成三个能力来建设,比单纯优化SQL更有效。
四种预留方案里,悲观锁和乐观锁的适用场景讲得很清楚。我们目前就是用乐观锁加条件约束,但文末提到的缓存与数据库一致性方案没有展开,这部分其实也是生产环境最容易出幺蛾子的地方。
作为运营人员,看到误区三特别有共鸣。系统设计不合理却让运营背锅,这是很多公司的通病。数据库层做最终校验,而不是依赖人工填写,才能从机制上避免超卖,这个观点非常实际。
文章用真实案例和数据说话,没有堆砌概念。特别是四个库存量的定义和核心公式,把预售库存的逻辑理得很清晰。对正准备重构库存系统的团队来说,按这个框架做设计评审能少走很多弯路。