数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水
目录

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水 | 九数云-E数通

eshutong 发表于2026年9月19日

高并发秒杀里,最容易被误解的不是“如何把库存扣得更快”,而是“扣减之后,系统能不能证明每一件库存去了哪里”。我在做秒杀压测和线上故障复盘时,见过库存显示为负数、订单支付成功却无法发货、回滚重复增加库存、同一用户拿到两张订单等问题。最后真正帮助我们定位问题的,往往不是某条总库存记录,而是一张按业务事件追加的库存流水表。数据库存的核心,不只是一个数字,而是这个数字每一次变化的原因、请求、订单、状态和责任边界。

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

一、先讲核心结论:秒杀库存不是一个字段,而是一条可验证的业务事实链

1. 库存流水比库存总数更重要

很多入门实现只有一张商品表,里面放着 stock 字段。用户下单时执行一条减库存 SQL,支付失败时再加回去。这个方案在低并发环境下可以工作,但它只保存了“现在还剩多少”,没有保存“为什么变成这个数”。

一旦出现库存异常,工程师只能从订单表、支付表、日志文件和应用监控中拼接过程。日志可能过期,订单可能被补偿,支付回调可能重复,应用日志还可能因为采样而缺失。没有库存流水,就没有一份稳定、可审计、可重放的库存事实。

我更倾向于把库存模型拆成三个层次:库存余额、库存流水、库存状态。库存余额回答“现在有多少”,库存流水回答“发生过什么”,库存状态回答“这笔占用最终是成功、释放还是挂起”。三者缺一不可。

层次核心问题典型字段主要用途
库存余额当前还能卖多少可用库存、锁定库存、已售库存快速判断和展示
库存流水库存为什么变化流水号、变更量、业务类型、业务单号审计、对账、追责、重算
库存状态占用最终走向如何锁定、确认、释放、异常处理超时和补偿

因此,秒杀系统的第一原则不是“先上缓存”,而是先定义库存事件。没有事件模型,缓存只是把不清楚的逻辑执行得更快;没有幂等边界,分布式锁只是把重复执行排队;没有流水,任何补偿都无法证明自己没有再次制造问题。

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

2. “库存为零”不等于“系统没有问题”

秒杀结束时库存为零,只能说明某个时点的余额为零,不能证明没有超卖。比如初始库存为 100,系统先后产生 100 条成功占用流水、3 条重复占用流水和 3 条错误释放流水,最终余额可能仍然显示为零,但订单和流水已经不一致。

我在复盘库存问题时,会先计算一个基本恒等式:

当前可用库存 = 初始可用库存 + 所有增加类流水之和 – 所有扣减类流水之和

如果还区分锁定和已售,则应该继续计算:

初始库存 = 当前可用库存 + 当前锁定库存 + 当前已售库存 + 已废弃或调整数量

这些公式不是财务报表上的装饰,而是排查库存事故的第一道门。只要余额和流水不能相互推导,就说明系统至少存在漏记、重复记、错记或并发覆盖中的一种问题。

3. 先定义业务语义,再选择数据库和缓存

“扣库存”在不同业务里可能代表不同含义。预售商品可能是下单锁定,现货商品可能是出库扣减,团购商品可能是配额占用,优惠券库存则可能是资格发放。若把这些动作都命名为 decrease_stock,后续的支付、取消、退款和仓库出库会混在一起。

我建议先把库存动作命名为业务事件,而不是数据库动作。例如:

  • INIT:初始化库存。
  • RESERVE:订单创建时临时锁定。
  • CONFIRM:支付成功后确认销售。
  • RELEASE:订单超时、取消或风控拦截后释放。
  • ADJUST_IN:盘点、补货或人工修正增加库存。
  • ADJUST_OUT:损耗、报废或人工修正减少库存。
  • REFUND_RETURN:退款并且商品重新进入可售库存。

事件名清楚,状态机才清楚;状态机清楚,幂等和补偿才有边界。这是我认为数据库存入门阶段最值得先掌握的判断。

二、真实场景:一次秒杀请求到底改变了哪些库存

1. 从用户点击到订单超时的库存生命周期

以一件限量商品为例,活动库存为 1,000 件。用户点击秒杀按钮后,请求并不能直接把库存标记为“已售”。通常需要经过活动校验、用户限购校验、库存预占、订单创建、支付确认几个阶段。

如果系统在订单创建时就永久扣减库存,那么大量未支付订单会把库存长期占住;如果系统等支付成功才扣库存,又会让用户在等待支付期间继续竞争同一份库存,导致支付成功时库存不足。因此,更稳妥的模型是将库存划分为可用、锁定、已售三种状态。

阶段可用库存锁定库存已售库存产生流水
活动开始1,00000INIT +1,000
创建订单99910RESERVE -1
支付成功99901CONFIRM 状态转移
订单超时1,00000RELEASE +1
仓库出库99901SHIPMENT_CONFIRM,不重复扣减

这里有一个容易踩坑的地方:支付成功不是再次扣可用库存,而是把“锁定”转为“已售”。如果确认动作又执行一次库存扣减,系统就会出现双扣。换句话说,库存流水不仅要记录数量变化,还要区分余额变化和状态转移。

2. 预占、确认、释放必须是互相对应的事件

一个完整的库存占用通常至少包含三种结果:预占后确认、预占后释放、预占后进入异常待处理。不能只设计成功路径,因为秒杀系统中真正考验稳定性的往往是支付超时、消息重复、订单取消、回调乱序和数据库短暂不可用。

我会给每次预占生成一个独立的占用号,例如 reservation_id。后续支付确认和释放都引用这个占用号,而不是只引用商品编号。这样可以明确回答:“释放的到底是哪一次占用?”

如果只按商品编号执行释放,两个订单同时超时就可能互相覆盖;如果只按用户编号执行释放,用户多次尝试又可能释放错误库存。库存操作的最小幂等单位,通常不是商品,而是业务动作加业务单号。

