数据库存:开发新手一页讲清:库存锁定与保证扣减一致性的关系
库存系统最容易出现的线上事故,不是“库存表少了一行”,而是两个用户同时看到还有 1 件商品,随后都完成了下单,数据库却接受了两次扣减。我的判断是:库存锁定解决的是“谁暂时不能再占用这件库存”,一致性扣减解决的是“数据库最终是否只承认一笔合法变更”。两者有关联,但绝不是同一个概念;只做锁定,可能造成死锁、超时和库存长期占用,只做扣减校验,又可能让高并发请求大量失败。
这篇文章把“库存锁定”“库存预占”“库存扣减”“订单取消释放”放到同一条链路里讲清楚。我会用开发新手最容易遇到的秒杀、普通电商下单、支付超时、仓库出库和多仓库存场景,解释数据库到底要保护什么、哪些代码看起来安全其实不安全,以及如何根据业务重要程度选择行锁、条件更新、乐观锁、分布式锁和库存流水。
假设商品库存为 1,用户甲和用户乙几乎同时提交订单。两次请求都会先读取库存,若代码只是执行“查询库存,再判断库存大于 0,最后更新库存”,那么两个请求可能都读到库存为 1。此时,问题已经发生在“读取与判断”之间,而不是发生在最终扣减语句本身。
库存锁定的核心作用,是让同一条库存记录在某个事务处理期间被一个请求占用,其他请求需要等待、失败或重试。它保护的是并发过程中的临界区。数据库一致性扣减的核心作用,则是通过约束条件、事务和原子更新,保证最终结果不能违反规则,例如可用库存不能小于 0、同一个订单不能重复扣减。
我通常用一句话向新手解释:锁是过程控制,扣减一致性是结果控制。过程控制可以减少竞争,但不能替代业务幂等;结果控制可以阻止非法扣减,但不能自动解决库存长期锁定和订单状态回滚。
| 概念 | 主要解决的问题 | 典型实现 | 不能单独解决的问题 |
|---|---|---|---|
| 数据库行锁 | 同一时刻谁可以修改某条库存记录 | SELECT … FOR UPDATE | 请求超时、重复请求、跨服务事务 |
| 条件扣减 | 库存不足时不允许更新成功 | UPDATE … SET stock = stock – 1 WHERE stock >= 1 | 订单重复提交、释放逻辑、支付状态 |
| 乐观锁版本号 | 检测记录是否被其他请求修改 | WHERE version = old_version | 大量冲突下的重试风暴 |
| 库存预占 | 订单未支付前暂时占用可售库存 | 冻结数量、预占流水、过期释放任务 | 数据库层面的原子性和唯一性 |
因此,一个可靠方案通常不是“锁定或扣减二选一”,而是把它们放在不同层次:数据库使用原子条件更新保证不超卖,业务层使用库存预占管理支付前的时间窗口,幂等键和库存流水保证重试不会重复扣减。

