数据库存:数据库管理员增长版:库存锁定的完整方法与步骤
在一次日均订单约12万、峰值每秒订单超过300笔的库存系统复盘中,我发现最容易被误判的故障并不是“数据库不够快”,而是库存锁定的边界没有定义清楚:同一件商品可以被两个事务同时读到,超卖只发生了几十件,却引发了退款、客服、仓库拦截和财务对账四条链路的连锁问题。库存锁定的核心不是给库存表加一把锁,而是让“读取可售库存、占用库存、确认订单、释放库存”成为一套可验证、可恢复、可观测的状态机制。
本文从数据库管理员和业务系统负责人的双重视角,拆解库存锁定的完整步骤,重点讨论高并发下的行锁、乐观锁、悲观锁、库存预占、幂等、超时释放、死锁、分库分表以及数据分析。文中的数据观察主要来自项目复盘中的脱敏样本和情景模拟,涉及性能的数字均会明确标注口径,不把示意数据当作行业统计。
很多库存系统只有一个 stock 字段,然后用加减操作完成所有业务。这种设计在单体、小流量场景中可以工作,但一旦出现支付延迟、订单取消、部分发货或重复请求,单一库存字段就无法回答一个关键问题:这件商品到底是卖掉了、被占用了,还是仍然可以售卖。
我通常会把库存至少拆成四个业务数字:总库存、已锁定库存、已售库存、可售库存。它们之间不是简单的展示关系,而是约束关系:
可售库存 = 总库存 – 已锁定库存 – 已售库存 – 风险冻结库存
其中,“风险冻结库存”可用于质量问题、盘点差异、供应商召回或仓库异常。没有这个维度,业务人员往往会直接修改可售数量,导致系统认为库存减少,但无法追溯减少原因。
| 库存字段 | 业务含义 | 允许变化的动作 | 常见风险 |
|---|---|---|---|
| 总库存 | 仓库或供应链确认的物理库存 | 入库、盘点调整、报损 | 把订单销售直接写成总库存减少 |
| 已锁定库存 | 已被订单占用但尚未最终结算 | 预占、释放、转为已售 | 超时订单长期不释放 |
| 已售库存 | 已完成支付或业务确认的销售数量 | 支付确认、售后回退 | 重复支付回调导致重复增加 |
| 风险冻结库存 | 暂时不允许售卖的数量 | 质量冻结、盘点冻结、风控冻结 | 冻结没有责任人和解冻时间 |
| 可售库存 | 当前能够接受新订单的数量 | 由规则计算,不建议人工直接写入 | 被多个系统分别维护后出现口径冲突 |
我的判断是:如果一个系统无法在一分钟内回答“这批库存为什么不能卖”,就不应该直接进入大促或高峰交易。库存数字是否正确,不仅取决于 SQL 是否成功,还取决于每次变更是否有业务事件、订单号、操作人和时间戳。

“锁定库存”这句话容易造成误解。数据库层面的锁通常只是让其他事务暂时不能修改某一行;业务层面的锁定则意味着某个订单获得了可验证的库存额度。前者由数据库事务和隔离级别保证,后者需要订单号、数量、状态、有效期和释放规则共同保证。
因此,我更倾向于把库存锁定定义为一个业务凭证:订单在某个时间点,以某个价格和某个仓库为条件,占用了某个 SKU 的若干数量。这个凭证必须具备唯一编号,否则后续支付回调、取消订单和定时任务都无法安全重试。
一个完整的库存锁定记录,至少应包含 reservation_id、order_id、sku_id、warehouse_id、quantity、status、expire_at、version、created_at 和 updated_at。如果订单涉及多个仓库,还需要明确锁定的是仓库库存、区域库存还是全国共享库存。
我在设计库存系统时,会先画状态机,不会先讨论“要不要用行锁”。推荐的基础状态如下:
状态机的价值在于限制非法操作。例如,已释放的锁定记录不能再次释放;已确认的订单不能被超时任务当作未支付订单处理;同一支付回调重复到达时,第一次完成状态转换,后续请求只能返回“已处理”,不能再次扣库存。
库存锁定最典型的错误代码是“先查后减”:事务先执行 SELECT stock FROM sku_stock WHERE sku_id = ?,判断库存大于购买数量,再执行 UPDATE sku_stock SET stock = stock - ? WHERE sku_id = ?。在没有并发的测试环境中,这套流程很容易全部通过。
但当两个请求同时读到库存为10时,它们都可能判断“库存足够”。如果更新语句没有再次携带库存条件,两个请求都会完成扣减。即使数据库最终没有出现负数,业务上仍可能已经产生了超卖,因为两个订单都拿到了同一份库存承诺。
-- 风险写法:判断和扣减之间存在并发窗口 START TRANSACTION; SELECT stock FROM sku_stock WHERE sku_id = 1001; -- 应用层判断 stock >= 2 UPDATE sku_stock SET stock = stock - 2 WHERE sku_id = 1001; COMMIT;
真正需要控制的是“判断和扣减必须由同一个原子条件完成”。在许多场景中,数据库的单条条件更新已经足够可靠:
UPDATE sku_stock SET available_stock = available_stock - 2, locked_stock = locked_stock + 2, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = 1001 AND warehouse_id = 10 AND available_stock >= 2;
执行后必须检查受影响行数。受影响行数为1,表示锁定成功;为0,表示库存不足、SKU不存在、仓库不匹配或条件已被其他事务改变。不能因为 SQL 执行没有报错,就把库存锁定当作成功。
订单创建、库存锁定、支付请求和支付回调,通常分布在不同服务、不同事务甚至不同数据库中。你可以在本地事务里同时写订单表和库存表,却无法把第三方支付平台也纳入同一个数据库事务。
因此,系统必须接受一个现实:库存锁定不可能永远依靠单个事务解决。它需要本地事务保证局部一致,再通过事件、重试、对账和补偿机制,逐步把跨系统状态拉回一致。
例如,订单创建事务可以同时写入订单和库存锁定记录,然后写一条待发送事件。事件发送失败时,后台任务根据订单和锁定记录状态重新投递;支付回调重复到达时,通过订单号和支付流水号做幂等;锁定超时后,再由释放任务安全地归还库存。
数据库管理员最容易被一个平均指标误导:平均查询耗时只有20毫秒,并不代表库存系统安全。高峰时,真正影响系统的是少数热点 SKU 被大量请求同时更新,导致同一行排队、锁等待延长、连接池耗尽,最终表现为订单接口整体超时。
在一个脱敏压测中,普通 SKU 每秒写入不到5次,爆款 SKU 却在2秒内收到超过800次扣减请求。数据库 CPU 只有62%,但热点行锁等待从8毫秒升到410毫秒。这个现象说明,不能只看 CPU、内存和平均响应时间,还要观察按 SKU 聚合的锁等待和失败率。

