《数据库存:技术负责人风险清单:超卖排查最需警惕的设计难扩展》真正要排查的,不是“库存表有没有加锁”,而是库存约束能否穿过查询、扣减、下单、支付、取消、回补和消息重试这条完整链路。库存只剩 1 件却生成 2 个有效订单,往往不是某一条 SQL 单独犯错,而是多个环节分别认为自己拥有最终解释权。
我处理库存类问题时,通常先把“库存数字”和“库存事实”分开看。库存字段只是当前快照,扣减流水、预占记录、订单状态、支付结果和回补事件,才共同构成可审计的事实链。如果系统只能回答“现在库存是多少”,却回答不了“这 1 件库存被哪个事件、在什么时间、由哪次请求消耗”,那么它在高并发和故障场景下就还不具备真正的可控性。
库存系统最重要的约束可以抽象为:有效售出量、有效预占量和库存回补量,不能突破可售库存边界。这里的“有效”很关键,因为待支付订单、已取消订单、支付失败订单和已释放预占,都不能被简单地按订单总数计算。
一个更接近实际业务的核算公式是:
可售库存 = 实物库存 – 已售库存 – 有效预占库存 – 风险冻结库存 + 已确认回补库存
不同业务的字段命名可能不同,但技术负责人必须要求团队明确这几个量的定义。很多超卖事故并不是库存扣减本身失败,而是系统把“创建订单”错误地当成“消耗库存”,又把超时订单的释放延迟或重复执行,最终让多个状态互相覆盖。
数据库可能完全按照 SQL 执行,没有丢数据,也没有违反字段约束,但业务仍然可能超卖。例如,库存服务成功扣减 1 件,订单服务因为网络重试又创建了一个重复订单;或者数据库扣减成功后,应用误判超时并重试扣减。数据库看到的是两次合法写入,业务看到的却是一次购买动作被执行了两次。
因此,我会把问题拆成三个层次:
库存系统的风险通常不会在开发环境中主动暴露。库存量较大、请求较少、订单链路较短时,先查后改、数据库锁、缓存扣减甚至定时对账都可能表现良好。真正的问题出现在热点 SKU、网络抖动、支付超时、消息重复和数据库锁等待同时发生时。
能在低流量下运行,不代表可以在高峰流量下扩展;能避免一次超卖,也不代表具备故障恢复能力。技术负责人需要评估的是失效边界,而不是只看一次压测是否通过。

假设商品可售库存为 1,请求 A 和请求 B 在相近时间进入库存服务。两者先执行查询,都读到 available=1;随后两者分别在应用层判断“库存大于 0”,然后创建订单或执行扣减。如果判断和扣减之间没有由数据库条件约束保护,就出现了经典竞态窗口。
| 时间 | 请求 A | 请求 B | 库存快照 |
|---|---|---|---|
| T1 | 读取库存,得到 1 | 等待执行 | 1 |
| T2 | 判断库存足够 | 读取库存,得到 1 | 1 |
| T3 | 创建订单 A | 判断库存足够 | 1 |
| T4 | 扣减库存 | 创建订单 B | 0 或被覆盖 |
| T5 | 返回成功 | 扣减或重试 | 订单数可能为 2 |
这里最容易误判的地方是:开发人员看到库存最终变成 0,就认为扣减成功;运营人员看到订单数量为 2,才发现库存不够。库存快照已经无法解释中间过程,必须回到请求日志、订单日志和扣减流水逐笔对齐。
如果库存字段是通过覆盖写入实现的,两个并发请求可能产生丢失更新。库存从 1 被两个请求分别写成 0,最终数据库显示 0,但实际上系统已经放行了两笔订单。这个案例说明,最终库存值不能单独证明库存约束成立。
反过来,库存显示为负数也不一定是唯一的超卖证据。某些系统允许库存短暂透支,后续由采购或人工补货;另一些系统把冻结库存和可售库存分开,负数可能只出现在内部调拨账户。排查时必须先确认字段语义,再判断是否违反业务规则。
我通常不会一上来就查看某条慢 SQL,而是先拉出四类记录:库存快照、库存变更流水、订单状态记录和支付结果。它们分别回答当前状态、变化过程、业务意图和最终交易结果。
如果四本账无法通过唯一业务号关联起来,后续所有“估计是缓存问题”“可能是锁失效”的结论都缺少证据。数据库库存系统最先要建设的往往不是复杂中间件,而是可追溯的库存变更模型。

