数据库存:运维团队案例思路:超卖排查怎样优化并发扣减
库存只剩 1 件,却有 2 个订单同时显示“扣减成功”,这类事故表面上像是数据库少了一条锁,实际排查时往往牵涉请求重试、事务边界、消息重复消费、库存流水缺失和读写延迟。我的判断是:超卖排查不能从“要不要加锁”开始,而要从订单、库存流水和扣减结果三条证据链开始。先证明哪里发生了重复扣减,再决定使用原子更新、悲观锁、乐观锁、队列串行化还是缓存方案。
本文以运维团队处理库存异常的视角,拆解一次并发扣减故障应该怎样定位、怎样复现、怎样修复,以及修复后如何证明问题真的消失。文中涉及的压测数字属于情景模拟,用于说明排查方法;实际结果会受数据库版本、索引、事务隔离级别、连接池和硬件配置影响。
很多团队看到库存字段变成负数,就直接把问题定性为超卖。但库存字段可能代表可售库存、锁定库存、物理库存或待同步库存。比如下单时先锁定库存,支付成功后才正式扣减,短时间内出现“可用库存减少”并不等于真实销售数量超过库存。
我通常先用下面的公式核对业务事实,而不是直接查询当前库存:
期末可用库存 = 期初库存 – 成功扣减数量 + 释放数量 + 人工调整数量
如果公式两侧无法对上,再继续区分是数据库扣减重复、取消订单未释放、消息重复消费、人工修正漏记,还是统计口径不一致。只有当成功订单数或实际发货数超过可售库存,才可以确认是业务意义上的超卖。
对于一条库存记录的简单扣减,我优先采用带条件的原子更新,而不是“先查询库存,再在应用层判断,最后执行更新”。正确的核心动作类似下面这样:
UPDATE product_stock SET stock = stock - 1, updated_at = CURRENT_TIMESTAMP WHERE product_id = ? AND stock >= 1;
执行后必须读取受影响行数。影响 1 行表示本次扣减成功,影响 0 行表示库存不足、商品不存在或更新条件没有满足。影响行数不是普通返回值,而是库存扣减是否成功的事实依据。
即使 SQL 本身不会把库存扣成负数,用户重复点击、网关超时重试、订单服务重试或消息重复投递,仍然可能让同一笔业务被执行两次。因此完整方案至少要同时处理四件事:
如果只修改 SQL,不补充请求幂等和库存流水,系统可能从“库存变负”变成“库存没变负,但订单数量重复”,问题只是换了一种形式。

以电商促销为例,用户提交购买请求后,系统通常要经过网关、订单服务、库存服务、数据库和消息队列。看起来只是“库存减一”,实际上至少存在以下节点:
只要其中任意两个节点对“扣减成功”的理解不一致,就可能产生异常。例如数据库已经扣减成功,但应用因网络超时没有收到响应,于是重试一次;如果没有幂等控制,第二次请求可能再次扣减。
我在做库存问题分析时,第一步通常不是看库存表,而是把以下几种现象分开。它们在业务投诉中经常被统称为超卖,但处理方式完全不同。
| 现象 | 可能原因 | 第一检查对象 | 是否一定是数据库并发问题 |
|---|---|---|---|
| 库存显示为负数 | 无条件扣减、人工改数、释放逻辑错误 | 库存变更流水 | 不一定 |
| 成功订单数超过可售库存 | 重复下单、重复消费、并发判断失效 | 订单与请求幂等记录 | 可能是 |
| 库存少了但订单不存在 | 扣减后创建订单失败、事务边界不完整 | 事务日志和异常重试 | 不一定 |
| 订单数正常但页面库存不准 | 缓存延迟、读副本延迟、统计口径不同 | 数据库主库与缓存 | 通常不是 |
这一步很重要。若把缓存延迟误判为数据库超卖,团队可能花时间增加锁和事务,却没有解决页面读到旧数据的问题;若把订单重复消费误判为 SQL 竞争,又会遗漏消息幂等。
库存问题最怕“每个系统都有日志,但日志拼不起来”。建议至少统一记录请求编号、订单号、商品编号、用户编号、库存变更前值、变更后值、数据库影响行数、事务编号、消息编号和重试次数。
其中,库存变更前值和变更后值不应该只靠应用层猜测。更稳妥的做法是写入库存流水表,记录本次动作类型、来源系统、业务编号和实际变更数量。库存字段回答“现在是多少”,库存流水回答“为什么变成这样”。
在运营和运维协同场景中,某数据分析平台可以把订单、库存流水、取消记录和消息消费记录汇总到同一个看板,帮助团队按商品、时间段、渠道和请求状态观察异常集中点。比如某促销商品在 10:00:00 至 10:00:03 之间出现大量重复请求,这类趋势很适合通过可视化快速发现。
但看板只能帮助定位“异常发生在哪里”,不能证明某条 SQL 是否安全。最终仍要回到数据库执行记录、影响行数、事务提交结果和库存流水完成核验。数据分析负责缩小范围,数据库日志负责确认事实。

