高并发秒杀里,库存扣减真正难的部分,不是把商品表里的 stock 减一,而是要回答一个更棘手的问题:在几十万请求同时涌入、网络重试、订单超时、支付失败、服务重启之后,系统能不能证明“每一件库存为什么减少、何时减少、最终是否应该恢复”。我在做秒杀链路压测和故障演练时反复遇到同一个结论:库存字段负责快速判断,库存流水负责解释事实;没有流水的库存系统,平时看起来很快,出问题时几乎无法审计和修复。
秒杀库存通常不能只设计一个“剩余库存”字段。最少要区分可售库存、锁定库存和已消耗库存。可售库存表示当前还能被新订单占用的数量,锁定库存表示已经被某个业务单据占用但尚未完成最终交易的数量,已消耗库存表示订单完成后真正确认销售的数量。
如果只维护一个剩余库存字段,订单超时释放、支付成功确认、人工补偿、活动取消这些动作都会变成“再加几件”或“再减几件”。这样的数字虽然可能暂时正确,却无法回答库存变动的业务原因,也无法判断某次重复请求是否已经处理过。
| 库存概念 | 业务含义 | 典型变化时机 | 是否允许直接人工修改 |
|---|---|---|---|
| 初始库存 | 活动开始前配置的销售上限 | 活动创建、库存导入 | 原则上不允许,需走调整单 |
| 可售库存 | 当前可以继续被锁定的数量 | 锁定、释放、库存调整 | 不允许直接改字段 |
| 锁定库存 | 已被订单占用但尚未最终确认的数量 | 下单成功、超时释放、取消释放 | 不允许直接改字段 |
| 已消耗库存 | 已经完成销售确认的数量 | 支付成功、履约确认 | 不允许回退,需走逆向流水 |
| 库存流水 | 每次库存业务变化的不可变记录 | 所有库存状态变更 | 严禁更新或删除 |
在我的实践中,库存的基础校验公式通常是:期末可售库存 = 期初可售库存 + 入库流水 + 释放流水 – 锁定流水 – 出库流水 + 其他调整流水。对于同时维护锁定库存的系统,还需要额外验证:初始库存 + 累计入库 – 累计销售 – 累计损耗 = 可售库存 + 锁定库存。
这两个公式不是报表装饰,而是故障排查时最有价值的边界条件。只要公式不成立,就说明存在漏记、重记、并发覆盖、状态错配或人工绕过流程的问题。

高并发请求不能每次都扫描流水表计算剩余库存,因此需要一张高性能库存汇总表承担实时扣减。一张典型的库存汇总表可以包含商品或活动维度、初始库存、可售库存、锁定库存、已消耗库存、版本号和更新时间。
流水表则负责记录事实。每一条流水必须具备业务单号、业务动作、变动数量、变动前后库存、幂等键、请求来源、操作时间和关联订单。流水一旦写入,不应该通过更新来“修正历史”,如果发现错误,应新增一条冲正或补偿流水。
CREATE TABLE inventory_summary (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
initial_qty INT NOT NULL DEFAULT 0,
available_qty INT NOT NULL DEFAULT 0,
locked_qty INT NOT NULL DEFAULT 0,
consumed_qty INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_activity_sku (activity_id, sku_id)
);
CREATE TABLE inventory_ledger (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
business_no VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
action_type VARCHAR(32) NOT NULL,
delta_available INT NOT NULL DEFAULT 0,
delta_locked INT NOT NULL DEFAULT 0,
delta_consumed INT NOT NULL DEFAULT 0,
before_available INT NOT NULL,
after_available INT NOT NULL,
before_locked INT NOT NULL,
after_locked INT NOT NULL,
source VARCHAR(32) NOT NULL,
operator_id VARCHAR(64) DEFAULT NULL,
created_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_idempotency (idempotency_key),
KEY idx_activity_sku_time (activity_id, sku_id, created_at),
KEY idx_business_no (business_no)
);这里有一个容易被忽略的设计点:流水表不仅记录 delta,也记录 before 和 after。只存“减少 1 件”不能证明当时库存是多少;保存前后快照后,审计人员可以判断这一条流水是否符合当时的状态,工程师也能快速定位并发覆盖或重复消费。
很多团队把库存流水当作普通操作日志,导致它只记录“接口被调用过”,却没有记录“库存实际改变了多少”。这两者完全不同。接口日志反映请求,库存流水反映账务事实;请求可以失败、重试、超时,账务事实必须稳定。
普通电商库存可能在几分钟内平滑地完成几百次扣减,秒杀则可能在开售后的前 1 秒集中到达大部分请求。假设活动库存只有 5000 件,开售瞬间收到 20 万次抢购请求,系统面对的不是“卖掉 5000 件”这么简单,而是要在极短时间内拒绝约 97.5% 的请求,同时保证成功的 5000 次不会重复扣减。
我在压测中观察过一个很典型的现象:数据库 CPU 并没有达到 100%,库存却先出现异常。原因是连接池耗尽、锁等待增加、事务超时和消息重复投递会先破坏业务时序。系统看起来还有数据库余量,但库存状态已经不能被可靠解释。
| 压力阶段 | 主要请求 | 最容易出现的问题 | 应重点观察的指标 |
|---|---|---|---|
| 开售前 1 分钟 | 预热、查询、资格校验 | 缓存未命中、热 Key 集中 | 缓存命中率、热点 Key QPS |
| 开售后 1 秒 | 大量抢购请求 | 数据库锁竞争、重复请求 | 库存扣减成功率、锁等待时间 |
| 开售后 10 秒 | 订单创建、支付初始化 | 库存锁定与订单状态不一致 | 孤儿锁定数、订单创建延迟 |
| 活动结束后数小时 | 超时取消、退款、补偿 | 释放重复、逆向流水错配 | 未释放锁定库存、对账差异 |
因此,库存流水设计不能只围绕“抢购成功那一刻”展开。真正需要覆盖的是完整生命周期:预占、创建订单、支付确认、支付超时、主动取消、退款、人工调整和最终对账。