事务只能保证它覆盖范围内的操作具备原子性或隔离性,不能自动覆盖跨服务调用、缓存写入和异步消息。库存扣减在事务 A 中完成,订单创建在服务 B 中完成,支付回调又由服务 C 处理,这三件事并不会因为都使用数据库就天然属于同一个事务。
评审时我会追问三个问题:事务从哪一行代码开始?在哪个提交点结束?如果提交后消息发送失败,系统用什么记录和补偿?如果团队只能回答“有消息队列会重试”,却没有幂等键、重试上限和死信处理,说明事务边界并没有被真正设计清楚。
普通事务中的一致性读,未必会阻止两个事务同时读取同一个库存值。两个事务都看到库存为 1,再分别执行后续逻辑,仍然可能放行两个订单。是否安全取决于数据库类型、隔离级别、查询锁模式、更新条件和事务内具体顺序,不能只看代码外面套了一个注解。
比起先查询再判断,更可靠的基础写法是让数据库直接参与裁决:
UPDATE sku_stock SET available_quantity = available_quantity - 1, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ? AND available_quantity > 0 AND status = 'ON_SALE';
执行后必须检查 affected rows。影响行数为 1,才表示本次库存裁决成功;影响行数为 0,表示库存不足、商品下架或版本条件不满足,应用不能继续创建可支付订单。
悲观锁适合需要强约束、并发量可控且事务链路较短的场景,但它会把热点 SKU 的请求串行化。请求量增长后,问题可能从“会不会超卖”变成“数据库是否被锁等待拖垮”。如果事务中还包含远程调用、复杂计算或订单写入,锁持有时间会被网络和下游服务放大。
我见过一种典型设计:事务先锁住库存行,再调用支付预授权接口,支付接口偶发耗时 2 秒。库存只有一行,所有请求都排队等待,最终连接池耗尽。这个系统或许没有超卖,但已经无法正常服务,属于用可用性换取局部一致性,而且代价没有被团队显式承认。
乐观锁通过版本号或条件更新检测冲突,避免长时间持锁。它在冲突率较低的普通交易中很有效,但热点 SKU 会让大量请求同时失败并重试。重试请求再次争抢同一行,可能形成“冲突,重试,再次冲突”的放大回路。
一个实用判断标准是观察更新冲突率和重试后的成功率。如果第一次更新失败率已经较高,而第二次、第三次重试几乎没有带来有效成功,就不应继续增加重试次数,而应该限流、排队或改变库存分配模型。
缓存适合处理热点流量,但缓存中的数字如果无法回溯到一条可重放的库存流水,就很难作为最终账本。缓存扣减成功而数据库写入失败时,系统需要知道这一次扣减是否已经产生订单、是否需要释放,以及缓存重建时该采用哪个版本。
尤其要警惕定时同步方案。定时任务可以修正差异,却无法保证差异在窗口期不影响下单。它更适合作为对账和修复机制,而不是实时一致性的主要保证。
消息队列可以削峰和解耦,但它带来新的状态问题:消息可能重复、延迟、乱序,消费服务可能执行成功后响应丢失,也可能消费成功但本地事务没有完成。只要业务动作不是幂等的,队列重试就可能让库存被重复扣减或重复回补。
因此,消息消费必须具备业务幂等键、消费记录、状态机校验和异常转人工机制。削峰解决的是请求到达速度,幂等和对账解决的才是业务结果可靠性。

一个系统可以有多个库存视图,但不能有多个互相独立的最终事实来源。数据库、缓存、搜索索引、报表平台都可以保存库存的派生数据,但必须明确哪个系统拥有最终写入权,其他系统如何订阅、重建和校验。
如果产品经理在页面看到的是缓存库存,订单服务使用的是数据库库存,运营报表又来自数据仓库,三者出现差异时,技术负责人必须能够解释差异产生的时间范围和业务影响。无法解释的多份库存,就是隐性风险。
最基础的安全模型是:库存扣减动作只能由一个明确入口执行;扣减必须带有业务条件;成功与否必须能从影响行数或返回结果判断;后续订单状态只能建立在扣减成功之上。
可以用下面的检查逻辑评审代码:
多数设计文档只描述“库存足够时如何下单”,却没有详细描述数据库超时、锁等待、服务重启、消息重复和支付回调延迟。事实上,库存事故大多发生在失败路径上。
技术评审可以要求团队逐一回答以下场景:
| 异常场景 | 必须确认的结果 | 危险信号 |
|---|---|---|
| 数据库扣减成功,订单响应超时 | 客户端重试不会重复扣减 | 仅靠前端按钮防重复 |
| 订单创建成功,库存服务响应丢失 | 订单状态可查询并补齐库存结果 | 依赖再次下单触发处理 |
| 支付超时 | 预占库存按期限释放 | 定时任务无幂等控制 |
| 回补消息重复 | 库存最多释放一次 | 只按订单状态直接加库存 |
| 缓存重建 | 从权威流水重建最新库存 | 直接读取旧报表覆盖缓存 |
扩展性不是“数据库还能承受多少 QPS”这么简单。库存系统需要同时评估热点集中度、单 SKU 写入竞争、失败重试比例、锁等待时间、消息积压和对账成本。
例如,全站每秒 10 万请求并不必然危险;如果请求分散在 10 万个 SKU 上,单个库存行压力可能很低。相反,全站每秒只有 5000 个请求,但 90% 集中在同一个 SKU 上,单行库存更新就可能成为瓶颈。库存系统真正的流量单位不是总 QPS,而是热点 SKU 的有效竞争度。

