数据库存:产品技术团队避坑版教程:并发扣减从准备到复盘
库存只剩 1 件,两个请求却都返回了“下单成功”,这类事故并不一定是数据库性能不够,更常见的原因是团队把“查库存”和“扣库存”拆成了两个动作,却没有定义重试、超时、回补和对账规则。《数据库存:产品技术团队避坑版教程:并发扣减从准备到复盘》不只讨论一条 UPDATE 语句,而是从需求准备、方案选型、事务边界、并发压测、异常补偿到线上复盘,完整拆解一套产品和技术团队都能执行的扣减方法。
我在参与库存、优惠券、账户额度和限量商品类项目评审时,反复看到一个现象:开发能证明“这条 SQL 在单线程下是对的”,但没有人能回答“同一个请求重试三次时到底扣几次”“订单创建成功但扣减事务提交失败时怎么办”“最终库存和库存流水对不上由谁修正”。真正的并发扣减能力,不是会不会加锁,而是系统能否在竞争、失败和重复发生之后仍然保持可解释、可恢复、可对账。
很多团队把扣减接口返回成功,直接等同于库存扣减成功。但在真实交易链路中,至少存在四个不同状态:请求被服务接收、数据库扣减成功、订单记录创建成功、用户最终获得履约资格。它们可能在同一事务中完成,也可能分散在多个服务和多个事务中。
如果产品需求只写“用户下单后扣库存”,开发就必须自行猜测扣减时机。是提交订单时扣,还是支付成功后扣?未支付订单是否占用库存?订单关闭后多久释放?退款是否回补?这些问题没有答案,技术上再严密的锁也只能保证一个局部动作,无法保证业务结果。
我建议把“扣减成功”定义为一组可验收的业务条件,而不是一个接口布尔值。至少要明确以下内容:
最基础、也最容易落地的防超卖方式,是将“库存大于零”的判断和扣减放进同一个数据库更新条件中:
UPDATE inventory SET available_stock = available_stock - 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ? AND available_stock > 0;
这条语句的关键不在于写法简短,而在于它把判断和修改交给数据库作为一个原子更新处理。并发请求竞争同一条库存记录时,只有满足条件且成功获得更新机会的请求,才能让受影响行数变为 1;库存耗尽后,后续请求的受影响行数应为 0。
但这条 SQL 并没有自动解决以下问题:同一个订单重复调用两次怎么办?更新成功后订单写入失败怎么办?数据库响应超时,但实际事务已经提交怎么办?库存回补是否可能执行两次?因此,原子更新是并发扣减的底座,不是完整库存系统。
技术团队通常关注锁等待、吞吐量和错误率,产品团队更关注用户能不能下单、订单状态是否可信。两组指标需要合并,否则压测可能显示接口很快,但业务最终结果仍然错误。
| 验收维度 | 技术问题 | 产品问题 | 建议验收结果 |
|---|---|---|---|
| 库存安全 | 更新条件是否原子 | 是否会超卖 | 最终库存不低于零,成功扣减数不超过可扣数量 |
| 业务幂等 | 是否有唯一约束或幂等记录 | 重复点击是否重复占用 | 同一业务动作只产生一次有效扣减 |
| 故障恢复 | 超时后如何查询和补偿 | 用户是否需要重复下单 | 状态可查询,重试不会制造第二次扣减 |
| 数据对账 | 是否保存流水和关联号 | 运营能否解释库存变化 | 订单、库存和流水三方可以按单号核对 |
| 性能边界 | 热点行锁等待是否可接受 | 高峰期用户是否频繁失败 | 在目标流量模型下,延迟和失败率满足业务阈值 |

在商品交易中,团队常把库存字段设计成一个 stock,但产品语义通常至少包含“物理库存”“可售库存”和“已预占库存”。物理库存是仓库或供应链层面的数量,可售库存是当前允许用户购买的数量,预占库存则表示已经被订单暂时锁定但尚未完成最终履约确认。
如果业务采用下单即扣减,订单创建和库存减少可能在同一个事务里完成,用户体验简单,但支付失败或订单关闭后必须回补。如果采用下单预占、支付后实扣,系统要维护预占时长、支付确认、超时释放和重复回补,状态更多,但对库存真实性的控制可能更符合履约流程。
| 模式 | 扣减时机 | 主要优点 | 主要风险 | 适合场景 |
|---|---|---|---|---|
| 下单即扣 | 订单创建时 | 链路短,库存确认快 | 取消和支付失败需要回补 | 实物商品、库存竞争强的限量活动 |
| 预占后实扣 | 下单预占,支付后确认 | 业务状态更贴近支付流程 | 预占超时、释放和延迟更复杂 | 支付等待时间较长的交易 |
| 支付后扣 | 支付成功回调时 | 减少无效订单回补 | 支付成功后可能无货,需要退款或替代履约 | 库存充足、允许延迟确认的业务 |
第一个来源是用户重复操作。移动网络抖动时,用户点击一次后没有立即看到结果,可能再次点击;客户端为了改善体验,也可能自动重试。服务端如果只依据请求到达次数扣减,就会把一次业务意图当成多次库存动作。
第二个来源是基础设施重试。网关、服务治理组件、消息消费者和定时任务都有可能重试。尤其是“服务端已经提交,但响应没有返回”的情况,调用方无法确定第一次是否成功,第二次请求必须依赖幂等键,而不能依赖“用户应该不会重复点”。
第三个来源是补偿任务。库存扣减失败后,团队往往增加一个定时扫描任务;订单关闭后又增加一个释放任务;支付回调失败后再增加一个补偿任务。如果这些任务没有统一状态机,就可能出现多条链路同时回补同一笔库存。
库存表中有很多 SKU,并不代表数据库压力会平均分布。日常流量可能集中在少数热门商品上,所有请求都更新同一行。此时瓶颈不是 SQL 文本长度,也不一定是数据库总连接数,而是同一行记录的锁竞争、事务排队和后续日志写入。
这也是为什么“数据库能处理很多查询”不能直接推导出“数据库能处理很多库存扣减”。普通查询可以分散到不同数据页和不同索引范围,热点扣减却要求大量请求连续修改同一条记录,最终会受到串行化竞争的限制。