第一套时钟是库存时钟,表示库存锁定或释放何时发生;第二套时钟是订单时钟,表示订单创建、支付、取消和退款何时发生。两套时钟经常不完全同步。例如库存先成功锁定,订单服务因为网络超时没有返回;或者订单已经创建,库存锁定消息因为消费失败尚未落库。
如果团队只使用同步调用链,往往会把两个时钟强行绑在一个长事务里。这样做在低并发时比较直观,但在秒杀峰值下会让数据库事务持锁时间变长,订单服务抖动也会直接传导到库存服务。
我的判断是:库存锁定和订单创建可以在业务上要求最终一致,但必须把“锁定是否成功”和“订单是否已完成”拆成两个可追踪状态。不能因为订单接口暂时超时,就直接认定库存扣减失败;也不能因为订单创建成功,就默认库存一定已经落账。
完整链路通常不是一条“扣减 1 件”的流水。更合理的状态变化是:库存锁定流水使可售库存减 1、锁定库存加 1;支付确认流水使锁定库存减 1、已消耗库存加 1;如果支付超时,则释放流水使锁定库存减 1、可售库存加 1。
这意味着库存流水表中的动作不是简单的加减,而是状态转移。工程师应该把每种动作允许的前置状态、数量关系和幂等规则写清楚,最好形成状态机,而不是靠多个 if 判断临时拼接。
最危险的写法是先查询库存,再在应用层判断大于零,最后执行更新。两个并发请求可能同时读到库存为 1,然后都执行减一,最终结果可能是负库存,也可能因为后一次覆盖前一次而产生少扣。
-- 不推荐:查询和更新分离 SELECT available_qty FROM inventory_summary WHERE activity_id = 1001 AND sku_id = 2001; -- 应用层判断 available_qty > 0 后再执行 UPDATE inventory_summary SET available_qty = available_qty - 1 WHERE activity_id = 1001 AND sku_id = 2001;
更可靠的最小扣减条件应放在数据库更新语句里,让数据库对行级修改进行原子判断。
UPDATE inventory_summary SET available_qty = available_qty - 1, locked_qty = locked_qty + 1, version = version + 1, updated_at = CURRENT_TIMESTAMP(6) WHERE activity_id = ? AND sku_id = ? AND available_qty >= 1;
执行后必须检查影响行数。影响行数为 1,说明库存锁定成功;影响行数为 0,可能是库存不足,也可能是条件不匹配。不要把“SQL 没报错”当成扣减成功,数据库执行成功与业务条件满足是两件事。
缓存预扣可以提高峰值吞吐,但它不能自动成为最终库存事实。只要存在进程重启、消息丢失、重复消费、缓存淘汰或补偿任务中断,缓存与数据库就可能出现差异。
我更倾向于把缓存定义为“快速闸门”,而不是“最终账本”。缓存负责尽早拒绝明显不可能成功的请求,数据库或可靠的库存服务负责产生正式流水。对于超卖风险极高的商品,缓存扣减必须绑定唯一请求标识,并且要能通过消息重放重新构造结果。
一个实用的判断方法是问三个问题:缓存节点丢失后能否恢复每一次扣减?消息重复后是否会重复生成流水?数据库对账时能否找到每一个成功响应对应的业务单号?只要其中一个问题回答为“不能”,缓存方案就还不完整。
订单表记录的是订单状态,不是库存事实。订单可能创建成功但库存未锁定,也可能库存锁定成功但订单创建超时;退款订单还可能对应部分发货、部分退款或售后补偿。
如果通过订单状态变化推算库存,系统会把“订单存在”误认为“库存已消耗”。这会在支付回调重复、取消消息乱序和退款部分成功时产生难以修复的差异。
有些实现会把同一条库存记录从“锁定”更新为“已消耗”,这样表面上记录数量较少,但丢失了原始动作。审计时只能看到当前状态,看不到中间发生过什么,也无法判断支付确认是否重复执行。
正确做法是每个动作新增一条流水。锁定、确认、释放和冲正各自拥有独立的业务单号或动作号,原始流水不改,逆向操作也不删除原始流水。
自增主键只能保证每条记录的物理编号不同,不能保证同一个业务请求不重复。网络重试可能为同一个订单生成两条不同主键的扣减记录,因此必须使用业务幂等键,并在数据库层建立唯一约束。
幂等键通常应包含业务动作,而不是只包含订单号。例如“活动号 + 商品号 + 订单号 + 锁定动作”可以作为锁定幂等键;支付确认和释放则应使用不同的动作键。这样同一订单的不同状态变化不会互相覆盖。
跨订单服务、库存服务、支付服务的大事务听起来一致性很强,实际上很容易因为网络调用、锁等待和回滚范围过大而降低可用性。秒杀系统最怕的不是某一条请求短暂失败,而是大量请求长时间占用连接和锁资源。
更合理的边界是:库存汇总更新与库存流水写入放在同一个本地事务中;跨服务动作通过可靠消息、状态机和对账任务实现最终一致。这样库存服务内部保持强一致,跨服务保持可恢复的一致。