3. 九数云适合做事后分析,不应成为秒杀扣减主链路

在库存场景中,九数云更适合放在分析和经营复盘层,而不是放在高并发扣库存的事务主链路里。原因很直接:秒杀扣库存要求毫秒级响应、严格原子性和清晰的事务边界;分析平台更擅长把订单、支付、库存流水、退款和仓储数据汇总后,观察转化与异常。

例如,我会将以下数据同步到分析层:

  • 商品维度:商品编号、活动编号、规格、初始库存、限购规则。
  • 请求维度:请求时间、用户分群、渠道、风控结果、失败原因。
  • 库存维度:预占、确认、释放、调整等流水。
  • 订单维度:创建、支付、取消、退款和发货状态。
  • 时延维度:请求耗时、排队耗时、数据库耗时、消息积压时长。

通过这些数据,可以分析“库存卖完了”背后的真实过程:是有效用户快速转化,还是机器人请求占满资格;是库存真的售罄,还是支付超时释放不及时;是数据库锁竞争,还是消息消费延迟导致订单状态落后。分析工具能帮助我们解释库存,但不能替代库存一致性本身。

三、数据库表怎么设计:先让每条流水都能被重放

1. 库存余额表只保存当前状态

余额表的目标是提供快速读写,不负责承载所有历史语义。一个基础设计可以包含商品、规格和活动三个维度,避免同一商品参加不同活动时互相污染。

CREATE TABLE inventory_balance (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
available_qty INT NOT NULL DEFAULT 0,
locked_qty INT NOT NULL DEFAULT 0,
sold_qty INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_activity_sku (activity_id, sku_id)
);

available_qty表示当前可以继续预占的数量,locked_qty表示已经被订单临时占用但尚未确认的数量,sold_qty表示已经确认销售的数量。version可以用于乐观锁,但不能把它误认为万能方案。

如果数据库执行的是“读取版本、应用层判断、再写回”的三步操作,版本号才有意义;如果更新语句没有把版本条件写进去,版本字段只是一个看起来专业的装饰。

2. 库存流水表要记录事实,不要只记录结果

流水表是后续对账、重算和事故恢复的核心。我的建议是采用追加写入,尽量避免修改已经完成的流水。若状态需要变化,可以记录状态变更事件,或者在允许的情况下使用独立状态表。

CREATE TABLE inventory_ledger (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
ledger_no VARCHAR(64) NOT NULL,
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
reservation_id VARCHAR(64) NULL,
biz_type VARCHAR(32) NOT NULL,
biz_id VARCHAR(64) NOT NULL,
request_id VARCHAR(64) NOT NULL,
user_id BIGINT NULL,
delta_available INT NOT NULL DEFAULT 0,
delta_locked INT NOT NULL DEFAULT 0,
delta_sold INT NOT NULL DEFAULT 0,
before_available INT NULL,
after_available INT NULL,
status VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE KEY uk_ledger_no (ledger_no),
UNIQUE KEY uk_biz_action (biz_type, biz_id),
KEY idx_sku_time (activity_id, sku_id, created_at),
KEY idx_reservation (reservation_id)
);

这张表有几个字段非常关键。biz_type + biz_id用于业务幂等,request_id用于链路追踪,reservation_id用于关联一次库存占用,before_availableafter_available用于快速审计,三组 delta则用于重算。

需要注意的是,前后余额字段属于“当时观察到的状态”,不能单独作为重算依据。真正用于重算的应是经过确认的增减量。因为并发环境下,不同事务看到的前后余额可能不具备全局顺序,流水号和提交顺序必须有明确策略。

3. 幂等键不能只选请求编号

请求编号适合识别一次网络请求,但不一定适合识别一次业务动作。用户点击一次,可能因为超时重试产生多个请求;支付平台回调一次,也可能发送多次通知。若每个请求都拥有不同的请求编号,系统仍然可能重复扣减。

更可靠的幂等键应该按照动作定义:

  • 预占动作:活动编号、商品编号、用户编号、订单尝试号。
  • 支付确认:支付单号或订单确认号。
  • 释放动作:订单号加释放原因,或者占用号加释放版本。
  • 人工调整:调整单号,不使用操作员姓名或备注作为唯一依据。

我会把“唯一约束”和“状态校验”同时使用。唯一约束防止相同动作重复插入,状态校验防止动作顺序错误。例如,已经确认销售的占用不能再次释放;已经释放的占用不能再次确认。

4. 流水表的索引不能只围绕查询页面设计

很多团队按“后台列表查询”去设计索引,却忽略了库存系统更常见的查询是对账、重放和异常扫描。至少要覆盖以下几类访问:

查询目的建议条件索引思路风险
按商品重算活动、规格、时间范围活动编号、规格编号、创建时间时间范围过大导致扫描
按占用处理超时占用号、状态、创建时间占用号与状态组合只按商品扫描会放大锁竞争
按订单对账业务类型、业务单号业务动作唯一索引缺失时重复动作难以识别
按请求追踪请求编号请求编号索引日志和数据库无法关联

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

四、并发扣减怎么做:原子条件比应用层判断更可靠

1. 最小可用方案是带条件的原子更新

在单库场景中,最基础也最实用的扣减方式,是把“库存大于零”和“库存减一”放进同一条 SQL。不要先查询库存,再在应用层判断,最后执行更新。因为两个请求可能同时读到相同库存,随后都认为自己可以扣减。

UPDATE inventory_balance
SET available_qty = available_qty - 1,

locked_qty = locked_qty + 1,

version = version + 1,

updated_at = CURRENT_TIMESTAMP

WHERE activity_id = ?

AND sku_id = ?

AND available_qty >= 1;

执行后检查受影响行数。受影响行数为 1,说明这次预占成功;为 0,说明库存不足或条件不满足。这里的关键不是 SQL 看起来简单,而是数据库把判断和修改作为一个原子操作执行。

