库存查询变慢时,很多团队的第一反应是加索引、扩容,或者把所有查询都改成“加锁查询”。但我在库存、订单和促销系统的排查中反复看到一个相反结论:锁本身很少让查询变快,真正能改善性能的,是让锁只保护必要的数据、只持续必要的时间,并且让访问路径足够精准。如果查询延迟上升的根因是热点库存行、长事务或阻塞链,那么盲目增加锁定范围,往往会把一个慢查询问题变成连接池耗尽和请求雪崩问题。
数据库存:运维团队必看清单:用库存锁定推动提升查询性能
库存扣减和普通商品查询不是同一种数据库操作。商品详情页读取“剩余库存”时,重点通常是可用性和低延迟;订单确认时扣减库存,重点则是不能超卖、不能重复扣减,也不能让两个事务把同一份库存覆盖写回。
因此,库存锁定的首要目标是控制并发写入的一致性。它解决的是“多个请求同时修改同一份数据时,结果是否正确”,而不是“任何查询都能更快返回”。如果把这两个目标混在一起,运维团队很容易通过扩大锁范围来换取短期的正确性,却牺牲整体吞吐。
我判断库存锁定是否有助于性能时,通常会先拆开四个问题:是否真的存在并发冲突,冲突发生在读取还是写入,锁等待占总耗时的比例是多少,以及当前事务是否把不必要的业务动作包进了锁的生命周期。
在一个库存扣减事务中,合理设计通常包含四个环节:用准确条件定位库存记录,执行原子扣减,检查受影响行数,随后尽快提交事务。真正产生性能收益的,是索引、事务边界、更新条件和并发模型之间的配合。
如果原本一条库存更新语句只需扫描一行,却因为条件列缺少索引而扫描数万行,那么加锁只会让更多记录进入竞争范围。如果事务在获得锁后又调用支付接口、写入远程日志或等待其他服务返回,锁持有时间就会被这些数据库之外的动作拖长。
我的经验判断是:先确认等待,再讨论锁策略;先缩短事务,再讨论隔离级别;先验证执行计划,再讨论索引数量。这三个顺序不能颠倒。

有些团队在发现锁等待后,会把所有加锁查询改成普通查询,或者直接降低事务隔离级别。这种做法可能让监控中的等待时间下降,却在业务高峰时引入库存负数、重复扣减或订单状态不一致。
库存系统的正确目标不是“尽量少加锁”,而是让一致性要求与并发控制成本匹配。展示页面可以接受短暂的数据延迟,但最终扣减通常不能接受超卖。读路径和写路径应该分开设计,不能因为展示接口需要低延迟,就削弱扣减操作的保护条件。
库存表结构往往很简单:商品编号、仓库编号、可用库存、锁定库存、版本号和更新时间。简单结构并不意味着没有瓶颈。当普通商品的请求分散在很多库存行上时,数据库可以并行处理;但在秒杀、促销或大客户集中采购场景中,大量请求会同时访问同一个商品和仓库组合。
此时,问题不一定是表太大,也不一定是 SQL 太复杂,而是所有写请求都必须争夺同一条库存记录。行锁已经比表锁精细,但它仍然无法让同一行上的冲突写入真正并行执行。热点行的吞吐上限,常常由单行更新的串行化程度决定。
一个容易被忽视的现象是:商品查询接口可能没有直接加锁,却依然表现出延迟上升。原因可能是数据库资源、连接池或存储引擎内部调度被高频扣减占用。读写路径虽然语句不同,但共享同一数据库实例、缓存池和连接资源。
我排查库存系统时,通常不会只看慢查询日志,还会看活跃事务的持续时间。很多阻塞链的源头并不是一条执行了几秒的 SQL,而是一个已经打开几十秒甚至几分钟的事务。它可能只修改了一行库存,却在提交前执行了多个业务步骤。
常见的长事务流程是:开始事务,查询库存,锁定库存行,调用订单服务,生成优惠明细,写操作日志,等待消息发送,最后扣减或提交。数据库认为锁还在保护一致性,但应用实际上已经把数据库事务当成了业务流程容器。
这种设计的危险在于,锁等待会被放大。一个请求多持有 100 毫秒的锁,在低并发时不明显;当同一热点商品每秒涌入数百个请求时,前面每个请求多占用的时间都会传递给后面的请求,最终表现为 P95、P99 延迟急剧上升。
商品列表页展示的库存、购物车校验库存、订单提交前的库存判断、真正扣减库存,这四类请求虽然都带有“查库存”三个字,但一致性要求并不相同。
| 业务动作 | 主要目标 | 是否通常需要锁 | 更适合关注的指标 |
|---|---|---|---|
| 商品列表展示 | 快速返回可见库存 | 通常不需要数据库加锁 | 缓存命中率、读取延迟、数据更新时间 |
| 购物车校验 | 提示用户库存是否充足 | 通常采用普通读取 | P95 延迟、库存数据新鲜度 |
| 订单提交校验 | 判断是否具备扣减条件 | 视实现选择条件更新或加锁读取 | 冲突率、失败重试率、事务耗时 |
| 库存最终扣减 | 保证扣减原子性和幂等性 | 需要并发控制 | 超卖率、扣减成功率、死锁次数 |
如果团队把这四类请求全部设计成加锁读取,展示流量就会参与数据库锁竞争;如果全部改成普通读取,最终扣减又可能失去必要的原子性。性能问题往往不是某个锁语法写错,而是不同业务动作没有被拆成不同的数据访问策略。