一个商品可能同时出现在活动库存、仓库库存、门店库存和渠道库存中。如果先写活动缓存,再写商品库存,最后由订单服务补一条流水,就会出现多个系统都声称自己是库存事实来源。
我建议先明确库存层级和扣减优先级。活动库存是营销约束,仓库库存是履约约束,渠道库存是销售分配约束。它们可以有不同的汇总表,但每次变化都要指定明确的归属层级和流水类型。
| 库存层级 | 解决的问题 | 是否直接决定秒杀成功 | 流水建议 |
|---|---|---|---|
| 活动库存 | 本次活动最多卖多少 | 通常是 | 记录活动锁定、释放和活动消耗 |
| 渠道库存 | 某渠道可卖多少 | 视分配策略而定 | 记录渠道配额和跨渠道调拨 |
| 仓库库存 | 实际可履约多少 | 通常不是第一道判断 | 记录拣货、出库、盘点和损耗 |
| 门店库存 | 门店现货或自提能力 | 视业务模式而定 | 记录调拨、销售和门店盘点 |
如果活动库存扣减成功后还要同步扣仓库库存,就必须定义两者之间的补偿关系。活动库存锁定不等于仓库已经出库,不能为了让数字看起来一致而提前生成出库流水。
库存系统通常有三个一致性层次。第一层是单行原子性:同一 SKU 的可售数量不能被并发更新破坏。第二层是库存汇总和流水一致:每次汇总变化都必须有对应流水。第三层是库存与订单最终一致:订单状态变化最终要驱动库存确认或释放。
第一层和第二层应尽量在一个本地事务中完成。第三层可以通过可靠消息和补偿任务完成,但必须有明确的最大延迟、失败重试次数和人工介入入口。
判断是否需要更强一致性时,我不会先看技术偏好,而会看业务损失。对于每件价值很低、允许少量人工补偿的商品,可以接受秒级最终一致;对于限量权益、票券、金融额度或不可逆资源,则需要更严格的预占和核销机制。
库存动作应该从状态机出发。一个最小状态机可以包含“可售,锁定,已消耗”三个核心状态,并允许“锁定,可售”的释放路径和“已消耗,可售”的退款回补路径。不同业务是否允许退款回补,要根据履约阶段单独定义。
| 动作 | 库存变化 | 允许的前置状态 | 幂等规则 |
|---|---|---|---|
| 锁定 | 可售 -n,锁定 +n | 可售数量不少于 n | 同一锁定动作号只能成功一次 |
| 支付确认 | 锁定 -n,已消耗 +n | 锁定数量不少于 n | 同一支付确认号只能成功一次 |
| 超时释放 | 锁定 -n,可售 +n | 对应锁定仍未确认 | 同一释放动作号只能成功一次 |
| 退款回补 | 已消耗 -n,可售 +n | 符合退款策略且未回补 | 同一退款动作号只能成功一次 |
| 库存调整 | 根据调整方向变化 | 必须有关联调整单 | 调整单号和版本唯一 |
状态机的价值在于拒绝非法顺序。例如同一个订单已经支付确认,就不能再执行超时释放;一个已经释放的锁定,也不能再次执行支付确认。单靠接口参数校验很难覆盖这些时序问题,必须在数据库中查询动作状态或维护动作关系表。
原子条件更新适合扣减动作简单、库存汇总行较少、只需要判断数量是否足够的场景。它的优点是 SQL 简洁、数据库能快速完成判断和更新;缺点是当业务需要同时校验复杂状态、用户限购和订单占用关系时,单条 SQL 会变得难以维护。
悲观锁适合需要读取后进行多项校验的场景,但必须控制事务范围和锁顺序。秒杀高峰下,如果所有请求都等待同一行锁,吞吐会迅速下降。悲观锁不是错误方案,错误的是把网络调用放在持锁事务里。
乐观锁通常使用版本号。更新时带上旧版本,成功后版本加一。如果失败则重试或返回库存竞争失败。它能避免长时间持锁,但热点 SKU 的版本冲突可能非常高,盲目重试反而会形成请求风暴。
我的经验是:单 SKU、单数量判断优先使用原子条件更新;需要跨记录约束时使用短事务加行锁;高热点且允许异步下单时使用队列化串行消费或分片库存。不要把某一种锁机制当成所有场景的标准答案。