悲观锁通常使用 SELECT ... FOR UPDATE。它能够在事务内锁住目标行,其他事务需要等待当前事务提交或回滚。对于库存扣减这类强一致、写冲突明显的场景,悲观锁直观且容易理解。
START TRANSACTION; SELECT available_stock, locked_stock FROM sku_stock WHERE sku_id = 1001 AND warehouse_id = 10 FOR UPDATE; -- 应用层确认 available_stock >= 2 UPDATE sku_stock SET available_stock = available_stock - 2, locked_stock = locked_stock + 2 WHERE sku_id = 1001 AND warehouse_id = 10; INSERT INTO inventory_reservation ( reservation_id, order_id, sku_id, warehouse_id, quantity, status, expire_at, created_at ) VALUES ( 'RSV202609190001', 'ORD202609190001', 1001, 10, 2, 'LOCKED', CURRENT_TIMESTAMP + INTERVAL 30 MINUTE, CURRENT_TIMESTAMP ); COMMIT;
但悲观锁并不自动解决三个问题。第一,事务如果包含远程调用,锁会被长时间持有;第二,查询条件没有合适索引时,锁的范围可能扩大;第三,死锁仍然可能发生。悲观锁适合短事务,不适合把支付、库存查询外部接口和物流分配全部塞进同一个事务。
乐观锁通过版本号或条件字段判断数据是否被别人修改。常见写法是先读取版本号,再执行带版本条件的更新:
UPDATE sku_stock SET available_stock = available_stock - 2, locked_stock = locked_stock + 2, version = version + 1 WHERE sku_id = 1001 AND warehouse_id = 10 AND version = 23 AND available_stock >= 2;
如果受影响行数为0,说明版本不一致或库存不足,应用可以重读后重试。但我不会对所有请求无限重试,因为在热点库存上,重试会把一次竞争放大成多次数据库写入。
乐观锁更适合读多写少、冲突概率可控的场景,例如后台库存调整、采购入库确认、低频的人工盘点。对于几千个请求集中争抢同一件商品的秒杀场景,单行版本号并不会消除竞争,只是把等待转换成失败和重试。
数据库行锁保护的是数据库事务中的行,分布式锁保护的是多个应用实例之间的临界区。两者解决的问题不同。一个请求即使成功拿到了缓存分布式锁,也可能在数据库提交前进程崩溃;一个请求即使拿到了数据库行锁,也不代表其他不经过该数据库的服务会遵守规则。
我通常不会为了“看起来安全”而同时叠加数据库行锁、缓存锁和应用层 synchronized。锁越多,越容易出现顺序不一致、过期时间不一致和故障恢复复杂的问题。
如果库存最终以关系型数据库为准,优先让数据库原子更新承担最终约束;如果使用缓存预扣,则必须把缓存扣减视为流量削峰或快速拦截手段,不能把缓存中的数字直接当作财务库存,除非已经建立可靠的落库和对账链路。
有些系统允许库存先扣成负数,等订单创建完成后再判断是否超卖。这种方式虽然能提高接口吞吐,但会把问题推给后面的仓库和客服。对于可替代商品或预约型业务可以接受,但对于不可替代、不可延期的实物商品,负库存通常意味着承诺已经失效。
如果业务确实允许超卖,必须把它设计成显式能力,例如设定超卖额度、可接受延迟、替代品规则和赔付预算,而不是让负数自然产生。“允许超卖”是经营策略,不是数据库异常的另一种说法。
当前库存适合查询,库存流水适合审计。没有流水,就无法解释某个 SKU 为什么从500件变成463件,也无法判断是订单锁定、支付确认、取消释放还是人工修正。
库存流水需要使用业务事件号做幂等键,记录变更前数量、变更数量、变更后数量、动作类型、订单号、仓库、操作者和来源系统。流水不应被简单覆盖;如果发生纠错,应新增一条反向或调整流水,保留完整链路。