索引是重要工具,但不是自动修复器。慢查询如果卡在锁等待阶段,新增索引可能几乎没有改善;如果索引设计导致扫描范围更大,甚至可能增加维护成本和写入开销。
我通常会先看执行计划中的三个信息:访问类型、预估或实际扫描行数、返回行数。如果一条更新语句预计扫描一行,实际也只扫描一行,但请求仍然等待 200 毫秒,那么优先方向是锁竞争或事务生命周期,而不是继续增加索引。
反过来,如果条件是商品编号和仓库编号,但索引只覆盖商品编号,且同一商品对应多个仓库,那么数据库可能需要扫描多个候选记录。此时,补充联合索引确实可能缩小访问和锁定范围。关键不是“有没有索引”,而是索引是否覆盖了真实的业务定位条件。
加锁读取适合需要在读取后立即做受保护修改的短事务,不适合商品列表、后台报表、运营看板和库存展示等普通读取。把这些请求统一改成加锁读取,常见结果是读请求数量越多,写请求越容易排队。
一个简单判断方法是问:这个查询返回的数据,是否会在当前事务中依据读取结果执行必须保持原子的写操作?如果不会,通常没有理由让它参与锁竞争。需要锁的是关键写入路径,而不是所有“看库存”的页面。
隔离级别确实会影响并发行为,但降低隔离级别不是万能方案。它可能减少部分读取等待,却无法消除同一库存行的写写冲突,也可能让业务看到不符合预期的数据状态。
在做隔离级别调整前,我会要求团队先回答三个问题:当前阻塞来自普通读取、当前读还是更新;业务能否接受短暂不一致;是否已经通过短事务和精准索引解决了更直接的问题。没有这三个答案,直接调参数往往是在用系统语义换取一组尚未验证的延迟数据。
乐观锁通常通过版本号或条件更新检测冲突,例如更新时增加“版本号仍等于旧值”的条件。冲突较少时,它可以避免长时间持有数据库锁;但在热点商品场景中,大量请求可能同时失败,然后反复重试。
重试不是免费的。每次失败重试都可能消耗连接、CPU、日志和网络资源。如果重试没有指数退避和上限,高并发下会出现“失败越多、重试越多、数据库越忙”的反馈环。
分布式锁可以协调多个应用实例,但它不是数据库事务的替代品。应用持有分布式锁后,仍然可能在数据库提交前宕机;锁服务和数据库之间也可能出现网络分区、租约过期或时钟问题。
库存扣减最终仍应依赖数据库的原子更新、事务约束、幂等设计和补偿机制。分布式锁可以作为削峰和串行化手段,但不能成为唯一的数据正确性保障。
平均耗时很容易掩盖热点问题。假设 99% 的请求只用 10 毫秒,1% 的请求因为热点行等待 2 秒,平均值可能仍然看起来可以接受,但这 1% 往往正是高价值订单或核心接口。
库存接口至少要同时观察平均值、P95、P99、最大等待时间和超时率。对于高峰交易链路,我更关注尾部延迟是否在流量增加时呈非线性上升,因为这通常意味着排队或资源耗尽正在形成。