最典型的伪代码是先查询库存,如果结果大于零,再执行扣减。单线程测试时,这段逻辑完全正常;并发执行时,多个请求可能在同一时间读到库存为 1,然后都进入扣减分支。
stock = SELECT available_stock FROM inventory WHERE sku_id = ?; if stock > 0: UPDATE inventory SET available_stock = available_stock - 1 WHERE sku_id = ?;
即便数据库在更新时不会允许库存变成负数,这段代码仍然可能出现“业务成功数”和“实际库存变化数”不一致。更危险的是,应用层可能在更新前就创建订单,或者无论更新受影响行数是多少都返回成功,导致用户拿到错误的下单结果。
如果业务确实需要先查询库存用于展示,查询可以保留;但展示库存和最终扣减不能共用“查询结果作为授权凭证”。真正的扣减资格,必须在最终写操作中重新判断。
数据库驱动返回“语句执行成功”,往往只代表 SQL 没有语法错误或连接异常,不代表满足了库存条件。对于带有 available_stock > 0 的更新,库存不足时语句也可能正常执行,只是受影响行数为 0。
服务层必须根据受影响行数区分结果。受影响行数为 1,才表示本次扣减真正改变了库存;为 0 时,需要进一步判断是库存不足、SKU 不存在、商品已下架,还是数据库驱动对匹配行和实际变更行的统计方式存在差异。
affectedRows = executeUpdate(
"UPDATE inventory " +
"SET available_stock = available_stock - ? " +
"WHERE sku_id = ? AND available_stock >= ?",
quantity, skuId, quantity
);
if affectedRows == 1:
return StockResult.SUCCESS;
if affectedRows == 0:
return StockResult.NOT_ENOUGH_STOCK;
throw new InventorySystemException("unexpected affected rows");生产实现中还应对 quantity 做正数校验,并限制单次最大扣减量。否则,一个异常参数就可能把库存减少过多,或者利用负数数量实现反向增加库存。
锁解决的是多个执行者在某个时间窗口内的互斥问题,幂等解决的是同一个业务动作被执行多次时结果是否保持一致。一个请求拿到锁后执行成功,客户端因为超时再次发起请求,第二次仍然可能在新的锁持有周期内再次扣减。
因此,锁和幂等不是替代关系。正确顺序通常是先识别业务动作,再进行库存竞争;否则即使锁能保证“同一时刻只有一个请求执行”,也无法保证“同一笔订单一生只扣一次”。
事务范围越大,表面上覆盖的操作越多,但锁持有时间也会变长。若一个事务同时调用营销、会员、支付或外部履约服务,任何下游延迟都会延长库存行的锁持有时间,最终把外部服务的不稳定传导到数据库。
我更倾向于把事务边界压缩到本地数据库能可靠完成的动作:幂等记录、库存变化、库存流水和必要的订单状态变化可以放在一个本地事务中;外部服务调用则通过状态机、可靠事件或补偿任务衔接,而不是让数据库事务一直等待网络返回。
接口平均响应时间很漂亮,并不能证明库存正确。压测工具可能把 HTTP 200 当成成功,但服务内部可能返回了库存不足、重复请求或待处理状态;也可能数据库最终库存正确,却缺少订单或流水,导致后续无法履约和对账。
库存压测必须在请求结束后执行状态核对。至少要把初始库存、成功扣减数、合法回补数、最终库存、成功订单数和库存流水数放到同一张对账表中。

我在方案评审时不会先问“要不要上缓存”或“要不要加分布式锁”,而是先问四个问题。第一个问题是库存是否必须强一致;第二个问题是扣减是否允许排队;第三个问题是失败后能否延迟处理;第四个问题是业务是否具备明确的补偿和对账能力。
如果库存关系到账户余额、授信额度或不可超卖的稀缺资源,强一致和可审计优先级高于极限吞吐。如果库存是可替代商品,业务允许短时间延迟确认,那么可以使用队列削峰或缓存前置,但必须接受异步状态和补偿成本。
| 判断问题 | 回答“是”时的倾向 | 回答“否”时的倾向 |
|---|---|---|
| 是否绝对不能超卖 | 优先数据库原子更新或强一致事务 | 可评估预扣、延迟确认和人工兜底 |
| 是否允许用户等待确认 | 可考虑消息队列削峰 | 优先同步确认库存结果 |
| 是否能接受最终一致 | 缓存和异步方案空间更大 | 减少跨组件写入,缩小一致性边界 |
| 是否有可靠对账和补偿能力 | 可以逐步增加异步化和多级库存 | 先补齐流水、状态和人工处理入口 |
总请求量只能告诉你系统整体有多忙,无法告诉你是否存在热点。假设每秒有 2000 个库存请求,如果平均分布在 2 万个 SKU 上,单个 SKU 的竞争可能很低;如果其中 80% 集中在 3 个 SKU 上,数据库面对的是几个高频更新热点。
因此压测模型必须包含真实的 SKU 分布。至少准备均匀分布、长尾分布和极端热点三组流量。只用平均分布压测,通常会高估数据库库存表的实际承载能力。
场景 A:1000 个 SKU,随机均匀访问
场景 B:20% 热门 SKU 承担 80% 请求
场景 C:1 个 SKU 承担 70% 请求,库存仅为 100 件
对于库存规模适中、扣减逻辑简单、数据库已经具备高可用能力的系统,我通常建议先用数据库原子更新建立正确性基线。原因很现实:方案简单、状态集中、排查路径短,出现异常时更容易还原完整事实。
只有当压测或线上监控明确显示热点行锁等待、数据库日志写入、连接池排队或事务提交延迟已经成为瓶颈时,才进入缓存前置、库存分片或消息队列削峰的架构讨论。不要为了“高并发”三个字,提前引入多个一致性来源。
缓存可以减少数据库压力,但会新增缓存与数据库不同步、缓存丢失、回写失败和库存初始化错误等问题。消息队列可以削峰,但会新增消息重复、消费积压、消费顺序、死信处理和用户等待等问题。分布式锁可以减少并发冲突,但会新增续期、误释放、锁服务故障和锁粒度失控等问题。
所以方案评审不能只写“预期提升性能”,还应写出“新增哪些故障场景、谁负责监控、如何重试、如何回滚、如何人工处理”。这份故障面清单,往往比架构图更能体现方案成熟度。

