库存锁定接口在高峰期变慢,最容易被误判成“数据库性能不够”或“连接池太小”。但在我参与过的库存、订单和仓储系统排查中,真正导致请求排队的往往不是数据库 CPU 打满,而是少数热点库存行被长事务持续占用:一个事务拿到锁后又执行了远程调用,后面的请求只能排队,超时请求再触发重试,最终形成“锁等待,连接池堆积,接口超时,重复重试”的放大链路。本文以产品技术团队自查为主线,拆解库存锁定为什么容易出现严重锁等待,以及如何从事务、SQL、索引、数据库监控和业务重试逐层定位。
当库存扣减接口出现 P95、P99 延迟升高时,第一反应不应是调大锁等待超时时间,也不应立即扩充数据库连接池。技术团队首先要回答四个问题:哪个事务正在等待,等待哪个资源,谁持有资源,持锁事务为什么还没有提交。
如果无法回答这四个问题,所谓“优化”通常只是把故障表象往后推。例如,把锁等待从 3 秒调到 10 秒,并不会减少竞争;把连接池从 100 增加到 300,反而可能让更多请求同时冲向同一个热点库存行。
我的判断顺序是:先确认阻塞关系,再缩短持锁时间;先修复原子更新和索引,再处理热点架构;最后才考虑连接池、超时和重试参数。
这五种情况经常同时出现。比如促销期间某个爆款 SKU 被集中请求,库存更新本来只需要几毫秒,但事务在锁内调用订单服务,持锁时间增长到几百毫秒;随后请求开始等待,应用重试又提高同一 SKU 的并发度,最终表现为整个库存服务变慢。

产品技术团队需要先统一问题口径。接口耗时升高不等于锁等待严重,数据库出现等待事件也不一定意味着库存表被“锁死”。至少要同时观察事务耗时、锁等待时长、阻塞会话数、连接池获取连接耗时、死锁次数和库存接口错误率。
| 现象 | 更可能的技术问题 | 首要证据 | 不要直接下的结论 |
|---|---|---|---|
| 数据库 CPU 不高,但接口大量超时 | 锁等待、连接池等待或长事务 | 等待链、事务开始时间、连接池获取耗时 | 数据库整体性能不足 |
| CPU 高、扫描行数多、单条更新变慢 | 索引缺失、执行计划异常或数据倾斜 | 执行计划、扫描行数、SQL 耗时 | 一定是锁表 |
| 请求偶发失败且日志出现 deadlock | 死锁 | 数据库死锁日志和事务资源顺序 | 只是普通锁等待 |
| 连接池活跃连接数持续满载 | 数据库响应慢、连接泄漏或线程堆积 | 获取连接等待时间、连接归还时间 | 增加连接池就能解决 |
很多业务表的写入会分散到不同订单、不同用户或不同流水号,但库存表恰恰相反。大量用户购买同一个商品时,所有请求都可能更新同一条 SKU 与仓库组合记录。数据库可以同时处理很多互不相关的库存行,却无法让同一行的多个写事务同时修改。
这也是库存系统常见的反直觉现象:数据库总 QPS 看起来不高,表数据量也不大,但某个热点 SKU 的响应时间已经失控。平均值会掩盖问题,因为普通 SKU 可能只占用几毫秒,而爆款 SKU 的等待时间已经达到秒级。
在设计监控时,我不建议只看库存表整体 QPS。更有价值的是统计一段时间内 SKU、仓库、批次和库存状态的写入集中度,例如前 1% 的库存资源承载了多少比例的更新请求。
库存锁定、支付确认、取消释放、超时回滚、发货扣减和人工校准,常常都会更新同一行库存。业务上这些动作并不相同,但数据库层面可能都表现为对可用库存、锁定库存或实物库存字段的写操作。
如果所有动作都在同一张表、同一条记录上完成,锁竞争会被叠加。尤其是取消回滚和订单超时任务,它们通常不是用户实时请求,却可能在整点或批量任务时集中执行,恰好与前台下单流量竞争库存行。
常见反例是先查询可用库存,应用层判断数量足够后,再执行扣减。这个流程看起来容易理解,却把“判断库存”和“修改库存”拆成了两个数据库动作。多个事务可能在判断阶段读到相同库存,随后一起尝试更新。
BEGIN; SELECT available_qty FROM inventory WHERE sku_id = :sku_id AND warehouse_id = :warehouse_id; -- 应用层判断 available_qty 是否足够 UPDATE inventory SET available_qty = available_qty - :qty WHERE sku_id = :sku_id AND warehouse_id = :warehouse_id; COMMIT;
这段逻辑并非绝对错误,但必须明确它依赖什么锁模式、隔离级别和业务约束。如果查询没有锁定,可能出现竞态;如果查询使用悲观锁,锁持有时间又可能从查询开始一直延续到事务提交,反而增加等待。
更常见的改法是将库存充足条件放入更新语句,让数据库一次完成条件判断和扣减。
UPDATE inventory SET available_qty = available_qty - :qty, locked_qty = locked_qty + :qty, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND warehouse_id = :warehouse_id AND available_qty >= :qty;
执行后必须检查影响行数。影响行数为 1,才表示本次扣减成功;影响行数为 0,可能是库存不足,也可能是条件不匹配、数据状态异常或请求参数错误。不要把所有 0 行更新都当成锁等待失败。