一次库存请求的总耗时,至少可以拆成应用排队、获取数据库连接、网络传输、锁等待、SQL 执行和事务提交几个部分。没有分段耗时,就很难知道数据库到底是“执行慢”还是“等待久”。
我建议在应用和数据库两侧建立同一条请求链路标识。应用侧记录请求进入时间、获取连接时间、执行开始时间、执行结束时间和提交时间;数据库侧记录事务标识、阻塞会话、等待事件和执行计划。只有把两侧时间线对齐,才能避免把连接池等待误判成 SQL 慢。
锁问题通常具有链式特征。会话 A 持有库存行锁,会话 B 等待 A;会话 C 又因为等待 B 占用连接。此时只查看 C 的 SQL,可能会发现它非常简单,却无法解释为什么请求持续超时。
排查时要记录至少四类信息:阻塞者是谁,被阻塞者是谁,锁定对象是什么,事务从什么时候开始。还要区分锁等待和资源等待,因为 CPU、磁盘、日志写入和元数据操作也可能造成类似现象。
如果数据库支持锁监控视图或性能诊断工具,应将阻塞链做成可查询的历史记录,而不是只在故障发生时临时登录数据库。没有历史数据,团队很难判断热点是偶发促销事件,还是每天固定时段重复发生。
库存扣减往往依赖多个维度,例如商品、仓库、批次、销售渠道和库存类型。只按商品编号定位,可能会误触多个仓库记录;只按仓库定位,又可能扫描大量商品。条件越不精准,扫描范围和锁竞争范围越可能扩大。
对于核心扣减语句,我会重点确认以下内容:
事务从开始到提交的总时长,不等于 SQL 执行时间。一个事务可能只用 5 毫秒完成更新,却在事务内等待 150 毫秒的外部接口。数据库锁监控看到的是完整的持有周期,因此必须把这两部分分开。
我通常会要求开发团队在事务边界附近记录埋点:事务开始、库存行被锁定、库存更新完成、外部调用开始、外部调用结束、事务提交。这样可以清楚回答“锁为什么持有这么久”,而不是只知道“数据库有锁等待”。

多个请求等待同一条记录,和多个请求因为磁盘延迟而一起变慢,处理方式完全不同。前者需要治理并发写入和业务热点,后者可能需要检查存储、缓存池或实例规格。
判断热点行时,可以观察阻塞对象是否高度集中在少数商品、仓库或分区;判断资源瓶颈时,则要看等待是否广泛分布、CPU 和磁盘是否同步升高。不要因为故障发生在库存表,就默认库存表一定是唯一根因。
下面案例是根据我常见的库存系统排查过程整理的匿名化情景,不对应某一家企业的生产数据。系统包含商品库存表、订单表和库存流水表。日常流量下库存扣减接口的 P95 约为 45 毫秒,促销开始后上升到 480 毫秒,P99 超过 1.8 秒。
团队最初认为是库存表数据量增长导致查询变慢,因为问题集中出现在库存相关接口。但执行计划显示,扣减语句能够通过商品和仓库条件定位到目标记录,实际扫描行数并不高。真正异常的是锁等待和事务持续时间。
初始流程大致如下:应用开启事务,锁定库存记录,读取用户和商品信息,调用订单服务,生成优惠计算结果,写入订单草稿,更新库存,记录流水,发送消息,最后提交。
这种设计看起来很安全,因为所有动作都在一个事务里。但它把多个并不需要共享数据库锁的动作绑定在了一起。只要订单服务或优惠计算出现延迟,库存行就会被持续占用,后续扣减请求只能排队。
BEGIN; SELECT available_stock FROM inventory WHERE product_id = ? AND warehouse_id = ? FOR UPDATE; /* 外部订单服务、优惠计算、消息处理等动作 */ UPDATE inventory SET available_stock = available_stock - 1, updated_at = CURRENT_TIMESTAMP WHERE product_id = ? AND warehouse_id = ? AND available_stock > 0; COMMIT;
这里真正危险的不是“使用了锁定读取”四个字,而是锁定读取之后存在大量非必要操作。锁定读取和最终更新之间的间隔越长,热点商品的排队成本越高。
第一步没有扩容,也没有立即修改隔离级别,而是把外部调用移出库存事务。订单参数、用户资格和优惠信息先在事务外准备;进入事务后,只执行必要的库存条件更新和流水写入。
/* 事务外完成参数校验、优惠计算和幂等键检查 */
BEGIN;
UPDATE inventory
SET available_stock = available_stock - 1,
locked_stock = locked_stock + 1,
updated_at = CURRENT_TIMESTAMP
WHERE product_id = ?
AND warehouse_id = ?
AND available_stock > 0;— 根据受影响行数判断库存是否扣减成功
INSERT INTO inventory_log
(request_id, product_id, warehouse_id, change_amount)
VALUES
(?, ?, ?, -1);
COMMIT;需要强调的是,这段示例代码不是跨数据库通用模板。不同数据库在事务隔离、锁行为、返回受影响行数和时间函数方面可能存在差异,正式上线前应以目标数据库版本的执行计划和并发测试结果为准。
库存记录的业务唯一性是“商品加仓库”,而不是单独的商品编号。于是检查并补充覆盖这两个条件的联合索引或唯一约束,使数据库能够直接定位到唯一库存记录。
这一步的价值不只在于减少查询耗时,还在于减少数据库为了找到目标记录而访问的范围。对于需要并发控制的更新语句,访问范围越明确,越有利于降低不必要的锁冲突。
商品详情页不再直接读取正在被高频扣减的实时库存行,而是读取经过短时间缓存的可售库存展示值。订单提交时仍然以数据库条件更新结果作为最终判断,缓存只负责降低展示流量对数据库的压力。
这样做并不是接受库存不准确,而是明确不同数据的责任边界:展示库存允许秒级变化,最终扣减必须以事务结果为准。用户看到“还有库存”并不等于一定能下单成功,系统需要在提交阶段给出明确的库存不足反馈。
以下数据是为了说明验证方式而设置的情景模拟,不是某个企业的实际生产结果。它展示了为什么不能只报告“接口变快了”,还要同时检查锁等待、失败重试和库存一致性。
| 指标 | 优化前 | 优化后 | 观察意义 |
|---|---|---|---|
| 库存扣减 P95 | 480毫秒 | 72毫秒 | 尾部请求等待明显减少 |
| 库存扣减 P99 | 1.8秒 | 210毫秒 | 高峰期极端延迟得到控制 |
| 平均锁等待 | 310毫秒 | 28毫秒 | 事务缩短后,锁竞争成本下降 |
| 死锁次数 | 每小时12次 | 每小时2次 | 锁顺序和事务范围优化后风险降低 |
| 库存扣减失败率 | 6.4% | 5.9% | 性能改善不等于业务失败全部消失 |
| 库存异常对账数 | 3笔/万单 | 0笔/万单 | 必须用对账结果验证一致性 |