如果一次购买数量为 n,条件应该写成 available_qty >= n,而不是循环执行 n 次减一。循环会放大锁竞争,也会让部分成功、部分失败的回滚逻辑变得复杂。

2. 乐观锁适合冲突可控的场景

乐观锁通常是先读取版本,再执行带版本条件的更新:

UPDATE inventory_balance
SET available_qty = available_qty - ?,

locked_qty = locked_qty + ?,

version = version + 1

WHERE activity_id = ?

AND sku_id = ?

AND available_qty >= ?

AND version = ?;

如果更新失败,应用可以重新读取并重试。但在秒杀热点商品上,版本冲突率可能非常高。重试并不会创造库存,只会让更多线程继续争抢同一行,增加 CPU、连接池和数据库日志压力。

我的判断是:库存很少、请求集中、成功比例低时,乐观锁的重试收益通常有限。它更适合修改冲突不激烈、业务允许短暂重试、热点分散的场景,而不是把所有秒杀请求都压到一个热点行上。

3. 悲观锁不是不能用,而是不能让锁覆盖外部调用

SELECT ... FOR UPDATE可以保证同一库存行的串行修改,但事务持锁期间如果调用支付、远程风控、消息服务或复杂库存计算,锁的持有时间会显著增加。秒杀高峰期,这种设计很容易让数据库连接堆积。

如果使用行锁,事务中应该只做必要的本地操作:校验关键状态、更新余额、写入流水、写入订单草稿或本地消息。远程调用必须放在事务之外,并通过状态机和可靠消息完成后续推进。

4. 数据库、缓存和队列应该各自承担不同责任

组件适合承担的职责不适合承担的职责
数据库最终库存事实、流水、订单状态、幂等约束承接无限突发流量
缓存快速拦截明显无库存请求、展示预估状态单独作为最终事实来源
消息队列削峰、异步创建订单、推进确认和释放替代幂等和事务约束
分析平台趋势分析、分渠道转化、异常监测、复盘报表直接执行核心扣减事务

缓存库存与数据库库存不一致并不可怕,可怕的是团队没有定义谁拥有最终解释权。我的原则是:缓存可以短暂错误,数据库和流水不能失去可验证性。缓存命中“有库存”后,仍要经过最终扣减;缓存命中“无库存”时,也要考虑补货、回滚和延迟更新造成的误判。

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

五、库存流水的状态机:把成功、失败、超时和乱序都写清楚

1. 先画状态转移,再写接口代码

库存状态机至少需要区分占用状态和订单状态。一个常见的占用状态可以是:INITRESERVEDCONFIRMEDRELEASEDEXCEPTION。状态之间不是任意跳转,而是由具体事件触发。

当前状态允许事件下一状态库存影响
INIT预占成功RESERVED可用减、锁定加
RESERVED支付确认CONFIRMED锁定减、已售加
RESERVED订单超时RELEASED锁定减、可用加
CONFIRMED重复支付回调CONFIRMED无变化,仅返回幂等成功
RELEASED迟到支付确认EXCEPTION不直接恢复销售,进入人工或自动补偿
EXCEPTION补偿完成RELEASED 或 CONFIRMED根据业务裁决生成新事件

最重要的一条规则是:重复事件可以安全返回,非法状态转移必须被拒绝或挂起。例如,支付回调重复到达不应该再次增加已售库存;订单已经释放后又收到支付成功,也不能简单地把状态改回确认。

2. 迟到消息比重复消息更危险

重复消息通常可以依靠幂等键拦截,迟到消息则涉及业务时间顺序。例如,订单超时释放已经完成,随后支付服务因网络延迟发送成功回调。此时如果系统只判断“支付成功就确认”,就可能把已经释放的库存再次确认,造成余额和订单事实不一致。

处理迟到消息需要同时判断业务状态和事件版本。可以使用订单版本号、支付单状态版本或事件产生时间,但不能只依赖客户端传入时间,因为客户端时间不可信。

在实际设计中,我会为库存占用保存一个单调递增的状态版本。每次状态推进都要求版本匹配;旧版本事件到达时,系统记录为重复或过期事件,不再改变库存。

3. 释放库存不能是“加一”这么简单

释放动作必须验证三件事:这笔占用存在、当前状态确实是锁定、释放动作尚未执行。只有三项都成立时,才允许把锁定库存减少并把可用库存增加。

UPDATE inventory_balance b
JOIN inventory_reservation r

ON b.activity_id = r.activity_id

AND b.sku_id = r.sku_id

SET b.available_qty = b.available_qty + r.quantity,

b.locked_qty = b.locked_qty - r.quantity,

b.version = b.version + 1

WHERE r.reservation_id = ?

AND r.status = 'RESERVED'

AND b.locked_qty >= r.quantity;

随后还要在同一事务中把占用状态更新为 RELEASED,并写入释放流水。若只更新余额而没有状态变更,任务重试时就会再次释放;若只更新状态没有余额变化,库存则会永久丢失。

4. 异常状态要有可操作的出口

很多系统把异常状态设计成一个“暂时放着”的垃圾桶,最终无人处理。真正可用的异常状态应该有进入条件、责任队列、重试次数、最后错误原因和人工处理入口。

  • 数据库扣减成功但流水写入失败:优先依靠本地事务或可靠事件补齐。
  • 余额更新成功但订单创建失败:通过本地消息或补偿任务推进订单创建。
  • 释放成功但消息重复:以释放流水的唯一约束拦截重复动作。
  • 支付成功但占用已释放:进入业务裁决,不自动强行改库存。
  • 流水存在但余额不一致:冻结自动调整,先重算和确认差异来源。

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

六、从一个小型案例看数据:库存对账到底怎么发现问题

1. 案例背景:1,000 件库存出现 7 件差异

下面使用一组情景模拟数据说明排查方法。活动初始库存为 1,000 件,活动结束后余额表显示:可用库存 0、锁定库存 12、已售库存 981。按照余额表计算,库存合计为 993,少了 7 件。