库存更新的过滤条件通常包括 SKU、仓库、批次、库存状态或租户。若条件字段没有合适索引,数据库可能扫描较多记录才能找到目标行。扫描时间变长会延长事务执行时间,某些数据库和隔离级别下还可能扩大锁影响范围。
这里有一个容易被忽略的判断:索引不仅影响查询快慢,也影响锁竞争的持续时间和范围。补索引不是“锁等待优化”的万能答案,但当执行计划显示扫描行数远大于实际更新行数时,索引往往是必须修复的基础问题。
下面是一条很常见的下单链路:用户提交订单后,库存服务开启事务;系统查询库存并锁定;写入库存流水;调用订单服务确认订单;调用营销服务核验优惠;最后提交事务。
在低并发环境下,这套流程可能长期没有问题,因为每个事务都能很快完成。但在促销活动中,同一 SKU 的多个请求会集中进入库存服务。只要其中一个事务在锁内等待远程服务,后续请求就会全部等待同一行资源。
为了说明排查过程,下面使用一组情景模拟数据,数据不是某个企业的生产事故记录。模拟条件是:单个热点 SKU、数据库单分片、库存更新采用行级事务、应用设置有限重试,观察优化前后的变化。
| 观察项 | 优化前情景 | 优化后情景 | 变化原因 |
|---|---|---|---|
| 库存更新本身耗时 | 约 8 毫秒 | 约 7 毫秒 | SQL 本身没有明显变化 |
| 事务总耗时 P95 | 约 420 毫秒 | 约 35 毫秒 | 移除持锁期间的远程调用 |
| 锁等待 P95 | 约 280 毫秒 | 约 18 毫秒 | 缩短锁持有时间后,等待链变短 |
| 接口超时率 | 2.8% | 0.4% | 减少等待和重复重试 |
| 连接池获取连接等待 | 约 160 毫秒 | 约 12 毫秒 | 长事务释放连接更及时 |
BEGIN; UPDATE inventory SET available_qty = available_qty - :qty, locked_qty = locked_qty + :qty WHERE sku_id = :sku_id AND warehouse_id = :warehouse_id AND available_qty >= :qty; INSERT INTO inventory_flow(order_id, sku_id, quantity, flow_type) VALUES (:order_id, :sku_id, :qty, 'LOCK'); -- 不应该放在持锁事务中的操作 CALL order_service_confirm(:order_id); CALL promotion_service_validate(:order_id); COMMIT;
最危险的地方不是 UPDATE 语句,而是 UPDATE 成功后事务没有立即提交。库存行已经被锁定,订单服务和营销服务的网络延迟却不可控。只要其中一个调用出现重试、连接排队或下游抖动,库存锁就会被被动延长。
排查时要把“SQL 执行时间”和“锁持有时间”分开记录。很多团队只在日志中记录 UPDATE 耗时,看到它只有几毫秒,就认为数据库没有问题。但真正影响后续请求的是从第一次加锁到 COMMIT 或 ROLLBACK 的完整区间。
一种较稳妥的方向是,先完成库存原子锁定,再通过可靠事件推进订单确认。这里的关键不是简单地把代码改成异步,而是要重新定义失败补偿、幂等和库存释放规则。
BEGIN;
UPDATE inventory
SET available_qty = available_qty – :qty,
locked_qty = locked_qty + :qty
WHERE sku_id = :sku_id
AND warehouse_id = :warehouse_id
AND available_qty >= :qty;
— 影响行数不为 1 时回滚并返回明确业务结果
INSERT INTO inventory_reservation
(reservation_id, order_id, sku_id, quantity, status)
VALUES
(:reservation_id, :order_id, :sku_id, :qty, 'LOCKED');
COMMIT;— 提交成功后发布库存锁定事件
— 下游根据 reservation_id 做幂等处理
这种方案减少了数据库锁持有时间,但会引入最终一致性窗口。例如库存已经锁定,订单确认暂时失败,系统必须依靠状态机、超时扫描或补偿消息释放库存。因此,不能只看接口变快了多少,还要对比锁定记录、订单状态、释放记录和库存流水是否能够闭环。

