数据库存:开发新手数据视角:用数据校验验证降低超卖风险
库存表里明明只剩 1 件商品,两个用户却同时拿到了支付成功通知,这不是“数据库少扣了一次库存”这么简单,而是库存读取、资格判断、库存扣减、订单创建和支付确认之间缺少了可验证的约束。开发新手最容易把超卖理解成一条 SQL 写错,实际上,真正稳定的方案必须让每一次库存变化都能被数据校验、被事务约束、被日志追溯。
我处理库存类需求时,通常不会先问“用哪种锁”,而是先问三个问题:库存的权威数据到底在哪里,什么时点算作占用成功,以及系统如何证明一件商品没有被两个订单同时占用。只有这三个问题回答清楚,乐观锁、悲观锁、唯一索引、消息队列和缓存才不会沦为零散的技术名词。
很多库存系统把“用户点击购买”“订单创建”“支付成功”“仓库出库”都称为卖出,但这四个节点的业务含义完全不同。如果库存从用户提交订单开始冻结,库存减少得早;如果支付成功才扣减,库存被占用的时间短,却可能引发支付回调重复、库存不足和订单补偿问题。
我建议开发新手先在需求文档里写出库存状态机,而不是直接设计一张库存表。一个常见的状态流转是:可售库存进入预占,预占在规定时间内等待支付,支付成功后转为已售,超时未支付则释放回可售库存。每个状态都应该有明确的进入条件、离开条件和可重复执行规则。
| 业务状态 | 库存含义 | 允许的变化 | 必须校验的条件 |
|---|---|---|---|
| 可售 | 可以被新订单占用 | 减少、保持不变 | 可售数量大于等于购买数量 |
| 预占 | 已被订单暂时占用 | 转为已售、释放 | 订单状态、过期时间、占用流水一致 |
| 已售 | 已经完成交易确认 | 原则上不可回退 | 支付结果和订单金额已经核验 |
| 已释放 | 原占用已经撤销 | 回到可售 | 同一占用记录不能重复释放 |
核心结论是:数据库存储的不是一个“剩余数字”,而是一组必须保持一致的事实。至少要同时关注库存快照、库存流水、订单状态、支付状态和操作幂等号。只维护一个库存总数,系统无法解释这个数字为什么变化,也无法在异常发生后安全修复。
数据库设计中最有价值的词不是“性能高”,而是“不变量”。不变量是无论并发多高、服务重试多少次,都不能被破坏的业务事实。例如,某个 SKU 的可售库存不能小于 0;一笔订单不能被两个用户占用;同一个支付回调不能让库存扣减两次。
对于最简单的库存模型,可以用下面的关系进行校验:
可售库存 = 初始库存 + 入库数量 – 已售数量 – 预占数量 + 释放数量
如果还存在退货、报损、调拨和锁定库存,就不能把所有变化都塞进一个“调整数量”字段。每一种库存变化都应该拥有独立的业务类型、业务单号、操作时间和操作人。这样做会多写一些数据,但能显著降低后期排查“库存为什么少了 37 件”的成本。
我在设计校验规则时,会把规则分为三层。第一层是数据库约束,例如非负约束、唯一约束和外键约束;第二层是事务内校验,例如扣减前确认可售库存充足;第三层是异步对账,例如每天比较库存快照与流水汇总。三层规则不能互相替代。