开发团队讨论“给库存加锁”时,经常没有先确认锁的对象。第一种是数据库行锁,锁住的是数据库事务中的某条记录;第二种是分布式锁,锁住的是一段业务代码或某个商品标识;第三种是业务库存锁定,锁住的是可售数量,也就是把库存从“可被其他订单购买”转成“已经被某个订单预占”。
三者的生命周期完全不同。数据库行锁通常在事务提交或回滚时释放;分布式锁一般设置几十秒到几分钟的过期时间;业务库存锁定则可能持续 15 分钟、30 分钟甚至数小时,直到支付成功、订单取消或自动关单。把数据库行锁误认为业务锁定,是新手项目中最常见的设计错误之一。
例如,用户创建订单后跳转支付页面,整个支付过程可能持续 10 分钟。如果把数据库事务一直挂着并持有行锁,数据库连接、锁资源和线程都会被长时间占用。正确做法通常是:短事务内完成“库存预占”和“订单创建”,提交事务释放行锁;库存数量本身通过冻结字段或库存流水表达为业务层面的长期占用。
高并发库存系统一定会出现失败请求。库存只有 100 件,瞬间来了 10,000 个请求,不可能让所有请求都成功。专业设计关注的不是把失败率伪装成零,而是保证失败请求不会造成负库存、重复扣减、订单无库存却支付成功、取消后库存没有释放等脏结果。
我在排查库存问题时,会先看四个账是否能对上:可售库存、冻结库存、已售库存和库存变更流水。只要这四个数字之间存在明确的转换关系,即使某个接口超时,也能通过流水重放、补偿任务或人工对账恢复。反过来,如果系统只有一个 stock 字段,任何异常都会变成“猜到底扣了几次”。
一个实用的库存恒等式是:
初始库存 = 可售库存 + 冻结库存 + 已售库存 + 损耗库存
对于不允许超卖的普通商品,系统还应保证:
可售库存 ≥ 0,冻结库存 ≥ 0,已售库存 ≥ 0,库存变更流水总量可追溯。
普通电商下单的并发量可能不高,但支付耗时长。用户下单后,库存需要在支付完成前暂时保留,否则用户支付成功时可能发现库存已经被别人买走。这里的核心矛盾是“库存占用时间长”。
秒杀场景则相反,库存非常少,请求非常集中。系统需要在几十毫秒内判断和扣减,核心矛盾是“竞争请求数量大”。如果仍然让每个请求都进入数据库事务并等待行锁,数据库很快会出现锁等待、连接池耗尽和响应时间陡增。
仓库出库场景又多了一层复杂性。订单支付成功不代表仓库一定能发货,拣货、复核、拆单、缺货和盘点都会影响实物库存。此时,销售库存和物理库存必须分开建模,否则线上显示“有货”不代表仓库真的找得到货。
| 场景 | 主要冲突 | 库存动作 | 更适合关注的指标 |
|---|---|---|---|
| 普通下单 | 支付时间长、订单可能取消 | 创建订单时预占,超时释放 | 支付成功率、冻结超时率、释放延迟 |
| 秒杀抢购 | 短时间大量请求争抢少量库存 | 限流、排队、原子扣减、异步下单 | 峰值吞吐、扣减冲突率、排队长度 |
| 仓库出库 | 销售库存与实物库存不一致 | 拣货占用、出库确认、盘点调整 | 缺货率、库存准确率、出库及时率 |
| 多仓配送 | 一个订单拆分到多个仓库 | 按仓库和商品维度分别预占 | 分仓成功率、调拨次数、履约时长 |