如果只看订单表,系统显示支付成功 981 单、待支付 12 单,看起来非常合理。但流水重算发现,预占总量为 1,012,确认总量为 981,释放总量只有 19,而按超时任务记录,理论上应该释放 26 件。差异正好是 7 件。

核对项目理论数量实际数量差异
初始库存1,0001,0000
预占数量1,0121,0120
支付确认数量9819810
超时释放数量2619-7
当前锁定数量12120
余额可推导库存1,000993-7

继续按占用号查询后,发现 7 个释放任务在更新订单状态时发生超时。任务认为状态已经处理完成,实际上释放库存的事务没有提交。由于任务表先被标记为成功,后续重试没有继续执行,最终留下了 7 件锁定库存差异。

如果系统只有余额表,排查很可能停留在“数据库为什么少了 7 件”。有了流水和占用状态,则可以定位到“哪些占用没有释放、释放任务在哪一步失败、是否需要重放”。这就是库存流水的实际价值。

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

2. 对账要分成余额对账、流水对账和订单对账

余额对账只检查当前三种库存之和是否合理;流水对账检查所有事件的增减是否能够推导余额;订单对账则检查订单状态与库存占用状态是否一致。三种对账的发现能力不同,不能只做其中一种。

  • 余额对账:发现可用、锁定、已售之间的数量不守恒。
  • 流水对账:发现漏记、重复记、金额方向错误和跨事务缺失。
  • 订单对账:发现支付成功但未确认、订单关闭但未释放等状态分叉。
  • 消息对账:发现事件已经产生但没有消费、消费失败后没有进入重试队列。

建议在活动期间进行轻量实时校验,活动结束后再进行全量重算。实时校验不宜执行复杂聚合,否则会反过来影响主链路;可以只监控负库存、锁定超时、重复幂等冲突和流水写入失败。

3. 如何设计可重放的库存流水

可重放的关键不是把所有历史日志保存下来,而是保证流水具备确定的业务顺序、唯一身份和明确数量方向。重放程序必须能够识别已经执行过的事件,不能把整个流水表无条件再执行一遍。

一个安全的重放流程通常是:

  1. 锁定需要修复的活动或规格,暂停新的库存调整。
  2. 导出余额快照、流水范围、订单状态和异常事件。
  3. 按业务事件序列重算理论余额,不直接修改生产余额。
  4. 将理论余额与实际余额逐项比较,确认差异原因。
  5. 对确定缺失的事件生成补偿流水,而不是篡改旧流水。
  6. 重新执行对账,确认补偿前后数量和状态都闭合。

我不建议直接把生产表中的 available_qty 改成计算结果。即便结果看起来正确,也可能掩盖未处理订单或迟到消息。正确的修复方式应该留下新的调整流水,让未来的人能看懂这次修复。

七、常见误区:很多“高并发优化”其实是在放大一致性风险

1. 误区一:把库存字段放到缓存里就算解决了秒杀

缓存可以降低数据库读压力,却不能自动解决最终一致性。缓存扣减成功而订单创建失败,缓存库存已经减少;缓存扣减失败但数据库释放库存,缓存又可能继续显示售罄。若没有回补、校准和最终扣减机制,缓存只是增加了一份需要维护的状态。

合理的做法是明确缓存的角色。它可以用于活动资格预热、无库存快速失败、热点请求削峰,但最终库存变更仍必须落到具有幂等约束的事实存储中。缓存初始化、回补和失效都应该有监控,不应依赖人工刷新。

2. 误区二:使用分布式锁就不会超卖

分布式锁只能约束拿到同一把锁的参与者。如果有一条路径绕过锁直接修改数据库,或者锁租约过期后旧持有者继续执行,依然可能发生并发错误。锁还会带来续期、故障转移、锁粒度和性能成本。

即便使用分布式锁,也要保留数据库层面的条件更新和唯一约束。锁是减少竞争的协调工具,数据库约束才是最后一道事实边界。两者不能互相替代。

3. 误区三:订单创建成功就等于库存扣减成功

订单和库存可能位于不同服务、不同数据库或不同事务边界。订单先成功、库存后失败会产生无库存订单;库存先成功、订单后失败又会产生占用未落单。必须明确哪一步是业务承诺,哪一步是临时状态。

常见选择有两种:先预占库存再创建订单,失败则释放;或者先创建待支付订单,再通过库存预占决定订单是否有效。无论选择哪种,都要把中间状态对用户和运营人员解释清楚,不能把“待处理”伪装成“成功”。

4. 误区四:只用数据库事务解决跨服务一致性

数据库事务可以保证本地余额和本地流水同时提交,但不能自动保证消息发送、订单服务、支付服务和仓储服务都成功。把远程调用塞进数据库事务会延长锁时间,也不能消除网络故障。

更稳妥的组合是本地事务加可靠消息:本地事务同时写余额、库存流水和待发送事件;后台投递事件,消费方按业务键幂等处理;失败进入重试和死信队列;最终通过对账发现长期未闭合的状态。

5. 误区五:只关注 QPS,不关注失败结构

秒杀压测报告经常只写吞吐量和平均响应时间,但库存系统更应该关注成功请求、库存不足请求、幂等重复请求、数据库冲突请求、消息积压请求和补偿请求的比例。

例如,系统每秒处理 20 万次请求并不代表库存能力足够。如果其中 19.8 万次请求最终都因为资格不符或库存已空而失败,真正需要保护的是那 2,000 次有效占用。把无效流量尽早拦截,往往比单纯扩容数据库更有效。

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

八、专业判断逻辑:什么时候该选单库,什么时候需要削峰和分片

1. 先算有效库存请求,而不是拿总访问量吓自己

容量规划不能只看页面访问量。应分别估算页面浏览、资格校验、库存尝试、订单创建、支付回调和释放任务的峰值。库存数据库真正承受的,通常是库存尝试和状态推进,而不是全部访问。