库存锁定方案的选择,首先取决于业务约束。数据库是 MySQL、PostgreSQL 还是其他关系型数据库,当然会影响语法和执行计划,但不会替你回答库存该何时释放、支付失败是否保留库存、拆单时如何分配等业务问题。
如果四个问题没有明确答案,数据库管理员很难判断事务边界,开发人员也很难设计正确的异常分支。库存锁定不是单纯的表结构问题,而是销售承诺、支付状态和仓库执行之间的契约。
| 场景 | 推荐起点 | 原因 | 必须补充的机制 |
|---|---|---|---|
| 普通电商下单 | 带库存条件的原子更新 | 事务短、实现简单、数据库约束清晰 | 幂等键、流水、超时释放 |
| 后台人工调整 | 乐观锁 | 修改频率低,冲突时提示人工确认 | 版本号、操作日志、审批 |
| 多 SKU 原子锁定 | 按固定顺序悲观锁 | 需要在同一事务内确认多条库存 | 统一排序、短事务、死锁重试 |
| 极端热点商品 | 分片库存或预扣削峰 | 避免所有请求争抢同一数据库行 | 对账、补偿、限流、降级 |
| 预约、预售商品 | 配额表加原子扣减 | 库存本质是额度而非即时物理货品 | 配额过期、履约上限、延期规则 |
我更推荐将“条件更新”作为普通库存扣减的默认方案,因为它把库存充足性判断放进数据库执行条件里,减少应用层读写之间的竞态窗口。只有当业务需要一次性检查多个 SKU、多个仓库或多个批次时,才考虑使用更长但更严格的锁定事务。

应用层可以给用户提示“库存不足”,但不能只依赖应用层判断。最终的库存充足性必须在数据库更新条件或存储过程约束中再次确认,因为多个应用实例可能同时执行同一段代码。
最小可用的扣减逻辑是:
available_stock >= quantity 条件的原子更新。这里最容易漏掉的是第4步。很多接口只检查数据库异常,不检查受影响行数,结果是库存不足时 SQL 正常执行,应用仍然向前端返回“下单成功”。在我的复盘中,这类逻辑错误比数据库死锁更常见。
库存主表保存当前汇总状态,锁定表保存每个订单的占用凭证,流水表记录不可变的变化过程。三者职责必须分开,否则同一字段既承担当前值又承担审计记录,后期很难处理并发和纠错。
CREATE TABLE sku_stock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
total_stock INT NOT NULL DEFAULT 0,
available_stock INT NOT NULL DEFAULT 0,
locked_stock INT NOT NULL DEFAULT 0,
sold_stock INT NOT NULL DEFAULT 0,
frozen_stock INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id),
CHECK (total_stock >= 0),
CHECK (available_stock >= 0),
CHECK (locked_stock >= 0),
CHECK (sold_stock >= 0),
CHECK (frozen_stock >= 0)
);
CREATE TABLE inventory_reservation (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
reservation_id VARCHAR(64) NOT NULL,
order_id VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
quantity INT NOT NULL,
status VARCHAR(20) NOT NULL,
expire_at DATETIME NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_reservation (reservation_id),
UNIQUE KEY uk_order_sku (order_id, sku_id, warehouse_id),
KEY idx_expire_status (status, expire_at),
CHECK (quantity > 0)
);
CREATE TABLE inventory_flow (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
order_id VARCHAR(64),
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
action_type VARCHAR(30) NOT NULL,
quantity INT NOT NULL,
before_available INT NOT NULL,
after_available INT NOT NULL,
operator_id VARCHAR(64),
created_at DATETIME NOT NULL,
UNIQUE KEY uk_event (event_id)
);实际项目中还要根据数据库版本确认 CHECK 约束是否真正生效,并通过应用层和定时校验双重保证数量不为负。表结构不能代替业务逻辑,但可以把明显错误挡在数据库边界内。
库存锁定至少需要两层幂等。第一层是请求幂等,同一个客户端请求重复提交时,不能生成两条锁定记录;第二层是事件幂等,同一个支付成功或订单取消事件重复消费时,不能重复确认或释放库存。
推荐使用业务生成的 reservation_id 或订单行号作为锁定唯一键,再用数据库唯一索引兜底。不要只依赖缓存中的“已处理”标记,因为缓存可能过期、丢失或被误删除。
幂等处理不等于简单返回成功。系统需要查询已存在记录的状态:如果原请求已经锁定成功,应返回原锁定结果;如果原请求处于处理中,应返回可重试状态;如果原请求已释放,则要根据业务规则决定是否允许重新锁定。
单 SKU 锁定优先采用一条条件更新,再写锁定记录。若需要保证主表与锁定表同时成功,应放在同一个本地事务中。
START TRANSACTION; -- 先尝试幂等查询 SELECT reservation_id, status, quantity FROM inventory_reservation WHERE order_id = 'ORD202609190001' AND sku_id = 1001 AND warehouse_id = 10 FOR UPDATE; -- 若不存在,则执行原子扣减 UPDATE sku_stock SET available_stock = available_stock - 2, locked_stock = locked_stock + 2, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = 1001 AND warehouse_id = 10 AND available_stock >= 2; -- 应用必须检查 affected_rows = 1 INSERT INTO inventory_reservation ( reservation_id, order_id, sku_id, warehouse_id, quantity, status, expire_at, version, created_at, updated_at ) VALUES ( 'RSV202609190001', 'ORD202609190001', 1001, 10, 2, 'LOCKED', CURRENT_TIMESTAMP + INTERVAL 30 MINUTE, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ); INSERT INTO inventory_flow ( event_id, order_id, sku_id, warehouse_id, action_type, quantity, before_available, after_available, operator_id, created_at ) VALUES ( 'EVT202609190001', 'ORD202609190001', 1001, 10, 'RESERVE', 2, 3650, 3648, 'order-service', CURRENT_TIMESTAMP ); COMMIT;
示例中的库存前后数量由应用实际读取并写入流水,不应该在生产代码里写死。为了避免流水与主表不一致,业务层应在更新前获取当前值,或者使用数据库返回能力读取更新前后值。
购物车订单往往包含多个 SKU。这里有两种不同业务选择:全部商品锁定成功才创建订单,或者允许部分商品锁定成功。前者一致性强但失败率可能更高,后者体验灵活但需要拆单、退款和库存释放逻辑。
如果选择“全部成功或全部失败”,必须固定加锁顺序。例如按 sku_id 升序、再按 warehouse_id 升序锁定所有库存行。两个事务按照相同顺序获取锁,可以明显减少循环等待。
多 SKU 锁定的事务时间应尽量控制在几十毫秒级。不要在事务中调用价格服务、地址服务、会员服务或外部风控接口。外部信息应在进入事务前准备好,或者拆成预校验与最终确认两个阶段。
支付成功并不意味着库存可以直接再减一次。锁定阶段已经从可售库存转入已锁定库存,支付确认只需要把已锁定库存转入已售库存。
START TRANSACTION; SELECT status, quantity, sku_id, warehouse_id FROM inventory_reservation WHERE reservation_id = 'RSV202609190001' FOR UPDATE; -- 只有 LOCKED 状态允许确认 UPDATE inventory_reservation SET status = 'CONFIRMED', version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE reservation_id = 'RSV202609190001' AND status = 'LOCKED'; -- 受影响行数为1时才执行库存状态转移 UPDATE sku_stock SET locked_stock = locked_stock - 2, sold_stock = sold_stock + 2, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = 1001 AND warehouse_id = 10 AND locked_stock >= 2; COMMIT;
这里也要检查两个受影响行数:锁定记录状态是否从 LOCKED 转为 CONFIRMED,库存主表是否成功完成状态转移。如果第一步成功、第二步失败,事务必须回滚;如果已经跨越数据库或服务边界,则要进入异常待处理队列,而不是静默忽略。
释放库存必须是一个明确的状态转换,不能直接执行“可售库存加一、锁定库存减一”两条 SQL。因为同一订单可能同时收到取消请求、支付成功回调和超时任务。
START TRANSACTION; UPDATE inventory_reservation SET status = 'RELEASED', version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE reservation_id = 'RSV202609190001' AND status = 'LOCKED' AND expire_at <= CURRENT_TIMESTAMP; -- 只有 affected_rows = 1 时,才归还库存 UPDATE sku_stock s JOIN inventory_reservation r ON s.sku_id = r.sku_id AND s.warehouse_id = r.warehouse_id SET s.available_stock = s.available_stock + r.quantity, s.locked_stock = s.locked_stock - r.quantity, s.version = s.version + 1, s.updated_at = CURRENT_TIMESTAMP WHERE r.reservation_id = 'RSV202609190001' AND r.status = 'RELEASED' AND s.locked_stock >= r.quantity; COMMIT;
生产环境中不建议把“状态更新”和“库存归还”拆成两个无条件执行的语句。可以将锁定记录行锁住后读取状态,再在同一事务中完成释放;也可以通过释放事件和幂等处理器完成。无论采用哪种方式,原则都是:释放动作只能发生一次,且只有原来确实处于锁定状态的数量才能被归还。
库存系统的测试不能只测正常下单。至少要覆盖同 SKU 并发扣减、重复请求、支付回调重复、取消与支付同时到达、数据库连接断开、消息发送失败、定时任务重复执行、服务进程在提交前后崩溃等场景。