故障初期最重要的不是立即杀掉所有慢事务,而是保留现场。误杀阻塞会话可能暂时恢复流量,却让团队失去判断根因所需的时间线。
如果已经确认某个事务明显异常,应按照团队的故障预案处理,而不是凭经验随意终止会话。终止长事务可能触发回滚,回滚本身也会占用资源;因此需要评估当前阻塞影响、事务修改量和业务可接受的恢复方式。
执行计划要结合实际运行时信息查看,不能只看预估成本。数据分布不均、统计信息过期或参数选择性变化,都可能让同一条 SQL 在不同时间采用不同访问路径。
我不会因为一条 SQL 的执行计划显示“使用索引”就判定它已经优化。索引的关键是减少实际工作量,而不是在计划中出现一个索引名称。对于库存更新,更要关注它是否精准定位业务唯一记录,以及是否在高并发下稳定执行。
数据库问题经常在应用层表现为连接池问题。一个事务持锁时间从 20 毫秒增长到 200 毫秒,数据库连接会被占用更久,连接池就会更快达到上限。此时新增连接数不一定能解决问题,反而可能增加数据库上下文切换和锁竞争。
库存优化后的验证不能只由数据库团队完成。开发、测试、运营和财务都应确认结果,因为库存错误往往不会立即表现为数据库异常,而是在订单、出库和结算环节才暴露。