可以采用以下估算方式:

  • 库存尝试峰值 = 总请求峰值 × 到达库存环节的比例。
  • 数据库写入峰值 = 预占请求峰值 + 确认请求峰值 + 释放请求峰值 + 调整请求峰值。
  • 流水写入峰值 = 每个库存事件对应的流水数量 × 事件峰值。
  • 重试放大系数 = 实际执行次数 ÷ 原始业务动作数。

如果预占一次写余额、一次写流水、一次写订单草稿,理论上一个成功请求可能带来三类写操作。再叠加支付确认和释放,不能只按“每次请求一次数据库写入”来估算。

2. 单库事务适合大多数初期业务

如果活动库存规模有限、有效请求经过网关和资格校验后明显下降、数据库连接池和锁等待可控,那么单库事务加条件更新往往是最值得优先采用的方案。它的优势是模型简单、排查容易、对账直接。

很多团队过早引入多级缓存、分库分表和复杂队列,结果是库存事实分散在多个系统里,故障时没人能回答哪个数字是真实的。架构复杂度应该由有效并发、可接受延迟、业务损失和运维能力共同决定,而不是由技术名词数量决定。

3. 队列削峰适合请求突发但业务允许排队

如果用户可以接受“排队中”“结果稍后通知”,队列是很有效的削峰工具。它把瞬时并发变成可控的消费速率,保护数据库和订单服务。

但队列引入了新的问题:消息重复、消费顺序、积压、重试、死信、用户等待体验和库存结果查询。队列不是把一致性问题消失,而是把问题从同步请求搬到了异步状态机。上线前必须设计消费幂等和用户可见状态。

4. 分片和库存分桶适合极端热点,而不是默认方案

单个热点商品对应一行库存时,所有请求会争抢同一行。库存分桶可以把 10,000 件库存拆成多个桶,让请求随机或按规则落到不同桶中,从而降低单行热点。

但分桶会增加查询和回收复杂度。系统需要解决总库存计算、桶之间的分配倾斜、释放回收、超时扫描和补偿一致性。若商品库存只有几十件,分桶带来的管理成本可能高于收益。

方案适合场景主要收益主要代价
单库原子更新并发中等、业务刚起步简单、可审计、易排查热点行存在竞争
缓存预扣加数据库确认读流量大、无库存请求多减少数据库无效请求需要处理缓存回补和漂移
队列削峰允许异步返回结果平滑写入峰值引入积压和消费状态
库存分桶单商品极端热点降低单行锁竞争回收、对账和倾斜更复杂
多库分片库存和流量规模都很大横向扩展写入能力跨库一致性和运维成本高

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

九、实施步骤:从一张表开始,不要一次性重写整个秒杀系统

1. 第一步:先补齐库存事件和幂等边界

如果现有系统只有一个库存字段,不要马上引入十几个中间件。先盘点现有动作:初始化、下单扣减、支付确认、取消释放、退款回库、人工调整分别在哪里发生,是否有业务单号,是否能重复执行。

建议先做一张事件清单,记录每个动作的前置状态、库存变化、唯一键、失败后的处理方式和是否允许重试。清单完成后,再建立流水表和唯一约束。

动作前置条件数量变化唯一键失败处理
预占可用库存足够、用户有资格可用减、锁定加订单尝试号返回失败或重试
确认占用状态为锁定、支付有效锁定减、已售加支付单号进入待确认
释放占用状态为锁定、达到释放条件锁定减、可用加占用号加释放版本定时重试
调整有审批通过的调整单按调整方向变化调整单号禁止静默重试

2. 第二步:让余额和流水进入同一个本地事务

在单库阶段,预占成功后至少要保证余额变更和预占流水同时提交。若其中任何一步失败,整个事务回滚。流水写入失败不能只打印日志然后返回成功,否则后续无法重算。

BEGIN;
UPDATE inventory_balance

SET available_qty = available_qty – :qty,

locked_qty = locked_qty + :qty,

version = version + 1

WHERE activity_id = :activity_id

AND sku_id = :sku_id

AND available_qty >= :qty;

— 检查受影响行数,必须为 1

INSERT INTO inventory_ledger (
ledger_no, activity_id, sku_id, reservation_id,
biz_type, biz_id, request_id,
delta_available, delta_locked, delta_sold,
status, created_at
) VALUES (
:ledger_no, :activity_id, :sku_id, :reservation_id,
'RESERVE', :order_attempt_no, :request_id,
-:qty, :qty, 0,
'SUCCESS', CURRENT_TIMESTAMP
);
COMMIT;

这里省略了订单写入和异常处理,但工程上必须检查受影响行数、唯一键冲突、事务提交结果和超时重试。特别是客户端已经超时而数据库实际提交成功时,重试请求必须依靠业务幂等键返回原结果。

3. 第三步:补上超时释放和对账任务

库存锁定之后必须有明确的过期时间。定时任务不应直接扫描整张流水表,而应根据占用状态和过期时间建立待处理集合。任务每次领取一批记录,执行幂等释放,再更新处理结果。

释放任务要允许重复执行,但重复执行不能产生数量变化。可以通过占用状态的条件更新保证只有第一个成功者获得处理权,其他任务读取到已释放状态后直接结束。

对账任务则应分层执行:先检查负库存和异常状态,再检查最近时间窗口,最后在低峰期做全量重算。全量重算不应直接锁住所有库存,而应生成差异报告,经过确认后再生成补偿动作。

4. 第四步:压测时故意制造失败

只压测全部成功的路径没有太大价值。库存系统最应该模拟的是数据库超时、支付回调重复、消息乱序、订单取消、释放任务重试、缓存故障和进程在提交前后突然退出。

  • 在余额更新成功后模拟流水写入异常,检查事务是否整体回滚。
  • 在事务提交后模拟客户端超时,重复发送同一预占请求。
  • 让支付确认消息早于订单创建消息到达,观察系统是否挂起而不是误确认。
  • 让释放任务同时启动多个实例,检查是否只有一个实例产生释放流水。
  • 在缓存显示有库存时强制数据库返回库存不足,确认用户结果和缓存修正机制。
  • 将消费延迟逐步提高,检查锁定库存、超时任务和告警是否按预期工作。

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