如果业务只是做一个内部领用系统,单字段库存或许可以暂时满足需求。但只要存在支付、取消、退款、拆单或仓库出库,就不建议只保留一个 total_stock 字段。最低限度应把可售数量和冻结数量分开。
一种常见模型是:
创建订单时,available_stock 减少,frozen_stock 增加;支付成功时,frozen_stock 减少,sold_stock 增加;订单取消时,frozen_stock 减少,available_stock 增加。每一个转移都应有订单号、商品编号、仓库编号、数量、操作类型、操作时间和幂等键。
这里有一个容易被忽略的细节:冻结库存不是“数据库里扣掉但以后再想办法”。冻结库存必须有明确的归属者和截止时间。没有订单号的冻结数量,无法判断该释放给谁;没有过期时间的冻结数量,迟早会变成永久占用。
库存扣减属于强一致交易链路,要求低延迟、可回滚、可追踪;库存分析属于查询和决策链路,关注周转率、缺货率、滞销天数、仓库差异和补货建议。两类工作混在同一张高并发交易表上,往往会让复杂查询拖慢扣减事务。
在实际项目中,我会把交易库作为事实来源,再通过消息、定时同步或 CDC 将库存流水汇总到分析层。像九数云这类数据分析工具,更适合用来观察库存周转、冻结超时、仓库差异和异常扣减,而不是直接承担库存扣减事务。
这一区分很重要:报表显示某 SKU 的可售数量为 0,并不能替代交易数据库对下一笔订单的条件校验;反过来,交易数据库保存了每次扣减,也不意味着业务人员已经能看清哪些库存长期冻结。交易系统和分析系统要互相提供证据,但不能互相越权。
下面这种代码非常常见,也非常危险:
SELECT stock FROM inventory WHERE sku_id = 1001;
if (stock > 0) {
UPDATE inventory
SET stock = stock - 1
WHERE sku_id = 1001;
}问题不在于“减一”这个表达式,而在于多个请求都可以在更新前读到相同的库存。即使最终结果没有出现负数,也可能出现两个订单都认为自己获得了库存。若 update 没有带上原库存条件,数据库不会替应用判断这次读取是否已经过期。
更安全的最小写法是把判断和扣减合并为一个原子更新:
UPDATE inventory SET available_stock = available_stock - 1, frozen_stock = frozen_stock + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = 1001 AND available_stock >= 1;
执行后必须检查 affected rows。affected rows 等于 1,才代表本次预占成功;等于 0,只能说明库存不足或条件不满足,不能继续创建一个“无库存订单”。
开发者知道 SELECT FOR UPDATE 可以锁住库存记录,于是把查询库存、创建订单、调用支付接口、等待支付结果全部放进一个事务。这在低并发测试环境里似乎没有问题,但线上会让数据库连接长时间被占用。
支付接口可能因为网络、用户输入验证码、第三方风控或回调延迟而耗时几十秒。此时其他订单请求会一直等待同一条库存记录。高峰期最先出现的往往不是超卖,而是数据库连接池耗尽、事务回滚数量上升和接口整体超时。
外部支付调用不能放在持有库存行锁的长事务里。正确做法是先在短事务内完成库存预占和订单创建,提交后再发起支付;支付成功或失败通过明确的状态机和幂等回调处理。
分布式锁可以减少多个应用实例同时执行同一段代码,但它并不能天然保证数据库更新一定成功。锁可能因为客户端崩溃、网络分区、过期时间过短或误释放而失效。即便锁服务正常,另一个不经过该锁的后台补库存任务、人工调整任务或仓库同步任务,仍然可能直接修改数据库。
我把分布式锁看成“降低竞争的交通指挥”,把数据库条件更新看成“最后一道护栏”。交通指挥失效时,护栏仍应阻止库存小于零。对于关键库存,数据库里的 UPDATE 条件不应被分布式锁替代。
| 方案 | 优势 | 主要风险 | 适用判断 |
|---|---|---|---|
| 仅分布式锁 | 代码直观,能减少同一商品竞争 | 锁失效、误释放、旁路写入 | 不能作为唯一一致性保障 |
| 仅条件更新 | 数据库原子性强,链路短 | 高冲突时失败请求多,业务状态仍需补偿 | 适合简单扣减和高并发抢购 |
| 行锁加事务 | 逻辑容易理解,适合多表一致写入 | 锁等待明显,事务不能过长 | 适合短事务、低到中等并发 |
| 预占加流水 | 能处理支付等待、取消和重试 | 状态机和补偿逻辑复杂 | 适合电商、票务和稀缺资源 |
负库存当然是明显故障,但库存不为负数并不代表系统正确。比如初始库存 10,两个请求都读到 10,随后都执行 SET stock = 9,最后数据库是 9,看起来没有负数,实际上应该售出 2 件,最终库存应为 8。这属于“丢失更新”。
还有一种更隐蔽的错误:订单创建成功但库存预占失败,系统仍返回支付链接;或者支付成功后回调重复到达,库存被从冻结转已售两次。此类问题不会立即表现为负库存,却会在日终对账时出现“订单数、库存流水和实物数对不上”。

我在设计库存表之前,会先问四个问题。第一,库存是否允许超卖;第二,库存需要被占用多久;第三,库存扣减是否必须和订单创建在同一个数据库事务中;第四,失败之后能否补偿。如果这四个问题没有答案,直接讨论用哪一种锁,通常只能得到一个局部方案。
如果业务允许“下单后再确认库存”,系统可以先创建待确认订单,再异步锁定库存;如果业务要求“支付成功即必须有货”,就必须在支付前完成预占,或者采用支付后快速确认与失败退款机制。业务承诺不同,技术成本也完全不同。
条件更新是大多数简单库存扣减的优先起点。它把“库存足够”和“库存减少”放在同一条 SQL 中,数据库能够保证单条语句的原子性。适合扣减逻辑简单、库存表结构稳定、无需长时间持锁的场景。
行锁适合一次操作需要读取库存、写入多条相关明细,并且整个事务可以控制在较短时间内的场景。例如同一数据库中创建订单主表、订单明细、库存预占流水,要求要么全部成功,要么全部回滚。使用行锁时,必须确保查询走唯一索引或高选择性索引,否则锁住的范围可能比预期更大。
乐观锁适合冲突不频繁、业务可以接受失败重试的场景。常见方式是增加 version 字段:
UPDATE inventory SET available_stock = available_stock - 1, frozen_stock = frozen_stock + 1, version = version + 1 WHERE sku_id = 1001 AND version = 27 AND available_stock >= 1;
如果 affected rows 为 0,说明版本已经被其他请求修改,当前请求需要重新读取、重试或直接失败。它的缺点是高并发下容易形成大量重试,重试次数必须设置上限,不能让失败请求无限循环。
很多新手认为把隔离级别调到 SERIALIZABLE,就能自动解决库存超卖。这个判断过于简单。串行化隔离确实能强化事务之间的可见性和冲突控制,但代价是更高的锁等待、更多的事务失败和更低的吞吐量。它也不会替你处理支付回调幂等、库存过期释放和跨系统一致性。
在多数库存业务中,我更倾向于使用默认隔离级别配合明确的原子条件更新,或者在短事务内使用行锁。只有当业务确实需要强串行化语义,且经过压测确认数据库能够承受时,才考虑提升隔离级别。
判断隔离级别是否合适,不能只看单元测试结果。至少应观察锁等待时间、事务回滚率、数据库 CPU、连接池使用率、P95 和 P99 延迟,以及库存冲突发生时的重试放大倍数。