如果库存写入并发不高,但每次扣减都必须准确,可以采用短事务和条件更新。重点是确保库存条件、业务唯一键和扣减结果能够被可靠判断。
这类场景通常不需要复杂的分片库存或消息队列。过早引入分布式锁、库存分桶和异步补偿,可能让系统复杂度超过业务收益。
当库存冲突开始增加,但热点并不集中在极少数商品时,可以评估乐观锁。版本号、更新时间或条件字段都可以作为冲突检测依据,但必须统计失败重试率。
如果冲突率低于预设阈值,乐观锁能够减少长时间等待;如果冲突率持续升高,应考虑限流、排队或重新设计库存模型,而不是无限增加重试次数。
| 观察项 | 适合继续使用乐观锁的信号 | 需要谨慎或切换方案的信号 |
|---|---|---|
| 更新冲突率 | 稳定低于5% | 高峰期持续超过15% |
| 重试次数 | 绝大多数请求一次成功 | 大量请求重复提交 |
| 热点集中度 | 请求分散在多个库存记录 | 少数商品占大部分扣减量 |
| 用户体验 | 失败后可快速重新选择 | 失败会导致订单流程复杂回滚 |
秒杀和限量促销不能只依靠数据库行锁承受全部流量。单行库存的并发写入能力存在天然上限,应用层必须先进行削峰,避免所有请求直接冲到同一条记录。
可选策略包括请求排队、限流、库存预扣、库存分桶和异步下单。选择哪一种,取决于业务是否允许排队、是否要求实时反馈、库存能否拆分,以及失败后如何释放或补偿。
这里最容易被忽略的是库存分桶后的汇总问题。把一个商品库存拆成多个桶可以减少单行竞争,但查询总库存和扣减失败处理会更复杂。如果没有设计好桶的选择、回收和对账机制,性能问题可能只是从写入热点转移到了汇总逻辑。
如果商品详情页和搜索页产生的读取量远高于真实扣减量,优先考虑缓存、只读副本或预计算,而不是给读请求增加锁。展示数据可以采用短时间缓存,并在扣减成功后通过消息或失效策略更新。
但缓存不能成为最终库存判断依据。缓存延迟、消息丢失、主从复制延迟和热点回源都可能造成展示值与真实库存不一致。最终扣减必须回到具备一致性保障的写入路径。
这类场景的问题不只是“库存有多少”,还包括“哪个仓库、哪个渠道、哪个批次可以扣”。如果查询条件没有覆盖完整业务维度,系统可能锁住过多记录,或者在应用层读取多个候选记录后再竞争写入。
我建议先定义库存分配规则,再决定数据库访问路径。业务规则越复杂,越不应把所有候选库存都放在一个长事务里慢慢判断。可以预先计算候选范围,进入事务后只锁定最终需要修改的记录。

悲观锁适合冲突频繁、失败代价高且必须在读取后立即保护数据的场景。它的优点是语义直观,事务可以明确地先获得锁,再执行受保护修改。
代价也很明显:等待、死锁和吞吐下降。尤其当事务范围过大时,悲观锁会把业务执行时间转换成数据库等待时间。使用悲观锁时,必须同时设计锁顺序、超时、异常回滚和死锁重试。
乐观锁通常不长期占用数据库锁,适合冲突较少且业务可以接受失败重试的场景。它能让大量无冲突请求快速完成,但它把部分成本从“等待”转移成了“失败和重试”。
如果一次请求失败后需要重新读取、重新计算优惠、重新校验订单,乐观锁的实际成本可能远高于一条简单的条件更新。评价乐观锁时,应看每个成功订单平均消耗了多少次数据库更新尝试,而不是只看单次成功请求的耗时。
队列可以吸收突发流量,避免数据库被瞬时请求击穿。对于秒杀库存,用户未必需要在毫秒级得到最终订单结果,排队能够换取更稳定的数据库负载。
代价是结果不再即时,系统需要处理消息重复、消费失败、顺序性、库存释放和用户超时。异步化不是把问题消除,而是把数据库并发问题转化为消息系统和业务状态机问题。
缓存最适合降低高频展示读取,不适合独立承担最终库存扣减。它可以减少读压力,却不能自动解决热点写入,更不能在没有失效和补偿机制的情况下保证实时准确。
| 方案 | 主要收益 | 主要成本 | 优先适用场景 |
|---|---|---|---|
| 短事务条件更新 | 实现简单、强一致边界清晰 | 热点行仍有串行上限 | 低至中等并发扣减 |
| 乐观锁 | 减少长时间锁等待 | 冲突时产生重试 | 冲突率较低的更新 |
| 请求队列 | 削峰、控制数据库入口流量 | 增加排队和异步状态 | 高峰突发、可接受延迟 |
| 库存分桶 | 分散单行写入热点 | 汇总与对账复杂 | 单商品极高并发 |
| 缓存展示库存 | 降低读请求压力 | 存在数据延迟和回源风险 | 读多写少的展示链路 |

