数据库存:数据库管理员一页讲清:表结构设计与保证扣减一致性的关系
库存、余额、优惠券次数、可售席位这类数据,最容易出现一种反常识故障:扣减 SQL 看起来没有问题,事务也已经加上了,线上仍然可能出现负库存、重复扣减或“订单成功但扣减流水缺失”。我在排查这类问题时,通常不会先问“有没有加锁”,而是先看表结构:一个业务对象究竟对应几行数据,数量字段是否可为空,唯一性由什么约束,扣减条件能否被索引准确命中。扣减一致性不是某一条 SQL 的功劳,而是表结构、约束、更新条件、事务边界、并发控制和幂等机制共同形成的结果。
表结构不是数据库里的“格式说明”,它实际上是在定义业务数据的底线。一个库存表如果允许同一个仓库、同一个商品出现多条记录,那么应用层看到的“库存”到底是其中一行,还是多行之和,就必须额外解释。
如果数量字段允许为 NULL,应用程序在计算可用量时还要区分“没有库存”“库存未知”和“数据缺失”。如果金额使用浮点数,扣减后可能出现精度误差。若订单号没有唯一约束,网络超时后的重试就可能插入两条看起来都合法的扣减流水。
所以我判断一张扣减表是否可靠,第一步不是看 SQL,而是看三个问题:
扣减操作不能只表达“数量减一”,还必须表达“只有剩余数量足够时才能减一”。这两个表达的安全性完全不同。
UPDATE inventory SET available_quantity = available_quantity - 1 WHERE sku_id = ?;
这条语句只表达了更新对象,没有表达库存不能小于零。只要应用层没有在前面做可靠判断,或者多个请求同时执行,数据库就可能执行出超出业务边界的扣减。
UPDATE inventory SET available_quantity = available_quantity - 1 WHERE sku_id = ? AND available_quantity >= 1;
第二条语句把业务约束放到了更新动作内部。数据库在执行更新时会重新判断条件,而不是依赖应用几毫秒之前读到的旧值。对于单行、数值型资源扣减,条件更新通常是我优先考虑的最低可用方案。
事务最擅长解决的是“多步操作要么一起成功,要么一起失败”。例如扣减库存、写入扣减流水、更新订单状态,这些动作如果处在同一个本地事务中,库存更新失败时,流水和订单状态也可以回滚。
但事务无法判断两个请求是不是同一件业务。客户端因为网络超时重试一次,可能产生两个独立事务;消息队列重复投递,也可能产生两个独立消费者事务。只要每次事务内部都合法,两个事务就都可能成功。
因此,事务保证原子性,幂等键保证同一个业务动作不被重复执行,二者不是替代关系。

在低并发测试中,常见流程是:应用先查询库存,判断库存大于零,再执行更新,最后写入订单。因为每一步之间几乎没有竞争,错误设计也能得到正确结果。
真正的问题往往出现在秒杀、批量发券、账户扣款、仓库分配等场景。多个请求几乎同时读取同一个数量,应用层都得到“还有库存”的结论,随后再分别执行扣减。问题并不在于某一条 SQL 语法写错,而在于读取和更新之间存在竞态窗口。
假设可用库存初始值为 1,两个事务的时间线可能是这样的:
| 时间 | 事务 A | 事务 B | 数据库状态 |
|---|---|---|---|
| T1 | 查询库存,得到 1 | 尚未执行 | 库存为 1 |
| T2 | 等待业务处理 | 查询库存,得到 1 | 库存仍为 1 |
| T3 | 更新为 0 | 准备更新 | 库存为 0 |
| T4 | 提交 | 仍依据旧查询结果执行扣减 | 可能出现负数或业务重复成功 |
不同数据库、存储引擎和隔离级别对最终结果的处理可能不同,但风险来源是一致的:应用层的判断与数据库真正执行更新之间,缺少一个不可被竞争打破的条件。
库存只是最容易被理解的例子。账户余额扣减、优惠券剩余次数、会议席位、API 调用配额、积分余额、仓储可用容量,本质上都属于“有限资源扣减”。它们共同拥有一个业务不变量:扣减之后不能超过当前可用资源。
不同资源的差异在于,扣减失败后的处理方式并不一样。库存不足一般返回售罄;账户余额不足可能触发支付失败;席位竞争失败可能需要重新分配;优惠券重复核销则更依赖幂等流水。
因此,设计表结构时不能只问“字段怎么命名”,还要问:资源的可用量如何定义,冻结量是否单独存储,扣减是否可逆,失败后是否需要补偿。
我经常看到一种设计:用一个字段同时表示总库存、可用库存和冻结库存,或者用一个字符串字段承载“可用、扣减中、已完成、已释放”等多个阶段。这样的设计在业务初期写起来很快,但一旦出现预占、释放、取消和重试,就难以判断某个数字到底代表什么。
更稳定的方式,是把数量的含义拆清楚。例如:
字段拆分并不意味着所有业务都必须存四个数字,而是要让每个字段只承担一种清晰的业务含义。字段含义越模糊,后续越难建立数据库约束、对账规则和异常修复脚本。