下面使用一个脱敏的限量商品场景说明排查方法。该场景不是某个公开事故的复述,数据为根据常见压测链路构造的样本推演,目的是展示证据如何逐层收敛。
商品初始可售库存为 100 件,活动开始后 30 秒内收到 2400 次购买请求。页面显示库存快速下降,但少量用户反馈“已下单后订单被取消”,运营对账发现支付成功订单数量与库存流水数量存在差异。第一反应是缓存延迟,但缓存和数据库的最终库存只相差 1 件,无法解释全部异常。
继续按请求 ID 查询后,发现部分请求经历了网关超时。客户端在 2 秒后重试,第一次请求其实已经完成库存预占,但响应没有返回。第二次请求使用了新的请求流水号,原有接口没有基于业务幂等键识别它们属于同一次购买动作。
| 观察项目 | 样本结果 | 初步判断 |
|---|---|---|
| 购买请求总数 | 2400 次 | 包含客户端超时后的重试请求 |
| 唯一业务幂等键 | 2268 个 | 约 132 次请求存在重复意图 |
| 库存预占成功次数 | 112 次 | 超过初始库存边界的风险不高,但需对齐订单 |
| 订单创建次数 | 118 次 | 有 6 次订单创建没有对应预占结果 |
| 支付成功订单 | 104 笔 | 存在待支付订单和取消回补链路 |
| 回补操作 | 8 次,其中 2 次失败 | 最终差异来自回补闭环缺失 |
这组样本说明,不能看到缓存和数据库相差 1 件,就把问题归咎于缓存。真正需要优先修复的是:请求幂等键没有贯穿网关、订单和库存服务;订单创建允许在库存结果未知时继续推进;回补失败没有形成可追踪的待处理状态。
某些代码会把“库存服务接口返回成功”当成库存扣减成功,但接口成功可能只代表请求已进入队列,或者代表数据库事务已经提交。不同语义没有被明确区分,就会导致订单服务在错误的时间点放行。
// 风险示例:接口返回 accepted 就继续创建支付订单
StockResult result = stockClient.reserve(skuId, quantity);
if (result.isAccepted()) {
orderService.createPayableOrder(userId, skuId, quantity);
}更稳妥的做法是区分“已接收”“已预占”“已确认”三个状态,并规定只有“已预占”才能创建具有库存承诺的订单。若采用异步模式,则订单应先进入待确认状态,不能直接生成一个对用户承诺库存的支付订单。
在这类问题中,修复效果不能只看超卖次数。更有价值的是同时观察条件更新失败率、重复请求拦截率、回补失败率、待处理库存事件数和人工对账耗时。一个方案可能把超卖压到零,却让所有请求都在数据库排队,或者让回补积压到第二天,仍然不算健康。
以下是该样本的情景对比,数值为模拟验证基准:

如果团队使用九数云这类数据分析工具,可以把库存快照、扣减流水、订单状态、支付结果和回补事件汇总成运营分析看板,用于观察 SKU 级差异、回补积压、支付转化和异常趋势。它适合帮助技术和业务团队发现“哪一类商品、哪一个时间窗口、哪一种状态组合最容易出问题”。
但必须明确,分析平台不是库存扣减的实时裁决器,也不应成为订单能否成立的最终依据。库存扣减仍应由交易数据库或库存服务完成,分析平台承担的是跨表关联、趋势观察、异常分层和复盘支持。
实践中,我建议至少制作四个分析视图:SKU 库存变更瀑布、订单与库存流水差异、预占超时分布、消息重试和回补失败趋势。这样做的价值不是让图表更漂亮,而是把原本分散在数据库日志、订单表和消息平台中的证据放到同一业务上下文中。