库存流水记录每一次数量变化,动作表则记录某个业务动作的生命周期。两张表职责不同。动作表可以记录锁定动作的处理中、成功、失败、已释放、已确认等状态,流水表只在实际库存变化发生时写入。
CREATE TABLE inventory_action (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
action_no VARCHAR(64) NOT NULL,
activity_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
business_no VARCHAR(64) NOT NULL,
action_type VARCHAR(32) NOT NULL,
quantity INT NOT NULL,
status VARCHAR(32) NOT NULL,
expire_at DATETIME(6) DEFAULT NULL,
retry_count INT NOT NULL DEFAULT 0,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_action_no (action_no),
KEY idx_expire_status (status, expire_at),
KEY idx_business_action (business_no, action_type)
);动作表的意义是让补偿任务有明确目标。比如订单创建超时后,补偿程序不是盲目扫描所有锁定流水,而是查询“已经锁定、尚未确认、已超过过期时间”的动作,再尝试生成释放流水。
补偿任务必须支持并发执行。可以通过状态抢占、租约时间或数据库行锁让多个 worker 分工,但不能让两个 worker 同时释放同一个动作。最终仍要依赖唯一幂等键保证第二次执行不会产生第二条有效释放流水。
一个标准的同步锁定事务通常包括四步:检查幂等键、原子扣减库存、写入库存流水、写入库存动作成功状态。这个事务不应该调用订单服务、支付服务或外部营销接口。
BEGIN; -- 1. 先尝试确认该动作是否已经成功 SELECT id, status FROM inventory_action WHERE action_no = ? FOR UPDATE; -- 2. 若不存在则创建处理中动作;已成功则直接返回幂等结果 -- 3. 原子扣减可售库存 UPDATE inventory_summary SET available_qty = available_qty - ?, locked_qty = locked_qty + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP(6) WHERE activity_id = ? AND sku_id = ? AND available_qty >= ?; -- 4. 影响行数为 1 时写入流水 INSERT INTO inventory_ledger ( activity_id, sku_id, business_no, idempotency_key, action_type, delta_available, delta_locked, delta_consumed, before_available, after_available, before_locked, after_locked, source, created_at ) VALUES (?, ?, ?, ?, 'LOCK', -?, ?, 0, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP(6)); -- 5. 更新动作状态为成功 UPDATE inventory_action SET status = 'SUCCESS', updated_at = CURRENT_TIMESTAMP(6) WHERE action_no = ?; COMMIT;
示例中的查询和插入需要根据实际数据库事务隔离级别、索引和错误处理进一步完善。特别要注意:如果先查询汇总表获得 before 数值,再更新汇总表,必须确保读取与更新之间不会被其他事务改变。可以使用行锁读取,或者使用更新语句返回前后值的数据库能力。
如果应用先执行扣减,再根据“原库存减数量”计算 after,理论上可能正确,但在复杂重试和异常恢复中容易出现计算错误。更稳妥的方式是让数据库返回实际更新前后的值,或者在同一事务中以锁定读取方式拿到准确快照。
对于 MySQL 等关系型数据库,应严格检查执行计划,确保活动号和 SKU 组合命中唯一索引。没有合适索引时,条件更新可能扫描大量记录,导致锁范围扩大。数据库官方文档对事务隔离、锁定读取和索引条件有详细说明,设计时应以实际数据库版本的官方手册为准,而不是只参考博客中的简化示例。
释放不是简单执行“locked 减 n、available 加 n”。如果支付确认已经成功,超时任务随后到达,就会把已消耗库存错误地释放回可售库存。释放动作必须绑定原始锁定动作,并检查该动作当前是否仍处于可释放状态。
BEGIN; SELECT status, quantity FROM inventory_action WHERE action_no = ? FOR UPDATE; -- 只有 LOCK_SUCCESS 或待释放状态才允许继续 -- 若已经 CONFIRMED,则直接返回“无需释放” -- 若已经 RELEASED,则返回幂等成功 UPDATE inventory_summary SET available_qty = available_qty + ?, locked_qty = locked_qty - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP(6) WHERE activity_id = ? AND sku_id = ? AND locked_qty >= ?; INSERT INTO inventory_ledger ( activity_id, sku_id, business_no, idempotency_key, action_type, delta_available, delta_locked, delta_consumed, before_available, after_available, before_locked, after_locked, source, created_at ) VALUES (?, ?, ?, ?, 'RELEASE', ?, -?, 0, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP(6)); UPDATE inventory_action SET status = 'RELEASED', updated_at = CURRENT_TIMESTAMP(6) WHERE action_no = ?; COMMIT;
释放动作的业务单号建议独立生成,不要复用锁定动作号。这样可以区分“锁定曾经发生”和“释放已经生效”,也便于统计释放原因、释放延迟和补偿成功率。
很多库存事故来自支付回调处理。订单支付成功后,系统再次对可售库存执行减一,造成重复扣减。正确逻辑是锁定库存减一、已消耗库存加一,可售库存不再变化。
如果业务要求支付前才真正扣减库存,也可以不维护锁定库存,但必须明确“预占”和“销售确认”之间的差异。对于秒杀商品,我通常建议先锁定再确认,因为它能把抢购成功与支付时点解耦,减少支付链路波动对库存热点行的影响。
限购规则不能只依赖库存表。如果每个用户限购 1 件,应建立用户活动购买记录或唯一约束。否则同一用户可以通过不同设备、重复请求或接口重试占用多份库存。
CREATE TABLE activity_buyer (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_activity_user_sku (activity_id, user_id, sku_id)
);用户限购记录与库存流水应在同一个本地事务中创建,或者采用可补偿的顺序。若先扣库存后写限购记录,写入失败时必须释放库存;若先写限购记录后扣库存,扣减失败时必须撤销资格。无论选择哪种顺序,都要设计失败补偿,而不是假设两个写操作永远同时成功。
库存流水表会持续增长,不能把所有历史记录都放在一张无限膨胀的热表中。常见做法是按活动日期、创建月份或业务分区归档。但分区键必须和主要查询条件匹配,否则归档只是增加管理复杂度。
如果主要查询是“某活动某 SKU 的完整流水”,可以按活动时间分区,并建立活动号、SKU、创建时间的联合索引。如果主要查询是“某订单的全部库存动作”,则必须保留业务单号索引,不能因为按时间分区而牺牲订单追溯能力。
归档不等于删除。建议至少保留在线查询需要的近期流水,并把历史流水转移到只读存储或分析库。归档前要生成活动级汇总快照,记录期初、期末和差异值,避免以后只能从大量明细重新聚合。