事务可以让一次扣减流程具备原子性,但它不等于串行化,也不等于幂等。两个独立事务都可能各自成功,只要它们没有违反数据库当前的隔离规则。
例如,事务 A 和事务 B 都先查询库存为 1,然后分别执行应用层计算。即使每个事务内部都包含查询、更新和提交,事务仍然可能把同一个资源判断为可用。
我在设计时会把问题拆成两层:如果担心“扣减后不能为负”,使用条件更新或锁定读取;如果担心“订单、扣减流水和库存不能半成功”,使用事务;如果担心“同一请求重试两次”,增加幂等键。
速度不能消除竞态窗口,只能缩短竞态窗口。只要读取和更新不是一个具有并发保护的数据库动作,就存在多个事务同时基于旧值作出判断的可能。
以下写法尤其需要谨慎:
SELECT available_quantity FROM inventory WHERE warehouse_id = ? AND sku_id = ?; -- 应用层判断数量是否足够后,再执行 UPDATE inventory SET available_quantity = available_quantity - ? WHERE warehouse_id = ? AND sku_id = ?;
如果业务确实需要先读取多个字段,再根据读取结果作复杂判断,应在同一事务中使用适当的锁定读取,并评估锁等待、死锁和事务时长,而不是简单地认为“查询加更新”天然安全。
行锁可以减少同一数据行上的并发写冲突,但它有明确的使用前提:事务必须足够短,查询条件要能命中正确索引,锁定对象必须与业务对象一致。
如果查询条件缺少索引,数据库可能扫描更多记录,锁等待范围扩大。如果事务拿到库存行锁后又调用外部服务,外部服务的延迟就会直接转化为数据库锁持有时间。高峰期可能出现锁等待、事务超时,甚至死锁。
我通常不建议把支付接口、库存供应商接口或远程 RPC 放在持有数据库行锁的事务中。更合理的做法,是在数据库内部完成短事务状态变更,再通过可靠消息或补偿机制推进外部流程。
乐观锁的核心是“更新时确认版本没有变化”,而不是在每一张表上机械增加 version 字段。如果系统已经采用严格的条件更新,且业务只需要判断数量是否足够,额外版本字段可能增加失败重试复杂度,却没有带来明显收益。
版本号更适合需要读取多个业务字段、经过应用判断后再提交更新的场景。例如编辑商品配置、修改账户状态、更新审批记录。对于极简的库存扣减,条件更新往往更直接。
唯一约束只能阻止相同唯一键的重复写入,不能自动完成“已扣减则返回成功、处理中则继续查询、失败则允许重试”的状态管理。
例如扣减流水表有 request_id 唯一键,第二次请求插入失败后,应用仍然需要判断:第一次请求究竟已经提交成功,还是只写入了部分数据。若没有清晰的状态字段和查询逻辑,唯一键冲突可能被错误地当成普通失败。

不变量是扣减设计的起点。不要一上来就讨论悲观锁还是乐观锁,先把“无论系统如何重试和并发,什么事情都不能发生”写出来。
以库存为例,至少可以列出以下不变量:
这些规则中,有些可以由数据库直接约束,有些需要 SQL 和事务配合,有些则必须依赖应用层状态机和定时对账。把它们混在一起,往往会导致“数据库负责一切”或“应用自己保证一切”两种极端。
表的唯一键应该回答一个非常具体的问题:什么组合在业务上只能存在一条记录?
如果库存按仓库和商品管理,常见的唯一组合就是 warehouse_id 加 sku_id。如果还要区分批次、销售渠道或货主,唯一键就可能扩展为 warehouse_id、sku_id、batch_id、owner_id。唯一键不是越短越好,而是要和扣减时的定位条件保持一致。
CREATE TABLE inventory (
id BIGINT NOT NULL PRIMARY KEY,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
available_quantity INT NOT NULL DEFAULT 0,
locked_quantity INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_warehouse_sku (warehouse_id, sku_id),
CHECK (available_quantity >= 0),
CHECK (locked_quantity >= 0)
);上面的 CHECK 约束是否真正生效,取决于数据库产品和版本。不能只因为 DDL 中写了 CHECK,就假设生产数据库一定会执行它。上线前应在目标数据库版本中用插入和更新测试验证约束行为。
数量字段一般使用整数类型,金额字段使用定点数类型。金额不要使用浮点数直接参与扣减,因为浮点表示存在二进制精度问题,可能在累计计算和比较时产生难以解释的结果。
数量是否允许负数,需要根据字段语义判断。可用量通常不允许小于零;调整量、冲正量或变更流水中的 quantity_delta 则可能允许正负。把所有数量字段都设置成“不能为负”,有时会阻碍正常的退货、冲正和库存调整。
我会把“当前状态表”和“变更流水表”分开设计。状态表用于快速读取和扣减,流水表用于追溯、审计和对账。只保留一个不断覆盖的库存数字,出现争议时很难回答“这个数字是怎么变成现在这样的”。
扣减 SQL 的定位条件、唯一约束和索引设计应该互相匹配。若业务通过 warehouse_id 和 sku_id 定位一行,索引就应支持这两个字段的组合查询,而不是只给一个低选择性的字段建立单列索引。
UPDATE inventory SET available_quantity = available_quantity - ?, updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = ? AND sku_id = ? AND available_quantity >= ?;
执行后必须检查 affected rows。影响行数为 1,通常表示本次扣减命中;影响行数为 0,可能是库存不足、记录不存在或参数条件不匹配。生产代码不应把这三种情况全部吞成一个模糊的“系统异常”。
需要特别注意的是,影响行数的具体语义可能受到数据库驱动配置影响。某些驱动对“匹配行数”和“实际发生变化的行数”的返回方式不同。数据库管理员和开发人员应在实际驱动版本下验证,而不是仅凭数据库客户端的显示结果判断。