一个合理的本地事务通常包括:校验订单是否已经处理、条件预占库存、写入库存流水、创建或更新订单状态。支付网关调用、短信发送、物流接口调用和报表刷新,不应放在这个事务里。
如果订单服务和库存服务使用不同数据库,就不能假设一个本地事务可以同时回滚两边。此时应设计事件和补偿,例如订单进入“待库存确认”,库存服务成功后变成“待支付”,库存失败后关闭订单。消息必须带业务唯一键,消费者必须支持重复消费。
我建议把状态机写成明确的允许迁移表,而不是在代码里到处写 if:
| 当前状态 | 事件 | 目标状态 | 库存动作 |
|---|---|---|---|
| 待库存确认 | 预占成功 | 待支付 | 可售减少,冻结增加 |
| 待库存确认 | 预占失败 | 库存不足关闭 | 不改变库存 |
| 待支付 | 支付成功 | 已支付 | 冻结转已售 |
| 待支付 | 超时关闭 | 已取消 | 冻结转可售 |
| 已支付 | 退款成功 | 已退款 | 按业务规则回补或进入售后库存 |
假设某电商活动中,商品 A 初始库存为 100 件。用户提交订单后需要在 30 分钟内完成支付,未支付订单自动关闭。系统每天约有 20,000 个订单请求,活动高峰每秒 300 个请求集中到商品 A。
如果系统在订单创建时直接把 stock 减 1,但不保存冻结明细,那么取消订单时只能执行 stock 加 1。看似简单,却无法判断某次加 1 是否已经执行过,也无法知道某个冻结库存属于哪个订单。一旦关单任务重复执行,就会把同一件库存释放两次。
更稳妥的设计是同时保留库存汇总和库存流水。汇总字段用于快速判断和展示,流水用于审计和修复。订单号加商品编号加库存动作类型可以组成幂等约束,防止同一订单重复预占或重复释放。
下面是一个简化的表结构示意。真实项目还要根据数据库类型补充金额、租户、仓库、批次、有效期和并发索引。
CREATE TABLE inventory (
id BIGINT PRIMARY KEY,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
available_stock INT NOT NULL DEFAULT 0,
frozen_stock INT NOT NULL DEFAULT 0,
sold_stock INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id),
CHECK (available_stock >= 0),
CHECK (frozen_stock >= 0),
CHECK (sold_stock >= 0)
);
CREATE TABLE inventory_log (
id BIGINT PRIMARY KEY,
request_id VARCHAR(64) NOT NULL,
order_id VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
action_type VARCHAR(32) NOT NULL,
quantity INT NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_request_action (request_id, action_type)
);需要注意,数据库 CHECK 约束是否真正生效,取决于具体数据库版本和存储引擎,不能只看建表语句是否执行成功。即使数据库支持约束,也不要把所有业务正确性都寄托在约束上;订单幂等、状态迁移和流水检查仍需在应用层实现。
inventory_log 中的 request_id 不应直接使用随机生成且每次重试都变化的值。客户端请求、消息重投和支付回调需要能够映射到同一个业务动作。通常可以使用“订单号 + 动作序号”或由上游生成的业务幂等号。
预占库存时,应让“库存足够”和“库存转移”在同一个原子更新中完成:
UPDATE inventory SET available_stock = available_stock - :quantity, frozen_stock = frozen_stock + :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND warehouse_id = :warehouse_id AND available_stock >= :quantity;
预占成功后,再写入库存流水和订单状态。如果使用同一个数据库,可以将这几个动作放在一个短事务中;如果是跨服务,则要通过事件确认和补偿,不要假设远程调用失败就能自动回滚本地数据库。
支付成功时,冻结库存转成已售库存:
UPDATE inventory SET frozen_stock = frozen_stock - :quantity, sold_stock = sold_stock + :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND warehouse_id = :warehouse_id AND frozen_stock >= :quantity;
订单超时关闭时,冻结库存释放回可售库存:
UPDATE inventory SET frozen_stock = frozen_stock - :quantity, available_stock = available_stock + :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND warehouse_id = :warehouse_id AND frozen_stock >= :quantity;
这三段 SQL 的共同点是:每次状态转移都带有数量下限条件,并且必须检查影响行数。仅仅“执行没有报错”不能代表动作成功;影响行数为 0 时,应用必须进入失败、重试、对账或人工处理流程。