下面案例使用脱敏后的零售业务模型说明,场景包括多个仓库、多个销售渠道和统一订单中心。改造前,系统只维护“库存数量”字段,订单创建时先查询库存,再异步扣减;改造后,增加已锁定库存、库存流水和锁定有效期,并将库存扣减改为带条件的原子更新。
改造前的主要问题有三类。第一,同一订单重复提交会产生两次库存扣减;第二,取消订单依赖消息通知,消息丢失后库存一直无法释放;第三,渠道库存与仓库库存的统计口径不同,业务人员每天需要人工比对。
改造后没有追求“所有数据实时一致”的口号,而是把一致性拆成三层:交易主库内的库存与锁定记录必须强一致;跨服务事件允许短暂延迟但必须可重试;分析报表允许按小时刷新,但必须标记数据截止时间。
下表是该类方案的情景模拟数据,用于展示指标关系,不代表公开行业统计。观察周期为连续14天,峰值日的订单流量约为平日的4.6倍。
| 指标 | 改造前 | 改造后 | 观察口径 |
|---|---|---|---|
| 超卖订单占比 | 0.37% | 0.04% | 订单完成后发现可履约库存不足的订单数 / 支付订单数 |
| 库存释放平均延迟 | 6.8小时 | 11分钟 | 取消或超时到库存归还可售池的平均时间 |
| 重复扣减事件 | 每天约63次 | 每天约4次 | 按订单行和事件号去重后的异常事件数 |
| 库存对账人工耗时 | 每天3.5小时 | 每天0.7小时 | 运营和财务用于订单、库存、流水比对的时间 |
| 热点行P99锁等待 | 410毫秒 | 96毫秒 | 热点 SKU 数据库行锁等待的99分位耗时 |
数据最值得注意的不是超卖率下降,而是库存释放延迟从小时级降到分钟级。很多团队只盯着超卖,却忽略了“被锁定但无法售卖”的库存会直接降低可售率。对于库存少、销售快的商品,释放延迟本身就是销售损失。