事务解决的是一组数据库操作是否整体提交、整体回滚,以及不同事务之间如何观察数据。它并不会自动把“查询库存”和“扣减库存”变成一个不可分割的业务动作。
例如下面的流程放在事务里,仍然存在并发风险:
BEGIN; SELECT stock FROM product_stock WHERE product_id = 1001; -- 应用层判断 stock > 0 UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1001; COMMIT;
两个事务都可能先读取到库存为 1,然后先后执行更新。如果更新语句没有库存条件,第二个事务仍然可能继续扣减。事务让操作具备提交边界,却没有表达“只有库存大于等于购买数量时才能扣减”这个业务约束。
应用层判断的最大问题是判断结果和最终写入之间存在时间窗口。并发请求可以在窗口中读取到同一个库存值。即使程序员认为代码执行顺序是“先判断、后更新”,数据库看到的却是多个事务交错执行。
库存约束应该尽量下沉到更新语句中。让数据库在同一个写操作里完成条件判断和数值变化,至少可以保证单行库存扣减不会在条件不满足时继续成功。
有些代码执行了带条件的更新,却没有检查影响行数,默认 SQL 没报错就代表扣减成功。这是另一种隐蔽故障。库存不足时,数据库可能只是返回 0 行受影响,并不会抛出异常。如果应用继续创建订单,业务层依然会产生“无库存订单”。
正确做法是将影响行数纳入业务分支,并将扣减结果写入日志:
UPDATE product_stock SET stock = stock - :quantity WHERE product_id = :product_id AND stock >= :quantity;
应用层应明确处理三种结果:影响 1 行代表成功;影响 0 行代表库存不足或记录不存在;执行异常代表数据库或连接问题,不能按照库存不足简单处理。
行锁在复杂库存流程中有价值,但它不是免费能力。热点商品的库存记录可能成为高竞争行,大量事务会在这里排队。更严重的是,如果事务持锁期间还调用支付、营销或远程库存接口,锁持有时间会被远程网络放大。
我的判断原则是:如果业务只是“库存足够就减去购买数量”,优先考虑条件原子更新;只有在必须先读取并执行复杂判断时,才考虑悲观锁。
乐观锁通过版本号或库存条件检测冲突,适合冲突概率可控的场景。但在秒杀热点商品中,失败请求可能远远多于成功请求。如果每个失败请求都立即重试三到五次,数据库收到的不是原始流量,而是被放大的流量。
乐观锁重试必须设置上限、退避时间和失败原因记录。对于极高竞争的商品,更适合在数据库之前做请求限流、库存预热或队列排队。
缓存可以降低数据库读取压力,却会引入新的状态同步问题。服务重启、消息积压、网络抖动、缓存淘汰和补偿失败,都可能使缓存数量与数据库数量暂时或长期不一致。
缓存扣减方案必须配套库存流水、异步落库、失败补偿和定时对账。否则,系统只是把“数据库锁竞争”换成了“缓存与数据库无法解释的差异”。
正常压测只能验证并发时数据库能否承受请求,不能验证超时、宕机和重复消息下的正确性。真实事故经常发生在“扣减已经成功,但调用方没有收到响应”的瞬间。
因此,压测必须加入故障注入:在扣减成功后人为延迟响应、在订单创建前终止服务、重复发送同一个消息编号,并检查最终订单数、库存数和流水数是否一致。