一条 UPDATE 语句返回成功,并不等于库存业务成功。开发新手经常只判断数据库连接没有报错,却不判断受影响行数。库存为 0 时,如果 SQL 没有把“库存大于购买数量”写进 WHERE 条件,数据库仍然可能返回执行成功,只是把库存改成了负数。
更可靠的判断方式是:把业务前置条件写到更新语句里,再检查 affected rows 是否等于预期值。对于购买数量为 2 的场景,扣减操作应当只允许在可售库存大于等于 2 时执行。受影响行数为 1,表示本次扣减获得了资格;受影响行数为 0,表示库存不足或版本冲突,必须进入失败分支。
UPDATE sku_inventory SET sellable_quantity = sellable_quantity - :buy_quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND sellable_quantity >= :buy_quantity AND version = :version;
这段 SQL 的重点不在语法,而在于读取到的库存、扣减条件和版本校验必须形成一个原子判断。如果先 SELECT,再在另一条 UPDATE 中扣减,中间就会留下并发窗口;如果只依赖应用层判断,数据库并不知道两个请求是否同时通过了判断。
假设数据库中某商品可售库存为 1。请求 A 和请求 B 几乎同时到达,代码先查询库存,再执行扣减。两个请求都在查询阶段读到库存为 1,于是都认为自己有资格购买。
| 时间点 | 请求 A | 请求 B | 数据库库存 |
|---|---|---|---|
| T1 | 读取库存 1 | 等待 | 1 |
| T2 | 判断 1 大于等于 1,准备扣减 | 读取库存 1 | 1 |
| T3 | 写入库存 0 | 判断 1 大于等于 1,准备扣减 | 0 |
| T4 | 创建订单成功 | 继续创建订单 | 可能为 0 或 -1 |
如果两个请求使用“读取旧值后写入新值”的方式,最终库存甚至可能停在 0,但订单却有两笔。这里最危险的地方是,库存数字看上去没有负数,监控也未必报警,真正的错误隐藏在“订单数量大于可售数量”的关系里。
所以我不会只监控库存是否小于 0,还会监控下面这个更接近业务事实的指标:
异常占用量 = 已支付订单商品数量 + 有效预占数量 – 可售库存变化对应数量
这个指标一旦大于 0,就说明存在订单、预占或库存流水之间的不一致。它比单看负库存更早暴露问题,也能发现“库存没有负数但订单已经重复占用”的隐性超卖。
日常商品库存通常允许补货、拆单和延迟发货,秒杀或限量促销则不同。活动库存可能只有几百件,却在几十秒内接收数万次请求。此时,数据库不仅承担库存扣减,还承受资格校验、订单创建、优惠计算和日志写入,热点行很容易成为瓶颈。
我见过一种常见错误:为了避免超卖,开发者给整个下单方法加了事务,但事务里又调用优惠服务、会员服务和地址服务。事务持续时间从几毫秒扩大到几百毫秒,锁持有时间变长,排队请求越来越多,最终系统出现大量超时。开发者以为锁不够强,实际上问题是事务边界过大。
另一个错误是把库存缓存当作最终事实。缓存中的数量减少得很快,但数据库扣减失败、消息丢失或服务重启后,缓存和数据库发生偏差。如果没有库存流水和定期对账,缓存只是在更快地传播错误。

发生疑似超卖后,不要先改库存数字。直接把库存加回去,可能掩盖重复扣减、重复释放或支付回调重试。正确做法是先锁定一个 SKU 和一个时间窗口,按相同业务主键关联库存快照、库存流水、订单明细、支付流水和消息消费记录。
我通常按以下顺序排查:
如果系统只有一张库存表,没有流水表,排查只能依靠应用日志。日志可能被切割、丢失或无法与数据库事务准确对应,因此后续修复仍然缺少证据。库存流水并非“以后做报表才需要”,它是线上故障恢复的基础数据。
错误代码通常是先查询库存,再在程序中判断,最后执行扣减。这种写法在单线程测试里完全正常,因为没有第二个请求同时修改数据。上线后,两个请求可能在同一时间读到同一个库存值,应用层的判断就失去了排他性。
正确思路是把条件放到写操作中,让数据库直接判断并完成扣减。查询可以用于展示页面,但不能作为最终库存资格的唯一依据。页面显示“剩余 1 件”只是提示,真正决定订单能否占用库存的应该是带条件的原子更新。
悲观锁可以让并发请求排队,但它的正确性取决于事务范围和索引条件。一个事务先锁住库存行,再调用外部接口,锁可能长时间不释放;如果多个商品按不同顺序加锁,还可能形成死锁。
此外,悲观锁不能自动解决重复支付回调。支付回调是另一条业务链路,即使下单时库存锁设计正确,回调重复执行仍可能重复更新订单和库存。因此,库存锁、状态机和幂等约束必须一起设计。
乐观锁的版本号能识别“这条记录在读取后是否被别人改过”,但它不一定能识别业务上的重复动作。比如请求 A 和请求 B 使用不同订单号同时扣减同一个 SKU,版本号可以保证其中一个更新失败,却不能阻止同一订单因为网络重试再次发起一笔新的扣减。
所以版本号解决的是并发写冲突,幂等键解决的是同一业务动作重复执行。两者的作用不同,不能用其中一个替代另一个。常见幂等键包括订单号、支付流水号、库存操作号和消息事件号。
确实,索引、唯一约束和外键会增加写入成本,但库存系统的主要风险不是多花几毫秒,而是错误数据扩散后需要人工核对几天。对库存业务而言,一条唯一约束经常比一套复杂的补偿脚本更可靠。
我一般会优先保留与业务事实直接相关的约束,例如库存操作号唯一、同一订单同一商品只能有一条有效占用记录、库存数量不能为负、状态流转必须符合规定。对于统计字段和可重建字段,可以减少强约束,通过异步重算降低写入压力。
消息队列适合削峰、异步化和跨服务解耦,但消息的“至少一次投递”意味着消费者必须允许重复执行。只要消费逻辑没有幂等,队列越可靠,重复扣减的机会反而越多。
我会把消息处理拆成三个步骤:先用事件号判断是否处理过,再执行状态转换,最后记录消费结果。消费成功但结果记录失败时,消息可能重试,因此状态转换本身也必须具备幂等条件,而不能仅依赖“已经处理过”这一个判断。