在我看来,库存并发优化最危险的误区是只追求锁等待下降,却没有同步设计“超时后的事实判断”。如果一个请求在客户端看来失败,但数据库已经成功锁定库存,重试逻辑就可能造成重复锁定或重复扣减。
不要只看 Service 方法上是否有事务注解。实际事务边界可能受到框架代理、线程切换、嵌套调用、手动提交和异常处理影响。建议在数据库连接层或事务拦截器层记录事务 ID、开始时间、提交时间和回滚时间。
一次库存请求至少应能关联到以下信息:请求 ID、订单号、SKU、仓库、数据库连接 ID、事务开始时间、库存 SQL 开始时间、锁等待开始时间和事务结束时间。
如果日志只有“扣库存开始”和“扣库存成功”,没有事务提交时间,团队就无法判断库存 SQL 执行完成后是否仍然持有锁。
重点搜索库存事务内的 HTTP、RPC、消息发送、支付校验、营销计算、第三方库存查询和文件操作。网络调用平均只有几十毫秒并不代表安全,因为线上故障往往由 P99 决定,而不是平均值。
一个下游服务平时耗时 20 毫秒,出现连接池不足时可能变成 800 毫秒。对于单个请求,这只是慢了一点;对于同一热点 SKU,它可能让几十个请求在数据库中形成等待队列。
长事务不一定来自一条慢 SQL,也可能来自应用没有及时结束事务。数据库监控中的“活跃事务时间”与应用日志中的“接口耗时”应该能够相互对上。如果一个事务持续 30 秒,而接口日志只记录了 2 秒,说明中间可能有异步线程、连接管理或监控缺失。
库存主记录和库存流水是否必须在同一事务中,需要根据一致性目标判断。若流水承担账务审计作用,拆分时必须有可靠事件和补偿机制;若只是普通操作日志,则没有必要让它拖长库存核心事务。
| 事务内操作 | 通常建议 | 主要原因 |
|---|---|---|
| 原子扣减库存 | 保留在核心事务内 | 确保库存数量变化具备明确的提交边界 |
| 预占记录写入 | 视一致性要求保留 | 用于幂等、释放和状态追踪 |
| 远程订单确认 | 移出持锁区间 | 网络延迟不可控,会延长锁持有时间 |
| 普通操作日志 | 优先异步记录 | 通常不应阻塞库存核心更新 |
| 营销规则复杂计算 | 尽可能提前计算 | 减少锁内 CPU 和数据库连接占用 |

库存扣减的核心不是把库存先读到应用内存,而是让数据库在同一个更新动作中完成条件判断和数量变化。典型写法如下:
UPDATE inventory SET available_qty = available_qty - :quantity, locked_qty = locked_qty + :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND warehouse_id = :warehouse_id AND available_qty >= :quantity;
执行完成后,要把影响行数作为业务判断依据。影响行数为 1,表示更新成功;影响行数为 0,需要结合库存查询、状态字段和请求幂等记录判断原因。不要依赖“先查询库存大于购买量,所以 UPDATE 一定成功”的假设。
如果库存表通过 sku_id 和 warehouse_id 定位唯一库存记录,那么这两个字段通常应具备唯一约束或适配业务访问模式的组合索引。是否需要把状态字段、批次字段加入索引,不能凭经验决定,要结合查询条件、数据分布和执行计划确认。
常见问题包括字段类型不一致、对索引列使用函数、隐式类型转换、条件中混入低选择性字段,以及实际执行计划没有使用预期索引。
EXPLAIN UPDATE inventory SET available_qty = available_qty - 1 WHERE sku_id = 10001 AND warehouse_id = 7 AND available_qty >= 1;
对于不同数据库,UPDATE 的执行计划展示方式和锁监控方法并不完全相同。MySQL、PostgreSQL、SQL Server 和 Oracle 的锁模型、隔离级别和系统视图都有差异,正式排查时应选择实际使用的数据库版本,不要把不同数据库的监控 SQL 混用。
库存更新通常只应该影响一条目标记录。如果执行计划显示扫描了数千甚至数万行,问题就不只是“这条 SQL 慢”,还意味着事务可能在更长时间内占用连接,并增加其他请求被阻塞的机会。
在范围条件、批次库存或多仓库存场景中,尤其要注意数据库实际访问的是主键、唯一索引、二级索引还是范围。具体是否存在间隙锁、范围锁或更大锁影响,必须结合数据库产品、隔离级别、索引结构和执行计划确认,不能笼统地说“用了范围查询就一定锁表”。
索引可以减少定位目标库存行的时间,但库存表索引过多也会增加 INSERT、UPDATE 和 DELETE 的维护成本。库存数量变化频繁时,每次更新都可能维护多个相关索引。
我的建议是先确认最主要的库存访问路径,再保留能够显著减少扫描范围的索引。索引优化要同时观察扫描行数、执行耗时、写入吞吐、锁等待和存储成本,不能只看单条查询是否变快。