先确认库存字段的含义。需要问清楚它是可售库存、锁定库存、仓库实物库存还是渠道库存。还要确认扣减时点:下单扣减、支付扣减、发货扣减,还是多个阶段分别预占和释放。
如果没有先确认口径,后续所有 SQL 分析都可能建立在错误前提上。例如页面显示剩余 0 件,但实际还有 10 件锁定库存;这属于库存模型或展示口径问题,不一定是并发扣减事故。
建议按商品和时间窗口导出以下数据:初始可售库存、成功订单数量、取消订单数量、释放数量、退款数量、人工调整数量和实际发货数量。先完成数量对账,再定位技术环节。
在实际排查中,我会特别关注“成功订单数”和“库存扣减流水数”是否相等。如果订单数是 10,扣减流水却是 12,说明可能存在重复扣减或回补缺失;如果订单数是 12,扣减流水也是 12,则要继续确认初始库存、渠道分配和人工调整是否正确。
单看一行库存记录通常看不出并发关系。需要将同一商品的请求按时间排序,关联请求编号、订单号和库存流水编号,观察是否存在以下模式:
其中,“同一请求被执行两次”和“两个不同请求同时竞争”是两类不同问题。前者重点看幂等和重试,后者重点看原子更新、锁和事务隔离。
需要核对实际执行的 SQL,而不是只看代码仓库中的模板。生产环境可能经过 ORM 拼接、分库路由或参数转换,最终 SQL 与开发者想象的不一定相同。
重点检查以下内容:
如果商品库存表中同一个商品存在多条有效记录,哪怕 SQL 带了库存条件,也可能一次更新多行,造成扣减数量超出预期。因此,库存记录的唯一性必须在数据库层得到约束,而不能只依赖应用代码。
如果库存扣减由消息触发,需要查询消息投递次数、消费次数、消费结果、重试原因和死信记录。消息系统通常只能提供至少一次投递语义,不能假设业务动作天然只执行一次。
消费端应该以业务编号建立幂等判断。例如库存流水表可以对“订单号、商品编号、扣减阶段”建立唯一约束。重复消息再次到达时,系统要返回“已处理”,而不是再次执行库存扣减。

为了验证扣减逻辑,我通常会先构造一个足够小的测试:商品初始库存设置为 10,模拟 100 个并发购买请求,每个请求购买 1 件。理论上,成功扣减数、成功订单数和最终库存变化应满足以下关系:
这里的 100 个并发请求不是生产数据,而是为了放大竞争窗口。测试重点不是单纯追求每秒处理多少请求,而是观察“数据库成功扣减数”和“业务成功订单数”是否始终一致。
错误实现通常类似下面的伪代码。它在低并发下很容易通过功能测试,因为请求之间很少同时读取库存。但当剩余库存较少时,竞争窗口会明显放大。
BEGIN; stock = SELECT stock FROM product_stock WHERE product_id = 1001; if stock >= quantity: UPDATE product_stock SET stock = stock - quantity WHERE product_id = 1001; INSERT INTO orders (...); COMMIT; else: ROLLBACK;
如果两个事务同时读取到库存为 1,它们都可能通过应用层判断。之后即使数据库对更新操作进行排队,也不代表第二个事务会重新按照最新库存执行应用层判断。最终是否超卖,取决于具体 SQL、隔离级别、锁策略和事务实现,不能靠“代码看起来有事务”来保证。
改进方案将库存下限写入更新语句,并为业务请求建立唯一幂等记录。流程可以拆成以下几个动作:
单库事务示例可以写成如下形式。示例中的表名和字段仅用于说明,生产环境还应根据实际订单模型补充唯一约束和异常处理。
BEGIN;
INSERT INTO request_dedup
(request_id, user_id, product_id, created_at)
VALUES
(:request_id, :user_id, :product_id, CURRENT_TIMESTAMP);
— 如果 request_id 已存在,则直接返回已处理结果
UPDATE product_stock
SET stock = stock - :quantity,
updated_at = CURRENT_TIMESTAMP
WHERE product_id = :product_id
AND stock >= :quantity;— 应用读取 affected_rows
— affected_rows = 0 时回滚并返回库存不足
INSERT INTO stock_ledger
(request_id, order_id, product_id, change_quantity, change_type, created_at)
VALUES
(:request_id, :order_id, :product_id, :quantity, 'SALE', CURRENT_TIMESTAMP);
INSERT INTO orders
(order_id, request_id, user_id, product_id, quantity, status, created_at)
VALUES
(:order_id, :request_id, :user_id, :product_id, :quantity, 'PENDING', CURRENT_TIMESTAMP);
COMMIT;这里有一个容易被忽略的细节:幂等记录、库存流水和订单记录之间的唯一关系,最好由数据库唯一约束提供最后一道保护。应用层的“先查询是否存在”仍然可能被并发请求同时绕过。
下面是一组示意性压测结果,用于说明不同实现方式的风险差异,不代表某个数据库或具体产品的真实性能。测试条件设为初始库存 10、并发请求 100、每个请求购买 1 件,观察成功请求、最终库存、重复扣减和锁等待。
| 实现方式 | 成功订单数 | 最终库存 | 重复扣减记录 | 主要问题 |
|---|---|---|---|---|
| 先查后改,无条件更新 | 18 | -8 | 8 条 | 应用层判断与数据库写入之间存在竞争窗口 |
| 事务包裹,但仍先查后改 | 15 | -5 | 5 条 | 事务存在,但没有表达库存下限约束 |
| 条件原子更新,无幂等 | 10 | 0 | 2 条 | 库存不超卖,但重复请求可能造成订单逻辑异常 |
| 条件原子更新加业务幂等 | 10 | 0 | 0 条 | 数据库和业务结果均符合预期 |
这组数据说明了一个关键判断:条件原子更新解决的是库存数量竞争,业务幂等解决的是同一动作重复执行。两者缺一不可。只加原子更新,可能得到正确库存,却仍创建重复订单;只加幂等,可能保证同一请求不重复,却无法阻止不同请求同时竞争库存。