如果业务只需要知道某个商品还有多少件,可以使用单 SKU 总量模型。如果不同仓库分别发货,就必须把仓库维度纳入库存主键。如果商品存在保质期、批次成本或先进先出要求,库存主键还要包含批次,不能只维护一个总数。
我见过“总库存正确但实际发不出货”的情况:华东仓有 3 件,华南仓有 0 件,系统展示全国库存 3 件,订单却承诺次日送达华南地区。这里不是超卖,而是可履约库存计算错误。开发者需要区分总库存、可售库存、可调拨库存、在途库存和区域可履约库存。
| 库存口径 | 适用判断 | 常见错误 | 建议校验 |
|---|---|---|---|
| 总库存 | 企业拥有的全部实物数量 | 把已锁定和残次品也算作可卖 | 总库存等于各库存状态之和 |
| 可售库存 | 当前允许新订单占用的数量 | 忽略预占和安全库存 | 可售库存不得小于零 |
| 可履约库存 | 满足地区、仓库和配送承诺的数量 | 只看全国总量 | 按仓库、区域和配送规则重算 |
| 在途库存 | 已经发出但尚未入库的数量 | 提前计入可售库存 | 必须有调拨单和入库确认 |
不能只看一天平均订单量。一个系统每天 100 万笔订单,平均每秒只有十几笔,可能很轻松;另一个系统每天 10 万笔订单,却在 10 秒内集中抢购同一个 SKU,数据库压力反而更大。
我会重点查看四个数据:单 SKU 峰值请求数、库存行平均锁等待、扣减失败率和重试次数。如果大量请求集中在少数几个 SKU,问题是热点行;如果请求分布均匀但数据库仍然慢,问题可能是索引、事务范围或下游调用。
可以用下面的简化指标估算风险:
热点集中度 = Top 10 SKU 请求量 ÷ 全部 SKU 请求量
库存竞争率 = 同一库存行并发请求数 ÷ 该库存行可处理请求数
重试放大率 = 重试请求数 ÷ 首次请求数
热点集中度超过 60% 时,我不会仅仅增加数据库连接数。连接数增加只会让更多请求同时争抢同一行,通常需要在入口做排队、限流、令牌预分配,或者把库存分桶后再汇总。