锁等待排查的核心不是找出“最慢的 SQL”,而是建立等待者与阻塞者之间的关系。需要确认当前等待会话、阻塞会话、事务开始时间、执行 SQL、等待对象、数据库连接 ID 和事务状态。
如果只查看慢 SQL 日志,可能只能看到等待者,却看不到真正持锁的长事务。等待者的 SQL 有时非常简单,真正的问题在另一个已经执行完业务计算、却迟迟没有提交的事务。
特别要记录“请求超时但数据库最终提交”的情况。这类请求不能简单按失败处理,否则客户端重试可能再次执行库存动作。订单号、预占单号或业务流水号应成为跨重试查询结果的依据。
当大量事务因为库存行锁等待时,它们会长期占用数据库连接。此时连接池活跃连接数上升,是锁等待导致的结果之一,而不是一定需要扩容的根因。
如果直接扩充连接池,更多请求会同时进入数据库,热点行的竞争更加集中。正确做法是分别测量“获取连接等待时间”和“拿到连接后的数据库执行时间”。前者高,说明应用侧已经排队;后者高,还要继续判断是锁等待、扫描、网络还是数据库资源不足。
| 问题类型 | 主要特征 | 排查重点 | 处理方向 |
|---|---|---|---|
| 锁等待 | 等待者与阻塞者之间存在明确资源关系 | 阻塞会话、持锁事务、等待对象 | 缩短事务、优化索引、统一资源顺序 |
| 死锁 | 多个事务互相等待,数据库主动回滚其中一个 | 死锁日志、加锁顺序、批量处理顺序 | 统一顺序、缩短事务、有限退避重试 |
| 慢 SQL | 没有明显阻塞者,但扫描或计算耗时高 | 执行计划、扫描行数、CPU 和 IO | 索引、SQL 改写、数据访问路径优化 |
| 连接池等待 | 请求长时间拿不到连接,数据库侧可能并不繁忙 | 连接获取耗时、连接泄漏、线程池状态 | 修复连接释放、控制并发、调整池大小 |