最危险的库存更新通常长这样:应用先读取数量,计算新值,再把新值写回数据库。两个请求都读到 10,分别计算成 9 并写入,最终库存可能只减少 1,而两个业务动作都已经被放行。
SELECT available_quantity FROM sku_stock WHERE sku_id = ?; -- 应用层计算 available_quantity - 1 UPDATE sku_stock SET available_quantity = ? WHERE sku_id = ?;
这个写法的问题不只在于没有锁,还在于数据库不知道“这次写入是基于哪个旧值计算出来的”。如果确实需要基于读取结果更新,至少应加入版本条件;更直接的方式是使用数据库原子表达式,让减法发生在数据库内部。
条件更新的安全性,只有在应用正确处理结果时才成立。下面这条 SQL 在数据库层面可以避免库存减到负数,但如果应用忽略 affected rows=0,仍可能继续创建订单。
UPDATE sku_stock SET available_quantity = available_quantity - ? WHERE sku_id = ? AND available_quantity >= ?;
技术负责人应在代码审查中确认:影响行数为 0 时返回的是库存不足、商品下架、版本冲突还是系统异常;这些结果是否被错误地统一为“稍后重试”;重试是否会再次创建订单;接口返回码是否能让上游停止推进。
库存扣减语句即便逻辑正确,如果没有匹配索引,也可能扫描大量记录、扩大锁影响范围或增加执行时间。通常需要确认 SKU 唯一性、条件字段选择性和执行计划,不能因为 SQL 很短就认为它一定高效。
建议至少检查:
库存快照适合快速读取当前值,库存流水适合审计和重建。只保留快照会让事故难以复盘,只保留流水又可能让实时查询成本过高。较稳妥的设计是快照与流水并存,并通过唯一业务事件号保证每次变化只落账一次。
库存流水至少应记录变更类型、变更数量、变更前值、变更后值、关联订单、幂等键、操作者、来源服务和处理状态。人工修正也不能直接改快照后不留痕,而应形成独立的调整流水。

很多系统把订单创建、库存扣减和支付确认混在一个“成功”结果里,导致任何一个环节超时都需要猜测前面到底执行到了哪里。更清晰的做法是把库存占用设计成独立状态,例如预占中、预占成功、已确认、已释放和释放失败。
订单也应有对应状态,而不是只使用待支付、已支付、已取消三个粗粒度状态。待支付订单可能已经成功占用库存,也可能只是创建了订单但尚未完成库存裁决。两者的超时处理完全不同。
“取消订单后库存加回 1”看起来简单,却没有说明这次取消是否已经回补过。定时任务重跑、消息重复消费或人工补偿,都可能重复增加库存。
回补动作需要绑定原始预占流水,并设置唯一约束或幂等状态。例如,只有预占状态为 RESERVED 的流水才能转为 RELEASED;已经释放过的流水再次收到消息时,只返回已处理,不再增加库存。
UPDATE stock_reservation SET status = 'RELEASED', released_at = CURRENT_TIMESTAMP WHERE reservation_id = ? AND status = 'RESERVED';
只有上述更新影响行数为 1 时,后续库存回补才应该执行。若影响行数为 0,需要查询实际状态,区分“已经释放”和“记录不存在”,而不是盲目执行加库存。
支付成功通常意味着交易结果确认,但不应让支付回调重复执行库存扣减。库存可能在下单时预占,也可能在支付成功时正式消耗,具体取决于业务模型。无论选择哪种方式,都必须使用订单状态和库存流水共同判断当前动作是否已经完成。
支付回调可能重复到达、延迟到达或乱序到达。若系统只依据“收到支付成功”就执行一次扣减,重复回调会造成重复扣减;若系统只依据订单状态,又可能在状态更新失败时漏掉库存确认。正确做法是让状态转换和库存事件都具备幂等控制。
库存数量有限、商品价值高、交易链路短且数据库吞吐可接受时,本地事务和条件扣减往往是更值得优先选择的方案。它的优点是约束集中,排查成本低,出现异常时可以直接查询同库数据。
如果库存、订单和支付必须跨多个服务,强一致事务的实施成本会显著上升。此时可以采用最终一致性,但必须接受短暂状态不确定,并投入足够的补偿、对账和人工介入能力。最终一致性不是降低设计要求,而是把一致性成本从事务阶段转移到了状态管理阶段。