库存数量、订单状态和支付状态不一定要求所有系统在同一毫秒内完全一致,但必须明确哪些判断不能使用旧数据。下单资格通常需要基于权威库存进行实时确认;商品详情页展示的“剩余数量”可以短暂延迟;经营分析报表则可以接受分钟级甚至小时级延迟。
我会把数据分成三类。第一类是决策数据,例如扣减时使用的可售库存,必须强一致或具备可靠的失败校验。第二类是过程数据,例如库存流水同步到分析库,可以异步但必须可追踪。第三类是展示数据,例如列表页的库存提示,可以使用缓存,但页面必须明确它不是最终购买承诺。
| 数据用途 | 允许延迟 | 是否可直接决定下单 | 推荐方式 |
|---|---|---|---|
| 扣减资格 | 原则上不允许旧读 | 可以 | 数据库条件更新或带版本事务 |
| 页面库存提示 | 数秒到数分钟 | 不可以 | 缓存加实时最终校验 |
| 经营分析 | 分钟到小时 | 不可以 | 同步流水到分析模型 |
| 仓库拣货数量 | 取决于履约流程 | 不应绕过订单状态 | 订单、库存和出库单关联校验 |
数据库事务负责阻止错误发生,分析工具负责帮助团队看见错误从哪里产生。以九数云为例,我更建议把它用于库存流水、订单明细、支付记录和仓库数据的关联分析,而不是把它当成另一个扣库存的系统。它适合做趋势观察、异常筛选、分渠道对比和管理层看板,权威扣减仍应在交易数据库完成。
在实际分析中,可以将库存流水表与订单明细表按 SKU、仓库和业务日期关联,再与支付流水按订单号关联。这样能回答几个数据库日志不容易直接回答的问题:哪个渠道的预占释放率异常,哪些 SKU 的支付成功率高但出库失败多,哪些仓库的库存调整次数明显高于其他仓库。
例如,我会在分析看板中设置以下字段:SKU 编码、仓库、可售库存、预占数量、支付成功数量、释放数量、出库数量、异常流水数和最后同步时间。看板不应该只放一张“库存余额”卡片,而应该让库存余额能够被流水和订单反向解释。
下面是一组情景模拟数据,用来说明分析方法,不代表九数云官方客户数据。某电商团队在一次 30 分钟促销中,发现库存表没有出现负数,但售后团队收到 17 个“支付成功后无法发货”的订单。团队一开始认为是仓库漏扫描,后来按 SKU 和仓库拆分后发现,异常主要集中在两个热门商品。
| 观察对象 | 正常 SKU | 热门 SKU A | 热门 SKU B |
|---|---|---|---|
| 订单支付成功数量 | 420 件 | 860 件 | 735 件 |
| 有效库存预占数量 | 438 件 | 801 件 | 691 件 |
| 释放数量 | 18 件 | 96 件 | 81 件 |
| 出库确认数量 | 419 件 | 843 件 | 721 件 |
| 订单与库存流水差异 | 1 件 | 17 件 | 14 件 |
从结果看,热门 SKU A 的支付成功数量并没有直接超过库存流水汇总,但订单与出库之间出现了 17 件差异。进一步查看时间序列后,异常集中在支付回调高峰的 4 分钟内。这个现象更像是预占释放与支付确认的状态竞争,而不是简单的扣减 SQL 漏写。

修复方案上线后,不要只观察数据库报错数量。更有价值的是比较上线前后的一组业务指标:每万笔订单的库存差异订单数、重复库存流水数、预占超时释放耗时、库存对账差异金额和人工处理时长。
在一次类似的方案评估中,团队采用条件更新、库存操作号唯一约束、支付回调幂等和每日对账四项措施。以下数据是情景模拟,用于展示验证口径。它说明一个重要事实:技术修复不一定让所有指标同时改善,实时失败率可能短期上升,但错误订单和人工补单应该下降。

库存主表应保存当前快照,满足快速读取和扣减;流水表应保存每次变化,满足追溯、对账和重建。两者的职责不同,不建议把所有历史变化拼接在主表的一串文本字段里。
CREATE TABLE inventory_snapshot (
id BIGINT PRIMARY KEY,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
sellable_quantity INT NOT NULL,
reserved_quantity INT NOT NULL,
sold_quantity INT NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL,
UNIQUE (sku_id, warehouse_id)
);
CREATE TABLE inventory_event (
id BIGINT PRIMARY KEY,
operation_id VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
order_id VARCHAR(64),
event_type VARCHAR(32) NOT NULL,
quantity INT NOT NULL,
before_sellable INT NOT NULL,
after_sellable INT NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (operation_id)
);主表中的 before 和 after 不需要保存,因为它只表示当前状态;流水表则建议同时保存变化前和变化后数量。这样在发现一条错误流水时,可以直接判断它是否基于正确的前置库存执行,而不必重新猜测当时发生了什么。
库存流水的 operation_id 必须来自业务动作,而不是每次 HTTP 请求临时生成的随机值。网络超时后,客户端可能再次提交同一订单;如果两次请求使用不同随机值,系统就无法识别它们其实是同一个动作。
一个稳妥的下单流程通常包含:校验商品和购买数量、生成业务幂等号、原子预占库存、写入订单明细、写入库存流水、提交事务、异步通知后续系统。优惠券计算、推荐商品、短信通知和数据分析同步不应放在库存事务里。
库存预占和订单创建是否必须在同一个数据库事务中,要根据数据归属判断。如果订单和库存位于同一数据库,通常可以放在同一事务中;如果属于不同服务,就不能假设分布式事务天然可靠,需要使用本地事务加事件记录、可靠消息或补偿状态机。
我更倾向于让库存服务先完成“预占成功”这一事实,再由事件驱动订单后续流程。若订单创建失败,必须发送释放事件;若释放失败,进入可重试的补偿队列。关键是让每一个失败分支都有明确状态,而不是在异常处理中直接把库存数字加回去。
接口返回“系统繁忙”并不代表库存没有变化。请求可能在数据库提交后、响应返回前断开连接。客户端重试时,系统必须根据幂等号查询原结果,而不是再次执行扣减。
| 失败时点 | 可能事实 | 客户端处理 | 服务端处理 |
|---|---|---|---|
| 扣减前参数校验失败 | 库存未变化 | 提示参数错误 | 不写库存流水 |
| 扣减条件不满足 | 库存不足或版本冲突 | 提示库存不足并刷新 | 记录失败原因,不重试无限次 |
| 事务提交后响应超时 | 可能已经预占成功 | 用幂等号查询结果 | 返回原订单或原失败结果 |
| 订单写入失败 | 库存可能已预占 | 不要直接重复下单 | 释放预占并记录补偿状态 |
| 支付回调重复 | 订单可能已经确认 | 返回已处理结果 | 按支付流水号幂等处理 |
实时监控负责尽快发现风险,定时对账负责发现跨系统累计偏差。两者的时间尺度不同,不应互相替代。实时监控可以关注负库存、扣减失败率、锁等待、重复操作号和支付成功未预占;对账则关注订单数量、库存流水数量、仓库出库数量之间的长期差异。
对于库存规模较大的系统,我建议按 SKU、仓库、渠道和业务日期建立对账分区。不要每天扫描全库再生成一张巨大的差异表,否则对账任务本身会影响线上数据库。先按更新时间或事件时间增量计算,再对异常 SKU 做全量重算。