这是库存系统里最常见、也最容易被忽视的根因。远程调用不一定每次都慢,但只要存在超时、重试或下游排队,就会把数据库锁持有时间变成不可控变量。
优先方案是将库存核心更新与远程确认拆开。提交库存预占后发布可靠事件,由订单服务依据预占单号幂等处理。若业务必须同步确认,也应尽量先完成所有可提前完成的校验,再进入短事务。
如果确认单行事务已经很短、索引也正确,但同一 SKU 的并发量仍远超单行处理能力,就要承认这是业务热点问题,而不是继续微调 SQL。
可以考虑库存分片、分段库存、按仓库拆分、预扣减、队列化处理或对单个 SKU 限流。每一种方案都会改变实时性、复杂度和一致性边界,不能简单认为“加缓存”就能解决写冲突。
当扫描行数明显大于实际更新行数时,应检查组合索引、字段类型、统计信息和数据分布。尤其要确认线上执行计划是否与测试环境一致,避免测试数据量太小导致优化器选择了生产环境不适用的路径。
例如下单流程先锁订单再锁库存,取消流程先锁库存再锁订单,两个事务就可能形成循环等待。批量扣减多个 SKU 时,如果不同请求按不同顺序锁定 SKU,也可能出现类似问题。
修复方法是规定稳定的资源顺序。批量操作可以按 SKU、仓库或主键排序后再执行,所有相关服务遵循同一顺序。死锁仍可能在极端情况下发生,因此还需要有限次数、带退避的重试,但重试不能替代顺序治理。
长事务的来源包括异常捕获后没有正确回滚、连接泄漏、手动事务控制遗漏提交、线程切换后事务上下文丢失,以及消息消费逻辑在事务中长时间等待。
建议设置长事务监控,并把事务持续时间分位数纳入库存服务看板。不要只设置一个很大的告警阈值;短事务的 P99 和异常长事务的最大值都需要观察。
锁等待超时、死锁和库存不足是三类完全不同的结果。锁等待超时表示资源未能在规定时间内获得;死锁表示数据库检测到了循环等待;库存不足是业务判断失败。三者应使用不同错误码和不同重试策略。
库存流水需要可追溯,但“必须记录流水”和“所有流水字段都必须与主库存同步提交”不是同一个命题。对于账务级库存,主库存和预占记录可能需要强一致提交;对于普通操作日志,可以通过可靠事件异步写入。
拆分事务后必须补充对账机制。至少要能根据库存变更号、订单号或预占单号重建数量变化,并识别锁定、确认、释放和人工调整之间的差异。
降低隔离级别有时可以减少读写冲突,但它不是库存系统的默认优化手段。库存扣减是否允许脏读、不可重复读或幻读,取决于业务模型。为了追求更低等待而牺牲库存判断可靠性,可能带来超卖或错误释放。

普通订单流量相对平稳时,通常不需要一开始就引入复杂的分片或队列。建议先采用带条件的原子更新,建立预占记录和业务幂等键,控制事务范围,并对锁超时和死锁进行有限重试。
这一场景的重点不是极限吞吐,而是保证库存不足、重复请求、请求超时和订单取消都能得到确定结果。
秒杀场景中,单行库存模型可能天然成为瓶颈。即使 UPDATE 只执行几毫秒,所有请求仍然需要竞争同一逻辑资源。此时可以使用分段库存、库存令牌、队列化扣减或按库存桶拆分,但必须配合防超卖和库存对账。
如果采用缓存或内存令牌,数据库不能完全失去最终校验职责。缓存扣减成功后,数据库写入失败、进程重启、消息重复和补偿延迟都需要有明确处理方案。
多仓系统应尽量让订单先确定发货仓,再更新对应仓库的库存记录。若每次扣减都同时扫描多个仓库并锁定候选库存,锁范围和事务时间都会扩大。
仓库选择可以在进入库存事务前完成,或者通过独立的库存分配流程生成明确的仓库结果。核心原则是:进入短事务后,只锁定真正需要变化的库存资源。
采购、生产领料和组合商品扣减可能一次涉及多个 SKU。批量事务最容易产生死锁和长时间占锁。建议先对资源按稳定规则排序,再按合理批量执行,并监控单批事务的最大耗时。
批量越大不一定越快。大批量可以减少事务提交次数,却会增加锁持有时间和回滚成本。应通过压测寻找吞吐、延迟、死锁率和恢复成本之间的平衡点。
超时未支付订单释放库存、库存盘点和批量校准任务,最好避开前台流量高峰。无法避开时,应限制每批处理数量、设置任务间隔,并避免一次扫描大量待释放记录。
后台任务还应具备断点续跑和幂等能力。否则任务失败后从头重跑,可能与前台请求反复争抢同一批库存行。

| 方案 | 优势 | 代价 | 适用情况 |
|---|---|---|---|
| 先查询再使用悲观锁 | 流程直观,业务判断集中 | 事务持锁时间容易变长 | 库存规则复杂且必须在锁内判断 |
| 带条件的原子 UPDATE | 判断与扣减集中在数据库动作内 | 影响行数语义需要设计清楚 | 扣减规则相对明确的高并发场景 |
| 乐观锁版本号 | 冲突时不长时间持锁 | 冲突重试可能增加应用压力 | 冲突比例可控、可接受重试的场景 |
| 队列化处理 | 可以平滑热点写入 | 实时性下降,系统复杂度上升 | 允许排队、需要保护数据库的热点业务 |
同步强一致方案更容易理解:库存、预占和订单结果在一个流程中确定。但它会把下游依赖的网络延迟带入库存事务。最终一致方案能缩短锁持有时间,却需要处理消息重复、补偿、状态机和对账。
选择标准不是“哪个架构更先进”,而是业务是否允许短暂的不确定状态。如果用户必须立即知道订单是否创建成功,可以保留同步确认,但仍应把库存核心事务做短;如果业务允许订单进入处理中状态,事件驱动通常更适合隔离外部依赖。
缓存适合削减读取压力或提前分配库存令牌,但不能自动解决数据库写冲突。缓存与数据库之间存在失效、重启、延迟和重复消费问题,库存这种强约束数据必须保留最终可校验的事实来源。
如果使用缓存预扣,至少要设计库存令牌生命周期、失败回收、数据库落库失败补偿、请求幂等和定期对账。只增加一个缓存计数器,而没有这些机制,往往只是把数据库锁等待换成了数据不一致。