下面使用一组明确标注为情景模拟的数据说明验证方法。假设某活动有 5000 件库存,开售 1 秒内收到 20 万次请求,其中约 2% 因客户端超时或网关重试产生重复请求,订单服务有 0.5% 的创建失败,支付超时比例为 8%。
如果系统只做数据库原子扣减,不做动作幂等,理论上最多可能出现重复锁定。即便最终库存没有负数,也可能出现“订单数少于锁定数”的隐性占用。加入动作表、唯一幂等键和释放补偿后,判断重点就从“库存有没有负数”变成“每个锁定动作是否最终进入确认或释放”。
| 观察项 | 情景结果 | 工程判断 |
|---|---|---|
| 收到抢购请求 | 200000次 | 不能直接作为库存层处理量 |
| 进入库存扣减请求 | 38000次 | 前置资格校验和限流有效降低压力 |
| 库存锁定成功 | 5000次 | 等于库存上限,必须逐条可追踪 |
| 订单创建失败 | 约25次 | 应在约定时间内产生释放流水 |
| 支付超时 | 约398次 | 应从锁定转为可售,不能重复释放 |
| 最终确认或释放 | 5000次动作闭环 | 锁定动作必须全部有终态 |
这里最重要的不是某个百分比,而是闭环数量。5000 条锁定成功流水,最终应该对应 5000 条确认或释放结果。如果只确认了 4600 条、释放了 350 条,还剩 50 条处于处理中,就必须继续追踪这 50 条,而不是用一个人工 SQL 把库存加回去。

接口成功率高,不代表库存正确。一个扣减接口可能返回成功 5000 次,但其中 20 次是重复请求;也可能返回失败,但数据库事务已经提交,客户端只是没有收到响应。库存系统必须以流水和动作表为准,不能以客户端收到的响应为唯一事实。
我通常会建立三组对账指标。第一组是数量对账:汇总表当前数量与流水聚合结果是否相等。第二组是动作对账:每个锁定动作是否进入确认、释放或明确失败终态。第三组是订单对账:订单状态与库存动作状态是否符合允许的组合。
| 对账维度 | 计算方式 | 异常示例 | 处理动作 |
|---|---|---|---|
| 数量对账 | 汇总库存与流水聚合相减 | 差异为 3 件 | 冻结自动修正,查找缺失流水 |
| 动作对账 | 锁定动作与确认、释放动作配对 | 50 个动作无终态 | 重试补偿或进入人工审核 |
| 订单对账 | 订单状态与库存动作状态交叉检查 | 已支付但库存仍锁定 | 补发确认事件并记录修复流水 |
| 幂等对账 | 业务幂等键重复计数 | 出现同键多条有效流水 | 阻断后续动作并进行数据修复 |
在生产系统中,我会每天或每小时按活动和 SKU 聚合流水,再与汇总表进行比较。查询必须区分可售、锁定和已消耗三个维度,不能只把所有 delta_available 相加后就结束。
SELECT activity_id, sku_id, SUM(delta_available) AS ledger_available_delta, SUM(delta_locked) AS ledger_locked_delta, SUM(delta_consumed) AS ledger_consumed_delta, COUNT(*) AS ledger_count, MIN(created_at) AS first_ledger_time, MAX(created_at) AS last_ledger_time FROM inventory_ledger WHERE activity_id = ? GROUP BY activity_id, sku_id;
实际对账时还要加上期初库存和历史归档快照。对于跨天活动,不能只查询当天流水,否则前一天的期末状态会被误认为当天期初状态。对账程序应输出差异原因分类,例如数量差异、状态差异、重复动作、缺失动作和时间延迟。
常见压测只关注平均响应时间、吞吐量和错误率,但库存系统还必须做故障注入。至少要模拟数据库连接中断、事务提交后响应丢失、消息重复投递、消息延迟、库存服务重启、订单服务不可用和释放任务重复执行。
每次故障演练结束后,不要只看服务是否恢复,还要看以下结果:是否出现负库存、是否出现重复幂等键、是否存在没有终态的库存动作、流水聚合是否等于汇总表、已支付订单是否都完成库存确认。