如果商品数量不大、并发稳定、订单和库存都在同一个数据库里,优先使用条件更新加数据库事务。此时不需要一开始就引入复杂的分布式锁和消息架构,最重要的是把库存条件、受影响行数、幂等号和库存流水做完整。
建议落地顺序如下:
这种方案的优点是容易理解、容易调试和容易回滚。缺点是热点 SKU 达到高并发后会出现行锁等待,需要通过压测确认单行库存的承载能力,而不能凭经验估算。
如果系统已经使用缓存、消息队列和独立订单服务,建议把缓存定位为流量层,把数据库定位为权威事实层。缓存预扣可以降低数据库压力,但必须保留数据库最终校验,并且每一次预扣都要有可追踪的令牌或操作记录。
在这个阶段,最值得投入的是幂等和补偿机制。需要明确消息事件的唯一键、消费记录的保存位置、失败重试次数、死信处理方式和人工介入条件。没有这些细节,异步化只会让错误更晚暴露、更难定位。
如果库存预扣成功后订单服务不可用,可以让库存保持“待确认”状态,而不是立刻回到可售。待确认记录应该有过期时间,超时任务负责释放,但释放动作必须先检查订单是否已经支付或确认,避免把已支付订单的库存错误释放。
高并发场景的第一原则是减少进入数据库热点行的请求数量。可以在入口使用活动令牌、排队、用户资格过滤和限流,让没有购买资格的请求尽早失败。数据库只处理已经通过前置筛选的有效请求。
库存分桶是一种常见思路:把一个总库存拆成多个逻辑桶,每个桶分别扣减,降低单行竞争;但它会增加汇总和分配复杂度。用户取消订单时,要释放原桶还是释放到统一池,也需要清晰定义,否则总库存看似正确,桶库存会逐渐失衡。
另一个取舍是预先发放库存令牌。令牌发放成功代表获得了购买资格,但不一定代表订单已经创建成功。因此,令牌必须与用户、活动、SKU 和过期时间绑定,消费后不可重复使用,过期后还要有回收机制。