接口成功率高不代表扣减正确。一次请求可能在数据库已成功更新后,由于响应超时被客户端判定失败;接口监控看到失败,数据库却已经完成扣减。相反,接口返回成功也可能只是订单服务接受了请求,库存服务实际上还在异步处理。
建议将以下指标放在同一时间窗口观察:
如果“库存更新成功次数”大于“库存流水写入次数”,说明扣减和流水写入之间可能存在事务边界问题;如果“库存流水写入次数”大于“订单创建成功次数”,则应继续查订单创建失败后的补偿;如果“消息重复消费次数”突然上升,优先检查消费幂等,而不是立即扩大数据库容量。

对于库存记录单行、购买规则简单、订单与库存处于同一个数据库的业务,条件原子更新通常是最稳妥的起点。它的优点是实现简单、事务短、锁范围明确,也便于通过影响行数判断结果。
这类方案不需要一开始就引入分布式锁或缓存。技术复杂度越低,出现异常时越容易还原执行过程。对大多数普通商品,先把 SQL、唯一约束、幂等和对账做好,往往比堆叠中间件更有效。
如果扣减前必须同时判断多个库存维度,例如仓库、批次、地区、会员等级和组合商品,单条条件更新可能无法完整表达业务规则。这时可以考虑行锁或更明确的事务控制。
使用悲观锁时要遵守三个原则:
如果一个订单需要锁定多个商品或多个仓库记录,必须固定加锁顺序。例如始终按照商品编号升序加锁,而不是按照请求传入顺序加锁。否则两个事务可能互相持有对方需要的记录,形成死锁。
乐观锁可以通过版本号控制更新。例如读取版本号为 20,更新时要求版本号仍为 20,成功后将其改为 21。如果影响行数为 0,说明记录已经被其他请求修改,当前请求需要失败或重试。
UPDATE product_stock SET stock = stock - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE product_id = :product_id AND version = :version AND stock >= :quantity;
乐观锁的关键不在于“失败后重试”,而在于判断重试是否有价值。普通商品的竞争通常可控,重试一到两次可能合理;热点商品的库存很快被少数请求消耗,继续重试只会增加数据库压力。
秒杀场景的核心矛盾不是数据库每秒能处理多少更新,而是大量请求最终都注定失败,却仍然进入数据库争抢同一行。此时应在入口增加限流、用户资格校验、购买次数校验或预扣库存机制。
队列串行化可以把同一个商品的扣减操作排成顺序,减少数据库热点行竞争,但代价是请求延迟增加、消息积压和失败补偿变得复杂。队列不是让库存天然正确,而是改变并发竞争的发生位置。
缓存适合承接库存展示、商品详情和活动资格等读请求。对于最终扣减,是否直接在缓存中执行,要看能否接受异步一致性和补偿成本。
如果库存扣减直接发生在缓存中,必须回答以下问题:
如果这些问题没有明确答案,缓存方案即使压测吞吐量很高,也不适合直接承担最终库存事实。
| 方案 | 正确性控制 | 性能特征 | 运维复杂度 | 优先适用场景 |
|---|---|---|---|---|
| 条件原子更新 | 强,适合单行库存 | 中高,受热点行影响 | 低 | 常规下单、库存模型简单 |
| 悲观锁 | 强,适合复杂事务 | 中等,存在锁等待 | 中 | 多条件判断、并发量可控 |
| 乐观锁 | 强,但依赖冲突处理 | 冲突高时下降明显 | 中 | 允许失败重试的更新 |
| 消息队列串行化 | 依赖幂等和补偿 | 吞吐可控,延迟增加 | 高 | 热点商品、秒杀扣减 |
| 缓存扣减 | 依赖最终一致性设计 | 高,但恢复复杂 | 高 | 极高并发、可接受异步处理 |