如果活动峰值只有每秒几十次请求,库存量大、商品价值低、允许人工补偿,可以使用关系型数据库汇总表加流水表的简单方案。重点是原子条件更新、唯一幂等键和定时对账,不必一开始就引入复杂的分布式库存服务。
这类业务最不应该做的是过度设计。把库存拆成多个服务、引入复杂消息拓扑,可能增加故障面,却没有带来同等收益。
这类业务建议采用“前置限流或缓存闸门 + 数据库库存汇总 + 可靠消息 + 库存流水”的组合。前置层减少无效请求,数据库事务保证正式库存事实,消息把订单和库存之间的状态变化解耦。
如果某个 SKU 极度热点,单行汇总记录会成为数据库热点。此时可以把库存拆成多个库存桶,每个桶维护独立数量,抢购请求随机或按策略选择库存桶,最终通过活动维度聚合。库存桶会增加对账和分配复杂度,只有在单行竞争确实成为瓶颈时才值得采用。
票券、稀缺名额、权益额度和某些金融类资源不能把“最终一致”理解成“最后再看”。这类业务应在扣减前完成严格资格校验,并使用明确的资源编号或额度锁定关系。
这类场景宁可牺牲部分吞吐,也不能用无限重试掩盖冲突。高并发下,失败应该尽快返回,而不是让请求长期占用连接等待库存行锁。
如果业务允许用户等待几秒,队列化往往比让所有请求并发竞争数据库更稳定。请求先完成资格校验和排队,库存消费者按照 SKU 或库存桶串行处理,再把结果异步通知用户。
队列化的代价是用户体验变成异步,且需要处理消息堆积、消费顺序和重复消费。它适合“抢到后等待订单结果”的业务,不适合必须在几十毫秒内返回最终确认的场景。

把每个动作都放进同步事务,状态更直观,但吞吐和可用性会受到下游服务影响。把所有动作异步化,吞吐更好,但用户需要等待结果,对账和补偿体系也更复杂。
我的建议是把强一致边界放在最小可控范围内:库存汇总变化和库存流水必须同步落库;订单、支付、履约之间通过事件驱动;对外响应可以返回“已受理”或“排队中”,但必须让用户知道最终结果如何查询。
| 方案 | 优点 | 缺点 | 适用条件 |
|---|---|---|---|
| 单行库存 | 模型简单、对账直观 | 热点 SKU 行竞争明显 | 峰值并发可控、规则复杂 |
| 库存分桶 | 分散热点、提升并发处理能力 | 分配、回收、对账复杂 | 单 SKU 极热点且扣减规则简单 |
| 队列串行消费 | 顺序清晰、易于控制热点 | 增加排队延迟和消息治理成本 | 业务允许异步返回结果 |
库存分桶不是“把一行拆成多行”这么简单。还要考虑桶耗尽、释放回哪个桶、活动结束时如何聚合、桶之间是否存在用户限购冲突。若无法解释这些问题,分桶可能只是把一个可见的瓶颈变成多个隐蔽的数据问题。
每次请求都写一条明细流水,审计能力最好,但数据库写入量和存储成本较高。按时间窗口聚合写入可以降低写放大,却会牺牲单请求追踪能力,也不利于处理重复消息。
秒杀库存不建议一开始就做过度聚合。至少在活动进行期间保留动作级明细,因为最需要排查问题的正是活动峰值期间。活动结束并完成对账后,再把历史数据归档为汇总快照和只读明细。
数量差异一旦出现,自动把库存改回“理论值”看起来很快,但可能掩盖真正的重复扣减或漏记流水。对于小额、低风险商品,可以设置小范围自动修复;对于高价值或不可逆资源,应先冻结并人工审核。
所有自动修复也必须产生调整流水,包含修复原因、原差异、依据、执行任务和审批信息。所谓“修复完成”不能意味着把数字改对,而是要让修复过程本身可追溯。

如果这些问题没有答案,不建议直接进入高并发压测。压测只能把问题更快暴露出来,却不能替代数据模型设计。尤其是唯一约束,一定要在数据库层建立,不要只依靠应用代码判断。
我建议把“提交成功但响应丢失”作为必测场景。很多系统只测试事务失败,却没有测试事务成功后客户端没有收到响应。这是最容易触发重复提交的真实故障之一。
| 监控指标 | 建议含义 | 异常时优先检查 |
|---|---|---|
| 库存锁定成功率 | 库存层对有效请求的处理结果 | 库存不足、锁等待、数据库错误 |
| 库存动作闭环率 | 锁定后进入确认或释放终态的比例 | 消息积压、补偿任务、状态机阻塞 |
| 未释放锁定数 | 超过过期时间仍未终态的锁定数量 | 释放任务故障或状态错配 |
| 流水汇总差异数 | 汇总表与流水聚合不一致的 SKU 数 | 事务边界、人工写库、归档遗漏 |
| 重复幂等请求数 | 同一动作重复到达的次数 | 客户端重试、消息重复、网关超时 |
| 库存行锁等待时间 | 热点竞争对数据库的影响 | 事务过长、热点 SKU、索引失效 |
监控不应只放在库存服务内部。还要把订单服务、支付回调、消息队列和补偿任务放在同一张业务看板上。库存动作闭环依赖上下游,单独看库存服务的 CPU 和响应时间,无法发现大量订单已经支付但库存尚未确认的问题。