假设有一张最初版本的库存表:
CREATE TABLE inventory ( sku_id BIGINT, quantity VARCHAR(20) );
这张表至少存在四个问题。第一,sku_id 没有主键或唯一约束,同一个商品可以出现多行。第二,quantity 使用字符串,数据库无法自然地进行可靠的数值约束和比较。第三,字段允许为空,空值含义不明确。第四,表结构没有仓库、批次或货主维度,无法表达真实扣减对象。
应用层可能采用这样的流程:先查询 quantity,转换成整数后判断是否大于零,再执行更新。这个流程既依赖应用正确处理类型转换,也把并发一致性责任完全放在代码执行时序上。
如果业务规则是“每个仓库的每个 SKU 只有一条可扣减库存记录”,表结构至少应明确这一点:
CREATE TABLE inventory (
id BIGINT NOT NULL PRIMARY KEY,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
available_quantity INT NOT NULL DEFAULT 0,
locked_quantity INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_inventory_warehouse_sku (warehouse_id, sku_id)
);这里的唯一键有两个作用。它首先防止同一个仓库和 SKU 被重复建档,其次为扣减语句提供稳定的单行定位条件。若没有唯一键,应用即便执行了看似正确的 UPDATE,也可能更新多行,造成总量被重复扣减。
对于简单的“扣减 N 件可用库存”操作,我会优先考虑下面这种写法:
UPDATE inventory SET available_quantity = available_quantity - ?, updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = ? AND sku_id = ? AND available_quantity >= ?;
应用必须把参数中的扣减数量绑定到两个位置,并在执行后读取影响行数。影响行数为 1,表示数据库接受了这次扣减;影响行数为 0,则应进一步判断库存不足、记录不存在还是请求参数不符合预期。
这条 SQL 的价值不在于“只有一行代码”,而在于它将读取和判断合并进了数据库的更新动作。数据库会基于当前行状态判断 available_quantity 是否满足条件,而不是使用应用层之前缓存的旧值。
如果扣减前必须同时检查库存状态、批次有效期、渠道归属和冻结状态,单条条件更新可能不够直观。这时可以在同一事务内锁定目标行:
BEGIN; SELECT available_quantity, locked_quantity, version FROM inventory WHERE warehouse_id = ? AND sku_id = ? FOR UPDATE; -- 应用或存储过程完成必要判断后执行更新 UPDATE inventory SET available_quantity = available_quantity - ?, locked_quantity = locked_quantity + ?, updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = ? AND sku_id = ?; COMMIT;
这种方式适合必须读取后做复杂判断的场景,但代价是事务持有锁的时间更长。锁定读取之后不要执行外部网络调用,也不要在事务中等待用户操作或排队处理不确定时长的任务。
对于多个请求都会读取并修改同一行,但业务允许冲突后快速重试的场景,可以使用版本号:
UPDATE inventory SET available_quantity = available_quantity - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE warehouse_id = ? AND sku_id = ? AND available_quantity >= ? AND version = ?;
如果影响行数为 0,可能是库存不足,也可能是 version 已经被其他事务更新。应用需要重新读取最新状态后再分类处理。不要无上限重试,否则热点商品会形成请求风暴,把数据库冲突放大成系统级延迟。
订单库存、账户扣款和优惠券核销通常不能只依赖库存主表。还需要一张记录业务动作的流水表:
CREATE TABLE inventory_deduction_log (
id BIGINT NOT NULL PRIMARY KEY,
request_id VARCHAR(64) NOT NULL,
order_id VARCHAR(64) NOT NULL,
warehouse_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
quantity INT NOT NULL,
operation_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_request_id (request_id),
UNIQUE KEY uk_order_sku_operation (order_id, sku_id, operation_type)
);一次请求进入时,可以先依据 request_id 查询是否已经处理。如果已成功,直接返回幂等结果;如果处于处理中,需要依据事务状态和补偿规则继续判断;如果明确失败,则根据业务规则允许重试。
这里有一个容易被忽略的边界:扣减主表和流水表必须怎样落库,取决于业务模型。如果两者在同一个数据库中,通常可以放在同一个本地事务里。如果主表和流水表跨库,或者流水需要进入消息系统,就不能简单地假设普通事务能够覆盖全部链路。