十、监控和告警:库存系统要监控“闭合程度”

1. 业务指标比单纯机器指标更有价值

CPU、内存、连接数和磁盘使用率当然要监控,但它们不能直接说明库存是否正确。库存系统至少要建立业务指标:

  • 预占成功率、库存不足率、资格拦截率。
  • 锁定库存数量、锁定超时数量、平均锁定时长。
  • 支付确认延迟、释放延迟、异常状态数量。
  • 流水写入失败数、幂等冲突数、非法状态转移数。
  • 余额重算差异数、未闭合订单数、消息积压数量。

其中“库存闭合率”特别值得关注。可以把已经完成确认或释放的占用数量,除以全部已创建占用数量。闭合率下降,通常意味着支付回调、超时任务、消息消费或状态推进出现了积压。

2. 告警要能指向动作,而不是只提示异常

“库存异常”这种告警没有足够行动价值。更好的告警应该带上活动编号、规格编号、差异数量、最早异常时间、受影响占用号和建议处理动作。

告警触发条件示例第一动作后续处理
负库存可用库存小于0立即冻结人工调整检查并发更新和补偿任务
锁定超时超过阈值仍为锁定检查支付与释放队列按占用号重试或裁决
流水缺失余额变更无法找到对应流水暂停自动修复检查事务和数据修复记录
消息积压队列延迟超过业务时限扩容消费者或降级入口评估迟到事件影响

3. 用分析看板做活动复盘

活动结束后,可以将库存流水与订单、支付、用户、渠道和仓储数据关联起来,分析每个阶段的转化。九数云在这里可以帮助团队搭建经营分析看板,例如按活动、商品规格、渠道和时间段查看预占成功率、支付转化率、释放率与异常率。

我比较看重的不是一张漂亮的销售额大屏,而是能够下钻到业务单号的异常分析:某个规格是否出现集中释放,某个渠道是否预占多但支付少,某个时间段是否数据库冲突激增,某种支付方式是否确认延迟更长。只有能从汇总指标回到明细流水,分析才真正服务于工程决策。

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

十一、不同业务情况下的行动建议与取舍

1. 小规模活动:优先选择简单、可审计的方案

如果库存数量不大,活动频率不高,用户可以接受几百毫秒到一秒左右的结果延迟,我建议采用单库余额表、库存流水表、订单占用表和超时释放任务。先把事件闭环做完整,再考虑缓存和队列。

这类方案的优势是开发快、故障排查直观、对账成本低。缺点是热点行竞争可能限制峰值,但可以通过活动前资格校验、限流和请求合并缓解。不要因为“秒杀”这个词就直接引入分布式库存。

2. 中等规模活动:缓存拦截加数据库最终确认

如果活动入口流量较大,但真正有资格的用户比例不高,可以在入口层使用缓存做活动状态、限购和明显无库存请求拦截。数据库仍然执行最终条件扣减,库存流水仍然是事实凭证。

这套方案的取舍是:响应速度更快,数据库压力更小,但需要维护缓存预热、失效、回补和监控。缓存中的数量只能作为快速判断,不能直接用于订单最终确认。

3. 高峰突发活动:队列削峰,但要接受异步体验

如果请求峰值远高于数据库稳定写入能力,同时业务允许用户看到排队结果,可以把预占或订单创建放入队列。前端显示排队状态,后台按可控速率消费,最终通过查询接口或通知返回结果。

这里的主要代价是用户体验从同步变成异步,系统必须处理消息积压和结果超时。库存锁定时间、支付窗口和排队时间要一起设计,否则用户还没有拿到订单,库存占用已经过期。

4. 极端热点商品:分桶之前先确认是否真的需要

库存分桶适合单个商品极度集中、数据库单行锁竞争已经成为明确瓶颈的场景。实施前应先用压测数据证明问题来自热点行,而不是连接池、索引、网络或无效请求过多。

分桶后必须增加桶级流水、总库存汇总和回收逻辑。对于库存少、活动短的商品,分桶可能让运营、对账和售后都更复杂。能用单库原子更新解决的问题,不要用分桶制造新的状态空间。

5. 对库存准确性要求极高的业务:牺牲部分速度换可验证性

医药、贵重物品、强监管商品或线下线上共享库存,通常更重视不超卖和可追溯。此时可以降低并发写入速度,增加预占确认、人工审核、库存冻结和严格对账。不要为了几毫秒的响应优势,放弃流水和业务审计。

如果业务允许少量“售罄误判”,可以采用更保守的库存阈值;如果绝对不能超卖,则必须让最终扣减受数据库约束或可靠库存服务控制。不同业务的损失函数不同,架构取舍不能只看技术指标。

数据库存:后端工程师从零入门:高并发秒杀先掌握库存流水

十二、给后端工程师的最终检查清单

1. 设计阶段检查

  • 是否明确区分可用、锁定和已售库存。
  • 是否为预占、确认、释放和调整定义了独立业务事件。
  • 是否有库存流水表,而不是只更新余额字段。
  • 每类动作是否拥有稳定的业务幂等键。
  • 是否定义了迟到消息、重复消息和非法状态转移的处理方式。
  • 是否明确缓存、队列和数据库各自的责任边界。

2. 代码阶段检查

  • 库存判断和扣减是否在同一个原子 SQL 或明确事务中完成。
  • 是否检查数据库更新的受影响行数。
  • 余额变更和流水写入是否处于同一事务边界。
  • 远程调用是否被错误地放在持锁事务中。
  • 重试是否会携带同一个业务幂等键。
  • 失败返回是否能被客户端安全重试。