最小可用的库存表至少应包含业务主键、可用库存、预占库存、更新时间和版本信息。字段不一定全部参与扣减,但它们有助于区分不同库存状态,并支持后续对账和故障定位。
CREATE TABLE inventory (
id BIGINT PRIMARY KEY,
sku_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
available_stock INT NOT NULL,
reserved_stock INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id),
CHECK (available_stock >= 0),
CHECK (reserved_stock >= 0)
);是否使用数据库层面的 CHECK 约束,要结合数据库版本和团队实际运行环境确认。即使数据库支持约束,也不能替代更新条件,因为约束通常是在写入结果层面阻止非法值,而业务需要在库存不足时给出明确的业务响应。
如果同一 SKU 分布在多个仓库,还要先定义扣减策略。是指定仓库扣,还是按仓库优先级扣?如果允许拆单,库存扣减可能涉及多行记录,事务边界和失败回滚会比单仓库场景复杂很多。
库存表告诉我们“现在有多少”,库存流水告诉我们“为什么变成这样”。没有流水,事故发生后只能通过订单日志、应用日志和人工猜测拼接事实;而日志可能被采样、过期或分散在不同服务中。
一条库存流水建议至少记录以下信息:
我建议把库存流水设计成追加记录,而不是反复覆盖一条“最近操作”。追加式流水更适合审计、重放和对账;人工修正也应记录原因和审批人,不能直接在库存表里执行一条看不出来源的加减 SQL。
一个常见设计是建立库存操作表,用业务幂等号做唯一约束。请求进入时先尝试写入操作记录;如果已经存在且状态为成功,就直接返回原结果;如果状态为处理中,则根据业务策略查询或等待;如果状态为失败,则只有明确允许重试时才重新执行。
CREATE TABLE inventory_operation (
id BIGINT PRIMARY KEY,
idempotency_key VARCHAR(128) NOT NULL,
order_id VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
quantity INT NOT NULL,
operation_type VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
result_code VARCHAR(64),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_idempotency_key (idempotency_key)
);幂等键必须对应“业务动作”,而不是简单使用用户编号。一个用户可能购买多个订单,同一订单也可能包含多个 SKU。较稳妥的做法是使用订单行号、扣减动作号或由业务方生成的唯一请求号,确保粒度既不会误合并,也不会被重复拆分。
在单数据库、同步扣减的场景中,可以把幂等记录、库存更新、流水写入和订单状态变更放入同一个本地事务。流程大致如下:
这里有一个容易忽略的细节:如果先插入幂等记录,再执行库存更新,库存更新失败时,幂等记录不能简单永久保留为“处理中”。它需要被标记为明确失败,或者在事务回滚后由下一次请求重新建立。否则,后续重试可能误判为“已有请求正在处理”,造成订单一直无法推进。
库存事务里不应直接等待支付、物流、营销权益或第三方接口。外部调用的响应时间和可用性不由库存服务控制,放在数据库事务中会让锁持有时间不可预测。
更合理的做法是:本地事务完成库存变化和状态记录,提交后发布业务事件;下游根据事件推进订单或履约状态;如果事件发布失败,使用可靠事件表、事务消息或定时补偿保证最终可达。这里的具体组件可以不同,但原则相同:不要把远程网络调用塞进本地行锁的保护区间。

数据库原子更新适合库存记录相对集中、扣减规则不复杂、业务要求强一致且团队希望控制系统复杂度的场景。它的优势是库存、流水和订单状态可以在同一个数据源内核对,排查路径短,回滚和备份机制也比较成熟。
它的短板也很明确:热门 SKU 会形成热点行,所有请求必须竞争同一条记录;如果扣减成功后还要同步写很多关联表,事务时间会增加;如果库存分布在多个仓库,跨行扣减可能扩大锁范围。
选择这个方案时,不要只压测平均流量。应重点关注单个热点 SKU 的每秒请求量、P95 和 P99 延迟、锁等待时间、事务回滚率以及数据库日志写入压力。
乐观锁通常通过版本号或旧库存值判断记录是否被其他请求修改。例如读取版本为 10,更新时要求版本仍为 10,并在成功后改成 11。更新失败后,应用可以重新读取并重试。
UPDATE inventory SET available_stock = available_stock - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ? AND version = ? AND available_stock >= ?;
乐观锁并不意味着没有锁,而是把冲突检测放到更新条件中。它适合冲突比例可控、失败能够快速返回或有限重试的场景。若所有请求都集中竞争一件热门商品,失败重试可能形成“重试风暴”,反而加重数据库压力。
我通常会给乐观锁设置严格的重试上限,例如最多重试一到两次,并区分“库存不足”和“版本冲突”。库存不足没有重试意义,版本冲突可以短暂重试,但连续失败应快速返回或进入排队流程。
缓存前置的价值,是让高频库存竞争先在内存层完成,减少数据库热点行压力。但缓存扣减成功并不等于订单已经成立,也不等于数据库最终一定能完成落库。
采用缓存前置时,需要回答以下问题:
如果团队没有库存流水、补偿任务和对账机制,直接使用缓存前置往往只是把“数据库锁竞争”换成“缓存和数据库不一致”。对于余额、授信等不可容忍差错的场景,更不能仅因为缓存吞吐量高就直接采用。
消息队列适合流量瞬间涌入、业务允许排队确认的场景。请求先登记业务动作并进入队列,消费者按照可控速度执行扣减,能够降低数据库瞬时压力。
但队列会改变用户体验。用户提交后可能得到“排队中”,而不是立即得到“库存确认”。产品必须设计处理中、成功、库存不足、系统异常等状态,并提供查询入口。否则技术团队虽然削平了峰值,用户却会因为看不到结果而重复提交,重新制造幂等压力。
消费者必须具备幂等能力。消息至少一次投递是常见现实,不能把“消息只消费一次”作为默认前提。每条消息应携带业务幂等号,消费前后都要记录状态;如果处理成功但确认消息失败,下一次重复消费必须能够识别已完成结果。
| 方案 | 一致性成本 | 性能特征 | 排障难度 | 上线前必须补齐的能力 |
|---|---|---|---|---|
| 数据库原子更新 | 较低 | 受热点行和事务能力限制 | 较低 | 受影响行数判断、流水、幂等 |
| 乐观锁 | 中等 | 冲突高时重试成本明显 | 中等 | 冲突分类、重试上限、退避策略 |
| 缓存前置 | 较高 | 适合热点流量和快速响应 | 较高 | 初始化、回滚、对账、故障切换 |
| 消息队列 | 中高 | 削峰明显,但存在处理延迟 | 较高 | 重复消费、积压、死信、状态查询 |