扣减系统的接口成功率很高,不代表数据一定一致。接口可能在数据库提交成功后响应超时,客户端随后重试;也可能扣减成功但流水异步写入失败。此时接口监控显示的可能只是一次 200 响应,而不是完整业务链路的真实结果。
我更关注以下数据:
这些指标能把“扣减失败”拆成可处理的原因。库存不足不一定是系统故障,锁等待过长则可能是索引或事务设计问题,重复请求次数过高可能说明上游超时设置不合理。
假设应用要扣减 3 件库存,执行条件更新后返回影响行数为 0。此时不能直接说“数据库扣减失败”,还要区分几种情况:
| 影响行数 | 可能含义 | 建议动作 |
|---|---|---|
| 1 | 目标记录存在且满足扣减条件 | 继续提交事务,返回成功 |
| 0 | 可用量不足 | 返回资源不足,不应盲目重试 |
| 0 | 仓库、SKU 或业务对象不存在 | 记录业务参数错误或数据缺失 |
| 0 | 乐观锁版本冲突 | 重新读取后决定是否有限次重试 |
| 异常 | 锁超时、连接中断或事务失败 | 根据提交状态和幂等键进行查询或补偿 |
下面的数值是用于建立监控基线的示意数据,不是某家企业的生产统计。真实系统应按照自己的流量、数据量、数据库规格和业务容忍度设定阈值。
| 观察项 | 建议关注的现象 | 可能指向的问题 | 处理方向 |
|---|---|---|---|
| 条件更新 0 行比例 | 稳定在 2% 以内,突增至 10% | 库存耗尽、参数错误或热点竞争 | 按原因拆分,不要统一重试 |
| 锁等待 P95 | 从 15 毫秒升至 200 毫秒 | 事务变长、热点行集中或索引失效 | 检查执行计划和事务日志 |
| 死锁次数 | 单小时从 0 次升至 30 次 | 多表更新顺序不一致 | 统一访问顺序并实施有限重试 |
| 重复 request_id | 每万次请求超过 50 次 | 上游超时、消息重复或客户端重试 | 检查幂等处理和超时策略 |
| 流水对账差异 | 日终出现非零差异 | 跨系统写入失败或补偿缺失 | 建立差异明细和人工复核流程 |

例如内部报表配额、低频积分调整或管理员手工扣减,业务并发不高,失败后也允许人工确认。这类场景不需要一开始就引入复杂的分布式锁和多阶段事务。
最低方案可以是:明确主键和唯一键,数量字段使用整数并设置 NOT NULL,使用条件更新,检查影响行数,保留基本操作日志。
取舍是实现成本低、维护简单,但它不适合高并发热点资源,也不适合必须严格保证重复请求安全的支付、核销和订单场景。
如果一次操作只涉及一条库存记录,扣减规则也只有“数量足够”,我通常不建议先查再改。条件更新能让业务条件和更新动作保持在同一个数据库操作中,代码路径短,锁持有时间也相对可控。
建议组合如下:
需要接受的代价是:影响行数为 0 时,应用必须有清晰的错误分类和返回策略,不能把所有失败都无限重试。
当扣减前需要检查多个字段之间的关系,例如库存状态、批次有效期和渠道限制,可以在同一事务中读取并锁定目标行,再完成判断和更新。
这种方案的重点不是“锁越重越好”,而是控制锁的范围和持有时间:
如果所有请求都集中扣减同一行,即使 SQL 写得正确,也可能出现锁竞争。此时问题不只是“会不会超卖”,还包括数据库吞吐、连接池耗尽和请求排队。
可选方案包括:在应用层对同一资源分片排队,使用消息队列削峰,预先分配库存段,或者把一个热点资源拆成多个可独立消费的库存桶。每种方案都会增加状态同步和异常恢复成本。
我的判断原则是:如果业务更看重严格顺序和准确结果,可以接受排队;如果更看重吞吐和响应时间,则需要通过分片、预扣减或缓存层降低单行热点,但必须保留数据库最终校验和对账机制。
当订单库、库存库、支付库不在同一个数据库中时,本地事务只能保证单库内部的一致性。即使在一个服务方法上加了事务注解,也不能自动回滚另一个数据库已经提交的动作。
这类场景通常需要结合可靠消息、事务消息、状态机、补偿任务和对账机制。设计时应明确每个状态的可重试条件,例如“库存已扣但订单待确认”是否允许重复扣减,还是只推进订单状态。
跨服务一致性真正需要解决的是状态最终如何收敛,而不是如何把所有动作强行塞进一个超长事务。