不要先重构整个库存系统。先冻结异常商品的自动扣减或限制购买,再保存订单、库存流水和数据库日志,避免后续补偿动作覆盖现场证据。
短期修复建议如下:
这种场景通常不需要立即引入缓存或队列。先降低逻辑复杂度,确保每次扣减都能解释,往往是成本最低的方案。
跨服务后,库存扣减和订单创建无法简单依赖一个本地事务。此时要明确哪个动作是主状态,哪个动作是补偿状态。
一种常见做法是库存服务先创建预占记录,再由订单服务确认;如果订单创建失败或支付超时,库存服务根据预占记录释放库存。每个状态变化都应具备幂等性,并记录业务编号和状态版本。
另一种做法是订单先创建待确认状态,再调用库存服务预扣。无论采用哪一种,必须保证失败可以重试,重复重试不会重复扣减,长期失败能够进入人工处理或补偿队列。
当数据库锁等待明显上升、库存更新失败率随并发快速增加时,说明瓶颈可能已经从逻辑正确性转向热点竞争。此时不能只调大数据库连接池,因为更多连接会让同一库存行排队得更严重。
建议按照以下顺序处理:
秒杀不应把“所有请求都同步写数据库”作为默认设计。大量失败请求如果直接冲击数据库,会让真正有机会成功的请求也被拖慢。
可以采用资格预校验、活动令牌、限流、缓存库存和异步订单等组合方式。但此时系统要接受一个事实:用户拿到的可能是“排队成功”而不是“订单已创建”。前端提示、订单状态和退款补偿都要围绕这个事实设计。
如果业务不接受较长的最终一致性窗口,例如医疗、票务或强履约场景,就不能只追求吞吐量,还要保留可核对的预占记录和严格的超时释放机制。
技术团队汇报时不要只说“数据库出现并发问题”。建议将影响拆成业务订单、库存数量、用户体验、财务结算和人工处理五个维度。
例如可以说明:本次异常涉及多少个商品、多少笔订单、多少笔重复扣减、多少库存需要人工核对、多少订单需要取消或补发,以及修复后通过了哪些并发和异常测试。这样的信息比单纯描述“增加了锁”更能帮助管理层判断风险是否已经收敛。

修复验收不能只写“压测通过”。建议提前定义可观测标准:初始库存为 10 时,任何并发场景下成功扣减数不能超过 10;同一请求编号最多产生一条成功库存流水;同一订单最多产生一次有效扣减;最终库存、订单和流水能够完成对账。
如果系统使用异步处理,还要定义一致性窗口。例如订单创建后 3 秒内应完成库存确认,超过时间进入异常状态;异常状态必须有补偿任务和人工查询入口。
第一类是同一商品竞争测试。设置低库存和高并发,验证多个请求同时争抢同一行时,成功数量是否受库存约束。
第二类是同一请求重试测试。让同一个请求编号连续发送多次,验证只产生一次订单和一次库存流水。
第三类是不同请求重复订单测试。模拟客户端超时、网关重试和用户快速点击,验证业务规则能否阻止重复购买。
第四类是故障恢复测试。在库存扣减成功后中断订单服务、延迟消息确认或模拟数据库连接异常,检查系统能否恢复,不会重复扣减或永久占用库存。
每次压测结束后,不要只检查库存字段。至少核对以下四个数量:
这四个数量应该能够互相解释。如果其中任何一项不一致,就应继续检查事务提交、异常补偿、重复消费和人工调整。压测通过的标准不是接口没有报错,而是所有事实数据能够闭合。
一次修复不能代替持续治理。建议每天或每小时对高价值商品进行库存对账,至少核对库存主表、库存流水、订单状态和释放记录。对账任务不能直接自动改库存,第一阶段应先生成差异清单,避免错误程序再次扩大损失。
当差异被确认后,再按照异常类型执行补偿:重复扣减需要判断是否取消订单或补发;扣减成功但订单失败需要释放库存;订单取消但库存未释放需要补回;统计延迟则只修正展示或报表口径。
技术监控可以关注数据库连接数、锁等待和慢查询,但库存事故还需要业务监控。建议设置以下告警:

如果线上已经发生库存异常,优先保护现场和控制继续扩散。不要在没有备份日志的情况下直接批量修改库存,也不要先删除重复订单再做分析。
短期修复的目标是让每一次库存动作都能解释。建议补充库存流水表、请求幂等表和订单唯一约束,并把影响行数、事务结果和消息编号纳入结构化日志。
同时完成一次最小并发压测,覆盖库存不足、请求重试、消息重复和服务中断四类情况。压测结果应保存为发布记录的一部分,而不是只在聊天工具中口头确认。
长期治理需要将库存看作一套可审计的状态机,而不是一个可以随时减一的数字。库存状态至少要能区分可售、预占、已扣减、已释放和人工调整。
每种状态变化都应有来源、业务编号、操作时间和操作者。这样发生异常时,团队可以回答“谁在什么时间,因为哪个业务动作改变了库存”,而不是只能根据当前库存倒推过去。
如果团队使用九数云等数据分析工具,可以将订单明细、库存流水、取消记录和消息消费结果按统一业务编号关联,建立商品维度和时间维度的异常看板。建议设置库存差异、重复请求率、扣减失败率、补偿完成率和热点商品锁等待等视图。
看板设计要避免只显示“异常数量”。更有价值的是展示异常数量的形成过程,例如从请求总量到有效订单、从有效订单到成功扣减、从成功扣减到库存流水,再到最终对账差异。这样运维人员能快速判断问题发生在哪一个转化节点。
不过,数据看板不应成为生产写库工具。人工调整库存必须走审批、留痕和复核流程,不能因为看板上出现差异,就直接执行一条修改库存的 SQL。

库存是实物商品、票务名额或有限权益时,正确性通常优先于极限吞吐量。即使接口响应慢一些,也不能让系统产生无法履约的订单。
如果库存是可随时补充的虚拟权益,业务可能允许短暂超发或异步补偿,方案可以更偏向吞吐量。但这必须由业务明确授权,不能由技术团队自行假设。
同步扣减的优点是用户能较快知道结果,数据库状态也更容易理解;缺点是热点商品会集中冲击数据库。异步扣减能削峰,但用户看到的是排队状态,系统还要处理积压、超时和失败补偿。
如果业务要求用户在提交后立即得到确定订单号,同步方案更合适;如果业务接受“排队后再确认”,异步队列才有发挥空间。
订单和库存都在同一个数据库,且事务耗时可控时,单库事务通常是更容易维护的选择。跨服务后,强行用远程调用拼接“分布式事务”可能带来长时间锁定和故障放大。
最终一致性方案并不是低配方案,它需要更完整的状态设计、重试、幂等、补偿和对账。团队如果没有持续维护这些能力,宁愿先收敛系统边界,也不要为了架构先进而过早拆分。
可以自动补偿的异常必须满足两个条件:异常类型明确,补偿动作幂等。例如订单超时未支付并且库存确实处于预占状态,释放库存通常可以自动执行。
如果库存差异来源不明,或者涉及人工调整、跨仓分配和财务结算,应先进入人工复核。自动把差异“修成零”可能让报表看起来正常,却掩盖更严重的数据链路问题。
| 决策问题 | 偏向保守方案 | 偏向高性能方案 | 需要承担的代价 |
|---|---|---|---|
| 库存是否绝对不可超发 | 单库原子扣减、严格幂等 | 缓存预扣、异步确认 | 高性能方案需要更复杂的补偿与对账 |
| 用户是否必须立即知道结果 | 同步扣减并返回确定状态 | 排队后异步确认 | 异步方案会增加状态等待和客服解释成本 |
| 订单库存是否同库 | 本地事务 | 消息最终一致性 | 跨服务方案需要处理重复消息和失败补偿 |
| 异常是否容易分类 | 人工复核后处理 | 自动补偿 | 自动化需要保证补偿动作不会二次伤害数据 |