下面使用一个情景模拟案例说明验证方法:某限量商品初始库存为 100 件,压测工具在 5 秒内发起 500 次扣减请求,每个请求扣减 1 件,其中 50 个请求使用重复幂等号,剩余请求具有独立业务号。
这个案例不是某家企业的线上事故数据,而是根据库存扣减测试中常见的竞争关系构造的样本。它的目的不是宣称某个固定吞吐量,而是展示产品和技术团队应该如何定义输入、观察过程并核对最终结果。
假设系统先查询库存,再在应用层判断,且没有检查更新受影响行数。多个请求可能在库存仍为 100 件时同时读取到可售状态,并在后续流程中创建订单。即使数据库最终库存被扣到 0,订单表里也可能存在超过 100 条“扣减成功”的记录。
更隐蔽的情况是,数据库库存没有负数,但业务成功订单数为 118。此时运营人员查看库存表会认为库存已经用完,开发人员也可能认为没有超卖,直到仓库发现实际可履约数量少于订单数量,事故才真正暴露。
如果服务先根据幂等号过滤 50 个重复请求,再使用带条件的原子更新,理论成功扣减数最多为 100。库存耗尽后,其他独立业务动作应返回库存不足或进入明确的排队状态,不应继续生成“已锁定库存”的订单。
压测结束后,团队应执行以下对账:
期初库存
成功扣减流水数量
+ 合法回补流水数量
= 期末可用库存
100
100
+ 0
= 0
如果库存表显示 0,但成功扣减流水只有 97 条,需要继续查找 3 件库存的去向;如果流水有 100 条但成功订单只有 98 条,需要检查订单写入或状态同步异常;如果成功订单有 100 条但幂等操作记录只有 98 条,则幂等记录设计或事务边界可能存在缺陷。

并发扣减测试不能只模拟“请求成功”和“请求失败”两种干净结果,还要人为制造服务端已提交、客户端未收到响应的情况。可以在数据库提交后延迟返回,或在响应返回前主动断开连接,观察客户端重试时是否产生重复扣减。
正确的验收结果应该是:第一次扣减已经成功时,第二次携带相同幂等号的请求返回第一次的处理结果;第一次事务确实失败时,第二次请求才可以继续执行;如果状态处于未知阶段,接口应返回可查询状态,而不是让客户端盲目重复创建订单。
可以让同一订单同时触发订单关闭事件、定时扫描任务和人工补偿接口,验证释放库存是否只执行一次。回补动作必须拥有独立的幂等号,例如“订单号加释放动作类型”,而不能只依赖订单当前状态。
如果订单已经完成释放,再次收到释放请求时,应返回“已处理”而不是再次增加可用库存。库存回补造成的超卖有时比扣减超卖更难察觉,因为库存表可能暂时看起来更充足,直到新的订单无法履约才暴露。

压测数据不能只准备一个商品和一个库存数字,还要准备订单、库存流水、幂等记录和回补任务的查询方式。每次测试最好使用独立批次号,便于在数据库中筛选本次测试产生的全部数据。
建议在测试开始前记录以下基线:
没有基线,就无法判断测试后的差异来自系统缺陷,还是测试环境原本就存在脏数据。压测前清理数据、压测后保留完整样本,比单纯提高并发线程数更重要。
第一种是库存充足场景,用于观察正常路径的吞吐量和延迟。第二种是库存刚好耗尽场景,用于验证最后一件或最后几件库存的竞争结果。第三种是请求远大于库存场景,用于验证库存不足后的错误处理和用户提示。
第四种是重复请求场景,同一个幂等号连续提交多次,观察是否只产生一次扣减。第五种是故障注入场景,在数据库提交、订单写入、消息发送和响应返回之间制造异常,验证系统能否查询状态并完成补偿。
| 测试场景 | 主要验证点 | 核心结果 | 不通过的常见原因 |
|---|---|---|---|
| 库存充足 | 正常扣减和流水写入 | 订单、库存、流水数量一致 | 事务提交范围不完整 |
| 库存为1 | 极端竞争下的原子性 | 最多1个请求成功 | 先查后扣、未检查受影响行数 |
| 重复请求 | 幂等处理 | 重复请求返回同一结果 | 幂等键粒度错误或无唯一约束 |
| 响应超时 | 未知结果恢复 | 重试不重复扣减 | 客户端把超时当成失败 |
| 补偿重复执行 | 回补幂等 | 同一订单只释放一次 | 只按订单状态判断,没有释放流水 |
性能指标包括吞吐量、平均延迟、P95、P99、数据库连接池等待、锁等待和事务回滚率。业务正确性指标则包括成功扣减数、成功订单数、重复扣减数、超卖数、少卖数、回补次数和对账差异数。
两类指标不能互相替代。吞吐量高但超卖一次,可能已经无法接受;延迟稍高但所有库存变化可审计、可恢复,反而可能更适合余额和限量资源业务。
业务正确性校验:
超卖数量 = max(0, 成功扣减数量 – 可扣减库存 – 合法额外补入数量)
少卖差异 = 期初库存 – 成功扣减数量
+ 合法回补数量 – 期末库存
重复扣减数量 = 同一幂等号对应的有效扣减次数 – 1
压测并发数应该来自产品和运营提供的流量假设。例如,活动峰值每秒有多少用户进入商品页,多少人点击立即购买,多少请求集中在前 1 个 SKU,客户端和网关是否会自动重试。没有这些输入,直接设置 1 万并发只是一个看起来专业的数字。
我建议至少准备三种模型:日常峰值、活动峰值和极端热点。每种模型都要写清持续时间、请求分布、库存规模、失败注入比例和验收阈值。短时间突刺与持续高峰对数据库的影响不同,不能用一次 10 秒测试代表整个活动周期。