连接池大小应与数据库承载能力、事务平均耗时和应用实例数量共同决定。池太小会导致应用侧排队,池太大则可能把数据库推入更激烈的竞争状态。
库存热点场景中,限制同一 SKU 或同一库存分片的并发,可能牺牲部分瞬时吞吐,却能降低超时、重试和数据库压力。对于用户体验而言,稳定返回“排队中”通常比大量请求最终超时更可控。
不要一开始就在生产环境直接调整事务和锁参数。可以构造一个单热点 SKU、一个普通 SKU、一个多 SKU 批量请求和一个慢下游调用场景,分别观察锁等待和事务耗时。
最小复现至少应覆盖以下测试:
库存系统压测的核心指标至少包括成功扣减率、库存一致性、P95/P99、锁等待时长、死锁次数、连接池等待、重试次数和补偿成功率。
吞吐量上升但库存对账出现差异,不能算优化成功;平均延迟下降但 P99 仍然很高,也不能说明热点问题已经解决。

事务边界和库存模型改造不适合一次性全量切换。可以先对少量仓库、少量 SKU 或内部流量启用新路径,比较优化前后的锁等待、订单成功率和库存对账结果。
发布期间应保留旧路径与新路径的可比日志字段,并设置快速回滚开关。特别是从同步改为异步时,要提前准备“处理中”“确认失败”“待释放”等状态,避免异常状态没有承接。
看板的价值不只是事故后定位。连续观察热点 SKU 分布,可以提前发现活动商品、仓库切换或定时任务带来的资源集中,给产品和技术团队留下调整库存策略的时间。
| 检查项 | 是/否 | 应保留的证据 |
|---|---|---|
| 库存更新是否使用原子条件更新 | 实际线上 SQL、影响行数处理逻辑 | |
| SKU 与仓库条件是否命中合适索引 | 执行计划、扫描行数、索引定义 | |
| 是否存在隐式类型转换或函数条件 | 参数类型、SQL 规范和执行计划 | |
| 是否记录锁等待和阻塞会话 | 数据库监控视图、死锁日志 | |
| 是否存在异常长事务 | 事务开始时间、提交时间和连接 ID |
| 检查项 | 是/否 | 应保留的证据 |
|---|---|---|
| 持锁期间是否执行远程调用 | 调用链、事务代码和下游耗时 | |
| 异常分支是否一定回滚并释放连接 | 异常测试、连接池监控和代码审查 | |
| 请求超时后能否查询最终业务结果 | 订单号、预占号和幂等记录 | |
| 死锁、锁超时和库存不足是否分开处理 | 错误码、告警和重试配置 | |
| 重试是否有次数上限和退避 | 客户端、服务端和消息层重试策略 |
| 检查项 | 是/否 | 应保留的证据 |
|---|---|---|
| 是否存在明显热点 SKU 或热点仓库 | 按 SKU、仓库分组的写入量和延迟 | |
| 库存锁定、确认和释放是否有状态闭环 | 状态机、超时任务和补偿记录 | |
| 批量扣减是否按稳定顺序加锁 | 排序规则、死锁日志和批量代码 | |
| 异步化后是否有对账机制 | 库存流水、订单状态和差异报表 | |
| 优化后是否验证超卖、少扣和重复扣减 | 并发测试报告和线上对账结果 |