多仓库系统要先确定承诺规则:是下单时锁定仓库,还是支付后再分配仓库。如果下单时锁定,库存预占必须带仓库维度;如果支付后分配,就不能在前端展示某个具体仓库的确定库存。
调拨中的库存不能同时计入两个仓库的可售数量。调拨单创建、原仓出库、新仓入库应该是三个不同事件。只有新仓完成入库确认,才能把在途数量转为新仓可售数量;否则订单可能被承诺给一个实际上尚未到货的仓库。
| 方案 | 优势 | 短板 | 适用场景 |
|---|---|---|---|
| 条件更新 | 实现简单,事务短,失败结果清晰 | 热点行竞争时吞吐受限 | 普通电商、后台出入库、并发可控 |
| 悲观锁 | 强制串行化,容易理解 | 锁等待、死锁和长事务风险较高 | 库存行少、事务非常短、冲突明显 |
| 乐观锁 | 不长期占用锁,适合冲突较少场景 | 冲突时需要重试,重试过多会放大流量 | 版本化库存、编辑型库存、低冲突写入 |
我的判断是:如果只需要防止库存扣到负数,优先使用条件更新;如果还要处理复杂的库存状态转换,再考虑短事务内的悲观锁;如果写冲突较少、业务操作可重试,可以使用乐观锁。不要因为“悲观锁更安全”就把所有业务调用都放进锁内。
同步扣减的好处是用户能够立即知道库存是否成功,数据路径短,问题容易定位。它的缺点是峰值期间数据库直接承受请求压力。异步扣减可以吸收流量,但用户看到的是“排队中”或“处理中”,需要处理超时、取消、重复消息和最终失败。
如果用户必须在支付前知道是否有库存,同步预占更适合;如果业务允许排队抢购,可以使用令牌和异步订单。选择异步不是为了追求架构先进,而是因为业务愿意接受结果延迟以及相应的用户体验。
精确库存适合库存数量可靠、仓库实时同步能力强的商品。安全库存适合供应链波动大、盘点误差明显或需要保障线下订单的场景。安全库存并不能修复超卖,它只是人为减少可售数量,给履约环节预留缓冲。
如果账面库存 100 件,安全库存设置为 10 件,那么可售库存最多是 90 件。这个规则必须在数据库扣减条件中体现,而不能只在商品页面上显示“剩余 90 件”。否则接口绕过页面后仍然可能卖出全部 100 件。

库存防超卖测试应该从库存为 1、购买数量为 1 的极端场景开始。这个场景最容易暴露并发问题:多个请求同时到达时,只允许一个请求成功,其他请求必须得到明确的库存不足、版本冲突或排队结果。
建议至少覆盖以下测试:
压测结束后,最重要的不是“接口返回成功了多少次”,而是核对最终数据是否满足业务不变量。对于库存为 100 的测试批次,应当能够证明:有效预占、已售、可售、释放和锁定数量之间的关系成立;失败请求没有产生有效扣减;重复请求没有产生重复流水。
我建议把验收结果分成四个层次:

事务隔离级别会影响并发读取结果,但它不能替代正确的更新条件。即使使用较高隔离级别,如果业务仍然先读后写、没有检查受影响行数,仍可能出现错误。开发者要确认库存扣减走的是主库,不能因为读写分离而从延迟副本获得旧库存。
测试时还要观察锁等待和死锁日志。死锁不一定表示系统设计失败,关键在于死锁是否可重试、发生频率是否可接受、事务是否按统一顺序访问资源。如果一个请求同时扣减多个 SKU,最好按照 SKU 的固定顺序加锁,降低循环等待概率。
如果你正在维护一个存在库存扣减风险的项目,不需要立刻重构所有服务。可以先完成四项低成本改造:检查扣减 SQL 是否带库存条件;检查代码是否判断 affected rows;为库存操作增加唯一业务号;建立库存流水表并记录变化前后数量。
这四项改造通常比引入复杂中间件更快产生收益,因为它们直接解决了“库存不足仍然扣减”“重复请求重复扣减”和“发生异常无法追溯”三个高频问题。
接下来应当补齐状态机和异常测试。把预占、支付、释放、取消、退货和人工调整分别写成状态转换表,再为每条转换定义前置条件和幂等规则。没有状态机的库存流程,往往靠多个 if 判断拼接,新增需求后很容易出现非法状态。
同时建立一个最小监控看板,至少包含:
当监控证明热点 SKU 已经成为瓶颈,再考虑缓存预扣、令牌分配、库存分桶和异步订单。升级前先用压测数据说明瓶颈在哪里:是数据库单行锁、订单写入、消息消费还是下游服务。如果没有瓶颈定位,架构升级很容易把一个简单问题变成多个难以对账的问题。
如果团队缺少数据分析能力,可以将库存流水、订单、支付和仓库数据同步到九数云中,搭建按 SKU、仓库、渠道和时间段切分的异常看板。这里的重点不是把交易写入分析工具,而是让业务和技术团队拥有同一套可核对的指标口径,并能够快速发现差异集中在哪个环节。