不要只看“有没有索引”,还要看扣减语句是否真正使用了正确索引。使用 EXPLAIN 检查访问路径,重点关注预估扫描行数、实际扫描行数、是否发生全表扫描以及是否出现隐式类型转换。
如果唯一键是 warehouse_id、sku_id,那么 UPDATE 条件也应该尽量使用这两个字段。字段类型必须一致,例如不要让数值型主键和字符串参数频繁发生隐式转换,否则可能影响索引使用。
索引过多也会增加写入和更新成本。库存表是高频更新表,除了支持唯一定位的索引外,其他索引都应有明确查询价值。每新增一个索引,都要评估扣减写入、批量调整和数据归档的代价。
上线前应模拟客户端超时后重试、消息重复消费、数据库提交成功但连接断开、扣减成功后流水写入失败、事务死锁后重试等情况。不能只测“正常请求扣一次”的理想路径。
至少要确认以下问题:
压测不应只记录平均响应时间。扣减系统更应该观察 P95、P99、锁等待、连接池使用率、回滚率、死锁数和影响行数为零的比例。
建议至少准备三组测试:

| 方案 | 主要优点 | 主要代价 | 适用场景 |
|---|---|---|---|
| 条件更新 | SQL 短、并发路径清晰、锁持有时间较短 | 复杂判断表达不够直观,需准确解释 0 行 | 单行数量扣减 |
| 锁定读取 | 便于在更新前检查多个字段 | 事务更长,可能增加锁等待和死锁 | 多字段、短事务判断 |
| 乐观锁 | 不必长时间持有锁,适合冲突后重试 | 冲突多时重试成本高,代码处理更复杂 | 配置编辑、低到中等冲突更新 |
| 队列串行化 | 降低热点行并发冲突,顺序容易控制 | 增加排队延迟、消息堆积和恢复设计 | 热点资源和高峰流量 |
如果用户必须在下单时立刻知道库存是否成功,核心扣减通常需要同步完成,并在数据库中形成明确结果。对于跨系统通知、报表同步、搜索索引更新,可以采用异步消息和最终一致。
不要把所有步骤都设计成同步强一致,否则外部依赖的延迟会拖长数据库事务。也不要把核心库存扣减完全交给异步流程,否则用户看到的“下单成功”可能只是请求进入队列,并不代表资源已经被真正占用。
分开存储可以清晰表达预占、确认和释放,便于审计和对账。但字段增多后,状态转换规则也会增加,开发人员必须维护数量守恒关系。
例如,在不考虑人工调整的情况下,可以建立这样的检查逻辑:
total_quantity = available_quantity
+ locked_quantity
+ used_quantity;
这不是所有业务都适用的硬公式。损耗、盘亏、调拨、退货和人工修正都可能需要额外字段或独立流水。重要的是明确每个数量的来源,不要为了让公式看起来简单而隐藏业务变化。
数据库约束的优点是所有写入入口都受到保护,包括后台脚本、临时任务和未来的新服务。应用校验的优点是可以表达复杂业务,例如不同仓库、渠道和会员等级使用不同扣减规则。
我的建议是:能由数据库表达的底线尽量下沉,必须依赖业务上下文的判断放在应用层,但关键更新条件仍要回到数据库执行。