数据库锁只是解决方案的一部分。库存异常可能来自数据库条件缺失,也可能来自重复请求、消息重复消费、事务边界错误、取消订单未释放或统计延迟。只有把这些问题分层,修复才不会偏离根因。
对于大多数单行库存扣减业务,我建议先建立以下最小闭环:
这个方案不一定是所有场景的最终架构,却是一个容易解释、容易验证、容易运维的起点。只有当热点竞争、吞吐压力或跨服务一致性确实成为瓶颈时,再引入队列、缓存、分片或更复杂的状态机。
如果你的系统已经出现库存异常,今天可以先挑选一个问题商品,导出它的订单、库存流水和消息消费记录,按照请求编号重建时间线。不要先改库存,先确认“哪一个动作多执行了一次”。
如果系统尚未发生事故,可以用初始库存 10、并发请求 100 的最小测试开始,分别验证先查后改、条件原子更新、重复请求和服务中断四种情况。测试结束后,只接受一种结果:库存、订单、流水和幂等记录能够相互解释,且成功扣减数永远不超过可售库存。
我最看重的不是某一种锁或某一个中间件,而是系统在故障发生后能否回答三个问题:库存为什么减少、这次动作是否已经执行过、异常能否安全补偿。能回答这三个问题,才称得上真正可运维的并发扣减系统。
我线上遇到过库存显示为负数,但订单数量并没有超过可售库存的情况,也遇到过库存看起来正常、实际成功订单却多于库存的情况。我不确定排查时应该先看库存字段、订单表,还是库存流水,怎样才能判断问题真正发生在哪一层?
不要一看到库存为负数就直接认定发生了超卖。实务中,预占库存未释放、人工调账、订单取消补偿失败,都可能造成负库存;真正的业务超卖,是成功订单数量超过了可售库存,或者同一份库存被多个订单重复确认。我通常先做三组数据核对,而不是直接查看库存表:成功订单数、库存扣减流水数、当前库存变化。
核对公式可以写成:初始库存 – 成功扣减数量 + 释放数量 ± 人工调整 = 当前可用库存。三组结果对不上时,再沿着订单号和请求号还原时间线。
检查对象重点字段可以发现的问题 订单表order_id、user_id、商品、状态重复下单、重复确认 库存流水request_id、stock_before、stock_after、affected_rows重复扣减、错误重试、库存覆盖 消息记录message_id、消费次数、重试次数重复消费、补偿失败 我在一次并发压测中把初始库存设为 10,同时发送 100 个请求。
错误实现返回了 13 个“扣减成功”,但数据库库存只减少了 10,根因不是数据库自动多扣,而是多个请求先读取库存、再在应用层判断,最后发生了覆盖写。这个细节很关键:库存异常不一定表现为负数,也可能表现为订单数与流水数不一致。
排查顺序建议固定为“订单确认结果 → 库存流水 → 请求日志 → 消息消费记录 → 数据库锁等待”。只有把同一商品在同一时间窗口的完整链路串起来,才能区分并发竞争、重复请求、重复消费和补偿任务误扣这几类问题。
我现在的代码是先查询库存,判断大于零后再执行减一操作,低并发测试一直没有问题。到了促销场景却出现多个请求同时成功,我想知道把判断条件直接写进 UPDATE 语句,究竟解决了哪个并发窗口,使用时还需要注意什么?
“先查库存,再扣库存”最大的问题,是库存判断和库存修改之间存在时间窗口。两个请求可能在几乎同一时刻读到库存为 1,随后都通过应用层判断;即使最后数据库只保留一个结果,业务层也可能已经为两个请求创建了成功记录。
更稳妥的单行扣减写法是把业务条件放进更新语句:
UPDATE product_stock SET stock = stock - 1, updated_at = CURRENT_TIMESTAMP WHERE product_id = ?AND stock >= 1;执行后必须检查受影响行数。影响 1 行,代表数据库确实完成了本次扣减;影响 0 行,代表库存不足、商品记录不存在或条件不满足,不能把它当成网络异常后无限重试。
实现方式库存为 1 时的并发结果主要风险 先 SELECT 再 UPDATE多个请求可能都判断成功并发窗口、重复成功 带 stock 条件的 UPDATE最多一个请求影响 1 行需正确处理影响行数 版本号乐观锁版本冲突请求更新失败高冲突时重试放大流量 我更倾向于把原子更新作为普通库存扣减的第一选择,而不是一上来就引入分布式锁。
原因是它的锁范围由数据库根据索引定位到具体库存行,代码路径短,故障面通常小于“应用锁加数据库锁”的组合方案。这里有一个容易踩坑的地方:product_id 必须具备唯一约束或至少有明确的唯一索引,否则同一商品存在多条库存记录时,影响行数和库存语义都会变得不可靠。
另外,事务里不要夹杂长时间的远程调用,否则即使 SQL 正确,也可能因为锁持有时间过长造成排队。
团队讨论时有人说加事务就够了,有人建议查询库存时使用行锁,还有人主张用版本号重试。我担心方案看起来都正确,但在热点商品上反而出现锁等待、死锁或重试风暴,应该根据什么条件做选择?
事务、锁和原子更新解决的不是同一个问题。事务负责一组操作的提交边界,悲观锁控制并发访问,乐观锁检测数据是否被别人修改;如果只是把代码包进事务,却仍然采用先查后改,超卖风险并不会自动消失。我的选型原则是先判断扣减逻辑是否简单。如果只是“库存大于零就减一”,优先使用带条件的原子 UPDATE;
如果必须读取库存后执行复杂计算,并且并发量可控,再考虑悲观锁;如果冲突比例不高、业务允许失败重试,才适合使用乐观锁。
方案适用条件我会重点防范的风险 条件原子更新单行、简单扣减影响行数未判断、索引缺失 悲观锁复杂读取后更新、并发可控锁等待、死锁、事务过长 乐观锁冲突概率较低、允许重试热点商品重试风暴 队列串行化极热点商品、可接受排队延迟消息积压、消费失败、恢复困难 我曾经见过一个版本号方案在普通测试中表现很好,但上线到限量商品后,库存只剩几十件,数千个请求同时更新同一行。
大量请求因为版本冲突重试,数据库 CPU 和连接池压力迅速上升。问题不是乐观锁“不安全”,而是它把数据库竞争转化成了应用层重试竞争。悲观锁也不是越早加越好。若事务先锁库存,再调用订单服务或支付服务,锁会被远程调用时间拉长,最终瓶颈从“是否超卖”变成“锁等待和连接池耗尽”。
锁内逻辑应尽量短,远程操作放到事务外,并通过幂等、消息和补偿处理跨服务一致性。因此,最实际的判断不是“哪种方案最好”,而是看四个指标:扣减逻辑复杂度、单商品并发冲突率、是否允许重试、能否接受异步延迟。方案必须结合这四项,而不能只根据技术名词做决定。
我们已经把 SQL 改成了条件扣减,但我担心用户重复点击、接口超时重试和消息重复消费仍然会让库存被扣两次。除了做一次并发压测,我还想知道应该验证哪些异常场景,以及线上需要长期监控什么指标。
只验证“100 个请求抢 10 件库存”还不够,因为很多库存事故不是一次并发写入造成的,而是扣减成功后的重试、重复消费或补偿任务再次执行。修复验证至少要同时覆盖数据库并发、接口重试、服务宕机和消息重复投递四类场景。
我会先做一个可核对的最小测试:初始库存设为 10,并发发送 100 个请求,要求成功扣减数不超过 10;随后分别统计成功订单数、库存流水数和数据库实际扣减数。理想结果不是只看库存为 0,而是三者在排除取消和释放后能够相互对账。
测试场景预期结果必须观察的证据 100 请求抢 10 件最多 10 次扣减成功影响行数、库存流水 客户端重复提交同一幂等号只生成一个结果请求号唯一约束 扣减后服务宕机订单与库存最终可恢复补偿记录、事务状态 消息重复投递同一消息不会重复扣减message_id 消费记录 接口层建议使用业务幂等号,例如由客户端或服务端生成 request_id,并在订单或库存流水表中建立唯一约束。
消息消费端也要保存 message_id 或业务事件号,先判断是否处理过,再执行后续动作;不能把“消息系统通常不会重复”当成幂等设计。线上监控不要只设置“库存小于零”一个告警。我更关注扣减失败率、数据库锁等待、死锁次数、同一请求号重复出现、库存流水与订单数量差异、消息积压和补偿任务失败数。
这些指标往往比库存变负更早暴露问题。最后要安排定时对账,但对账任务本身也必须幂等,不能为了修复差异再次盲目扣库存。正确做法是先生成差异清单,标明订单、流水、消息和人工调整来源,再由业务规则决定释放、补扣、冻结或人工复核。这样才能把一次 SQL 修复,变成可持续的运维闭环。


读者评论
文章把“超卖”拆分为库存负数、重复订单、缓存延迟等不同现象,这个思路比较实用。先核对订单和库存流水,再定位数据库或消息链路,能避免一上来就盲目加锁。
带条件的原子更新和检查受影响行数是很关键的细节。很多系统虽然写了库存条件,却忽略了0行更新的业务处理,最终仍可能生成无库存订单。
文中对事务和并发控制的区分讲得比较清楚。事务只能保证提交边界,不能自动解决查询与更新之间的竞争窗口,这一点对排查库存问题很有参考价值。
库存流水、请求编号、消息编号和重试次数统一关联,确实能提高事故定位效率。相比只看当前库存,保留变更前后值更有助于还原真实执行过程。
文章没有把所有问题都归因于数据库锁,而是同时关注重复请求、消息消费和取消释放。实际落地时还需要结合数据库版本、索引和压测结果选择方案。