产品需求中应明确库存扣减时机、库存不足提示、订单超时规则、支付失败处理、取消订单回补、是否允许人工修正以及用户看到的处理中状态。每条规则都应有一个可测试的结果,不能只停留在自然语言描述。
例如,“订单未支付自动释放库存”至少要进一步写清:订单创建后多久释放、释放是否受支付回调竞态影响、释放失败是否重试、用户重新支付时是否重新确认库存。规则越具体,技术越容易建立状态机和验收用例。
开发方案至少应包括数据模型、关键 SQL、事务边界、幂等键、异常分类、重试规则、回补方式、监控指标和降级策略。不能只提交一张架构图,让评审者自行猜测失败后会发生什么。
关键 SQL 应说明受影响行数的业务含义;幂等表应说明状态转换;补偿任务应说明扫描条件和最大重试次数;对于缓存和队列,还要说明数据初始化、故障切换和对账口径。
测试用例应同时查询订单表、库存表、流水表和幂等表。一个接口返回成功,不代表四张表的状态都正确;一个接口返回失败,也不代表数据库没有发生变化。
测试尤其要覆盖响应超时、服务重启、数据库连接断开、消息重复投递、补偿任务重复触发和人工重试。很多库存事故并不是正常路径无法执行,而是异常路径没有明确的最终状态。
上线前应配置库存负数告警、库存流水与订单差异告警、补偿失败告警、幂等冲突异常告警、数据库锁等待告警和消息积压告警。告警必须绑定处理人和处置步骤,否则只是把问题从系统里“显示出来”,没有形成止损能力。
高风险活动还应准备开关:暂停某个 SKU、暂停自动扣减、切换到排队模式、限制单用户购买量、关闭自动重试或启用人工审核。止损开关不一定优雅,但比事故扩大后直接改数据库更安全。

事故复盘的第一步是锁定事实时间线:第一笔异常请求何时进入,库存何时发生变化,订单何时创建,哪个事务提交,哪次响应超时,补偿任务何时启动。没有时间线,团队很容易把猜测当成根因。
建议从日志、数据库流水、订单状态、消息记录和监控曲线中交叉还原。单一日志源可能丢失上下文,例如应用日志显示“扣减失败”,但库存流水显示事务其实已经提交,这通常意味着网络响应阶段发生了异常。
库存超卖并不只有一种原因。先查后扣造成的是并发竞态;重复请求造成的是幂等缺失;回补两次造成的是补偿幂等缺失;缓存数量大于数据库造成的是多数据源不一致;订单成功但库存未扣造成的是事务边界或事件可靠性问题。
| 现象 | 优先检查对象 | 可能根因 | 短期止损 |
|---|---|---|---|
| 成功订单超过库存 | 扣减 SQL 和受影响行数 | 先查后扣、错误放行 | 暂停 SKU,冻结异常订单 |
| 库存减少但没有订单 | 库存流水和事务日志 | 订单写入失败、异常回滚不完整 | 按流水进入待核查队列 |
| 库存增加超过应回补数量 | 回补流水和任务执行记录 | 补偿重复执行、释放无幂等 | 暂停自动回补,重新对账 |
| 接口超时后重复扣减 | 请求号和响应链路 | 未知结果被当成失败重试 | 改为状态查询,限制重试 |
| 高峰期大量库存不足 | 热点分布和锁等待 | 热点行竞争、队列积压 | 限流、排队或切换活动策略 |
“并发量太高”通常只是触发条件,不是根因。更有价值的根因描述应该是:“库存扣减采用先查询后更新,更新语句没有库存条件,且服务未检查受影响行数;在 500 个并发请求竞争库存为 1 的场景下,接口层错误返回了多个成功结果。”这样的描述才能直接对应修复方案和回归测试。
同样,“补偿任务有 bug”也过于宽泛。应该继续追问:任务是否有执行记录?同一订单是否可能被多个实例同时扫描?状态更新和库存回补是否在同一事务中?任务超时后如何判断上一轮是否已成功?只有把问题落到控制点,复盘才不会停留在口号。
复盘结尾常见的表述是“加强测试”“增加监控”“优化代码”。这些话方向没有错,但无法确认是否完成。改进项应写成可验收任务,例如“在库存扣减接口增加受影响行数断言,并补充库存为1、并发500、重复幂等号50个的自动化测试;测试结果要求超卖数为0、重复扣减数为0、三方对账差异为0”。
如果事故暴露的是架构边界问题,还应补充长期措施:是否需要拆分预占和实扣状态,是否需要可靠事件,是否需要库存对账平台,是否需要在活动前进行容量评估。短期修复保证不再复现,长期改进则降低同类问题再次出现的概率。