在支付型业务中,冻结库存平均值可能并不高,但冻结时间超过业务承诺的订单会制造隐性缺货。例如系统平均冻结 8 分钟,看起来健康;如果有 2% 的订单冻结超过 2 小时,这些订单可能长期占用热门商品,导致真实可售库存被压低。
我会把冻结库存按年龄分桶观察:0 到 5 分钟、5 到 15 分钟、15 到 30 分钟、30 分钟以上。超过支付有效期的库存应进入释放队列,释放任务不能只依赖单次定时扫描,还应有重试、告警和对账补偿。
通过数据分析工具观察库存时,可以把订单状态、预占时间、支付时间、释放时间和库存流水关联起来。例如在九数云中搭建库存监控看板时,重点不是只显示“当前库存”,而是同时显示冻结库存占比、冻结超过时限的数量、释放失败次数和订单库存差异。这样业务人员看到的是风险来源,而不仅是一个孤立数字。
如果系统每天只有几百次库存操作,商品数量有限,且不存在复杂支付链路,我建议先使用单数据库事务、条件更新和库存流水,不要一开始就引入复杂的分布式锁。复杂组件会增加部署、监控和故障排查成本。
这个阶段最值得投入的不是性能优化,而是让开发团队能在 10 分钟内回答“这 1 件库存为什么没了”。有明确流水后,测试、运维和业务都能快速定位问题。
当业务出现支付等待、订单取消和退款时,建议把库存预占从“数据库锁住一段时间”升级为“业务字段和流水表达一段时间”。数据库行锁只存在于创建订单的短事务中,订单提交后立即释放。
推荐执行顺序如下:
关单任务应按照过期时间扫描待支付订单,但不能假设扫描一次就一定成功。释放库存时再次检查订单状态,只有订单仍然是待支付,才允许转成已取消并释放库存。订单状态更新和库存释放必须具备幂等保护。
秒杀系统最大的误区,是把所有请求直接压到库存数据库,再期待数据库通过行锁自动排队。这样做相当于让数据库承担网关、排队系统和业务事务三种工作,结果通常是连接池迅速满载。
更合理的链路是:前置限流、用户资格校验、请求排队、单商品串行化或分片处理、数据库最终条件扣减。缓存可以用于快速判断活动是否开始、用户是否重复参与,但最终库存结果仍应由可靠的持久化层确认。
秒杀场景还需要明确“抢到资格”和“订单创建成功”的区别。用户拿到排队号不代表已经获得库存;只有库存预占成功并生成有效订单,才可以向用户承诺购买成功。
多仓场景不能只维护一个全局 available_stock。一个商品总共有 100 件,并不代表任意仓库都能为任意地址发货。库存应至少按商品、仓库、批次或货主维度拆分,订单分仓成功后,再对具体仓库库存执行预占。
如果一个订单需要多个仓库共同履约,就要考虑部分成功。库存 A 仓预占成功、B 仓预占失败时,系统是等待调拨、改为拆单,还是全部释放并重新分配?这个决策必须在产品规则中写清楚,不能让程序通过异常分支“随机决定”。
多仓库存的对账也要区分销售库存、锁定库存、在途库存和物理库存。仓库系统回传延迟时,交易库暂时显示可售并不一定是程序错误,但必须有同步时间、差异阈值和异常处理流程。