当库存主表和订单系统分离后,数据库管理员通常无法仅靠 SQL 判断业务损失。此时可以把库存流水、订单状态、支付结果、释放任务和仓库出库记录汇总到分析层,按 SKU、仓库、渠道、时间段和订单状态进行交叉分析。
在一个实际的数据分析过程中,我们按照“锁定成功,支付成功,确认成功,出库成功”的链路拆分转化。某仓库的锁定成功率并不低,但“确认成功到出库成功”的转化明显低于其他仓库,最终定位为仓库库存同步延迟,而不是订单系统的锁问题。
如果企业已经使用九数云等数据分析平台,可以把库存流水和订单明细连接起来,建立锁定时长、释放原因、支付转化率和仓库履约率的看板。这里的重点不是把所有数据做成漂亮图表,而是让每一个库存异常都能回到订单号和事件号。
建议至少建立以下分析维度:
我特别建议增加“库存承诺损失”指标:在某个时间窗口内,因已锁定库存未及时释放而无法接受的新订单数量。它比单纯的库存不足次数更能反映锁定策略对销售的影响。

普通电商的库存特点是 SKU 数量多、单个 SKU 的并发相对分散、订单有效期通常为15至30分钟。此时没有必要一开始就引入复杂的分布式库存架构。
这个方案的优点是结构清晰、故障定位较容易、数据库约束充分。它的短板是热点 SKU 仍然可能产生行锁竞争,需要通过限流、排队或库存分片解决,而不是单纯提高数据库连接数。
秒杀场景不能让所有请求直接打到同一个库存行。即使数据库能够承受瞬时流量,热点行更新也会形成排队,最终用户看到的是大量超时和重试。
我建议把链路拆成三层:
如果使用分片库存,可以把一个 SKU 的1000件库存拆到10个逻辑桶,每个桶100件,减少单行热点。但分片会带来分配不均和桶耗尽问题。用户不能直接看到某个桶的库存,系统还要设计桶之间的调度和失败回退。
多仓库业务的难点不是锁本身,而是库存归属。用户下单后,如果系统还没有确定发货仓库,就不能简单地把全国库存当作一个数字扣减。
一种做法是先锁定全国共享库存,等地址、配送时效和仓库规则确定后再分配仓库。这种方式灵活,但可能产生“全国有货、目标仓无货”的履约落差。
另一种做法是先根据地址、承运商、时效和仓库优先级选定仓库,再锁定具体仓库库存。这种方式更接近真实履约,但选仓失败时需要重新计算,订单接口的复杂度更高。
| 分配方式 | 优点 | 短板 | 适用情况 |
|---|---|---|---|
| 先锁全国库存 | 下单成功率高,流程简单 | 后续可能无法匹配具体仓库 | 仓库可调拨、配送时效要求低 |
| 先选仓再锁定 | 库存承诺更接近真实履约 | 选仓规则复杂,失败需要重算 | 时效敏感、生鲜、区域库存 |
| 区域库存池 | 兼顾灵活性和区域履约 | 库存池边界和调拨规则复杂 | 多区域销售、仓网较成熟 |
预售商品的库存往往代表生产能力、供应商额度或预计到货量,而不是仓库中已经存在的物品。此时库存锁定可以采用配额表,但必须把交付时间、最大可售量和延期处理写进业务规则。
例如,供应商承诺10月交付5000件,系统可以将5000件作为可售配额,订单锁定时减少配额;如果供应商后续只能交付4700件,剩余300件不能靠修改库存数字解决,而要进入延期、退款或替代品流程。
很多企业有订单系统、仓库系统、采购系统和数据平台,每个系统都显示一个“库存数”。如果没有明确事实来源,任何锁定方案都会陷入口径冲突。
我建议按库存状态划分责任:订单系统负责销售承诺和锁定状态,仓库系统负责实物库存和出库状态,采购系统负责在途与预计到货,数据平台负责汇总分析但不直接写回交易库存。某个系统只能修改自己负责的状态,跨系统变化通过事件传递。

单条条件更新是我最常推荐的默认方案。它把库存充足判断与扣减放在一个数据库操作中,代码量小,数据库容易理解,异常也较容易回滚。
它的不足是所有请求仍然可能争抢同一行,热点 SKU 的吞吐受限于单行更新能力。此外,多 SKU、多仓库的复杂锁定不能简单靠一条 SQL 完成。对于普通业务,它通常是可靠性和实现成本之间最均衡的选择。
悲观行锁的优势是业务直觉强,事务内读取到的库存不会被其他事务同时修改。对于必须同时检查多项库存条件的订单,它比复杂的应用重试更容易控制。
但它要求团队严格控制事务边界。事务中一旦包含远程调用、复杂计算或大范围查询,锁等待会迅速放大。数据库管理员还需要观察死锁、锁等待、事务持续时间和索引命中情况。
乐观锁不让请求长时间等待,而是用版本冲突告诉应用“数据已经被修改”。在低冲突场景中,这种方式可以减少锁持有时间;在高冲突场景中,它会产生大量失败请求和重试请求。
因此,乐观锁并不是“性能更高”的同义词。它更像一种冲突处理方式:冲突少时收益明显,冲突多时必须配合退避、限重试和用户提示,否则数据库压力可能被重试放大。
缓存预扣能够把大量请求拦截在数据库之前,适合大促和极端热点商品。但它引入了新的事实来源:短时间内缓存数字和数据库数字可能不同。
如果采用这种方案,必须明确以下问题:缓存扣减成功后数据库写入失败怎么办;消费者重复消费怎么办;缓存节点故障后如何恢复剩余库存;活动结束后如何核对未成交库存;用户看到锁定成功但订单创建失败时如何回滚。
只要这些问题没有可执行的补偿方案,就不应为了追求吞吐而贸然采用缓存预扣。高吞吐方案的真正成本,往往不在正常路径,而在异常恢复和最终对账。