优先选择数据库原子更新加本地事务。库存表使用 SKU 和仓库的唯一键,更新语句带库存下限条件,服务检查受影响行数,库存流水和幂等记录一并落库。
此时不必急着引入缓存和消息队列。先通过真实流量模型压测热点 SKU,确认数据库连接池、锁等待和事务延迟。如果所有指标都在阈值内,简单方案往往比多组件架构更容易维护。
库存为 1 或库存为几十件时,重点不是让所有请求都成功,而是确保最多只有合法数量的请求成功。应优先采用原子条件更新、严格幂等和快速失败。
产品可以考虑增加排队或抽签机制,减少数千个请求直接竞争数据库同一行。若仍采用同步接口,应对重复点击、客户端重试和网关自动重试进行限制,并在库存耗尽后尽快停止无意义请求。
如果业务允许用户看到“排队中”,可以评估消息队列削峰或分配令牌。此时必须设计状态查询接口,让用户通过订单号或请求号查询最终结果,而不是依靠前端不断重试。
队列方案上线前要压测消费者处理能力、消息积压、重复消费、消费失败和死信恢复。活动期间还要监控队列滞留时间,因为用户能否接受的不是单纯的消费速度,而是从提交到最终确认的整体等待时间。
余额和授信不是普通商品库存,技术选型应把可审计性、不可重复扣减和财务对账放在第一位。建议采用强一致数据库事务、唯一业务流水、严格状态机和日终对账,慎用只依赖缓存的扣减。
任何人工修正都应产生独立的调整流水,并保留审批原因。不要直接修改余额字段来“把数字改对”,因为这样虽然能暂时消除差异,却会破坏后续审计和责任追踪。
这类业务首先要定义扣减顺序和失败策略。若一个订单需要从多个仓库扣减,单条原子 SQL 已经不能覆盖整个业务动作,必须考虑多行事务、库存锁定顺序、死锁重试和部分成功回滚。
如果允许拆单,可以把每个仓库库存动作设计为独立子操作,再由订单聚合状态判断整体结果;如果不允许部分成功,则需要在提交前完成可用性确认,或使用预占状态避免扣了一部分后无法完成剩余部分。
某些预约、服务排班或非实物资源业务,系统中的数量更接近可预约名额,而不是严格的仓库实物。若业务允许人工确认,可以采用“申请成功、待确认”的状态,避免把短暂的数据库竞争直接映射成确定性的履约承诺。
但这并不意味着可以放弃幂等和流水。只要用户会收到“名额已占用”或“预约成功”的承诺,就必须能够解释这次占用何时产生、何时释放以及是否被其他请求重复使用。

老系统改库存时,最危险的做法是直接替换扣减代码,却没有留下改造前后的对照数据。建议先增加库存流水、请求号记录和三方对账任务,观察一段时间真实流量下的差异。
如果当前系统没有幂等号,可以先由网关或订单服务生成业务请求号,并把它透传到库存服务。即使暂时不改变扣减逻辑,也可以先统计重复请求比例、超时比例和补偿执行次数,为后续改造提供事实依据。
这是收益较高、改动相对可控的一步。保留原有库存查询用于展示或提示,但真正扣减时改为带条件的原子更新,并检查受影响行数。
上线时可以先对部分 SKU 启用新逻辑,比较新旧路径的成功率、库存差异、延迟和锁等待。不要只根据接口错误率判断效果,因为旧逻辑可能返回成功但库存结果已经错误。
在新逻辑稳定后,增加幂等操作表和唯一约束。对于已经存在的历史订单,需要先定义迁移策略:是按订单号生成幂等记录,还是只对新订单启用幂等。迁移过程中必须避免把同一历史扣减再次当成新动作。
回补也要单独治理。扣减幂等和回补幂等最好使用不同的动作类型,但共享明确的业务关联关系。这样既能防止重复释放,也能在对账时区分“原始扣减”和“后续回补”。
只有当热点 SKU 的锁等待、连接池排队或数据库写入能力达到明确阈值时,才评估缓存、队列或库存分片。升级时应保留数据库作为最终可审计来源,或者明确新的权威来源及其对账机制。
架构升级不是替换组件,而是改变状态流转方式。上线前要重新定义用户状态、订单状态、库存状态、消息状态和补偿状态,并重新编写压测与故障演练用例。
数据库 CPU 上升不一定代表库存已经出错,接口延迟下降也不一定代表扣减正确。监控应将技术指标和业务指标通过 SKU、订单号和批次号关联起来。
例如,某热门 SKU 的锁等待突然升高,同时库存不足率、客户端重试率和订单处理中比例也升高,这才说明用户行为和数据库竞争正在互相放大。单独看 CPU 或 QPS,很难判断真正的业务影响。
第一层是数据安全告警,例如库存为负、成功扣减超过初始库存、流水与库存差异超过阈值。第二层是业务链路告警,例如订单成功但库存状态缺失、支付成功但库存未确认。
第三层是执行过程告警,例如幂等冲突异常增长、补偿任务失败、消息积压和处理延迟。第四层是容量告警,例如热点行锁等待、数据库连接池耗尽、事务回滚率和 P99 延迟持续升高。
| 告警层级 | 示例指标 | 触发后的第一动作 |
|---|---|---|
| 数据安全 | 库存负数、三方对账差异 | 暂停异常 SKU,冻结自动修正 |
| 业务链路 | 订单成功但库存未确认 | 进入待核查状态,避免继续履约 |
| 执行过程 | 幂等冲突、补偿失败、消息积压 | 限制重试并检查任务执行状态 |
| 容量压力 | 锁等待、连接池排队、P99延迟 | 限流、排队或切换降级策略 |
库存负数告警出现后,如果团队只能在群里讨论,而没有暂停售卖的开关,监控就没有真正的事故价值。每个高风险告警都应绑定一个操作手册:谁确认、先查什么、是否暂停 SKU、是否停止补偿、何时恢复售卖。
恢复售卖也不能只看接口恢复。必须先完成库存、订单和流水对账,确认异常订单如何处理,并确保新的扣减不会覆盖人工修正结果。