库存压力具有明显的长尾特征。少量爆款 SKU 可能占据绝大部分扣减请求,其他商品几乎没有写竞争。此时增加普通读副本帮助有限,因为库存裁决本质上是写操作,而且写竞争集中在同一行。
建议按 SKU 统计以下指标:请求占比、扣减成功率、条件更新冲突率、锁等待时长、数据库事务耗时和重试次数。只有把总流量和单 SKU 流量同时看,才能判断是容量问题、热点问题还是业务重试问题。
| 维度 | 单行条件扣减 | 技术判断 |
|---|---|---|
| 一致性 | 强,约束集中在数据库 | 适合常规交易和中等并发 |
| 实现复杂度 | 低 | 便于审计和故障复盘 |
| 热点吞吐 | 受单行写竞争限制 | 需要关注锁等待和连接池 |
| 扩容方式 | 难以仅靠读扩展解决 | 必要时引入预分配或分段 |
| 数据修复 | 相对直接 | 前提是流水完整 |
单行扣减不是落后的设计。很多团队的问题不是它不能用,而是没有设置使用边界,在热点流量已经超过数据库可承受范围后仍然继续堆加重试和连接数。
可以把总库存拆成多个库存段,例如将 1000 件库存分配给 10 个逻辑段,用户请求先竞争某个库存段,再由段内原子扣减完成。这样可以降低单行热点,但会出现库存段分配不均、某个段提前耗尽、段回收和全局核算复杂等问题。
分段库存尤其需要防止“段内安全、全局超卖”。如果多个服务都能自行申请库存段,却没有中央分配规则,那么各服务可能重复获得同一批库存的使用权。每一段库存都必须有唯一归属、有效期、分配状态和回收记录。
当热点活动的请求远超可售库存时,继续让所有请求进入库存数据库没有意义。库存只有 100 件,却允许每秒数万请求反复争抢,系统必然把压力转化为锁等待、连接排队和大量失败重试。
更合理的方式是,在网关、活动服务或队列入口进行限流和削峰,让数据库只处理有机会成功的请求。限流会牺牲部分即时响应,却能保护库存裁决链路。对限量商品而言,拒绝无效竞争请求,往往比让数据库替所有请求排队更符合业务目标。

设计缓存库存时,我最关注的不是单次扣减耗时,而是缓存丢失、主从切换、网络分区和重建过程。一个缓存键如果只能保存剩余数量,不能关联版本、流水位置或冻结状态,重建时就可能从过期快照恢复,造成库存回放错误。
至少应明确以下规则:
消息系统通常至少一次投递时,消费端必须假设同一事件会到达多次。库存预占成功、订单创建成功和回补事件不能只依赖消息唯一性,因为生产端可能重发,消费端可能在处理完成后没有及时提交消费确认。
建议每个库存事件包含事件 ID、业务订单号、库存流水号、事件类型、版本号、产生时间和过期时间。消费端先写入消费幂等表或更新状态,再执行后续动作,并确保状态转换具有条件约束。
订单表分库分表可以解决容量和查询压力,但库存约束不能简单跟着订单分片走。一个 SKU 的库存记录如果被多个分片持有,就会产生跨库扣减;如果库存集中在单库,订单分片又无法通过本地事务保证订单和库存同时提交。
分片前要明确库存的归属模型。常见选择包括:SKU 维度集中库存、仓库维度库存、区域库存或预分配库存。每种模型都对应不同的售卖边界和回补规则,不能只因为“订单量大”就直接把库存表拆开。