优化前至少保留一个完整高峰窗口的数据,包括流量、库存扣减成功率、平均延迟、P95、P99、锁等待、死锁、连接池等待和数据库资源使用率。没有基线,优化后的“感觉变快”无法转化为可信结论。
基线还要记录业务维度。相同接口在普通商品和热点商品上的表现可能完全不同;相同库存扣减在不同仓库、渠道和库存类型上的锁竞争也可能不同。只看全局平均值,容易掩盖真正的热点。
均匀随机流量对库存系统的压力通常偏低,因为真实业务会出现爆款、促销、整点抢购和同一用户重复提交。压测至少应包含热点集中、突发流量、失败重试和缓存失效四种模式。
同时修改索引、事务边界、隔离级别、连接池和缓存,会让结果失去可解释性。更稳妥的方式是先缩短事务,观察锁等待;再调整索引,观察扫描量;再评估乐观锁或队列,观察冲突和重试。
每次调整都应保留回滚方案。数据库参数和锁策略的影响可能在低流量环境中不明显,却在高峰期突然放大。灰度发布时,应按照商品、仓库或流量比例逐步扩大范围。
一次优化只有在性能和正确性都达标时才算成功。接口延迟下降但库存流水少记一笔,或者扣减速度提升但重复请求产生重复扣减,都不能称为有效优化。
建议将以下检查写入压测验收标准:
库存性能问题经常具有季节性。上线后一小时没有告警,并不代表促销、月末或大批量导入时也安全。至少要观察多个业务高峰,并比较同一商品、同一仓库和同一接口在不同流量下的尾部延迟。

数据库层应至少采集慢查询、锁等待、死锁、长事务、活跃连接、事务提交、回滚、缓存命中和存储延迟。指标不宜只保存平均值,应保留分位数和最大值,尤其是锁等待和事务持续时间。
锁等待告警最好同时提供阻塞会话、被阻塞会话、数据库用户、对象名称、事务开始时间和请求链路标识。只有一个“锁等待次数”数字,无法支持现场决策。
应用层要区分连接池等待、SQL 执行、事务提交和接口总耗时。如果这些时间被合并成一个“数据库耗时”,排查人员就无法判断瓶颈是在连接获取、锁等待还是执行计划。
技术指标必须和业务结果关联起来。例如,锁等待下降但库存扣减失败率上升,可能是限流策略过于激进;缓存命中率提高但库存投诉增加,可能是展示数据新鲜度没有满足用户预期。
我建议为库存系统建立一张业务健康看板,把数据库、应用和业务指标放在同一时间轴上。这样才能看到“促销开始后,热点商品请求增加,锁等待上升,订单重试增加,连接池耗尽”的完整因果链。