如果系统是单库、单行库存、同步下单,且没有特别复杂的预占流程,我建议先实现以下最小可靠组合:
这套方案不一定是吞吐量最高的方案,但它的优点是控制点少、行为容易解释、故障容易定位。对很多业务系统来说,先把这八项做完整,比一开始引入复杂的分布式组件更有价值。
如果存在订单创建后暂时占用库存、支付成功后确认、超时后释放的流程,应将预占和最终扣减区分开。建议通过 locked_quantity 和操作类型明确表示资源状态,而不是反复修改同一个 quantity 字段。
每一次释放都必须关联原始预占流水。不能收到一个“取消订单”事件就盲目增加可用库存,否则重复取消或消息重复消费会造成库存凭空增加。
账户余额比普通库存更敏感。金额字段应使用精确数值类型,扣减必须关联业务单号,成功、失败、处理中和已冲正等状态需要清晰区分。
对于金额扣减,我不会只依赖余额表。余额表适合快速查询当前余额,账户流水则负责解释每一次变化。对账时应能够通过流水汇总、冲正记录和当前余额之间的关系发现异常。
当单行成为明显热点时,先确认系统到底需要什么:是绝对不超卖,是极低延迟,还是尽可能高的吞吐。三者都要求极高时,单靠数据库一行 UPDATE 很难同时满足。
可以考虑库存分片、预分配、队列削峰或分段扣减,但每一种优化都会引入新的状态同步和故障恢复问题。优化前应先用监控证明瓶颈确实来自热点行,而不是连接池、网络、慢查询或上游重试。