如果业务库存量中等、峰值流量可预测、库存必须强一致、团队缺少成熟的补偿和对账能力,那么数据库原子更新加幂等、流水和本地事务通常是更稳妥的起点。
简单方案的价值不只是开发快,更是出了问题后容易回答三个问题:哪一次请求改变了库存、这次变化关联了什么订单、如果要恢复应该执行哪一个幂等补偿动作。
当热点流量已经通过监控和压测证明数据库单行竞争无法满足目标,或者业务明确允许排队和延迟确认时,缓存前置、消息队列、库存分片才有引入价值。
引入复杂组件前,应先把新增成本写出来:数据同步、状态查询、重复消费、积压治理、故障切换、补偿任务、对账系统和运维值守。如果这些成本没有负责人,架构升级只是在未来制造更难定位的故障。
余额、授信、不可替代的稀缺资源、需要财务审计的扣减业务,不应仅凭吞吐量选择方案。此类系统更看重一次动作是否可证明、是否不可重复、是否能够恢复和审计。
如果一个方案能把接口延迟从 100 毫秒降到 10 毫秒,却让库存差异只能通过人工猜测修复,它未必是性能优化,可能只是把成本从接口等待转移到了财务、客服和履约团队。
无论最终使用哪种组件,我认为并发扣减至少应具备五个基本能力:

把待扣减、扣减成功、扣减失败、待确认、已释放、已关闭和异常待处理状态画出来,标注每个状态由谁触发、允许跳转到哪里、重复触发时返回什么结果。
确认真正的库存变化使用带条件的原子更新,并在服务层检查受影响行数。不要先做复杂架构,先用库存为 1、并发 500、重复请求和响应超时四个场景验证基础正确性。
按测试批次或活动批次,把订单、库存和流水导出到同一份结果中,核对期初库存、成功扣减、合法回补和期末库存。任何差异都要保留样本,不要直接删除脏数据后宣布测试通过。
如果锁等待和热点延迟没有达到业务阈值,就先保持简单方案;如果确实达到瓶颈,再根据是否允许延迟确认,选择队列、缓存或库存分片。升级前先写故障面和补偿方案,升级后重新做全链路压测。
并发扣减最容易被误解的地方,是大家都在讨论“怎么让更多请求成功”,而库存系统真正要保证的是“只有合法数量的业务动作成功,并且每一次成功都能解释、能追踪、能恢复”。数据库原子更新解决的是竞争窗口,幂等解决的是重复动作,库存流水解决的是事实追溯,补偿和对账解决的是异常闭环。产品和技术团队只要按照这条边界逐层验证,就能在不盲目堆叠组件的前提下,把库存事故从不可控的线上惊喜,变成可测试、可监控、可复盘的工程问题。
我原本以为“先查询库存,再执行扣减”已经足够简单,直到压测时发现库存只剩 1 件,多个请求却同时拿到了可购买结果。后来我想比较数据库原子更新和分布式锁,究竟哪一种更适合产品早期或中等并发的库存场景?
我在一次库存扣减演练中,先实现了“查询库存,判断大于 0,执行扣减”的普通流程。测试数据设置为初始库存 100,模拟 500 个并发请求,结果接口层看起来没有明显报错,但订单意向记录多于实际库存,问题出在查询和扣减之间存在并发窗口。
更稳妥的第一步,是把“库存是否足够”和“库存减一”合并成数据库的一次条件更新: UPDATE inventory SET available_stock = available_stock – 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ?
AND available_stock > 0;关键不是 SQL 执行成功,而是检查受影响行数。受影响行数为 1,说明本次扣减成功;为 0,则只能说明没有完成扣减,可能是库存不足,也可能是商品不存在,业务层应进一步区分。
在相同的测试模型下,数据库原子更新的验收重点是“成功扣减数不能超过初始库存”,而不是单纯追求接口吞吐量。
下面是我通常采用的方案对比: 方案主要优点容易被忽略的风险更适合的场景 条件原子更新实现简单,数据边界清晰热点行竞争、数据库连接压力中等并发、强一致扣减 悲观锁事务内控制直观锁等待、死锁、事务变长扣减逻辑复杂且必须串行 分布式锁可在应用层控制访问顺序锁超时、续期、释放和故障恢复需要保护多步非数据库操作 我的判断是:如果团队还没有证明数据库成为瓶颈,不要因为“高并发”三个字就先引入分布式锁。
条件更新更容易压测、对账和回滚;只有当扣减前后包含多个无法放进同一事务的资源操作,或者热点库存已经明显拖垮数据库,才值得进一步评估锁、缓存前置或异步削峰。还要检查 sku_id 的索引是否有效,避免条件更新退化为大范围扫描。
事务也不能无边界扩大:订单写入、库存流水和扣减是否放在同一事务中,必须结合锁等待、失败补偿和业务一致性一起判断。
我遇到过接口已经扣减成功,但客户端因为网络超时再次提交的情况。用户只看到一次下单动作,服务端却收到两次请求,我想知道库存扣减的幂等键应该怎么设计,才能既防止重复扣减,又不误伤真正的重试?
并发控制和幂等控制解决的不是同一个问题。原子更新可以防止库存变成负数,却不能判断两个请求是不是同一个业务动作;如果同一订单因为超时被提交两次,两个请求都可能合法地完成一次扣减。我在一次接口重试演练中,把同一个 order_id 连续发送 3 次。没有幂等约束时,库存流水出现 3 条扣减记录;
加上业务唯一键后,第一次请求完成扣减,后续请求只返回第一次的处理结果,不再执行扣减。推荐把幂等判断设计成独立的业务事实,而不是依赖内存变量。
常见做法是使用订单号、请求幂等号或业务流水号,并在数据库建立唯一约束:
CREATE UNIQUE INDEX uk_inventory_order ON inventory_deduction(order_id, deduction_type);扣减流程可以拆成“记录业务动作,执行库存变更,写入流水,返回结果”。实际实现时,必须明确唯一记录和库存更新是否处于同一事务中;如果不在同一事务中,就要配套状态机和补偿任务,不能只靠异常重试。
重复来源典型表现建议处理方式 客户端超时重试同一订单重复调用接口订单号或幂等号加唯一约束 网关自动重试服务端收到相同请求入口透传幂等键,禁止重新生成 消息重复投递消费者再次处理同一消息消费记录表或唯一业务流水 人工补偿重复执行库存被多次回补回补动作单独编号并实现幂等 有一个经常被忽略的细节:幂等键必须覆盖“业务动作类型”。
同一个订单可能先预占库存,后确认扣减,最后因取消发生回补。如果只用 order_id 做全局唯一,就可能把合法的回补或状态变更误判为重复操作。我建议库存流水至少记录 order_id、deduction_type、操作前库存、操作后库存、请求幂等号、处理状态和失败原因。
这样复盘时才能回答“这笔库存为什么减少”,而不是只看到库存表里少了一个数字。
我发现很多技术方案评审一谈库存,就直接讨论缓存和消息队列,却很少有人先问库存是在下单时扣、支付时扣,还是先预占再确认。我想建立一套不靠“哪个性能最高”来拍板的选型方法,避免系统复杂度超过业务需要。
库存方案的第一道分界线不是并发量,而是业务承诺。如果用户看到的是“下单成功即锁定库存”,系统就必须快速给出确定结果;如果业务允许排队或异步确认,才有空间用消息队列削峰。我参与方案评审时,通常先让产品回答三个问题:库存什么时候减少、未支付订单是否预占、取消订单如何释放。
三个问题没有答案时,直接讨论缓存还是数据库,往往是在用技术方案掩盖业务规则缺失。
业务条件优先方案主要代价评审时必须追问 库存量适中,要求即时结果数据库条件更新热点行竞争数据库连接和锁等待是否可接受 冲突后可重试乐观锁高冲突时重试放大最大重试次数和失败提示是什么 多步操作必须串行事务加行锁事务变长、死锁风险锁的持有时间能否压缩 热点商品流量突增缓存前置或分片缓存与数据库一致性回写失败和数据恢复怎么做 允许异步排队消息队列削峰结果延迟、重复消费、积压用户如何查询最终结果 我的经验是,数据库条件更新适合作为默认基线,因为它的正确性边界最容易解释:库存大于 0 才允许减,受影响行数决定结果,流水和订单可以围绕事务展开。
先用这个方案完成压测,才能知道瓶颈到底是锁竞争、连接池、磁盘写入,还是业务接口本身。缓存前置并不等于性能自动提升。它把数据库压力转移到缓存和一致性链路上,必须回答扣减成功后如何落库、服务宕机时如何恢复、缓存库存和真实库存不一致时以谁为准。
消息队列也不是“保证不丢不重”的开关,消费者幂等、失败重试、死信处理和积压告警都要同时落地。可以采用分阶段策略:第一阶段用数据库原子扣减建立正确性基线;第二阶段根据热点商品的锁等待和吞吐数据决定是否引入缓存;第三阶段只有在业务允许结果延迟时,才考虑队列削峰。这个顺序能显著降低过度设计和故障排查成本。
我以前只看接口成功率和平均响应时间,压测报告看起来很漂亮,但后来对账才发现订单、库存和流水数量对不上。我想知道一套真正有效的验证方法是什么,尤其是库存只剩 1 件、请求远超库存时,应该如何判断成功、失败和补偿是否正确?
库存压测不能只验证接口是否返回 200,因为“接口返回成功”和“库存事务提交成功”不是同一个事实。真正的验收对象应该是订单、库存表和库存流水三套数据在测试结束后能够互相解释。我通常会先准备三组场景。第一组设置库存充足,用来检查正常链路;第二组把库存压到即将耗尽,用来观察竞争和失败处理;
第三组设置库存为 1、并发请求为 500,用来验证系统是否只允许一个合法扣减。
测试场景示例参数必须验证的结果 库存充足库存 100,请求 50成功数、流水数和订单数一致 库存耗尽库存 10,请求 100成功扣减不超过 10,失败原因可区分 极端竞争库存 1,请求 500成功数等于 1,不出现负库存或重复流水 重复请求同一订单重复 3 次只产生 1 次有效扣减 异常回补扣减后模拟订单失败回补一次且可追踪、可重试 每次压测结束,我都会用下面的公式做对账: 期初库存 – 成功扣减数量 + 合法回补数量 = 期末库存同时还要检查:成功订单数是否等于有效扣减数,库存流水是否存在重复业务号,失败请求是否真的没有改变库存,补偿任务是否重复执行。
只要其中一项对不上,接口再快也不能通过验收。指标方面,平均响应时间只能作为参考,至少应记录 P95、P99 延迟、数据库受影响行数、锁等待、连接池使用率、重试次数和补偿积压量。一次演练中,平均响应时间只有 42 毫秒,但 P99 达到 1.8 秒,根因是热点库存行出现锁等待;
如果只看平均值,这个问题很容易被掩盖。线上复盘建议分四层写。先记录现象和影响,例如影响了多少订单、商品和用户;再定位根因,是条件缺失、事务过长、幂等遗漏还是监控缺失;最后把改进措施写成可验收任务,例如“补充库存为 1 的并发测试,并要求成功扣减数与流水数一致”,而不是笼统地写“加强测试”。


读者评论
文章把并发扣减从单条 SQL 扩展到幂等、补偿和对账,比较贴近真实项目。尤其是“数据库执行成功不等于扣减成功”的区分,对排查库存异常很有帮助。
关于先查库存再扣减的误区讲得清楚。实际开发中还应结合受影响行数、唯一业务单号和事务边界,否则单线程测试通过也可能在高并发下出问题。
内容没有把数据库锁当成万能方案,而是同时讨论重复请求、超时重试和回补,这一点比较客观。对预占库存和下单即扣两种模式的取舍也有参考价值。
热点行锁竞争的分析很实用,提醒团队不要只看数据库总吞吐量,还要观察单个热门 SKU 的等待时间。文中的压测数据属于情景模拟,不能直接当作性能基准。
文章覆盖面较完整,但落地时还需要根据订单、支付和库存系统的实际边界设计状态机,并明确异常对账责任,否则补偿任务越多,重复回补的风险越高。