3. 上线阶段检查

  • 是否有负库存、锁定超时、流水缺失和消息积压告警。
  • 是否可以根据流水重算指定商品或活动的库存。
  • 是否能按占用号定位订单、支付、消息和释放任务。
  • 是否做过提交前崩溃、提交后超时、重复回调和乱序消息演练。
  • 是否准备了活动结束后的全量对账方案。
  • 人工调整是否必须生成调整单和调整流水。

4. 复盘阶段检查

  • 库存闭合率是否达到预期。
  • 预占到支付确认的转化是否存在异常渠道或规格。
  • 释放延迟是否导致锁定库存长期占用。
  • 数据库冲突主要集中在哪个商品、时间段或接口。
  • 缓存与数据库差异是否能够自动发现和修正。
  • 异常是否最终形成了可查询、可解释、可重放的业务记录。

十三、结尾:秒杀系统真正要优化的,是失败之后还能证明自己正确

高并发秒杀的难点从来不只是把库存扣到零,而是让每一次扣减、确认、释放和调整都能被证明。一个只保存库存总数的系统,在正常路径上看起来很快,遇到支付超时、消息重试和进程故障就会失去解释能力。

我给刚入门的后端工程师的建议是:先不要急着背缓存、锁和分片的方案。先画出库存状态机,定义业务事件,建立库存流水,给每个动作加上幂等约束,再用带条件的原子更新完成最小闭环。只有当压测数据证明单库方案确实成为瓶颈,才逐步引入缓存削峰、消息队列、库存分桶和分片。

库存余额是结果,库存流水才是证据;高并发优化解决的是速度,库存流水解决的是系统能否被信任。下一步可以从现有项目中挑一个库存接口,列出它会产生的所有业务事件,补一张流水表和一组对账 SQL,再针对重复请求、超时释放和乱序回调做三组故障测试。完成这一步,你才真正从“会写扣库存代码”进入了“能设计库存系统”的阶段。

常见问题解答(FAQ)

1. 为什么高并发秒杀不能只设计一张库存表,还要设计库存流水表?

我以前以为只要把商品表里的库存字段扣减正确,秒杀系统就算完成了。后来测试订单重复提交、支付超时和取消退款时,发现库存数字虽然没有变成负数,却完全解释不清每一件库存是怎么变化的。

库存表和库存流水表解决的是两个不同问题。库存表回答“现在还剩多少”,适合高频查询;库存流水回答“为什么还剩这些”,用于幂等判断、异常排查和库存对账。只有库存快照而没有流水,系统出问题后通常只能依赖日志,而日志可能过期、缺失,甚至无法和业务单据一一对应。

我建议至少拆成两张表:product_stock保存商品当前库存,stock_flow记录每次变化。流水中应包含商品或 SKU、业务类型、业务单号、请求号、变化数量、变化前库存、变化后库存和创建时间。比如订单预占记为 -1,超时释放记为 +1,补货也记为正数,但必须统一正负号规则。

场景只有库存表增加库存流水后 重复点击只能看到库存多扣了一次可通过请求号或订单号识别重复操作 支付超时不知道是否已经释放可查看预占和释放是否成对出现 库存对不上只能人工猜测可按业务类型重算变化过程 我的判断是,库存流水不是为了“把表设计得更复杂”,而是为了让库存从一个不可解释的结果,变成可审计的业务事实。

秒杀规模不大时,它甚至比引入缓存和消息队列更值得优先完成。

2. 数据库条件更新真的能防止高并发秒杀超卖吗?

我在本地用库存为 1 的商品压测过,先查询再扣减的写法很容易让多个请求同时读到库存 1。后来改成带库存条件的 UPDATE,结果明显稳定,但我不确定这是不是已经等于解决了完整的秒杀问题。

数据库条件更新可以解决“多个请求同时扣减同一条库存记录”这一层问题,但不能代表整个订单流程已经安全。基础 SQL 可以写成: UPDATE product_stock SET available_stock = available_stock – 1 WHERE product_id = ?

AND available_stock >= 1;执行后必须检查受影响行数。返回 1,说明本次扣减成功;返回 0,可能是库存不足,也可能是商品不存在。不能只判断 SQL 有没有报错,因为库存不足通常是业务失败,不是数据库异常。我做过一个简单对比测试:库存设置为 1,并发发送 100 个扣减请求。

先查后改的实现需要额外加锁,否则在压力上来时会出现多个请求基于旧库存继续执行;条件更新则会让数据库在更新瞬间判断库存条件,最终最多成功一次。这个方案的关键不是“加了一个减法”,而是把库存判断和扣减合并成一次受保护的写操作。

方案主要风险适用判断 先查询再扣减读写之间存在竞态窗口不建议用于核心库存扣减 条件 UPDATE不能覆盖支付、重试和释放适合作为数据库基础方案 悲观行锁高峰期锁等待明显并发可控且强一致优先 还要注意事务边界。

如果库存更新成功后,订单创建失败,而两者不在同一个事务或没有补偿机制,就会出现库存被占用但没有订单。因此,条件更新解决的是“不超卖的原子扣减”,不是“秒杀业务全链路一致”。

3. 库存流水怎样设计,才能避免重复扣减和重复释放?

我最困惑的是网络重试:客户端以为请求超时就再次提交,服务端却可能已经扣过库存;支付超时任务也可能因为执行失败被重复调度。库存流水到底应该依靠什么字段判断一次操作是否已经执行过?

库存流水的核心不是多记录几列,而是为每一种业务动作建立可判断的幂等边界。建议同时保留业务单号和请求号,例如订单预占使用订单号作为业务关联,客户端重试则使用同一个请求号;释放库存则使用“订单号 + 释放动作”作为唯一业务键。

常见字段可以这样设计:biz_type表示 PRE_OCCUPY、RELEASE、RESTOCK 等动作,biz_id关联订单或补偿单,request_id表示本次请求,change_qty表示数量变化。数据库层再根据动作定义唯一约束,避免把所有操作粗暴地只按订单号去重。