第一优先级不是马上改 SQL,而是冻结证据。保留异常时间窗口内的库存快照、流水、订单状态、支付记录、消息记录和应用日志,避免后续定时任务或人工修复覆盖原始状态。
不要直接执行“把库存加回去”这类没有依据的修复。错误修复可能把缺货商品重新放售,也可能造成二次超卖。所有数据修正都应关联原始异常、修正原因、操作者和修正后的复核结果。
上线前至少做三类压测:低库存并发扣减、客户端超时重试、支付超时后批量回补。很多团队只压“库存充足时的成功下单”,没有压库存为 1、库存为 0 和回补重复的边界条件。
建议建立最小验证矩阵:
| 测试场景 | 预期结果 | 必须观测的信号 |
|---|---|---|
| 库存1件,100个并发请求 | 最多1个扣减成功 | 成功影响行数、订单数、库存流水数 |
| 同一幂等键重复请求10次 | 只产生一个业务结果 | 幂等拦截数、重复订单数 |
| 扣减后模拟网络超时 | 重试不重复扣减 | 请求链路和库存流水关联 |
| 回补消息重复投递 | 只释放一次 | 回补状态和受影响行数 |
| 缓存重建 | 结果与权威账本一致 | 重建耗时和差异数量 |
不要为了追求架构先进而过早引入缓存扣减、分段库存和复杂分布式事务。单库条件更新、短事务、库存流水和幂等控制,往往已经足够可靠。此时最值得投入的是索引、执行计划、异常日志和对账能力。
如果商品分散、热点不明显,数据库写入竞争有限,增加中间件只会扩大故障面。技术负责人应优先选择能让团队快速定位问题、快速恢复数据的方案。
极端热点场景不能只靠数据库行锁硬扛。应在请求入口增加限流和资格筛选,在缓存或队列层吸收峰值,再由权威库存服务完成最终裁决。对于库存极少的商品,提前生成库存令牌或预分配库存段,也可能比让所有用户直接访问数据库更有效。
但这类方案必须同步建设异常处理能力。缓存扣减成功后服务宕机怎么办?队列积压导致支付窗口过期怎么办?库存段分配后未使用如何回收?如果这些问题没有答案,就不宜为了短期吞吐贸然切换复杂模型。
拆分前先画出库存约束图,而不是先画服务边界。把每个库存字段、订单状态、消息事件和回补动作标出来,明确它们的写入者、读取者和最终一致性要求。
如果拆分后无法保留本地事务,就必须补充事务外的证据机制:可靠事件表、幂等消费表、补偿任务、对账报表和人工处置入口。没有这些配套,服务拆分只会把一个容易定位的事务问题变成多个服务之间的猜谜问题。
优点:实现简单,库存约束集中,影响行数清晰,流水容易和数据库事务关联。对于普通电商、零售和库存规模可控的业务,这是我通常建议优先落地的基线方案。
代价:热点 SKU 会产生单行写竞争,数据库主库压力集中。它需要配合限流、短事务和失败快速返回,不能通过无限重试掩盖竞争。
优点:模型直观,适合需要在同一事务内完成多项库存校验的场景,例如组合商品、仓库库存和批次库存同时判断。
代价:锁等待、死锁和长事务风险较高。事务内不应调用不可控的远程服务,也不应把复杂业务计算放在锁持有期间。
优点:减少长时间阻塞,对冲突较低的库存更新很合适。版本号还能帮助识别并发修改,便于构建条件更新。
代价:高热点下冲突率和重试成本会迅速上升。必须设置重试上限、退避策略和降级路径,不能把失败请求无条件重新投递。
优点:能够吸收热点请求,降低数据库在活动瞬间受到的写冲击,适合对吞吐和响应速度有较高要求的场景。
代价:一致性和恢复复杂度明显提高。必须明确缓存与数据库谁是最终账本,并建立可重放流水、差异对账和异常回补机制。
优点:可以把瞬时并发转换为可控处理速度,减少数据库锁竞争,并为失败重试提供缓冲。
代价:用户会感知到排队和结果延迟,消息重复、乱序、积压和消费失败都需要额外治理。它更适合能接受异步确认的业务,不适合所有场景。
优点:把一个热点库存行拆成多个竞争单元,能够提高并发处理能力。对于库存量大、活动流量集中且团队具备较强运维能力的系统,扩展价值较高。
代价:库存分配、段耗尽、释放、回收和全局核算都会变复杂。它不是简单的表结构调整,而是库存所有权模型的变化。