代码评审中经常出现这样的结论:“这里已经判断过库存了,应该不会超卖。”但并发系统不能依赖“应该”。只要关键条件没有进入原子写操作,只要重复请求没有幂等键,只要库存变化没有流水,系统就无法证明自己是正确的。
数据库的价值不是替开发者猜业务,而是把业务不变量落实为可以执行的约束。库存大于等于购买数量、操作号只能出现一次、已支付订单不能被释放,这些规则必须尽量靠数据库和事务表达,而不是只写在注释里。
低并发系统最好的方案往往不是最复杂的方案,而是条件更新、短事务、唯一约束、库存流水和对账。高并发系统也不是锁越多越安全,而是应该减少无效请求进入热点、缩短事务、让失败可重试、让异步流程可对账。
我的判断顺序始终是:先定义库存事实,再写不变量;先做原子校验,再做并发优化;先保留流水证据,再讨论缓存和消息。顺序反过来,系统可能跑得很快,却无法解释一个订单为什么成功。
你可以先选一个真实 SKU,统计过去一周的初始库存、入库、预占、支付、释放、出库和人工调整数据,尝试用流水重算当前可售库存。如果算不出来,优先补数据模型;如果算得出来但与主表不一致,优先补对账;如果数据一致但高峰超时,再根据热点集中度和锁等待决定是否引入排队、分桶或令牌。
一套防超卖方案的完成标准,不是测试环境里连续下单 100 次都成功,而是在线上出现超时、重试、重复回调、消息延迟和人工调整之后,系统仍然能够回答:哪一个操作改变了库存,为什么改变,是否只改变了一次,以及当前库存是否仍然可以被流水证明。
我在做订单功能时,曾经以为页面先判断库存大于 0,再提交订单就足够安全。后来我把库存设为 1,同时发起两个几乎同时到达的请求,才发现两个请求都可能看到库存为 1;我想知道问题到底出在前端、接口,还是数据库。
不能。前端判断只能改善用户体验,不能作为最终的库存安全校验。原因是前端看到的库存只是某个时间点的快照。假设初始库存为 1,请求 A 和请求 B 几乎同时读取到库存 1,页面就会同时允许两个人点击购买。即使前端代码完全正确,两个请求在到达服务端时仍然可能争抢同一件商品。
更可靠的做法,是让数据库在扣减动作中同时完成条件校验:
UPDATE product_stock SET stock = stock - :quantity WHERE sku_id = :sku_id AND stock >= :quantity;执行后必须检查影响行数。影响 1 行,通常表示扣减成功;影响 0 行,表示库存不足、SKU 不存在,或者条件已经不满足。不要仅根据接口是否返回 200 来判断扣减成功,因为请求成功到达数据库,不代表库存更新成功。
校验位置作用能否单独防超卖 前端提前提示库存不足,减少无效提交不能 服务端校验参数、商品状态和订单状态不能单独保证并发安全 数据库在更新时验证库存条件是关键控制点,但仍需配合幂等和事务 我的判断是:前端可以告诉用户“当前看起来还有库存”,但只有服务端和数据库共同确认“这一次扣减确实成功”,订单才应该进入后续流程。
我看到很多示例都使用“库存大于购买数量时直接扣减”的 SQL,于是有些疑惑:既然这条语句已经是原子更新,是不是就不需要事务了?如果扣库存成功了,但订单写入失败,系统应该怎样处理这部分库存?
条件更新可以解决“库存扣减本身的并发竞争”,但不能自动保证订单、库存和库存流水三者一致,因此多数订单场景仍然需要事务。例如一次下单通常至少包含三个动作:扣减库存、创建订单、写入库存流水。
如果库存扣减成功后,订单插入因为数据库异常或参数错误而失败,系统就会出现“库存少了一件,但找不到对应订单”的数据孤儿。条件更新并不能回滚后续失败的操作。比较稳妥的基本流程是: BEGIN;
UPDATE product_stock SET stock = stock - :quantity WHERE sku_id = :sku_id AND stock >= :quantity;检查影响行数是否为 1;插入订单;插入库存扣减流水;COMMIT;其中任何一步失败,都应回滚事务。需要注意,事务不是“防超卖万能开关”。它负责保证一组数据库操作要么一起成功、要么一起失败;真正防止库存被扣成负数或被重复扣减,还依赖条件更新、唯一约束和幂等设计。
机制主要解决的问题没有解决的问题 条件更新库存不足时不允许扣减,并减少并发竞争窗口订单重复提交、库存释放、跨服务一致性 事务订单、扣减、流水的一致提交不能代替库存条件判断 唯一约束阻止同一订单流水重复写入不能处理所有业务补偿 如果扣库存和订单创建发生在同一个数据库中,我通常会优先考虑短事务;
如果它们分属不同服务,则不能简单把本地事务当成全局事务,需要额外设计消息确认、补偿或库存回补流程。
我不想只凭“加了一条库存判断”就认为系统安全,希望用一组小数据复现问题并对比改造前后的结果。应该怎样设置初始库存、并发请求和验收指标,才能判断到底有没有超卖?
建议先用小规模、可重复的测试,而不是一开始就上大并发压测。小数据更容易核对每一笔订单、每一条库存流水,也更适合开发新手定位问题。可以先设置初始库存为 10,并发请求为 20,每个请求购买 1 件。理想结果是最多 10 个请求扣减成功,另外 10 个请求失败;
最终库存为 0,成功扣减总量不能超过 10。
测试指标错误方案可能出现的结果安全方案的验收标准 成功订单数大于 10不超过初始可售库存 最终库存出现负数或与流水不符大于等于 0 扣减流水数同一订单出现多条重复流水每个订单最多成功扣减一次 库存流水合计与成功订单数量不一致等于实际成功扣减数量 至少要测试四组场景。
第一组是单线程购买,确认基础逻辑正确;第二组是 20 个请求同时购买,观察是否出现成功数超过 10;第三组是同一个订单号重复提交,验证幂等;第四组是在扣库存后故意让订单写入失败,确认事务是否回滚。
可以用下面的条件作为基础判断: 成功扣减总量 = 0 每个订单最多扣减一次 库存流水总量 = 实际成功扣减总量我特别建议把“成功订单数”和“库存流水合计”同时记录。
只看最终库存并不够,因为某些错误代码可能一边产生重复订单,一边通过覆盖写入把库存显示成一个看似正常的数字,只有对账流水才能发现真实扣减次数。
我准备给库存字段增加非空和非负约束,觉得这样至少能避免库存变成负数。但我不确定:如果最终库存仍然是 0,订单数量却超过了实际库存,数据库约束能不能识别这种情况?还需要补哪些校验?
不能把“库存不为负数”等同于“没有超卖”。数据库约束只能阻止某些非法字段值,无法独立判断订单承诺数量是否超过可售库存。例如初始库存为 1,两个请求都读取到 1,随后业务层各自创建订单。如果库存字段采用了不安全的覆盖写入,最终库存可能仍显示为 0,但系统已经产生了 2 个成功订单。
此时没有负数,单靠 CHECK (stock >= 0) 也不一定能发现超卖。非负约束更适合作为最后一道数据质量防线: stock INTEGER NOT NULL CHECK (stock >= 0)真正的防超卖链路至少应包含四层校验。第一层校验购买数量必须是正整数,避免传入 0 或负数;
第二层校验 SKU 存在且处于可售状态;第三层使用带库存条件的原子扣减;第四层为订单号或扣减流水设置唯一约束,防止重复请求重复扣库存。
异常类型适合使用的机制 购买数量为 0 或负数接口参数校验 库存不足条件更新并检查影响行数 库存字段出现负数数据库 CHECK 约束 同一订单重复扣减订单号或扣减流水唯一约束 订单与库存流水不一致事务、对账和补偿任务 还要区分“库存字段正确”和“业务承诺正确”。
库存字段是当前数据状态,订单承诺则涉及已支付、已预占、已取消和待释放库存。退款、支付超时、订单取消等流程没有设计好时,即使数据库约束全部生效,库存仍可能因为没有及时回补而出现业务层面的不准确。因此,非负约束值得加,但它应被看作安全网,而不是完整方案。
判断系统是否可靠,最终要看并发测试、重复请求测试,以及订单、库存、流水三张数据是否能够持续对账。


读者评论
文章把超卖从“库存减成负数”扩展到订单、支付和流水的一致性,这个角度比较实用。尤其是用 affected rows 判断扣减是否真正成功,比单纯检查 SQL 有没有报错可靠得多。
对新手来说,库存状态机和三层校验讲得很清楚。不过文中的情景数据属于模拟值,实际项目还需要结合数据库类型、事务隔离级别和压测结果验证,不能直接当作线上指标。
比较认同“库存流水是故障恢复基础数据”的观点。很多系统只保存库存总数,出问题后只能靠日志猜原因。建议再补充退货、取消订单和超时释放的幂等处理示例,会更方便落地。