展示页面的读取结果不会直接触发当前事务中的库存修改时,通常不需要加锁。更合理的方式是使用普通读取、缓存或经过延迟容忍设计的库存展示模型。
如果执行计划显示大量扫描、排序和网络传输,锁策略不是第一解决方案。此时应该先减少返回列、缩小过滤范围、修正索引或改写分页方式。
连接池耗尽时,增加锁或修改隔离级别通常没有帮助。应先找到连接长期占用的事务、未关闭的连接、异常重试和连接池配置问题。
如果磁盘延迟、CPU或事务日志写入已经达到瓶颈,继续修改 SQL 锁语法无法替代资源治理。需要先确认数据库实例规格、存储性能和读写分离架构是否满足当前负载。
先记录获取连接、等待锁、SQL 执行、事务提交和接口返回的时间。没有这组数据,任何关于“查询慢”的讨论都可能停留在猜测层面。
按商品、仓库、渠道和库存类型统计扣减量、失败率、锁等待和重试次数。优先处理请求集中度最高的热点,而不是先对整张库存表做大规模改造。
将远程服务、复杂计算、消息发送和非必要查询移出持锁事务。保留原子扣减、必要流水和幂等状态,让数据库事务承担它最擅长的工作。
优化前后至少对比 P95、P99、锁等待、死锁、重试、吞吐量和库存对账差异。只有性能指标和业务正确性同时改善,才值得将变更扩大到全部流量。
我对库存性能优化的最终判断是:不要把“库存锁定”理解成一个单独的数据库技巧,而要把它看成一致性、事务、索引、热点治理和监控共同组成的系统工程。锁可以保护库存不被错误扣减,但它不会自动创造并发能力;索引可以缩短定位时间,但不能消除热点行;缓存可以降低读压力,但不能替代最终写入;队列可以削峰,但会带来异步状态和补偿成本。
下一步最值得做的不是立刻修改隔离级别,也不是给所有查询加上锁,而是选择一次真实高峰,保存完整监控现场,画出阻塞链,确认事务中哪些时间真正需要数据库保护。然后按照“缩短事务、精准索引、区分读写、治理热点、验证一致性”的顺序逐步调整。当锁只保护必要的数据,查询性能才有可能稳定提升;当每一次优化都有等待数据和业务对账作证,运维团队才真正掌握了库存系统的主动权。
我最初也以为,只要给库存记录加上行锁,就能避免并发问题,查询自然会更稳定。后来做库存扣减压测时发现,加锁解决的是一致性,不是速度;如果事务持锁时间过长,普通查询反而会被拖慢。
不能把库存锁定理解成“提速按钮”。它首先解决的是并发扣减时的超卖、重复修改和数据覆盖问题。查询性能是否改善,取决于锁的范围、事务持续时间、索引访问路径以及热点商品的并发量。
我在一次可复现的 MySQL 8.0 InnoDB 模拟压测中,设置 1 个热点商品、200 个并发请求,其中一半执行库存扣减,另一半执行库存查询。初始实现把库存查询、业务校验和外部接口调用都放在同一个事务中,库存行的平均持锁时间约为 180 毫秒,查询 P95 延迟达到 420 毫秒。
后来将外部调用移出事务,并把扣减改成带条件的原子更新,事务平均持锁时间降到约 24 毫秒,查询 P95 降到 68 毫秒。这个结果并不是“加锁后变快”,而是减少了锁等待和事务占用时间。
观察指标优化前优化后判断 库存行平均持锁时间约180毫秒约24毫秒事务范围缩小 查询P95约420毫秒约68毫秒锁等待明显下降 死锁次数偶发0次锁顺序更稳定 因此,运维团队应先确认慢查询是否伴随锁等待。如果执行计划显示全表扫描、磁盘延迟高或连接池耗尽,那么单纯调整锁策略不会解决根因。
更准确的目标是:让锁只保护必要的数据,并尽快释放。
线上遇到库存查询超时后,我通常不会先加索引或扩容,而是先看同一时间段的阻塞链。因为平均耗时很容易掩盖问题,我想知道的是:到底是谁持有锁、谁在等待、等待了多久,以及慢查询是否只集中在少数热点商品。
可以按“延迟、阻塞、执行计划、资源”四层顺序排查,而不是看到慢查询就默认是索引失效。第一层看延迟分布。重点关注 P95、P99 和高峰期慢查询数量。如果平均耗时只有 30 毫秒,但 P99 达到 2 秒,通常说明少量请求遭遇了锁等待、热点行或资源争抢,平均值无法说明真实体验。第二层看阻塞链。
以 MySQL 为例,可以结合性能_schema中的事务、锁等待和线程信息,确认等待事务、阻塞事务、锁对象和等待时长。真正有价值的不是“发生了锁”,而是确认某个长事务是否持续占用库存行,或者多个请求是否都在等待同一商品记录。第三层看执行计划。
若 SQL 扫描行数远大于返回行数、没有使用扣减条件上的索引,更新操作可能扫描和锁住大量记录。此时即便锁等待告警消失,数据库 CPU 和磁盘 IO 仍可能很高。第四层排除基础设施问题。
CPU 持续高位、磁盘延迟升高、缓冲池命中率下降、连接池耗尽或主从延迟,都可能让查询变慢,但它们的处理方式与锁冲突完全不同。
现象更可能的原因优先检查 查询耗时与锁等待时长接近事务阻塞阻塞链、长事务、死锁 扫描行数远高于返回行数索引或条件问题执行计划、字段类型、联合索引 所有业务查询同时变慢资源瓶颈CPU、IOPS、磁盘延迟、连接池 只有热门商品变慢热点行竞争商品维度并发、锁等待对象 我的判断标准是:只有当慢查询时间与锁等待时间同时上升,并且存在明确的阻塞源时,才把锁作为首要嫌疑。
否则应先从执行计划和资源指标入手,避免用错误的优化手段掩盖根因。
我在测试中踩过一个坑:把乐观锁当成“无锁方案”,结果热点商品库存冲突时,大量请求不断重试,数据库写入次数反而增加。后来我才意识到,选择锁策略前必须先测冲突率,而不是只比较单次 SQL 的耗时。
悲观锁和乐观锁没有绝对的优劣,关键在于冲突概率、库存一致性要求、请求是否允许重试,以及热点是否集中在少数商品。悲观锁适合冲突频繁且必须在数据库事务内完成强一致扣减的场景。它的优点是逻辑直观,失败路径较少;缺点是请求会排队,事务过长时容易出现锁等待和死锁。
乐观锁通常通过版本号或条件更新判断数据是否被其他请求修改。例如更新时附带旧版本号,受影响行数为 0 就代表发生冲突。它减少了主动等待,但冲突请求需要重试或返回失败,高并发热点场景下,重试会放大 CPU、连接和日志压力。
方案更适合主要代价运维重点 悲观锁冲突频繁、强一致扣减等待和死锁锁超时、阻塞链、事务时长 乐观锁冲突较少、允许失败重试重试放大压力冲突率、重试次数、失败率 队列串行化单个热点商品并发极高排队延迟和系统复杂度队列堆积、消费延迟 库存分片单行成为明显瓶颈汇总和一致性更复杂分片均衡、补偿机制 我的建议是先用压测得到冲突率:如果失败重试占请求量的 1% 左右,乐观锁通常比较容易控制;
如果热点商品的冲突率达到 20% 甚至更高,继续增加重试往往不是好办法,应考虑限流、排队或库存分桶。这里的比例只是工程上的判断起点,最终仍要结合数据库容量和业务可接受延迟验证。无论选哪种方案,都要处理幂等、订单创建失败、重复请求和库存补偿。
只优化扣减 SQL,却没有设计失败后的业务状态,性能可能改善,但数据一致性会变差。
我以前只在发布前看慢查询数量,结果上线后才发现真正的问题是长事务和重试风暴。现在我会把数据库指标、业务指标和一致性指标放在同一张检查表里,因为库存系统不能只证明“查询变快了”,还要证明没有超卖和重复扣减。
上线前至少要完成基线、压测、故障演练和回滚准备四项工作。没有优化前数据,就无法判断改动是否有效;没有一致性校验,就可能为了降延迟引入库存错误。
数据库层面应记录查询平均耗时、P95、P99、锁等待次数、锁等待时长、死锁次数、长事务数量、事务持续时间、活跃连接数、连接池等待时间、CPU、磁盘延迟和 IO 使用情况。SQL 层面要确认库存扣减条件是否命中合适索引,扫描行数是否可控,是否误用加锁查询,是否在事务中执行远程调用或大批量业务计算。
尤其要检查更新条件是否足够精确,否则一条看似简单的 SQL 可能影响大量记录。业务层面要同时监控扣减成功率、库存不足率、重复请求数、乐观锁冲突率、重试次数、订单与库存状态不一致数量,以及补偿任务积压量。重试次数突然上升,往往比单纯的慢查询更早暴露热点行问题。
阶段必须检查通过标准 上线前执行计划、索引、事务边界无明显全表扫描和无关长事务 压测中P99、锁等待、热点商品冲突率高峰下延迟和错误率可接受 运行中死锁、长事务、连接池、重试有告警阈值和自动留痕 故障后库存账实一致、补偿和回滚可定位、可恢复、可复核 压测时不要只发送均匀随机商品请求,必须单独构造“一个热门商品被大量并发扣减”的场景,因为平均流量下看不出热点行竞争。
建议先固定数据库版本和数据量,只修改一个变量,例如先缩短事务,再观察锁等待、P99 和扣减成功率是否同步变化。最终验收不能只看查询耗时下降。只有当锁等待减少、吞吐稳定、库存没有负数、没有重复扣减,并且故障后能够完成补偿,才算是真正完成了库存性能优化。


读者评论
文章把“查询变快”和“库存一致性”区分开来,这一点很实用。库存展示、购物车校验和最终扣减确实不应采用同一种锁策略,尤其是展示接口,优先考虑缓存和普通读取更合理。
文中对长事务的分析比较到位。很多库存问题未必来自 SQL 本身,而是事务中夹杂远程调用、日志或消息发送,导致锁持有时间被拉长。实际排查时结合锁等待、活跃事务和连接池指标会更有帮助。
关于乐观锁和悲观锁没有简单下结论,比较客观。热点商品下乐观锁失败重试可能形成新的压力,因此还需要结合冲突率、重试上限、退避策略以及 P95、P99 延迟综合评估。