操作推荐幂等键重复执行结果 库存预占PRE_OCCUPY + order_id返回原预占结果,不再扣减 库存释放RELEASE + order_id已释放则直接返回成功 退款回补RESTOCK + refund_id已回补则不重复增加库存 我更倾向于“数据库唯一约束 + 业务状态检查”双保险。

唯一约束防止并发写入两条相同流水,状态检查则防止订单已经支付后,延迟到达的超时任务又把库存释放出去。只做其中一层都不够:只做代码判断会有并发漏洞,只做唯一索引又无法理解订单当前状态。还有一个容易踩的坑是把“请求成功”当成“扣减成功”。

接口超时后,调用方应携带原始幂等号重试,服务端需要返回第一次操作的结果,而不是再次执行一次库存变更。

4. 支付超时后如何释放库存,并通过库存流水定位异常?

我见过一种情况:订单取消后库存没有恢复,运营人员只能直接把库存字段加回去。这样虽然短期内把数字修正了,却留下了重复释放和后续对账困难的问题。库存释放和故障排查应该怎样设计才不会越修越乱?

库存释放不能简单理解为再执行一次加法,它应该是一个有前置条件、有幂等标识、可追踪结果的业务动作。一个较稳妥的流程是:先读取订单状态,只有订单仍处于待支付且未释放状态时,才执行库存释放;随后在同一事务中更新订单状态、增加可用库存并写入 RELEASE 流水。

如果订单服务和库存服务是分开的,就不能假设跨服务事务天然存在。此时应通过可靠消息、重试任务或补偿表推进状态,并让释放操作具备幂等性。比如释放任务连续执行 3 次,系统最终也只能产生一条有效释放流水,库存只能增加一次。

我排查库存异常时,不会先直接修改库存字段,而是先按商品和业务单号拉取流水,建立一条变化链: 顺序动作数量变化库存结果 1初始库存010 2订单 A 预占-19 3订单 A 超时释放+110 4补偿任务重复执行应为 0仍为 10 对账时,可以按流水类型计算理论库存,再与库存快照比较。

重点关注三类异常:有扣减无订单、有订单无扣减、有释放但没有对应预占。生产环境中还应监控库存快照与流水差异、未支付订单占用时长、重复释放次数和补偿任务数量。我的建议是:人工修库存只能作为最后手段。

如果确实需要修复,应生成一条带有操作人、原因、工单号和前后库存的 ADJUST 流水,而不是直接执行一条 UPDATE。这样修复动作本身也能被审计,后续才能判断问题是业务缺陷、重复消息,还是人工误操作。

读者评论

黄思妍

文章把可用、锁定、已售三种库存状态拆开讲,这一点很实用。以前我只关注库存字段是否扣减成功,忽略了支付超时和取消订单的释放逻辑,实际很容易出现库存长期被占用的问题。用 reservation_id 关联预占、确认和释放,确实比按商品编号处理更可靠。

田浩然

库存流水的价值不只是记录历史,文中提到的库存恒等式更值得落地。出现库存对不上时,可以先用初始库存、可用库存、锁定库存和已售库存做反推,再定位漏记或重复记账。对于秒杀、优惠券这类高并发场景,这种对账思路比单看日志有效。

高若溪

文中区分分析平台和扣库存主链路的边界比较准确。经营分析可以汇总订单、支付、库存和时延数据,但库存扣减仍需要放在具备原子性和明确事务边界的系统里。尤其是支付成功时应完成锁定转已售,而不是再次扣减,这个细节很容易被忽略。

免责申明:本文内容通过AI工具匹配关键字智能整合而成,仅供参考,帆软及九数云不对内容的真实、准确或完整作任何形式的承诺。如有任何问题或意见,您可以通过联系jiushuyun@fanruan.com进行反馈,九数云收到您的反馈后将及时处理并反馈。
咨询方案
咨询方案二维码

扫码咨询方案

热门产品推荐

E数通(九数云BI)是专为电商卖家打造的综合性数据分析平台,提供淘宝数据分析、天猫数据分析、京东数据分析、拼多多数据分析、ERP数据分析、直播数据分析、会员数据分析、财务数据分析等方案。自动化计算销售数据、财务数据、绩效数据、库存数据,帮助卖家全局了解整体情况,决策效率高。

相关内容

查看更多
数据库存:运维团队核心指标:判断事务一致性是否正在缓解历史难追溯

数据库存:运维团队核心指标:判断事务一致性是否正在缓解历史难追溯

数据库存问题最难处理的,往往不是某一笔事务失败,而是“事务到底有没有完整落地”在几天后已经无法证明。一次订单状 […]
数据库存:项目经理年度规划:灾备演练怎样持续改善提升查询性能

数据库存:项目经理年度规划:灾备演练怎样持续改善提升查询性能

数据库存:项目经理年度规划:灾备演练怎样持续改善提升查询性能 很多团队把灾备演练安排在年度计划末尾,结果演练当 […]
数据库存:运维团队操作手册:灾备演练中的容灾恢复怎么落地

数据库存:运维团队操作手册:灾备演练中的容灾恢复怎么落地

数据库存:运维团队操作手册:灾备演练中的容灾恢复怎么落地 数据库容灾恢复真正失败的原因,通常不是“没有备份”, […]
数据库存:项目经理实战复盘:数据迁移中库存超卖的定位步骤

数据库存:项目经理实战复盘:数据迁移中库存超卖的定位步骤

数据库存:项目经理实战复盘:数据迁移中库存超卖的定位步骤 数据迁移上线后的库存超卖,最危险的地方不在于“少了几 […]
数据库存:技术负责人老板关心什么:表结构设计能否解决异常恢复难

数据库存:技术负责人老板关心什么:表结构设计能否解决异常恢复难

数据库存:技术负责人老板关心什么:表结构设计能否解决异常恢复难 数据库出现误删、重复扣款、批量导入污染、任务重 […]

让电商企业精细化运营更简单

整合电商全链路数据,用可视化报表辅助自动化运营

让决策更精准