第一条底线是库存裁决必须唯一且可验证。无论使用数据库、缓存还是队列,都必须有明确的成功条件和失败条件,不能让订单服务根据模糊的“请求已接收”继续推进。
第二条底线是每次库存变化必须可追溯。库存快照可以被重建,缓存可以被清空,消息可以被重放,但如果没有库存流水和唯一业务事件,系统就没有恢复依据。
第三条底线是扩展方案必须包含失败路径。任何提高吞吐的设计,都应同时回答重复、超时、丢失、乱序、回补和人工介入问题。只谈成功链路的架构方案,实际上还没有完成设计。
如果你负责的系统目前还没有超卖事故,先不要急着更换数据库或引入更多中间件。选择一个库存量较小、交易频率较高的 SKU,做一次从请求到库存流水的全链路演练,重点验证幂等、回补和日志关联。
如果系统已经出现过库存差异,先建立对账视图,把库存快照、扣减流水、订单状态、支付结果和回补任务放到同一个分析口径中。九数云这类分析工具可以用于搭建这种复盘和监控视图,但实时库存裁决仍应留在交易链路内。
如果系统即将面对大促或秒杀,优先做热点 SKU 压测,观察单 SKU 锁等待和无效请求比例,而不是只看全站 QPS。库存越少、请求越集中,就越应该在数据库之前增加限流、资格筛选或排队机制。
如果系统正在分库分表或拆服务,先重新绘制库存所有权和状态流转图。只要团队无法清楚说明“谁能扣库存、谁能回补库存、重复事件如何处理、异常库存如何恢复”,就不应把拆分上线作为单纯的性能项目。
我的最终判断是:超卖排查的终点从来不是找到一条错误 SQL,而是证明库存约束在并发、重试、跨服务和数据修复之后仍然成立。数据库条件扣减是可靠基线,库存流水是审计依据,幂等和状态机是故障边界,限流与分段是扩展手段。只有这四层同时成立,库存系统才不是“目前没出事”,而是真正具备可验证、可恢复、可扩展的交易能力。
我曾在一个最小库存压测场景中复现过这个问题:库存初始值为 1,两个请求几乎同时查询库存,结果都读到 1,随后分别创建订单。数据库最后可能只扣减 1 次,但订单却生成了 2 个,这种情况到底应该从哪里排查?
最先排查的不是数据库有没有加锁,而是“库存判断”和“库存扣减”是否属于同一个不可分割的原子动作。典型高风险代码是先查询 available,再在应用层判断大于 0,最后执行扣减。两个请求只要在查询和更新之间交错,就可能同时通过判断。
更可靠的做法是把库存约束直接写进更新条件:UPDATE stock SET available = available – 1 WHERE sku_id = ?AND available > 0。执行后必须检查影响行数,只有影响行数为 1,才允许继续创建有效订单;
影响行数为 0 时,必须明确返回库存不足,不能依靠后续流程“补救”。我建议用库存为 1、并发请求数为 2 的场景做第一次验证,再逐步增加到 100、1000 个并发请求。重点观察四个数字:成功扣减数、有效订单数、扣减流水数和最终库存。
正常情况下应满足:成功扣减数 = 有效订单数 = 扣减流水数,且最终可售库存不能小于 0。
排查对象正确表现危险表现 条件更新影响行数只有一个请求成功应用未检查影响行数 订单创建时机扣减成功后创建先创建订单再扣库存 库存流水一笔扣减对应一个业务事件订单数与流水数无法对齐 真正的根因通常不是“SQL 写错了一行”,而是库存约束没有成为系统所有后续动作的前置条件。
我们团队以前也把“加锁”当成库存安全的结束条件,低流量时测试一直通过,但热点商品上线后出现锁等待、重试放大,甚至订单状态和库存状态不一致。锁到底保护了什么,什么时候只是看起来安全?
锁只能保护它覆盖到的资源、事务和时间范围。如果库存查询加了行锁,但订单写入发生在另一个事务中,或者事务提交后才发送异步消息,那么系统仍然可能在不同步骤之间出现状态裂缝。悲观锁适合库存行较少、事务链路短、并发压力可控的场景。它的主要代价不是代码复杂,而是热点 SKU 会把大量请求串行化。
一次压测中,库存只有一行、并发请求从 100 增加到 1000 时,业务逻辑没有变化,但锁等待和连接池占用会明显上升,最终瓶颈从应用线程转移到数据库。乐观锁通常通过版本号或条件更新实现。它减少了长时间持锁,但失败请求会重试;
当冲突率达到较高水平时,重试并不会提高吞吐,反而会把一次竞争变成多次数据库写请求。因此,必须设置重试上限,并记录每次冲突,而不是无限重试。
技术负责人应重点核对以下边界:锁是否覆盖库存裁决、订单创建是否依赖扣减成功、事务回滚是否被应用正确感知、更新影响行数为 0 时是否真的终止后续流程,以及死锁和超时是否会触发重复提交。
方案低并发表现热点场景风险 悲观锁逻辑直观锁等待、死锁、连接堆积 乐观锁冲突少时效率较好重试放大竞争 条件更新约束清晰、实现简单热点行成为吞吐瓶颈 我的判断是:锁是库存安全的局部机制,不是跨订单、支付、消息和补偿链路的完整一致性方案。
我在评估缓存扣库存方案时,最担心的不是 Redis 性能,而是“扣减成功后数据库写入失败”这种半成功状态。假设缓存已经从 1 减到 0,但订单服务超时了,系统重启或缓存重建时到底应该相信哪个数字?
Redis 适合承担高并发拦截和削峰,但它是否能作为最终库存事实来源,取决于系统有没有完整、可重放、可审计的库存流水。单纯把一个数字放进缓存,再通过异步消息同步数据库,遇到进程崩溃、消息重复、消息丢失或缓存淘汰时,就很难证明每一次扣减都发生过且只发生了一次。
最危险的双库存模型是:Redis 负责扣减,数据库负责订单,系统没有唯一幂等键,也没有记录扣减前后值。此时出现异常后,团队往往只能比较“当前缓存值”和“数据库库存值”,却无法回答某一笔扣减由哪个请求触发、是否已经创建订单、是否应该回补。更稳妥的设计需要先明确职责。
缓存可以作为快速准入层,数据库或独立库存账本负责最终核算;每次扣减都应携带业务单号、SKU、变更类型、变更数量和幂等键。数据库写入失败时,系统要么有可靠补偿,要么能通过流水重放恢复,而不是依赖人工修改缓存数字。可以用故障注入测试验证方案:先让缓存扣减成功,再人为阻断订单写入;
随后重复投递同一消息、删除缓存并重建,最后检查库存、订单和流水是否仍能对齐。若系统只能通过人工查询日志猜测应该加回几件库存,这个方案就还没有达到生产安全标准。
设计方式优点必须补齐的能力 缓存仅做快速判断降低数据库无效请求数据库条件扣减与失败处理 缓存直接扣减吞吐高、响应快持久化流水、幂等、补偿、重建校验 消息异步同步数据库削峰解耦重复消费、丢失、乱序和回补处理 判断缓存库存设计是否可靠,关键不在于缓存能承受多少请求,而在于故障发生后能否重新计算出同一个结果。
很多系统早期只有一个商品表和一个库存表,单行更新简单稳定;后来为了提升吞吐,又加了分库分表、消息队列和库存分段。可一旦发生取消回补或支付超时,原本清晰的库存约束就被拆散了,我该如何判断这种架构是否已经到了必须改造的阶段?
单库存行的问题通常不是功能错误,而是扩展性错误。一个热点 SKU 的所有扣减都争抢同一行,流量增加后,数据库会通过锁把请求排队。表面上没有超卖,实际上响应时间、连接池和重试次数已经开始恶化。库存分段或预分配可以降低单行竞争,但会引入新的核算问题。
例如总库存为 100,被分到 4 个分段后,某个分段可能先耗尽,另一个分段仍有剩余;如果回补、迁移和分段失效没有明确规则,就可能出现少卖。也就是说,分段解决的是局部竞争,不等于自动解决全局库存约束。分库分表后,订单、库存和支付可能位于不同数据库,原来的本地事务边界被拆开。
异步补偿虽然能提高系统可用性,却必须面对重复执行、消息延迟、消费失败和状态乱序。没有幂等记录和可追溯流水时,补偿程序可能把一次取消重复回补两次。
我通常用三个指标判断是否已经触碰扩展边界:热点 SKU 的锁等待是否持续升高,库存扣减冲突后的重试比例是否明显增加,以及订单、扣减流水和库存余额是否需要频繁人工对账。若这三项同时恶化,继续单纯增加数据库规格,往往只能延后问题,不能改变架构瓶颈。
设计适合阶段升级信号 单行条件扣减普通流量、事务链路短热点行锁等待持续上升 库存分段热点商品、需要分散写压力分段不均、回补难核算 异步补偿跨服务、允许最终一致重复消费和人工修复增多 分库分表容量或吞吐达到单库边界跨库库存约束无法验证 上线前至少要验证库存为 1、库存分段耗尽、订单超时、支付成功后库存服务失败、消息重复投递和缓存重建这六类场景。
一个能在正常路径跑通的库存系统,不代表它能在失败路径中保持账实一致。


读者评论
文章把超卖从单一锁问题扩展到订单、支付、回补和消息重试的完整链路,这个判断比较客观。尤其是强调四本账关联,对实际事故排查很有参考价值。
条件更新并检查 affected rows 是比较实用的基础方案,但文中也说明了它在热点商品下可能带来锁竞争,不能简单当成万能解法,这一点分析得比较到位。
把库存快照和库存事实区分开很重要。很多系统只关注最终库存值,却忽略扣减流水、幂等请求和重复消息,确实容易导致复盘时无法还原真实过程。
文章覆盖面较广,对事务、缓存、乐观锁和消息队列的风险都有提及。不过不同业务的库存口径差异很大,落地时仍需要结合预占、支付和退款规则细化状态机。