行锁的优点是直观、可靠、与数据库事务天然结合。对于同一数据库中的订单和库存操作,使用行锁可以让开发人员较容易理解“锁住、修改、提交”的过程。
它的代价是锁等待。当大量请求争抢同一 SKU 时,后续请求会排队;如果事务中还有其他 SQL,锁持有时间会被不必要地拉长。行锁还要求索引设计准确,查询条件不清晰时可能产生更大范围的锁影响。
我的建议是:行锁可以使用,但只保护数据库内极短的关键区,绝不要用它覆盖支付、网络调用或人工确认。
条件更新的优势是简单、高效和容易水平扩展。它适合“库存足够就扣减,不足就失败”的原子场景,尤其适合秒杀中的最终扣减动作。
它的不足是业务语义不会自动完整。库存扣减成功后,订单写入失败怎么办?订单取消时如何释放?支付回调重复怎么办?这些都需要事务、消息和补偿机制解决。条件更新只能保护一个状态变更,不能替代整个订单状态机。
如果团队刚开始建设库存系统,我通常会建议先把条件更新和流水做好,再根据压测结果决定是否需要分布式锁或缓存预扣。
乐观锁不主动阻塞其他请求,而是在提交时检查版本。它适合冲突较少、失败可重试的业务,例如后台库存调整、低频资源预约和管理端编辑。
对于单个热门 SKU 的高并发抢购,乐观锁可能产生大量版本冲突。如果每次冲突都重试三到五次,原本 1,000 个请求可能被放大为 4,000 次数据库更新尝试。此时需要限制重试次数、加入随机退避,或者在入口处排队。
分布式锁适合跨多个资源、需要保护复杂临界区的场景,例如同一订单同时操作多个 SKU,需要防止重复执行一段跨服务流程。但它带来了锁续期、过期、网络异常、持有者崩溃和误释放等新问题。
如果只是执行一条带库存条件的 UPDATE,通常没有必要为了这条 SQL 引入分布式锁。组件越多,故障模式越多;好的架构不是组件最多,而是用最少的组件覆盖真正存在的风险。
缓存扣减速度快,适合承接突发流量,但缓存不是天然的最终事实来源。缓存扣减成功后,数据库写入失败、消息丢失、服务重启或缓存与数据库不同步,都会造成库存漂移。
如果采用缓存预扣,必须提前回答:缓存重启如何恢复、数据库最终校准由谁完成、扣减消息是否可重复消费、订单失败如何回补、缓存数量和数据库数量不一致时谁优先。没有这些机制,缓存只是把超卖问题从数据库搬到了另一个系统。

库存测试至少要覆盖并发、重复、超时、回调乱序和服务崩溃。单线程测试通过,并不能证明两个并发事务同时到达时结果正确。测试还要验证失败之后是否留下孤儿订单、冻结库存和未记录流水。
压测时不要只看吞吐量。库存系统更应关注扣减成功率、库存不足失败率、重复请求拦截率、锁等待时间、事务回滚率、释放延迟和对账差异。高峰期间平均响应时间很漂亮,但 P99 达到数秒,用户仍然会认为系统不可用。
| 监控层 | 建议指标 | 异常信号 | 排查方向 |
|---|---|---|---|
| 交易层 | 订单创建成功率、支付成功率、重复请求率 | 订单成功但支付失败显著上升 | 订单状态机、支付回调和库存预占 |
| 库存层 | 可售库存、冻结库存、已售库存、库存差异 | 冻结库存长期增长或出现负数 | 关单任务、释放逻辑和库存流水 |
| 数据库层 | 锁等待、事务时长、回滚率、连接池占用 | P99 延迟和锁等待同步上升 | 事务是否过长、索引是否命中 |
| 补偿层 | 消息重试次数、补偿成功率、对账差异数 | 补偿队列持续堆积 | 幂等键、消息消费和异常数据 |
库存告警不能只设置“库存小于 10”这种静态阈值。更有价值的告警是冻结库存占可售加冻结总量的比例异常、超过支付时限的冻结数量持续增长、同一订单出现两条相同动作流水、库存汇总与流水汇总产生差异。