库存锁定的业务监控至少包括锁定成功率、库存不足率、重复请求率、释放成功率、超时释放延迟、支付确认成功率和异常待处理数量。每个指标都要明确分母,否则“成功率99%”可能只是因为大量无效请求没有进入统计。
其中,状态冲突率不能简单被当成错误。支付成功回调和超时释放同时到达时,拒绝其中一个是正常的并发保护结果。真正需要关注的是冲突后是否进入正确状态,以及是否有库存数量泄漏。
数据库层要把平均耗时和尾部耗时分开看。库存系统特别容易出现平均响应时间正常、P99 长时间恶化的情况。建议监控行锁等待、死锁次数、长事务数量、连接池等待、慢查询、热点 SKU 更新次数和事务回滚率。
索引设计也直接影响锁范围。库存查询应尽量命中 (sku_id, warehouse_id) 唯一索引,过期锁定扫描应命中 (status, expire_at) 索引。没有索引的条件更新可能扫描大量记录,导致锁竞争和数据库负载同时升高。
排障时不要第一时间把责任归结为“数据库锁失效”。数据库可能严格执行了应用发来的两次合法扣减,真正的问题是应用没有用业务唯一键防止重复请求,或者释放任务没有判断锁定状态。
建议建立“库存主表,锁定表,库存流水”三方对账。库存主表提供当前汇总,锁定表提供未完成占用,流水表提供变化来源。三者不能只比较总数,还要按 SKU、仓库、订单和事件类型分组。
-- 检查主表中的锁定库存与锁定记录汇总是否一致 SELECT s.sku_id, s.warehouse_id, s.locked_stock, COALESCE(SUM(r.quantity), 0) AS reservation_locked FROM sku_stock s LEFT JOIN inventory_reservation r ON s.sku_id = r.sku_id AND s.warehouse_id = r.warehouse_id AND r.status = 'LOCKED' GROUP BY s.sku_id, s.warehouse_id, s.locked_stock HAVING s.locked_stock <> COALESCE(SUM(r.quantity), 0);
这类对账 SQL 不应直接用于在线交易链路,而应在只读副本、分析库或低峰时段运行。对账发现差异后,系统应生成异常任务,不要让人工直接修改主表而不留下调整流水。

库存服务至少应对外提供锁定、确认、释放、查询和对账接口,并明确每个接口的状态前置条件。订单服务不应直接修改库存主表,后台人员也不应通过通用数据库客户端直接改可售库存。
如果确实需要人工调整,应该提供带审批和原因的调整接口,自动生成调整流水,并要求填写关联单据。人工修正不是禁区,但必须可追溯、可回滚和可复核。
锁定有效期要结合支付方式、用户体验和库存周转设计。普通实物订单可以设置15至30分钟,预约订单可能需要数小时,人工审核订单则应采用独立状态,不宜复用普通超时任务。
有效期不是一个固定数字。要观察锁定转支付的时间分布:如果90%的支付在3分钟内完成,设置30分钟可能会造成大量无效占用;如果大额企业订单需要人工审批,30分钟可能过短。建议按订单类型、支付渠道和风险等级设置不同期限。
释放任务可能因为部署、网络和数据库故障重复执行,所以每一次处理都必须可重入。任务领取记录、批次号和状态条件可以帮助控制并发;即使多个任务扫描到同一条记录,也只能有一个事务成功把状态从 LOCKED 改为 RELEASED。
扫描任务不要一次性查询所有过期记录。应使用分页、批量大小限制和合理索引,避免大事务长时间占用锁。每批处理后记录成功、跳过、失败和重试数量,连续失败达到阈值时告警。
订单量相同,锁定时长不同,数据库和业务库存压力可能完全不同。锁定时间越长,已锁定库存越多,可售库存越少,释放任务和对账任务也越重。
容量规划至少要考虑:峰值订单行数、单个热点 SKU 的更新频率、订单平均 SKU 数量、锁定平均时长、支付回调峰值、释放任务批量、库存流水增长速度和异常重试比例。