不要第一时间直接执行“把库存改回去”。修复数值之前,应先保留主表当前值、最近变更时间、相关请求号、订单号和流水记录。否则修复动作可能覆盖原始证据,后续无法判断是重复扣减、人工调整、数据迁移还是事务边界错误。
建议先查询:
SELECT warehouse_id, sku_id, available_quantity, locked_quantity, version, updated_at FROM inventory WHERE warehouse_id = ? AND sku_id = ?; SELECT request_id, order_id, quantity, operation_type, status, created_at, updated_at FROM inventory_deduction_log WHERE warehouse_id = ? AND sku_id = ? ORDER BY created_at DESC;
重复扣减不一定是数据库执行了两次,也可能是同一个订单有两个不同 request_id,或者重试请求没有复用原始业务号。排查时要把客户端、网关、消息消费者和数据库日志串起来。
如果数据库里确实出现两个不同事务,应该继续确认它们是否对应同一订单、同一 SKU、同一操作类型。只有把业务维度关联起来,才能判断是幂等键生成错误,还是调用链发生了重复投递。
扣减超时经常被误判为数据库性能不足。实际原因可能是某个事务拿到库存锁后执行了远程调用,也可能是多个事务按不同顺序更新库存和订单,形成死锁等待。
应同时检查慢查询日志、锁等待视图、事务开始时间、事务持有时间和执行计划。只调大连接池或增加重试次数,往往会把锁竞争变成更高的数据库负载。
如果主表扣减成功但流水没有记录,需要判断两者是否在同一个数据库事务中。若在同一事务中却仍然出现差异,重点检查异常捕获、事务传播、连接切换和异步线程是否绕开了原事务。
若两者本来就跨库或跨服务,则需要按设计好的最终一致流程处理,不能用“重新补写一条流水”简单掩盖。补写必须标记来源、补偿原因和原始请求号,避免后续对账把补偿记录再次计入正常扣减。
我认为,设计扣减系统时最有效的顺序不是“先选锁,再写 SQL”,而是:
如果顺序反过来,先讨论某种锁或某个中间件,团队很容易忽略最基础的问题:表中是否存在重复业务记录,字段是否能表达真实状态,扣减对象是否能够被唯一定位。
第一,任何关键扣减都必须有可验证的业务不变量。“库存不能为负”只是一个边界,“一个请求只能扣一次”“取消只能释放此前锁定的数量”同样重要。
第二,任何关键数量都必须有来源和去向。当前余额或可用库存只告诉你结果,流水、请求号和操作类型才解释过程。没有过程记录,异常修复只能依靠猜测。
第三,不要把事务、锁、约束和幂等混成一个概念。它们解决的是不同层次的问题:约束限制非法数据,条件更新限制非法扣减,事务保证本地原子性,锁或版本控制处理并发,幂等机制处理重复动作。
如果你正在维护一张库存、余额或配额表,可以今天就做一次小范围检查:找出所有扣减 SQL,确认是否存在先查后改;检查数量字段类型和非空属性;检查业务唯一键;确认每条扣减是否检查影响行数;再用同一个请求号重复提交两次。
如果这五项中有两项以上无法明确回答,不建议立即通过增加重试或加大数据库规格来解决。先补齐表结构、条件更新和幂等设计,再用并发压测验证锁等待、回滚率和对账结果。
真正可靠的扣减,不是“数据库里有一个不会变成负数的数字”,而是任何一次变化都能被约束、被解释、被追溯,并且在并发、重试和异常之后仍然能够收敛到正确结果。
我以前排查过一类库存异常:扣减 SQL 本身没有报错,接口也返回成功,但同一个商品在数据库里出现了两条库存记录。应用每次只更新其中一条,查询总库存时却把两条记录相加,结果就是可用库存、扣减流水和订单状态互相对不上。表结构看起来只是字段和索引,实际上它决定了数据库能否识别“同一个扣减对象”。
表结构影响扣减一致性,最容易被忽略的不是字段长度,而是表的业务粒度。比如“一个仓库中的一个 SKU”应该只有一条库存主记录,如果表结构允许同一组 warehouse_id + sku_id 出现多行,扣减时就必须额外决定更新哪一行,统计时又要决定是否聚合多行。这个歧义会直接变成并发和对账问题。
我通常会先问一个问题:扣减动作究竟锁定什么对象?
如果答案是“仓库内某个 SKU”,那么表上至少应有明确的主键和业务唯一约束:
CREATE TABLE inventory ( id BIGINT PRIMARY KEY, warehouse_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, available_quantity INT NOT NULL DEFAULT 0, locked_quantity INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_warehouse_sku (warehouse_id, sku_id) );这里的 NOT NULL、默认值和整数类型,解决的是数据底线问题:可扣减数量不能因为 NULL 变成三值逻辑,数量也不应使用浮点数。唯一键解决的是对象唯一问题,但它并不能代替扣减逻辑;它只能阻止同一个仓库和 SKU 被重复建档。
我在设计表结构时,会把“可用量”和“冻结量”分开,而不是用一个字段加减所有状态。预占库存、支付成功、订单取消分别对应不同的状态变化,字段拆开后,数据库管理员可以更容易检查每次变更是否符合业务不变量。
设计方式常见问题判断 一个 SKU 多行且无唯一约束扣减对象不明确,统计口径容易不一致高风险 可用量、冻结量混在一个字段预占和释放难以审计,异常难定位不推荐 可用量为整数且非空数据边界清晰,便于条件更新基础要求 业务组合建立唯一键重复建档会在数据库层失败推荐 我的判断是:表结构不是“扣减一致性”的全部,但它决定了后续 SQL、锁和幂等方案有没有稳定的落脚点。
连扣减对象都没有定义清楚时,继续讨论悲观锁还是乐观锁,往往只是把问题推迟。
我曾经在压测中把库存初始化为 1,同时发起 20 个并发请求。先查询再更新的写法在低并发环境下几乎看不出问题,但并发一上来,多个请求都读到了库存为 1,最终影响行数显示成功的请求超过了实际可扣减数量。后来改成带条件的单条 UPDATE,并检查影响行数,结果才稳定下来。
“先查后改”的核心问题不是查询语句错误,而是查询和更新之间存在竞态窗口。事务 A 查询到库存为 1,事务 B 也可能在 A 更新前查询到库存为 1;如果应用层都判断为“库存充足”,后面的更新就可能基于过期判断继续执行。
对单行数值扣减,我更倾向于把业务条件放进 UPDATE: UPDATE inventory SET available_quantity = available_quantity – ?WHERE warehouse_id = ?AND sku_id = ?
AND available_quantity >= ?;这条语句最关键的部分不是减法,而是 available_quantity >= ?。数据库在执行更新时重新判断条件,应用不再依赖之前单独读取的旧值。
执行后必须检查影响行数:影响 1 行表示本次扣减成功,影响 0 行则可能是库存不足、记录不存在或请求条件不匹配。我做过一次简单对比测试:库存为 1,使用同一数据库连接池,分别发送 20 个并发扣减请求。先查后改的实现出现过多次“成功数大于 1”的结果;条件 UPDATE 的成功数始终不超过 1。
这个结果不能直接代表所有数据库和业务的性能,但足以说明两种写法的并发语义完全不同。
写法并发风险必须补充的控制 SELECT 后应用判断,再 UPDATE读写之间有竞态窗口锁、版本控制或改为条件更新 带数量条件的 UPDATE单行扣减更容易保持边界检查影响行数,处理重试 SELECT FOR UPDATE 后 UPDATE锁等待和死锁风险更明显短事务、索引、超时和重试 版本号条件更新版本冲突会导致更新失败明确重试和失败分类 但条件 UPDATE 也不是万能方案。
如果一次扣减涉及多条库存记录、批次分配或跨表写入,就不能只看这一条 SQL。此时需要重新设计事务边界、锁顺序和失败处理。
我踩过一个很典型的坑:订单服务因为响应超时重试了一次,第一次事务其实已经提交,第二次请求又开启了一个全新的事务。两个事务各自都满足原子性,库存却被扣了两次。这个问题让我后来把“事务是否成功”和“这是不是同一个业务请求”分成两个独立问题处理。
事务解决的是一组数据库操作的原子性,例如库存扣减和扣减流水要么一起提交,要么一起回滚。但事务并不知道两个请求是否代表同一个订单动作,因此它不能自动防止客户端重试、消息重复消费或网关超时后的重复提交。扣减流程至少要区分四种结果:扣减成功、库存不足、请求重复、数据库异常。
把它们都归为“扣减失败”,会导致上游盲目重试,反而放大重复扣减和锁竞争。
一种常见做法是建立扣减流水表,以业务请求号或订单号作为幂等键:
CREATE TABLE inventory_deduction_log ( id BIGINT PRIMARY KEY, request_id VARCHAR(64) NOT NULL, order_id VARCHAR(64) NOT NULL, sku_id BIGINT NOT NULL, quantity INT NOT NULL, operation_type VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL, UNIQUE KEY uk_request_operation (request_id, operation_type) );在同一事务中,可以先尝试写入幂等记录,再执行库存扣减;如果唯一键冲突,就读取原记录状态,而不是再次扣减。具体是“先写流水”还是“先扣库存”,要结合失败补偿策略决定,但无论采用哪种顺序,都必须让重复请求得到可识别的结果。
机制它能解决什么它不能解决什么 事务库存与流水的原子提交不能识别重复请求 唯一幂等键阻止同一业务动作重复落库不能单独完成库存扣减 条件 UPDATE防止数量突破条件边界不能判断请求是否重复 对账与补偿处理外部异常和历史脏数据不能替代实时约束 我的判断是:扣减一致性至少有两个维度,一是数值不能扣成负数,二是同一个业务动作不能执行两次。
前者主要依赖条件更新和数据库约束,后者必须依赖幂等键、流水和状态判断,不能把两者都寄托在事务上。
我现在审查库存、余额或配额表时,不会先看开发者有没有使用某一种锁,而是先检查扣减条件是否命中唯一索引、事务里是否包含外部调用,以及失败后能不能安全重试。以前见过一个方案,SQL 写了 FOR UPDATE,但条件字段没有合适索引,压测时锁等待变长,最终超时和死锁反而更多。
我会把上线前检查分成四层:数据结构、更新 SQL、事务并发和幂等补偿。这样做的好处是能避免把不同问题混在一起。例如,字段允许 NULL 属于结构问题;影响行数未检查属于 SQL 问题;事务里调用外部接口属于边界问题;重复消息未拦截则属于幂等问题。第一步是检查表是否能唯一定位扣减对象。
扣减条件中的字段应有主键或合适的唯一索引,数量字段应为整数、非空并有合理默认值。索引不是越多越好,真正要看的是执行计划是否命中预期索引,以及更新是否可能扫描或锁定过多记录。第二步是检查 SQL 是否把业务边界落实到数据库动作中。
推荐确认以下内容: 是否使用 available_quantity >= quantity 之类的条件更新;是否严格检查影响行数;是否可能因为条件不唯一而更新多行;是否存在隐式类型转换、函数包裹索引列等导致索引失效的写法;失败结果是否区分库存不足、版本冲突、重复请求和数据库异常。
第三步是检查事务边界。库存扣减和本地流水通常应在同一事务中完成,但不建议在事务内调用支付、物流或其他外部接口。外部调用一旦变慢,数据库锁会被长时间占用,表现为连接池耗尽、锁等待上升和死锁概率增加。第四步是验证异常路径,而不是只测正常成功路径。
我通常至少安排以下测试:库存为 1 时并发发起 20 次扣减;同一个请求号连续提交 3 次;数据库提交后模拟响应超时;扣减流水写入失败;事务发生死锁后自动重试。每个测试都要核对库存、订单、流水和接口返回,而不是只看 HTTP 状态码。
检查层关键问题不通过时的改进方向 表结构扣减对象是否唯一,数量是否可计算补充唯一键、非空约束和字段拆分 索引与 SQL条件是否命中索引,是否检查影响行数查看执行计划,改为条件更新 事务相关写操作是否原子,是否存在长事务缩短事务,固定锁顺序,设置超时 幂等与补偿重复请求和提交超时能否安全处理增加请求号、唯一键、对账任务 最终的选型不应是“所有场景都使用行锁”或“所有场景都使用乐观锁”。
单行、简单数量扣减通常优先考虑条件 UPDATE;需要读取后做复杂决策时再考虑悲观锁;冲突可接受且失败可重试时可以使用版本号。真正可靠的方案,是让表结构、SQL、事务和幂等机制各自承担清晰的责任。


读者评论
文章把表结构、条件更新、事务和幂等的边界讲得比较清楚,尤其是“事务不能解决重复请求”这一点,对排查库存和余额扣减问题很有参考价值。
从数据库设计角度看,非空约束、唯一键和明确的数量字段确实容易被忽略。文中对可用、冻结、已使用数量的拆分说明实用,但具体字段设计仍需结合业务流程决定。
条件更新适合单行、简单数量扣减,不过复杂场景还要考虑索引、隔离级别、死锁和失败重试。文章覆盖面较全,如果能补充不同数据库的执行差异会更完整。