对账不等于每天把库存字段相加一次。至少应做三种对账:订单与库存流水对账、库存汇总与流水聚合对账、系统库存与仓库实物对账。
订单与库存流水对账,用来发现订单已支付但没有实扣、订单已取消但没有释放等问题;库存汇总与流水聚合对账,用来发现程序直接改字段却没有写流水的问题;系统与实物对账,则用来发现损耗、盘点差异、入库延迟和拣货错误。
对账发现差异后,不要直接把 stock 改成“看起来正确”的数字。应通过专门的库存调整单记录原因、责任人、原值、新值和审批信息。否则短期数字对上了,长期审计和责任追踪仍然是空白。
如果这些问题中有三项以上无法回答,系统还不适合直接承载限量商品或高峰促销。可以先降低并发、缩短库存有效期、增加人工复核,再逐步补齐自动化能力。
库存锁定与一致性扣减的关系,可以归纳为三句话:锁定控制并发过程,条件更新控制扣减结果,库存流水和状态机控制业务生命周期。三者缺一不可,但承担的责任不同。
如果只做数据库行锁,支付等待会拖垮事务;如果只做一个 stock 减一,订单取消和重复回调会造成库存漂移;如果只做缓存扣减,缓存与数据库不一致时会失去事实依据。真正稳健的系统,必须让每一个库存变化都能回答“谁在什么时间、因为什么订单、执行了什么动作、是否已经执行过”。
开发新手可以按以下顺序落地,而不是一次性堆满所有中间件:
我的最终判断是:库存系统的第一性问题不是“用什么锁”,而是“库存从可售到冻结、从冻结到已售的每次转移是否可证明、可重复检查、可补偿”。当这条状态链清楚后,锁只是实现细节;当状态链不清楚时,再高级的锁也只能把问题延后暴露。
我刚开始做订单系统时,以为锁定库存成功就等于库存已经扣掉了,支付成功后只需要改订单状态。后来测试“库存为 1、两个请求同时下单”的场景,才发现锁定、确认扣减、释放其实是三个不同动作,它们之间到底应该怎样衔接?
我的判断是:库存锁定解决“暂时不让别人使用”,最终扣减解决“确认库存已经被消耗”,两者有关联,但绝不能当成同一个动作。例如某 SKU 总库存为 10,已售出 4 件,当前已有订单锁定 3 件,那么新订单真正能使用的数量通常是 3 件。
锁定成功后,这 3 件只是进入某个订单的占用范围,并不代表支付已经完成。
动作解决的问题失败或异常时怎么处理 锁定防止同一批可用库存被其他订单继续占用支付失败、取消或超时后释放 确认扣减将临时占用转为正式销售消耗必须防重复确认 释放把未成交的占用返还可用库存释放动作必须幂等 我在测试时最容易踩的坑,是把“锁定数量加 1”和“可用库存减 1”拆成两个没有明确事务边界的动作。
中间一旦服务崩溃,锁定记录和库存数字就可能不一致。更稳妥的做法是:锁定时用原子条件更新扣减可用库存,同时写入锁定明细;支付成功时再将锁定记录改为已确认,并写库存流水。因此,一条实用的状态链应当是:待下单 → 已锁定 → 待支付 → 已确认,或者在支付失败、取消、超时后进入已释放。
不同业务可以选择下单时扣减、支付时扣减或出库时扣减,但必须先定义“库存数字的口径”,再设计状态流转。
我曾经写过这样的逻辑:先查询 available_stock,判断库存大于购买数量后,再执行 update。单元测试完全通过,但压测时两个请求都买到了最后一件,数据库里甚至出现过库存被旧值覆盖的情况。到底是哪一步产生了并发漏洞?
漏洞出在“查询”和“更新”之间存在时间窗口。假设库存为 1,请求 A 和请求 B 几乎同时查询,都读到库存为 1;随后它们分别计算出 0 并写回数据库,业务层可能让两个订单都显示成功,但库存只减少了一次。我用两个并发请求做过一个极简测试:初始库存为 1,购买数量都为 1。
先查后改的方案在 1000 组测试中出现过双成功;改成数据库条件更新后,成功数稳定为 1,另一个请求通过影响行数判断库存不足。
推荐的最小 SQL 思路是:
UPDATE sku_stock SET available_stock = available_stock - :quantity WHERE sku_id = :sku_id AND available_stock >= :quantity;执行后不能只判断 SQL 有没有报错,还要检查受影响行数。影响 1 行表示扣减成功;影响 0 行通常表示库存不足、SKU 不存在,或者条件已经被其他并发请求抢先消耗。这条 SQL 的关键不是“用了 update”,而是把“库存足够”放进了同一个数据库更新条件里。
数据库会在执行这条语句时完成条件判断和数值变化,避免应用层先读旧值、再写旧值。但条件更新也不是完整库存方案。订单创建、库存流水、订单状态变更仍然要考虑事务;支付回调、消息重试和客户端重复提交还要使用订单号或业务流水号做幂等。
我的经验是:条件更新负责守住数量底线,事务负责绑定数据库内动作,幂等负责防止同一业务被处理两次。
我在测试订单超时任务时遇到过一个很隐蔽的问题:定时任务执行了一次,支付回调又触发了一次取消流程,结果同一笔锁定库存被加回两遍。库存表看起来没有报错,但可用库存已经比真实库存多了,应该怎样设计释放逻辑?
释放库存不能简单写成“订单取消就执行 available_stock = available_stock + quantity”。因为取消消息可能重复、定时任务可能重跑、服务响应超时后调用方可能再次提交。释放动作必须先判断锁定记录是否仍处于可释放状态。
我通常会给库存锁定明细设置明确状态,例如 LOCKED、CONFIRMED、RELEASED,并为订单号或业务流水号建立唯一约束。释放时只允许 LOCKED 状态转为 RELEASED,已经 CONFIRMED 或 RELEASED 的记录不能再次返还。
逻辑可以抽象为: 如果 lock_status = LOCKED: 将 lock_status 更新为 RELEASED 恢复对应可用库存 写入 RELEASE 流水 否则: 直接返回“无需重复释放”实际落库时,状态判断和状态变更最好放在同一个事务中。
例如先执行带条件的状态更新,确认影响行数为 1 后再恢复库存并写流水;如果影响行数为 0,就说明这笔锁定已经被确认或释放,不应再次加库存。还要特别处理“支付成功和超时释放同时到达”的竞态。
两条流程不能分别先查状态再决定动作,而应竞争同一条锁定记录的状态迁移:谁先把 LOCKED 改成 CONFIRMED 或 RELEASED,谁获得处理权,另一方发现影响行数为 0 后停止后续库存操作。
我更建议把“释放原因”记录下来,例如 USER_CANCEL、PAY_TIMEOUT、PAY_FAILURE、MANUAL_COMPENSATION。这样发生对账差异时,可以看出库存是被哪条链路释放的,而不是只看到一条无法解释的库存增加记录。
我看过一些实现,简单扣减用条件更新,复杂流程用行锁,也有人无论什么场景都先加分布式锁。我不想为了一个 SKU 扣减引入过重的架构,但又担心并发量上来后出问题。开发新手应该怎样选择,并且如何验证方案真的可靠?
我的选择顺序通常不是先问“要不要加锁”,而是先看业务动作是否简单、冲突是否集中、事务是否能快速结束。对于单 SKU 的直接扣减,带库存条件的原子更新往往是最小且清晰的方案;只有当多个库存记录必须一起检查和变更时,才考虑更复杂的锁策略。
方案适合场景主要代价 条件更新单 SKU、数量扣减、规则简单复杂业务状态需要额外事务设计 乐观锁冲突可接受,失败后可以重试高冲突时重试次数可能快速增加 悲观锁多个字段或多条库存记录必须串行处理锁等待、死锁和吞吐下降 分布式锁需要协调多个服务实例的进入顺序不能替代数据库最终校验和幂等 我测试过一个常见误区:在应用层加互斥锁后,单机测试全部通过;
部署成 3 个服务实例后,三个实例各自持有不同的锁,超卖问题又出现了。因此,进程内锁只能解决单实例并发,不能直接推导出整个系统的一致性。如果使用乐观锁,可以让更新条件同时包含版本号,例如“库存足够且 version = 旧版本”,成功后递增版本。冲突时返回失败或有限重试。
若使用悲观锁,则必须缩短事务,避免在持有数据库行锁时调用支付、库存查询等远程服务。无论选哪种方案,我都会用下面几组测试验证:库存为 1 时并发购买 1 件;库存为 10 时并发购买总量超过 10 件;同一订单重复提交;支付回调重复到达;取消和支付成功同时到达;释放任务重复执行。
最终检查的不只是库存数字,还要核对订单状态、锁定记录和库存流水数量。如果一个方案只能回答“正常请求怎么扣库存”,却回答不了“请求超时后重试怎么办、数据库提交成功但响应丢了怎么办、定时任务重复执行怎么办”,那它还不能算可靠的库存方案。


读者评论
锁是过程控制,扣减一致性是结果控制”这个区分很实用。以前我把数据库行锁一直持有到支付完成,结果高峰期锁等待严重。文章提到用短事务做库存预占,再通过冻结库存管理支付窗口,比较符合实际。
库存恒等式的部分很有价值,尤其是把可售、冻结、已售和流水放在一起核对。实际排查时,单看一个stock字段很难判断是重复扣减、取消未释放,还是仓库盘点造成的偏差,保留订单号和幂等键确实必要。
秒杀和普通下单采用同一种扣减方案确实容易出问题。普通订单更关注冻结超时和释放,秒杀则要控制锁等待、连接池和重试风暴。文中的条件更新适合做底线保护,但高并发场景还需要限流或排队,不能只依赖数据库。