交易告警需要秒级或分钟级响应,例如库存锁定失败率突增、死锁持续增加、热点行等待超过阈值、异常待处理队列积压。分析看板则用于观察趋势,例如不同 SKU 的锁定转化、仓库释放延迟和库存承诺损失。
九数云等分析工具更适合承接跨系统分析、维度下钻和周期对比,但交易链路中的强一致判断仍然应该放在数据库和交易服务中。不要把分析看板上的“库存数”当作实时扣减依据,必须显示数据更新时间和数据来源。
不一定。普通单 SKU 扣减通常可以使用带库存条件的原子更新,既能保证库存不会被扣成负数,也能减少应用层先查后减的竞态。只有在需要一次性检查多条库存、多个批次或多个仓库时,才更有必要使用悲观锁。
库存条件更新主要判断“库存是否足够”,乐观锁主要判断“数据版本是否仍然是我读取的版本”。两者可以同时使用:既要求版本一致,又要求可售库存大于购买数量。实际选择要看冲突概率和重试成本。
常见原因是库存扣减和订单创建不在同一个本地事务中,或者库存提交后订单服务超时。解决方式不是直接补扣或补加,而是通过锁定记录状态、可靠事件、订单查询和补偿任务确认最终结果。库存锁定成功但订单未创建时,应能够安全释放或转入异常处理。
如果订单有效期是30分钟,可以每1至5分钟运行一次释放任务,并通过分批处理控制数据库压力。真正重要的不是任务每分钟运行,而是释放延迟是否满足业务目标,以及任务失败后是否有告警和重试。
会,所以应提前规划分区、归档、冷热数据和分析库同步。流水表不能因为增长快就删除关键记录,尤其是支付确认、释放、人工调整和异常补偿记录。可以将历史流水归档到低成本存储,但必须保留按订单号和事件号查询的能力。
如果业务极其简单、没有预占、没有支付延迟、没有取消释放,也许可以。但只要存在下单后等待支付、取消订单、库存冻结或多仓库分配,单一字段就很难支撑状态解释和对账。至少应保留锁定数量、已售数量和流水记录。
因为热点行锁等待、连接池排队、长事务和网络等待不一定直接体现为 CPU 满载。库存系统必须把锁等待、事务持续时间、连接池等待和 P99 延迟一起观察,不能只看数据库平均 CPU 使用率。
我对库存锁定的最终判断可以概括为三句话。第一,库存扣减必须由数据库原子条件或等价的强约束完成,不能依赖应用层先查后减。第二,锁定必须拥有业务凭证、状态机、幂等键、有效期和释放机制。第三,任何跨系统一致性都要通过事件、重试、补偿和对账来完成,而不是假设一次调用永远成功。
如果你的业务是普通电商,先从库存主表、锁定表、流水表、条件更新和幂等机制做起,不要过早引入复杂架构。如果你的业务有秒杀热点,先治理无效流量和热点行,再考虑分片库存或缓存预扣。如果你的业务是多仓库履约,优先明确库存责任边界和选仓规则,否则再精细的数据库锁也无法解决仓库口径冲突。
下一步可以按以下顺序执行:
库存锁定的成熟度,不是看系统有没有使用某一种“高级锁”,而是看任何一次库存变化能否回答四个问题:谁锁的、锁了多少、何时确认或释放、如果中途失败如何恢复。能回答这四个问题,数据库才真正从“保存库存数字”升级为支撑业务增长的库存系统。
我在做库存扣减压测时发现,单纯依赖应用层先查询库存、再执行扣减,在并发上升后很容易出现超卖。我想知道数据库行锁和版本号到底应该怎么选,以及两种方案分别适合什么场景。
我的判断是:库存数量是强一致资源,默认优先采用“条件更新”,而不是先查询再加锁。最小可用写法是:UPDATE inventory SET available = available – 1, version = version + 1 WHERE sku_id = ?
AND available >= 1 AND version = ?,然后根据受影响行数判断扣减是否成功。在一次模拟 500 个并发请求争抢 100 件库存的测试中,先查询再更新的实现出现过超卖;改成带 available >= 1 条件的单条更新后,最终库存不会低于 0。
版本号适合需要识别并发修改的场景,但必须处理版本冲突重试,不能无限重试,否则会把数据库压力转化为线程堆积。
方案优点风险适用场景 条件更新SQL短,锁持有时间短冲突时需要重试或返回失败高并发扣减、库存预占 悲观锁业务语义直观容易形成锁等待和死锁低并发、事务内需要连续读取和修改 乐观锁读多写少时吞吐较好高冲突时重试成本明显后台编辑、低冲突库存变更 如果一次业务要同时扣减多个 SKU,我不会简单地对每个 SKU 逐条加锁,而是先按 SKU 的固定顺序排序,再执行更新。
这样可以降低事务 A 锁住 SKU-1、等待 SKU-2,而事务 B 锁住 SKU-2、等待 SKU-1 的死锁概率。
我以前见过一种实现:接口先把库存减掉,接着调用订单服务,订单服务失败后再补库存。网络抖动时补偿任务会延迟,用户看到的可售库存和实际订单状态经常对不上,我想知道库存锁定、支付、释放这几个阶段应该怎样拆分。
库存锁定不等于库存最终扣减,建议把库存拆成“可用、锁定、已售”三个可追踪状态。下单时只做可用转锁定,支付成功后再做锁定转已售,超时取消则做锁定转可用。这样即使支付服务暂时不可用,也不会直接污染最终销售数据。
我更推荐在同一个本地事务里完成“创建订单”和“锁定库存”:先写订单主表,再执行带条件的库存更新,任一步失败就整体回滚。跨服务场景不要把数据库事务强行延伸到支付系统,而应使用业务事件、可靠消息表或事务消息,并给每个订单建立幂等键。
核心数据可以按下面方式记录: 字段作用关键约束 available当前可售数量不能小于 0 reserved已锁定但未完成支付数量释放和支付只能操作一次 sold已完成销售数量支付成功事件幂等 request_id请求或订单幂等标识建立唯一索引 实际执行时,我会把状态流转限制为固定路径:available – 1,reserved + 1;
支付成功时 reserved – 1,sold + 1;超时取消时 reserved – 1,available + 1。每次状态变化都写库存流水,流水中的订单号、动作类型和业务请求号建立唯一约束,避免重复消费消息造成重复加库存。最容易被忽略的是超时释放。
不要依赖用户再次访问页面触发释放,而应由定时任务扫描过期锁定记录,并采用分批处理,例如每批 500 条、每次只处理明确过期的数据,同时设置最大执行时长,避免清理任务长时间占用库存行锁。
我想把库存放在一张商品表里,用一个数量字段直接减库存,但担心热点商品会让整行成为竞争点。我的疑问是,应该怎样写表结构、索引和更新语句,才能既保证不超卖,又不把数据库拖垮。
防超卖的第一原则不是增加线程锁,而是让数据库在一次原子更新中完成“判断库存”和“扣减库存”。
例如:UPDATE sku_stock SET available = available – :qty, reserved = reserved + :qty WHERE sku_id = :sku_id AND available >= :qty。
执行后检查影响行数,等于 1 才算锁定成功,等于 0 则返回库存不足或稍后重试。表结构上,我通常把库存余额和库存流水分开。余额表只服务于高频读写,字段尽量少;流水表记录订单号、SKU、变更数量、变更前后余额、动作类型、请求号和创建时间。
不要把大量 JSON 扩展字段、商品描述和库存余额放在同一张热点表里,否则每次扣减都会扩大行记录和缓存压力。
设计项推荐做法原因 库存余额按 SKU 保持单行或合理分片减少无关字段更新 库存流水按时间或业务周期归档避免历史数据拖慢写入和查询 幂等索引订单号、动作类型、请求号唯一阻止重复扣减或重复释放 扣减 SQL数量条件放在 WHERE让数据库原子判断库存 在一次 10 万次扣减请求的压测里,单行热点库存的瓶颈通常不是 SQL 是否足够短,而是所有请求都在等待同一行。
此时可以先用缓存或队列削峰,但缓存只能承担排队和快速失败,最终库存确认仍要回到数据库或可信库存服务,不能用缓存中的数字直接作为支付依据。如果单个 SKU 的并发仍然过高,可以把库存拆成多个库存桶,例如将 10,000 件库存拆到 20 个桶,由请求按规则选择桶,再通过汇总校验控制总量。
不过分桶会增加查询、回滚和对账复杂度,我只会在单行锁竞争已经成为明确瓶颈,并且有完善流水对账能力时采用,而不会一开始就过度设计。
我在生产环境遇到过接口偶发超时,但数据库里库存行一直处于锁定状态,重试几次后还出现死锁。我想建立一套可以落地的排查方法,知道该看哪些指标、怎样设置超时,以及什么情况下应该让请求失败而不是继续重试。
库存问题排查要先区分三类现象:锁等待是事务还没结束,死锁是多个事务互相等待,库存不释放则通常是业务状态机或补偿任务缺失。三者都表现为“库存异常”,但处理方式完全不同,不能统一靠重启应用解决。我会优先检查数据库的活动事务、锁等待关系、最早开始时间和 SQL 绑定参数,再回查应用日志中的订单号和请求号。
若发现事务内包含远程调用、文件上传或复杂查询,通常会把远程调用移出事务;数据库事务只保留创建订单、条件扣减和流水写入,尽量控制在几十毫秒级。
指标建议关注点异常信号 锁等待时间按接口、SKU、数据库节点统计持续高于接口超时预算 死锁次数记录参与事务和 SQL 顺序固定 SKU 组合反复出现 锁定超时库存按创建时间分桶超过订单有效期仍未释放 库存流水差额余额与流水汇总对账出现无法解释的正负差额 重试必须有边界。
我一般只允许数据库死锁或瞬时连接失败重试 1 到 2 次,并采用很短的随机退避;库存不足、参数错误和幂等冲突不应该重试。无限重试会把一个短暂的锁冲突放大成连接池耗尽,最终让正常请求也无法执行。对于“锁定后订单一直未支付”的记录,应由定时任务按过期时间释放,并设置人工干预入口。
人工补偿不能直接修改余额字段,而应复用正式的释放流程,写入补偿原因、操作人、原请求号和新流水号,完成后再进行库存余额、订单状态和流水总额三方对账。


读者评论
文章把库存锁定从单纯的 SQL 操作提升到状态机和业务凭证,尤其是总库存、锁定库存、已售库存、风险冻结库存的拆分很实用。实际项目里,订单取消和支付失败经常导致库存口径混乱,设置 reservation_id、expire_at 和状态流转确实有助于追溯。
热点 SKU 的分析比较有价值。数据库 CPU 只有 62%,但锁等待达到 410 毫秒,说明平均响应时间和 CPU 不能代表库存系统健康状况。建议实际落地时继续补充锁等待监控、连接池占用和按 SKU 的失败率,否则很难快速定位高峰期瓶颈。
文中对“先查后减”风险的说明很清楚,受影响行数必须作为锁定成功依据,这一点容易被忽略。对于订单、支付、库存跨服务的场景,单靠数据库事务确实不够,还需要幂等、事件重试、超时释放和对账机制,实施成本也要提前评估。