发现库存汇总表与流水聚合不一致时,第一步应冻结相关 SKU 的自动调整能力,并保存当时的汇总快照、流水范围、订单状态和消息消费位点。直接改数字会让后续调查失去现场。
第二步是判断差异类型。若汇总表少扣而流水记录完整,可能是流水写入后汇总更新失败;若汇总表多扣但流水缺失,可能是事务边界被拆开或存在人工写库;若数量相等但订单状态不一致,则属于状态差异,而不是数量差异。
如果确认某条流水错误,应该新增一条冲正流水,引用原流水编号和修复原因。冲正流水的数量方向与原流水相反,但它不是简单“撤销历史”,而是新的业务事实。
INSERT INTO inventory_ledger (
activity_id, sku_id, business_no, idempotency_key,
action_type, delta_available, delta_locked, delta_consumed,
before_available, after_available,
before_locked, after_locked, source, operator_id, created_at
) VALUES (
?, ?, ?, ?, 'REVERSAL',
?, ?, ?,
?, ?, ?, ?, 'REPAIR', ?, CURRENT_TIMESTAMP(6)
);修复动作还应关联审批单、故障编号或补偿任务编号。只有这样,半年后重新查看某个 SKU 时,才能解释为什么出现一条与正常订单无关的库存变化。
低风险日常库存可以按天对账;秒杀活动建议在活动前、活动中和活动后分别执行。活动中不一定要扫描全部明细,但应实时监控负库存、重复幂等键和锁定超时。活动结束后应尽快完成全量动作闭环核对。
如果库存量很大,可以采用分层对账:实时监控关键硬指标,小时级聚合活动汇总,日级完成明细与汇总表的全量核对。不要为了追求实时全量扫描而拖慢交易库,报表和审计查询最好使用只读副本或分析库。
如果团队现在还没有库存流水,我建议先完成一个可控的最小闭环,而不是立刻建设庞大的库存中台。最小版本至少包括库存汇总表、不可变流水表、库存动作表、数据库原子扣减、幂等唯一键、订单超时释放和基础对账任务。
做到这些,系统即使暂时没有缓存闸门、库存分桶或复杂消息编排,也已经具备基本的可解释性和可恢复性。反过来,如果这些基础能力缺失,再多的中间件也只是把问题分散到更多地方。
我在评审库存系统时,通常不会先问“每秒能处理多少请求”,而会先问:如果扣减接口返回超时,但数据库事务已经提交,重试会发生什么?如果支付确认和超时释放同时到达,哪一个动作可以生效?如果活动结束后少了 3 件库存,工程师能否在 10 分钟内指出具体是哪三条业务动作造成的?
这些问题比单一吞吐数字更能判断系统成熟度。秒杀高峰总会过去,真正留下来的,是订单状态、库存状态、消息状态和人工补偿之间是否还能互相解释。
我的独特判断是:高并发库存系统的核心能力不是“把库存扣得快”,而是“在任何一次异常之后,仍然说清楚库存为什么是这个数字”。汇总库存让系统跑得快,库存流水让系统经得起重试、审计和故障;只有两者在同一个可靠事务中落地,再用状态机、消息和对账补齐上下游,秒杀库存才真正具备生产可用性。
我以前设计库存模块时,最初只记录商品、变更数量和创建时间,结果一旦出现少卖或重复扣减,根本无法还原是哪笔订单导致的。现在我想重新设计库存流水表,但不确定哪些字段是真正排查问题必需的,哪些只是看起来完整却没有实际价值。
库存快照表回答的是“现在还剩多少”,库存流水表回答的是“这个数字为什么变成现在这样”。秒杀场景中,后者比前者更重要,因为接口超时、消息重试和支付取消都可能让库存发生多次状态变化。
我建议至少保留以下字段: 字段作用排查价值 activity_id、sku_id定位活动和商品避免同一商品跨活动串账 operation_type区分预扣、确认、释放、补偿、人工调整还原库存状态迁移 quantity本次变更数量计算累计变化 before_quantity、after_quantity记录变更前后结果快速发现并发覆盖和异常跳变 order_id、order_item_id关联交易明细从订单反查库存,或从流水反查订单 idempotency_key标识一次业务操作阻止重复扣减 status、trace_id记录处理状态和链路标识定位异步失败、重试和补偿 一个常见坑是只保存变更数量,不保存前后库存。
这样虽然节省少量存储,但对账时只能重新聚合全部流水,无法判断某次更新是否基于正确的旧值。对于热点 SKU,我更倾向于保留前后值,并为“商品加时间”和“订单号”建立查询索引。流水表还应区分系统操作与人工调整。人工补库存不能直接改库存快照,否则后续对账只会看到一个无法解释的数字;
正确做法是生成带操作人、原因和审批单号的调整流水。
我见过一种实现:先查询库存,判断大于 0 后再执行减库存。单线程测试完全正常,但并发压测时偶尔会出现库存变成负数,或者多个请求都返回成功。我想知道,数据库层面怎样写扣减 SQL,才能真正把库存不足判断和库存更新绑定起来?
库存判断和库存更新必须是同一个数据库原子操作,不能拆成“先查询、后扣减”两个步骤。拆开后,多个请求可能同时读到剩余 1 件库存,随后都认为自己有资格扣减。
基础实现可以使用带条件的 UPDATE:
UPDATE sku_stock SET available_stock = available_stock - :quantity locked_stock = locked_stock + :quantity version = version + 1 updated_at = CURRENT_TIMESTAMP WHERE activity_id = :activity_id AND sku_id = :sku_id AND available_stock >= :quantity;应用层只根据受影响行数判断结果:影响 1 行代表扣减成功,影响 0 行代表库存不足、商品不存在或活动条件不满足。不要根据接口是否抛异常判断库存成功,因为“SQL 执行成功但没有匹配记录”并不一定会抛异常。在一次可复现的并发测试中,将库存设置为 100,使用 500 个并发请求,每次购买 1 件。
采用先查询再更新的写法时,成功请求数可能超过 100;改成条件更新后,成功数应不超过 100,最终库存也不会低于 0。这个测试还必须检查唯一幂等键,否则同一订单的重复请求仍可能分别通过条件更新。是否使用 version 字段,要看冲突处理方式。条件更新已经能满足很多库存扣减场景;
如果还要保护复杂的库存计算,可以增加 version 并要求旧版本匹配。但 version 冲突后需要重试,热点商品下频繁重试反而会放大数据库压力。我通常不把 SELECT FOR UPDATE 作为秒杀入口的默认方案。它的逻辑直观,但所有请求都争抢同一库存行,容易形成锁等待;
只有在并发规模可控、事务链路较短且强一致优先时,行锁才更合适。
我最担心的是数据库已经把库存减掉了,但服务在写流水或返回结果时突然超时。此时用户可能重试,消息也可能重复投递,最终出现库存少了、订单状态不一致、流水还查不到的情况。库存表和流水表到底应该怎样组织事务与补偿?
库存扣减和库存流水写入之间不能只靠应用代码的执行顺序保证一致性。真正需要先确定的是:流水是否必须在接口返回前可查。如果库存表和流水表在同一个数据库,且流水属于强审计要求,优先采用同库事务。推荐的同步流程是: 开启事务,先依据幂等键检查是否已处理;执行带库存条件的更新;读取或计算变更前后数量;
写入成功流水;更新订单或预扣状态;最后提交事务。任意一步失败都回滚,这样不会出现库存已扣但流水完全缺失。如果库存扣减后还要通知订单、支付或仓储服务,不建议在数据库事务里直接调用消息系统。
更稳妥的做法是使用 Outbox 或本地消息表,把待发送事件和库存变更放在同一个数据库事务中提交,再由独立投递程序发送消息。
故障场景不可靠写法可恢复写法 库存提交成功,接口超时客户端重试并再次扣库存用业务幂等键返回原处理结果 数据库提交成功,消息未发出依赖接口线程继续发送Outbox 定时扫描并重试 消息重复消费每次消费都执行扣减消费者用唯一索引或状态机拦截重复操作 流水写入失败只记录错误日志事务回滚,或进入明确的补偿状态 需要特别注意:消息队列本身不等于最终一致性。
它只负责传递消息,仍然需要可靠投递、消费幂等、失败重试、死信处理和定期对账。没有这些配套机制,异步化只是把问题从接口线程转移到了消息链路。
很多秒杀方案都会把库存放进 Redis,再通过消息队列异步写数据库。我在实际选型时发现,这种架构看起来吞吐量很高,但一旦 Redis 预扣成功后订单创建失败,或者消息长期积压,库存就可能一直少一部分。我该如何判断缓存预扣是否值得引入?
Redis 预扣的主要价值是削峰和保护数据库,而不是自动保证库存一致性。它适合请求瞬间高度集中、单个热点 SKU 成为数据库写热点,并且业务能够接受短暂异步处理的场景。
采用缓存预扣时,至少要把链路拆成四个可验证阶段:Redis 原子判断并扣减、生成带幂等键的库存事件、数据库落库并写流水、失败后的释放或补偿。任何一个阶段缺少状态记录,后面都无法判断“缓存扣了但数据库没扣”究竟是处理中还是已经丢失。
一个更完整的事件字段可以包括: { event_id": "唯一事件号 activity_id": "活动号 sku_id": "商品号 order_id": "订单号 quantity": 1 operation_type": "RESERVE trace_id": "链路号 }Redis 脚本中要同时完成库存判断、扣减和幂等标记,不能先 GET 再 DECR。
数据库消费者收到事件后,仍然要依靠唯一约束和状态机保证重复消息不会重复扣减。对账时则要分别核对缓存预扣量、数据库库存变化、订单状态和流水状态。
我会用下面的标准做选型: 业务条件优先方案原因 并发中等、库存强一致数据库条件更新加同库流水事务链路短,故障面较小 热点 SKU 瞬时流量极高Redis 原子预扣加可靠消息先吸收突发流量,保护数据库 库存数量很小且不能丢失数据库同步扣减缓存异常或消息积压的代价过高 允许订单排队处理队列削峰加数据库落库用处理时延换取写入稳定性 如果没有做过热点 SKU 压测、消息积压演练和缓存回源验证,我不建议仅因为“秒杀”这个关键词就引入 Redis。
复杂架构的判断标准不是组件数量,而是出现超时、重复、宕机和补偿失败后,团队能否准确回答库存差异来自哪一步。


读者评论
把可售、锁定、已消耗三种库存拆开很有必要,尤其是支付超时和订单取消场景。以前只维护一个 stock 字段,出现重复释放时很难判断到底是哪次操作造成的。用不可变流水加冲正记录,后续对账和追责会清晰很多。
文中提到库存时钟和订单时钟分离,这一点很贴近实际。库存锁定成功但订单接口超时,并不等于扣减失败,直接重试很容易造成重复占用。建议再补充消息表、消费状态和异常补偿的落地示例,工程实现会更完整。
before 和 after 同时记录是个容易被忽视的细节,单看 delta 确实无法还原并发场景下的真实状态。不过高峰期每笔流水都写完整快照可能增加数据库压力,实际还需要结合分库分表、异步落账和定期对账来平衡性能与审计性。