库存锁等待严重,可能来自行级资源竞争、长事务、索引扫描、死锁、连接池排队或激进重试。它们在用户侧都可能表现为接口变慢,但修复方向完全不同。
真正可靠的判断必须建立在等待链和事务证据上:谁在等待、谁持有锁、锁住了什么、事务为什么没有提交。缺少这条链路,调整参数和重写 SQL 都可能只是猜测。
如果完成这三步后,热点 SKU 仍然出现稳定且高强度的单行竞争,再考虑库存分片、队列化、令牌预扣或更复杂的架构方案。架构升级应建立在证据之上,而不是因为一次高峰超时就直接引入高复杂度系统。
库存系统的成功标准从来不只是“接口更快”。还必须同时满足库存数量正确、订单状态可追踪、请求重试不重复、异常能够补偿、流水能够对账。
下一步可以从一次真实高峰请求开始:选出延迟最高的三个 SKU,拉取对应时间窗口内的事务、阻塞会话、执行计划、连接池和重试日志,按本文自查表逐项填证据。先找到最长的持锁事务,再决定是修 SQL、改事务、控热点,还是调整架构。库存并发治理最有价值的产出,不是一句“锁等待下降了”,而是一套能解释每次库存变化、每次等待和每次异常结果的证据链。
我负责排查过一次促销压测,库存接口 P99 从 180ms 升到 4.8s,但数据库 CPU 只有 42%。一开始团队以为是慢 SQL,后来发现真正的问题是少数热点 SKU 上存在阻塞链。我想知道,锁等待、慢 SQL、连接池排队到底应该怎么区分?
不要先看数据库 CPU 就判断数据库“性能不够”。锁等待严重时,CPU 可能并不高,因为大量请求并没有真正执行计算,而是在等待其他事务释放资源。我通常先把一次请求拆成四段观察:获取连接耗时、执行 SQL 耗时、锁等待耗时、事务提交耗时。如果获取连接已经很慢,问题偏向连接池;
如果执行计划扫描行数很大,问题偏向 SQL;如果 SQL 本身执行时间不长,但等待时间明显增加,才重点怀疑锁竞争。
现象优先排查对象常见误判 数据库 CPU 高,扫描行数多慢 SQL、索引、执行计划误认为一定是锁表 CPU 不高,但 SQL 等待时间长阻塞事务、锁等待盲目扩容数据库 获取连接耗时持续升高连接池、长事务、线程池继续增加连接数 请求失败后流量再次上升重试策略、热点资源把重试当成恢复手段 数据库侧至少要记录等待会话、阻塞会话、事务开始时间、等待对象和当前 SQL。
应用侧则要把订单号、SKU、仓库、请求 ID 与数据库连接 ID 关联起来,否则只能看到“有锁等待”,却无法确认是哪类商品、哪条业务链路在制造阻塞。我的判断标准是:同一资源上的等待是否集中出现,是否存在一个持锁时间明显更长的事务,以及等待时间是否随事务提交立即消失。
满足这三个条件,通常比单看接口 RT 更能证明问题来自锁等待。
我见过一种很容易被忽略的写法:事务先锁住库存行,接着调用订单服务或营销服务,最后才提交。功能测试完全正常,但高并发时一个远程接口偶发超时,就会让库存锁多持有几秒,随后大量请求排队。我想知道,这种事务边界应该怎样改才不会牺牲一致性?
库存行被锁住后,真正危险的不是一次数据库更新,而是“锁已经拿到,但事务还没有提交”。如果事务中包含 RPC、HTTP、支付校验、复杂营销计算或消息发送,锁的持有时间就会被最慢的外部依赖决定。在一次最小压测中,单纯执行库存更新和提交,事务耗时约 12ms;
加入一个平均 300ms、偶发 2s 的远程调用后,热点库存行的等待时间迅速堆积。请求量没有增加很多,但 P95 从 35ms 上升到 620ms,说明持锁时间比并发量更直接地放大了等待。建议把链路拆成“事务内必须完成”和“事务外可以补偿”两部分。事务内只完成库存状态变更、幂等记录和必要的核心流水;
远程调用放到提交后的事件处理阶段,并为失败准备重试、补偿或对账机制。
操作是否建议放在持锁事务内原因 按条件扣减可用库存是需要保证原子性 写入库存操作幂等记录通常是防止重复扣减 调用订单、支付或物流服务否耗时不可控且可能超时 复杂促销规则计算尽量否容易延长事务时间 非核心操作日志视一致性要求决定避免把非关键写入绑在热点行上 但“移出事务”不等于直接改成异步就结束了。
需要明确库存成功、订单创建失败、消息发送失败等异常状态,并提供可重放的事件或补偿任务。我的经验是,先缩短持锁区间,再设计最终一致性,通常比先调大锁等待超时更可靠。
我们曾经排查过一段“先查询库存、代码判断、再执行更新”的逻辑,开发人员认为每条 SQL 都很快,所以没有问题。但并发上来后,库存不足判断和实际扣减之间出现了竞态,执行计划也显示扫描范围比预期大。我想知道,怎样写 SQL 才能同时避免超卖和扩大锁范围?
库存判断和扣减最好合并为一次带条件的原子更新,而不是先查再改。
通用写法如下:
UPDATE inventory SET available_qty = available_qty - :qty WHERE sku_id = :sku_id AND warehouse_id = :warehouse_id AND available_qty >= :qty;执行后必须检查影响行数。影响行数为 1,才代表扣减成功;影响行数为 0,可能是库存不足、条件不匹配、数据状态不允许,或者请求没有正确命中目标记录。不要把所有 0 行更新都当成“系统异常”,也不要只依赖应用层先查询出来的库存值。索引的关键不只是让查询变快,还在于让数据库尽快定位目标记录。
若库存记录按“SKU 加仓库”唯一确定,通常应围绕这两个字段建立唯一约束或等价的高选择性索引,并用执行计划确认更新实际命中了该索引。
检查项风险信号处理建议 组合条件无索引扫描大量库存记录建立匹配业务定位条件的索引 字段类型不一致出现隐式转换统一参数和列的数据类型 条件包含函数索引难以有效使用改写条件或增加合适的派生字段 库存表索引过多更新和提交时间变长清理低价值索引并实测写入收益 批量更新顺序不固定不同事务锁定顺序不同按稳定顺序处理 SKU 需要特别注意,不能脱离数据库类型直接断言“这条 SQL 一定只锁一行”。
隔离级别、存储引擎、索引选择和范围条件都会影响锁的实际范围。最终应结合执行计划、锁监控和并发压测验证,而不是仅凭 SQL 外观判断。
我做过一次并发测试,普通商品的库存请求基本稳定,但一个爆款 SKU 占了约 70% 的扣减请求。系统出现锁超时后,应用立即重试,结果数据库连接数和等待事务同时上升,故障反而扩大了。我想知道,遇到库存锁等待时,应该先加缓存、拆库存,还是先处理事务和重试?
库存锁等待的修复顺序不能从“加缓存”开始。缓存可以减少读压力,却不能直接消除多个请求对同一库存行的并发写入;如果核心扣减仍然落到同一个热点记录上,写锁竞争依旧存在。我更建议按照四个层次处理。第一步确认阻塞链和长事务;第二步移除持锁期间的远程调用与非必要计算;第三步修正原子更新、索引和统一加锁顺序;
第四步才根据热点集中度评估分段库存、队列化、限流或库存分片。
顺序动作不建议的替代做法 1确认谁持锁、谁等待、等待多久只看数据库 CPU 2缩短事务并处理长事务直接调大锁等待超时 3优化 SQL、索引和加锁顺序盲目扩大连接池 4针对热点 SKU 做架构治理把所有请求无限重试 死锁与锁超时也不能使用同一套重试策略。死锁通常需要有限次数、带随机退避的重试;
锁超时则要先判断阻塞是否仍然存在;库存不足属于业务结果,不应重试。多个框架层、服务层都配置重试时,还要防止一次用户请求被重复放大。最终验证不能只看接口恢复。至少要同时比较 P95、P99、锁等待时长、失败率、连接池等待时间和库存对账结果。只要出现超卖、重复扣减或少扣,哪怕接口变快,也不能算优化成功。


读者评论
文章把库存锁等待与长事务、远程调用、重试放大串起来了,排查顺序也比较合理。实际落地时还需要结合具体数据库的锁监控语法,不能只照搬指标名称。
将条件判断放进UPDATE并检查影响行数,这个建议很实用。不过影响行数为0确实可能有多种原因,最好配合业务日志区分库存不足、参数异常和并发冲突。
文中强调按SKU和仓库观察热点,而不是只看库存表整体QPS,这一点容易被忽略。对于促销和批量回滚同时发生的场景,还应单独统计任务流量。
移除持锁期间的远程调用是关键优化,但事务拆分后也会带来状态一致性和失败补偿问题,通常需要配合事件、幂等或补偿机制设计。
关于连接池和重试的分析比较客观,盲目扩容确实可能加剧热点行竞争。建议再补充退避策略、最大重试次数及死信处理的具体示例。