库存只剩 1 件时,两个请求同时完成“查询库存”和“创建订单”,并不意味着数据库会自动替你做出正确判断。真正容易出问题的地方,往往不是 stock - 1 这条减法,而是库存判断、订单幂等、支付超时、消息重试和库存回补之间没有形成一个完整闭环。本文从后端工程实践出发,拆解数据库库存扣减怎样逐步实现,降低超卖、重复扣减和库存账实不符的风险。
数据库存:后端工程师最佳实践:库存扣减怎样稳步实现降低超卖风险
对于普通电商下单、团购、预约和门店库存等场景,我不会一开始就推荐分布式锁、缓存预扣或复杂的消息编排。只要库存最终落在同一个关系型数据库中,最先应该解决的是:把“库存是否充足”和“库存减一”合并为数据库能够原子执行的条件更新。
UPDATE sku_stock SET available_stock = available_stock - 1, sold_stock = sold_stock + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ? AND available_stock >= 1;
执行后必须检查受影响行数。受影响行数为 1,才代表本次扣减真正成功;受影响行数为 0,可能表示库存不足、SKU 不存在,或者更新条件没有满足。仅仅判断 SQL 没有报错,不能说明库存已经扣减成功。
这条 SQL 能解决的是“库存不能在并发下被扣成负数”。它不能自动解决同一个订单重复提交、支付失败后库存释放、消费者重复消费以及数据库写入成功但响应超时等问题。因此,条件更新只是库存系统的底座,不是完整方案。
库存系统经常把并发和重复混在一起处理,最后误以为“加锁之后就安全了”。实际上,并发控制解决的是多个请求在同一时间修改同一份库存;幂等控制解决的是同一个业务动作因为重试、重复点击或消息重复投递而执行多次。
| 问题类型 | 典型表现 | 主要解决手段 |
|---|---|---|
| 并发竞争 | 两个订单同时购买最后一件商品 | 条件更新、行锁、乐观锁、串行化 |
| 请求重复 | 用户连续点击两次下单按钮 | 请求幂等键、唯一索引、幂等记录表 |
| 消息重复 | 消费者重启后再次处理扣库存消息 | 消费记录、业务流水唯一约束、状态机 |
| 异常未完成 | 扣库存成功但订单创建失败 | 事务、补偿、对账、超时扫描 |
我的判断标准是:先确认本次操作是否唯一,再确认它能否在并发下正确执行,最后才讨论吞吐量是否需要缓存和异步化。顺序反过来,系统往往会先获得高吞吐,随后获得一堆难以对账的库存差异。

低并发普通下单场景,通常可以从“条件更新加事务加幂等”开始;当一个热点 SKU 的请求量明显超过数据库单行更新的承载能力,再考虑 Redis 入口预扣、消息队列削峰和异步落库。更大规模的系统,还要面对分片库存、分仓分配、库存中心和跨区域一致性。
我不建议把“Redis 预扣库存”当成数据库方案的自然升级。它实际上改变了库存的权威来源和异常处理方式,系统需要增加预扣失败补偿、订单超时释放、消息重试去重和定期对账。性能提升不是免费获得的,通常是用系统复杂度和运维成本换来的。
假设商品表中有一条库存记录,SKU 为 1001,可售库存为 1。接口收到请求后,先执行查询,再由应用程序判断库存是否大于 0,最后执行普通更新。这是许多初版系统最容易采用的流程。
SELECT available_stock FROM sku_stock WHERE sku_id = 1001;
UPDATE sku_stock SET available_stock = available_stock - 1 WHERE sku_id = 1001;
请求 A 和请求 B 可能在几乎相同的时间读取到库存 1。A 判断库存充足,B 也判断库存充足。即使数据库对两次更新加了行锁,两次更新仍然可能依次执行,最终库存变成负数,或者在缺少条件保护时,两份订单都被认为是成功订单。
| 时间点 | 请求 A | 请求 B | 数据库库存 |
|---|---|---|---|
| T1 | 读取库存为 1 | 尚未读取 | 1 |
| T2 | 判断库存充足 | 读取库存为 1 | 1 |
| T3 | 执行普通扣减 | 判断库存充足 | 0 |
| T4 | 订单处理成功 | 执行普通扣减 | -1 或产生错误订单 |
这里的关键不是数据库有没有锁,而是库存充足这个判断没有和扣减动作绑定在同一条原子更新中。应用层看到的库存只是某一个时间点的快照,不能把这个快照当成后续扣减的永久许可。
页面展示库存、购物车提示、商品详情页的“仅剩少量”提醒,都可以读取缓存或数据库中的库存值。这类查询的目标是改善用户体验,允许存在短暂延迟。
真正影响交易结果的库存判断必须发生在写操作中。即使前端已经显示“有货”,提交订单时仍然要重新执行带条件的扣减。展示库存可以是近似值,交易库存必须是受数据库约束保护的结果。
“下单就扣库存”和“支付成功才扣库存”没有绝对的优劣,关键在于业务承诺。秒杀商品通常需要下单时锁定库存,否则用户支付成功后可能发现商品已售罄;普通现货商品则可能采用下单锁定、支付超时释放的模式。
| 库存时点 | 优势 | 代价 | 适用场景 |
|---|---|---|---|
| 下单时扣减 | 及时占住库存,交易承诺清晰 | 需要处理取消、超时和回补 | 秒杀、限量商品、强交易约束 |
| 支付后扣减 | 减少无效锁定,库存周转更灵活 | 可能出现支付成功但库存不足 | 库存充足、允许支付前确认的业务 |
| 下单时锁定 | 区分可售和锁定,状态更清晰 | 需要维护锁定库存和释放任务 | 普通电商、团购、预售 |

事务可以保证一组数据库操作要么全部提交,要么全部回滚,但它不会自动把两个并发事务变成一个事务。两个事务都可以独立读取到库存充足,然后按照各自的执行路径修改数据。
如果事务中使用的是“先查询,再普通更新”,事务只是把两步操作放进了同一个事务边界,却没有消除应用判断与数据库写操作之间的竞争窗口。要解决这个问题,仍然需要条件更新、行锁或者版本校验。
BEGIN; SELECT available_stock FROM sku_stock WHERE sku_id = ? FOR UPDATE; -- 在事务内判断库存 UPDATE sku_stock SET available_stock = available_stock - 1 WHERE sku_id = ?; INSERT INTO order_stock_flow(order_id, sku_id, action, quantity) VALUES (?, ?, 'DEDUCT', 1); COMMIT;
上面的悲观锁方案可以成立,但前提是查询、判断、更新和流水写入都在同一事务中,且事务期间不调用支付、物流或其他外部服务。否则锁持有时间会被外部网络延迟放大。
分布式锁解决的是多个服务实例之间的临界区互斥,不等于库存、订单和消息已经形成一致状态。锁可能在业务执行中途过期,客户端可能拿到锁后发生网络隔离,释放锁的操作也可能因为异常而失败。
更重要的是,如果库存最终写入数据库,数据库本身仍然需要条件约束。把所有正确性都寄托在外部锁上,会让数据库失去最后一道防线。对于单个 SKU 的简单减库存操作,带条件更新通常比“先获取分布式锁,再查询库存,再更新数据库”更直接。
Redis 的原子递减可以快速处理热点请求,但它把系统从单一数据源变成了两个库存状态源。Redis 扣减成功后,如果订单创建失败,库存就需要回补;如果服务在 Redis 扣减后宕机,系统还要知道这次预扣是否已经进入订单流程。
我在评估这类方案时,会要求团队先回答三个问题:哪一个组件是库存最终权威来源?预扣成功但订单失败如何释放?Redis 和数据库出现差异时谁来发现、谁来修复?如果三个问题没有明确答案,提前引入缓存只会把一个可复现的数据库问题变成难以定位的分布式问题。
库存不为负,只能证明某个库存字段没有低于零。它不能证明同一个订单没有扣两次,也不能证明取消订单后库存一定回补,更不能证明仓库实际可发货数量与系统库存一致。
例如,同一个订单因为接口超时被客户端重试两次,两个请求分别扣减了库存 1 件和 1 件,但数据库中的库存仍然是非负的。此时系统没有发生“负库存超卖”,却发生了库存少了 1 件、订单多扣 1 件的业务错误。
乐观锁失败后重试,在低冲突场景下很有效;但热点 SKU 上,大量请求可能不断读取旧版本、更新失败、再次读取和重试,最终形成数据库压力放大。重试次数越多,不代表成功率一定越高。
比较稳妥的做法是设置有限重试次数,并在连续冲突后快速失败或转入排队流程。对用户而言,“库存竞争激烈,请稍后重试”通常比接口长时间等待更可控;对系统而言,快速失败可以保护数据库连接池。

只用一个 stock 字段,往往无法表达订单生命周期。更清晰的模型至少应该区分可售库存、锁定库存和已售库存。
available_stock 可售库存
reserved_stock 锁定库存
sold_stock 已售库存
常见状态变化可以表示为:下单时 available_stock - 1、reserved_stock + 1;支付成功时 reserved_stock - 1、sold_stock + 1;订单取消或支付超时时 reserved_stock - 1、available_stock + 1。
如果业务采用下单即确认销售,也可以不维护锁定库存,但仍建议保留库存流水。库存字段描述“现在有多少”,流水描述“为什么变成这样”,两者职责不同。
库存扣减必须能够回答“这次操作是否已经执行过”。常见唯一键包括订单号、订单号加 SKU、请求流水号和业务事件编号。
我通常会把业务唯一键写进库存流水表,并用唯一索引做数据库级保护。应用层幂等判断用于减少重复处理,唯一索引用于防止并发下两个请求同时通过应用判断。
CREATE TABLE inventory_flow (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
flow_no VARCHAR(64) NOT NULL,
order_id VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
action VARCHAR(16) NOT NULL,
quantity INT NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_flow_no (flow_no),
UNIQUE KEY uk_order_sku_action (order_id, sku_id, action)
);唯一约束要结合业务动作设计。扣减和释放不是同一个动作,因此不能简单地把订单号和 SKU 作为所有流水的唯一键,否则一次扣减后就无法记录合法的释放动作。
如果订单表、库存表和库存流水表在同一个数据库中,通常可以把它们放在同一个本地事务里。事务中只做数据库操作,不调用外部接口,提交后再通过可靠事件通知其他服务。
如果订单和库存位于不同服务,就不能再假设一个本地事务可以覆盖两边。此时要明确采用哪种模式:库存先锁定、订单异步创建;订单先创建、库存异步确认;或者通过可靠消息、状态机和补偿机制最终收敛。
| 一致性边界 | 建议实现 | 主要风险 |
|---|---|---|
| 订单与库存同库 | 本地事务加条件更新加流水 | 热点行锁竞争、事务过长 |
| 订单与库存分服务 | 状态机加可靠消息加补偿 | 消息重复、处理中状态滞留 |
| 缓存预扣与数据库落库 | 预扣流水加异步落库加对账 | 缓存与数据库库存差异 |
| 多仓库库存 | 分仓锁定加调拨或分配规则 | 仓间库存分配和取消回补复杂 |
库存系统的瓶颈经常由“单个热门 SKU 的并发集中度”决定,而不是由全天总请求量决定。一天有几百万请求,如果均匀分散到几十万 SKU,数据库可能运行正常;一个爆款 SKU 在几秒内接收数十万请求,即使总订单量并不高,也可能把单行记录变成热点。
因此,我会重点观察四个数据:单 SKU 每秒请求数、条件更新冲突率、数据库行锁等待时间和库存接口 P99 延迟。只有这些指标共同显示单库方案接近边界,才有必要引入缓存预扣或排队。

在单库场景中,最小实现可以只有库存表、订单表和库存流水表。接口接收到请求后,先校验 SKU、购买数量和用户权限,再执行带条件的库存更新。更新成功后创建订单和扣减流水;任何一步失败,都要回滚库存更新。
BEGIN;
UPDATE sku_stock
SET available_stock = available_stock – ?,
reserved_stock = reserved_stock + ?,
updated_at = CURRENT_TIMESTAMP
WHERE sku_id = ?
AND available_stock >= ?;
— 检查 affected_rows
— affected_rows = 0 时回滚并返回库存不足
INSERT INTO inventory_flow
(flow_no, order_id, sku_id, action, quantity, created_at)
VALUES
(?, ?, ?, 'RESERVE', ?, CURRENT_TIMESTAMP);
INSERT INTO orders
(order_id, user_id, status, created_at)
VALUES
(?, ?, 'PENDING_PAYMENT', CURRENT_TIMESTAMP);
COMMIT;这里有一个经常被忽略的细节:扣减数量是变量时,条件必须写成 available_stock >= quantity,不能只写 available_stock > 0。否则一次购买 5 件的请求,可能在库存只有 2 件时仍然通过条件判断。
假设用户一次购买 3 件,库存为 4,条件更新执行后库存应为 1。如果请求重试,不能再次扣减 3 件。因此,批量扣减和幂等记录必须在同一个业务边界内处理。
UPDATE sku_stock SET available_stock = available_stock - :quantity, reserved_stock = reserved_stock + :quantity WHERE sku_id = :sku_id AND available_stock >= :quantity;
如果一笔订单包含多个 SKU,事务还要考虑更新顺序。建议按照 SKU 编号或库存记录主键排序后依次更新,尽量让不同事务采用相同的锁顺序,从而减少死锁概率。
如果扣减前需要读取多个字段进行复杂判断,例如检查批次有效期、门店可售范围、预留上限和用户购买限制,那么单条条件更新可能无法表达全部规则。这时可以在事务中使用 SELECT ... FOR UPDATE 锁定库存行。
BEGIN; SELECT available_stock, reserved_stock, max_buy_per_user FROM sku_stock WHERE sku_id = ? FOR UPDATE; -- 在事务内完成库存、用户限购和批次规则判断 UPDATE sku_stock SET available_stock = available_stock - ?, reserved_stock = reserved_stock + ? WHERE sku_id = ?; COMMIT;
使用行锁时,要把事务设计得足够短。不要在锁住库存后调用支付接口、发送同步 HTTP 请求或等待人工风控结果。正确做法是先在事务内写入处理中状态,提交后再异步执行外部动作。
乐观锁适合冲突相对可控,并且业务允许快速失败或有限重试的场景。版本号可以帮助系统判断库存记录是否被其他请求修改,但版本号本身不能识别“同一个订单是否已经扣过库存”。
UPDATE sku_stock SET available_stock = available_stock - :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND version = :version AND available_stock >= :quantity;
如果更新失败,服务可以重新读取版本并重试一次或两次。但对于热点 SKU,我更倾向于设置快速失败、请求排队或入口限流,而不是让大量请求持续重试数据库。

如果每次重试都生成新的随机请求编号,服务端无法判断两次请求是否代表同一个业务动作。幂等键应在业务上稳定,例如订单号、客户端生成的业务流水号,或者“用户号加购物车提交号”。
接口可以要求客户端携带请求号,也可以由服务端在首次创建订单时生成订单号。关键是后续重试必须沿用同一个标识,并且服务端需要保存处理结果,而不是只保存“处理过”这个布尔值。
| 请求状态 | 服务端行为 | 客户端建议 |
|---|---|---|
| 首次处理中 | 创建幂等记录,执行业务流程 | 避免高频重复提交 |
| 已成功 | 返回原订单号和原处理结果 | 不要重复创建订单 |
| 处理中 | 返回处理中状态或查询地址 | 轮询结果,不立即生成新请求号 |
| 已失败且可重试 | 按照业务规则允许重试 | 复用或关联原业务流水 |
| 库存不足 | 明确返回库存失败 | 不要无间隔自动重试 |
普通应用日志适合排查请求路径,但不适合作为库存账务依据。库存流水至少要记录业务流水号、订单号、SKU、动作类型、数量、操作前库存、操作后库存、来源事件和创建时间。
CREATE TABLE inventory_flow (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
flow_no VARCHAR(64) NOT NULL,
order_id VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
action VARCHAR(16) NOT NULL,
quantity INT NOT NULL,
before_available INT NOT NULL,
after_available INT NOT NULL,
source_event VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_flow_no (flow_no),
KEY idx_order_id (order_id),
KEY idx_sku_created (sku_id, created_at)
);操作前后库存字段有助于定位差异发生在哪一步,但不能替代数据库真实状态。对账时,应该结合库存初始值、扣减流水、释放流水、退款回补和人工调整记录进行核算。
库存系统最怕“任何状态都能随意改”。例如一个已经支付的订单又被超时任务释放库存,或者一个已经释放库存的订单因为重复消息再次释放。状态机要限制合法迁移,库存动作也要与状态迁移绑定。
| 订单当前状态 | 事件 | 目标状态 | 库存动作 |
|---|---|---|---|
| 待支付 | 支付成功 | 已支付 | 锁定转已售,或保持已扣减 |
| 待支付 | 支付超时 | 已取消 | 释放锁定库存 |
| 待支付 | 用户取消 | 已取消 | 释放锁定库存 |
| 已支付 | 发货 | 已发货 | 不重复扣减 |
| 已取消 | 重复取消消息 | 仍为已取消 | 不重复回补 |
许多团队在扣库存时设计了唯一流水,却在释放库存时直接执行 stock = stock + quantity。如果取消消息重复消费,库存就会被多次加回,最终出现虚高库存。
BEGIN;
INSERT INTO inventory_flow
(flow_no, order_id, sku_id, action, quantity, created_at)
VALUES
(?, ?, ?, 'RELEASE', ?, CURRENT_TIMESTAMP);
— 如果 flow_no 已存在,说明释放动作已经执行过
— 只有首次插入成功,才执行库存回补
UPDATE sku_stock
SET available_stock = available_stock + ?,
reserved_stock = reserved_stock - ?
WHERE sku_id = ?
AND reserved_stock >= ?;
COMMIT;实际项目中还需要根据数据库方言处理“插入唯一流水后再更新库存”的事务逻辑。核心原则不变:释放库存不是简单的加法,而是一个具有唯一业务身份的状态变化。

当大量请求集中购买同一个热点 SKU 时,数据库单行记录可能成为竞争热点。Redis 预扣可以在请求入口快速淘汰库存不足请求,并将成功请求送入后续订单流程,从而减少数据库直接承受的无效请求数量。
典型流程如下:
缓存扣减必须使用原子操作。多个命令先读后写可能产生与数据库“先查再扣”相同的竞态问题。对于涉及预扣、记录请求号和设置过期时间的操作,通常需要使用脚本或其他原子执行机制。
第一类是预扣成功但订单创建失败。这时需要记录预扣流水,并通过同步补偿或可靠消息将数量加回。不能只依赖接口返回失败,因为服务可能在返回前已经宕机。
第二类是数据库写入超时。数据库超时不等于写入一定失败。消费者重试前,要通过业务流水号查询是否已经成功,避免把“未知结果”当成失败再次执行。
第三类是消息重复消费。消息队列通常需要消费者具备幂等能力。消费成功后才确认消息,可能导致重复投递;先确认再写数据库,又可能导致消息丢失。因此,消费者必须依赖业务唯一键和状态机,而不是依赖消息只到达一次。
第四类是订单超时释放。释放任务需要扫描待支付订单,并使用条件更新限制状态转换,例如只有从“待支付”成功转换为“已取消”的订单,才允许执行库存释放。
UPDATE orders SET status = 'CANCELLED', cancelled_at = CURRENT_TIMESTAMP WHERE order_id = ? AND status = 'PENDING_PAYMENT' AND expire_at <= CURRENT_TIMESTAMP;
只有当订单状态更新的受影响行数为 1 时,才执行释放库存。受影响行数为 0,说明订单可能已经支付、取消或被其他任务处理,不能继续回补。
缓存预扣系统不能只看实时接口成功率,还要定期比较缓存预扣记录、数据库库存流水和订单状态。对账任务不应直接粗暴地“以某一边覆盖另一边”,而应先生成差异清单,再根据订单状态和流水状态执行可审计的修复。
| 差异类型 | 可能原因 | 建议处理 |
|---|---|---|
| 缓存已预扣,数据库无流水 | 消息未投递、消费者失败 | 查询预扣状态,重投或释放 |
| 数据库有扣减,订单不存在 | 事务边界错误或订单写入异常 | 按流水追踪,建立补偿订单或释放库存 |
| 订单已取消,无释放流水 | 取消事件丢失或任务失败 | 补写释放事件并执行回补 |
| 释放流水重复 | 重复消费或幂等失效 | 冻结异常数据,禁止继续自动累加 |

假设一家零售业务每天订单量约 8 万笔,SKU 数量约 20 万,绝大多数商品没有明显热点。高峰期订单接口每秒约 500 次,但单个 SKU 每秒请求通常不超过 20 次。
这个场景不需要为了“可能的秒杀”提前搭建复杂的库存中心。更稳妥的实现是库存表按 SKU 建立主键,订单和库存流水同库,使用条件更新、唯一流水号、本地事务和定时对账。数据库连接池、慢查询和锁等待做好监控后,系统维护成本较低。
建议流程如下:
在这个案例中,Redis 可能仍然适合做页面缓存和限流,但不一定适合做库存权威来源。架构越简单,故障排查路径越短,库存差异也越容易通过数据库流水定位。
假设一款限量商品库存 500 件,活动开始后 5 秒内收到 10 万次请求。此时真正需要处理的不是“如何让 10 万次请求都进入数据库”,而是如何快速识别大多数注定失败的请求,并保护后端订单和库存链路。
可以采用分层方案:入口限流、用户和设备去重、缓存预扣、消息削峰、数据库最终落库。缓存中的预扣数量不能直接当成已售数量,只有数据库流水和订单状态完成后,才算完成最终确认。
对于这种场景,我会重点要求以下数据可追踪:
如果商品实际存放在多个仓库,库存扣减还要先决定由哪个仓库履约。一个全局库存数可能显示还有 10 件,但这 10 件分散在不同区域,用户所在地区未必能够使用。
此时库存扣减的业务顺序通常是:确定履约仓、锁定仓库库存、创建订单、等待支付、确认销售或释放锁定。仓库选择规则、仓间调拨和取消回补都需要单独建模,不能简单地把多仓库存相加后执行一条全局更新。

库存系统压测必须包含成功、库存不足、重复请求、超时和异常重试。只测成功路径,容易得到一个漂亮的吞吐量,却无法发现库存不足时的数据库压力、重复消费时的幂等漏洞以及支付超时后的回补问题。
最小测试矩阵可以包含以下场景:
正确性指标包括负库存数量、重复扣减数量、重复释放数量、订单与流水不一致数量。任何一个指标出现非零,都应该触发告警或进入异常队列。
并发指标包括行锁等待时间、死锁次数、乐观锁冲突率和数据库连接池等待时间。这些指标能够判断系统是被数据库 CPU 限制,还是被热点行竞争限制。
链路指标包括消息生产失败率、消费重试次数、消息积压量、预扣转订单成功率和补偿任务失败数。它们用于判断问题发生在缓存、消息、订单还是数据库环节。
业务指标包括库存扣减成功率、支付转化率、超时订单比例、库存释放及时率和库存对账差异。技术指标正常,并不代表交易链路正常。
库存对账的核心不是生成复杂报表,而是建立可以反复验证的关系。以锁定库存模型为例,可以使用以下约束:
available_stock + reserved_stock + sold_stock
= initial_stock + inbound_quantity – adjustment_quantity;
进一步核查时,还要将扣减、释放、退款和人工调整分别统计。对于每个 SKU,系统应该能够根据流水重建库存变化,并定位某一笔差异具体发生在订单、消息还是补偿环节。
“库存异常”是一个没有行动价值的告警。更好的告警应该包含 SKU、订单号、流水号、当前状态、最近一次事件、重试次数和建议处理动作。
| 告警 | 触发条件示例 | 处理动作 |
|---|---|---|
| 负库存告警 | 任一 SKU 可售库存小于 0 | 立即冻结自动扣减,检查条件更新和人工调整 |
| 释放积压告警 | 超时订单超过阈值未释放 | 检查定时任务、消息积压和数据库锁等待 |
| 幂等冲突告警 | 同一订单出现多次扣减尝试 | 区分正常重试与重复扣减,检查客户端和网关重试 |
| 对账差异告警 | 流水重建库存与库存表不一致 | 生成差异清单,暂停自动修复并人工确认规则 |

如果订单量中等、热点不明显、订单和库存可以放在同一个数据库,建议采用条件更新、事务、唯一索引、库存流水和超时释放。先把异常流程做完整,再根据压测结果决定是否拆分服务。
这个方案的优点是数据路径短、排查容易、运维成本低。缺点是热点 SKU 会竞争数据库行,单库吞吐受限。只要业务流量尚未达到边界,不要为了架构“看起来先进”而主动增加缓存和消息依赖。
秒杀场景中,大部分请求最终都会失败。如果让所有请求都经过订单服务、库存服务和数据库,任何一个环节都可能被无效流量拖垮。此时应先在入口做限流、验证码、用户去重和资格校验,再使用缓存原子预扣过滤库存不足请求。
秒杀方案的关键取舍是:接受订单结果异步确认,换取数据库压力下降。用户界面需要展示“排队中”“抢购成功”或“库存不足”,而不能把消息已投递直接包装成最终订单成功。
如果扣减前要判断批次、效期、门店、用户限购和组合库存,建议在同一事务内锁定必要记录,并严格控制事务时长。复杂规则下,盲目使用单条 SQL 往往难以表达完整约束,乐观锁大量重试也可能造成更高成本。
这种方案要重点治理死锁、锁等待和事务超时。多个 SKU 的更新顺序必须统一,事务中不能调用外部服务,失败后要有明确的重试和用户反馈策略。
当库存、订单、支付和仓储分属不同服务时,很难依赖一个本地事务保证所有状态同时完成。此时可以采用事件驱动流程,但必须把处理中、成功、失败和待补偿状态持久化。
最终一致并不意味着“先写一边,另一边以后再说”。它意味着系统明确知道哪些状态尚未完成、由谁负责重试、重试多久、失败后如何人工介入,以及最终如何通过流水和对账确认收敛。
药品、贵重物品、票务名额和强监管业务更关注可审计性,而不是单纯的峰值 QPS。这类系统应该保留完整库存流水、操作人、来源事件和状态变化记录,必要时采用更严格的串行化控制。
在这类场景中,快速失败、人工复核和库存冻结并不是系统能力不足,而是风险控制的一部分。宁可让少量用户稍后重试,也不要让错误库存进入履约环节。


库存扣减的最小正确路径是:带条件的原子更新、受影响行数判断、业务幂等、库存流水和明确的事务边界。这个路径不一定能承接所有秒杀流量,但它能够让系统在普通场景下拥有可解释、可测试、可对账的基础。
如果基础路径还没有处理重复请求和库存释放,就直接增加 Redis、消息队列和分布式锁,系统只会拥有更多状态和更多异常分支。技术组件数量增加,不等于一致性能力增加。
库存扣减成功,只代表系统为某个业务动作占用了数量;订单创建成功,代表订单数据已经落库;支付成功,代表交易状态完成。三者可能在同一事务中,也可能分属不同服务,但工程设计必须明确它们之间的关系。
只有把这些状态拆开,才能正确设计超时释放、支付回调、消息重试和库存对账。否则,任何一个“失败”都会变成需要人工猜测的灰色状态。
如果你正在改造现有库存接口,可以按以下顺序推进:
我对库存系统的核心判断是:真正稳的方案,不是承诺“绝对不会超卖”,而是让每一次扣减、释放、重试和异常都拥有唯一身份、明确状态和可追溯证据。当系统能够在库存不足、服务重启、消息重复、支付超时和数据库超时之后仍然完成对账与收敛,库存扣减才算真正从“能运行”进入“可长期运营”的阶段。
我以前在做限量商品下单接口时,直觉上也是先 SELECT 判断库存是否大于 0,再在代码里执行扣减。单元测试一直通过,但一到并发压测就出现库存扣减次数超过实际库存的情况,我想弄清楚问题究竟出在数据库、事务,还是应用代码的执行顺序上。
问题的根源是“查询库存”和“扣减库存”不是一个原子动作。假设数据库中只有 1 件库存,请求 A 和请求 B 先后读取库存,两个请求都可能读到 available_stock = 1。随后它们分别执行扣减,应用层的判断已经失去了时效性。
典型错误流程如下:
SELECT available_stock FROM sku_stock WHERE sku_id = 1001;— 应用层判断 available_stock > 0
UPDATE sku_stock SET available_stock = available_stock - 1 WHERE sku_id = 1001;即使数据库启用了事务,如果两个请求在读取阶段没有形成有效的互斥关系,仍然可能同时通过应用层判断。事务解决的是一组操作的提交边界,并不会自动把两个独立请求变成排队执行。
单库扣减更稳妥的起点,是把库存约束直接放进 UPDATE: UPDATE sku_stock SET available_stock = available_stock – 1, sold_stock = sold_stock + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ?
AND available_stock > 0;执行后必须检查 affected_rows,而不是只判断 SQL 是否报错。affected_rows = 1 表示扣减成功,affected_rows = 0 表示库存不足、SKU 不存在或条件未满足。
这个做法的价值在于,判断和扣减由数据库在同一条更新语句中完成,应用层不再依赖一份可能已经过期的库存读数。
方案并发风险适用定位 先查询再普通更新存在竞态窗口页面展示、非关键统计 带条件的原子更新可避免扣成负数单库库存扣减首选 查询加行锁后更新依赖事务和锁等待控制需要复杂库存判断 需要注意,条件更新只能解决“单次扣减不能超过可售库存”,还不能解决重复提交、支付失败回补和消息重复消费。
库存系统真正上线前,还要配合幂等键、库存流水唯一约束以及超时订单补偿。
我在设计库存服务时遇到过两种完全相反的建议:有人认为高并发必须使用乐观锁,另一部分人则坚持 SELECT FOR UPDATE 才可靠。实际压测中,我发现热点 SKU 的失败重试和锁等待都可能放大问题,所以想按真实业务场景做选择,而不是简单背结论。
乐观锁和悲观锁并不存在脱离业务的“最佳方案”。我的判断标准不是哪一种锁更先进,而是扣减时是否需要读取库存并完成复杂判断、同一个 SKU 的并发冲突有多严重,以及失败后能不能安全重试。
如果只是把可售库存减 1,优先考虑带条件的原子更新:
UPDATE sku_stock SET available_stock = available_stock - 1, version = version + 1 WHERE sku_id = ?AND available_stock > 0;这种写法实际上已经利用了数据库的并发控制能力,不一定需要先读取版本号。它的优点是事务短、代码简单,失败时也容易根据受影响行数返回“库存不足”或“并发冲突”。
乐观锁更适合“读取后还要做判断”的场景,例如需要同时检查仓库、批次、预售规则和库存冻结状态:
SELECT available_stock, version FROM sku_stock WHERE sku_id = ?;
UPDATE sku_stock SET available_stock = available_stock - ?, version = version + 1 WHERE sku_id = ?AND version = ?AND available_stock >= ?;但乐观锁并不是免费方案。热点商品库存为 100、并发请求为 10000 时,大量请求可能在版本校验处失败。如果代码设置自动重试,数据库收到的更新次数会进一步放大,用户请求的 P99 延迟也会明显升高。因此,重试次数必须有限,并且要区分“库存不足”和“版本冲突”。
悲观锁适合必须在同一事务中读取当前库存、执行多项判断再更新的场景: BEGIN;
SELECT available_stock FROM sku_stock WHERE sku_id = ?FOR UPDATE;-- 在事务内完成判断、扣减和流水写入 COMMIT;它的主要风险是锁等待和死锁。持锁期间不能调用支付、物流或其他外部服务,否则一次网络抖动就可能把数据库事务拖长。
实际选型可以遵循以下顺序: 业务特征建议起点重点风险 单字段扣减条件更新幂等与结果判断 多条件复杂判断事务加行锁锁等待、死锁 冲突可控且可重试乐观锁失败重试放大流量 热点 SKU 极高并发缓存预扣加异步落库补偿和对账 我的经验是,先用条件更新解决基本正确性,再通过压测观察锁等待、冲突率和数据库 CPU。
没有测试数据就直接上分布式锁,通常只是把一个可定位的数据库问题,变成更难排查的跨组件问题。
我曾经见过一个秒杀接口把库存全部放进缓存,入口压测数据很好看,结果缓存扣减成功后订单服务偶发超时,最终数据库库存和缓存库存对不上。后来我才意识到,缓存扣减成功并不等于订单成立,想请教这种架构怎样设计才不会把问题从超卖变成库存丢失。
Redis 预扣库存不能彻底解决超卖,它主要解决的是热点流量直接冲击数据库的问题。Redis 可以用原子操作快速判断并扣减,但“缓存扣减成功、订单写入失败、消息发送失败、服务进程宕机”这些异常仍然需要业务补偿。一个更完整的流程通常是: 请求进入后,先通过 Redis 原子扣减进行快速拦截;
扣减成功后生成全局唯一的订单号或请求流水号,再创建订单或投递库存处理消息;数据库最终写入订单、库存结果和库存流水;如果后续失败,则通过可靠重试或补偿任务把预扣库存释放。
Redis 预扣至少要处理以下几类结果: 故障场景可能后果处理方式 Redis 扣减成功,订单创建失败缓存库存被提前消耗记录预扣流水并回补 消息发送成功,消费者重复消费同一订单重复扣减业务幂等键加唯一约束 数据库超时但实际已提交重试造成重复处理查询业务流水后再决定是否重试 用户支付超时库存长期被锁定定时关闭订单并幂等释放 缓存重启或数据恢复异常缓存与数据库库存不一致重建缓存并执行库存对账 数据库仍然应该保存最终库存状态、订单状态和库存流水。
库存流水建议至少记录 order_id、sku_id、operation_type、quantity、request_id 和 created_at,并为 request_id 或“订单号加 SKU”建立唯一约束。这样即使消息重复到达,也不会重复生成扣减或释放记录。
我不建议把 Redis 的库存值直接当成唯一事实来源,除非系统已经具备完善的持久化、恢复、对账和补偿能力。更实际的职责划分是:Redis 负责快速准入和削峰,数据库负责业务最终状态,消息队列负责异步解耦,定时任务负责超时处理,监控系统负责发现差异。是否引入 Redis,应该看数据库是否真的成为瓶颈。
普通电商下单通常可以先采用条件更新、事务和幂等;只有当热点 SKU 的并发量明显超过单库承载能力,或者需要削峰限流时,才值得承担预扣库存带来的一致性成本。
我在处理支付超时订单时遇到过最棘手的问题:关闭订单任务重复执行,库存却被回补了两次;另一种情况是订单关闭成功,但回补消息没有正常消费,库存一直被锁住。库存扣减我能理解,但释放、退款和补偿怎样做到既不漏回补也不重复回补?
库存回补不能简单理解为执行一次 available_stock = available_stock + 1。它本质上也是一条需要幂等、可追踪、可重试的业务操作。扣减通常发生在用户下单瞬间,回补却可能由取消、支付超时、风控拦截或售后流程触发,异步链路更多,失败概率也更高。
建议为每个库存动作生成唯一业务流水。例如扣减使用 DEDUCT,订单取消使用 RELEASE,退款回补使用 REFUND。每条流水都带上 order_id、sku_id、quantity 和 event_id,并对 event_id 建立唯一索引。
INSERT INTO stock_flow (order_id, sku_id, operation_type, quantity, event_id) VALUES (?, ?, 'RELEASE', ?, ?);如果插入流水时触发唯一键冲突,不能直接当成系统异常。
它通常意味着这次释放事件已经处理过,应用应查询已有流水并返回幂等成功,避免再次增加库存。回补库存时,建议把“写入释放流水”和“增加库存”放在同一个本地事务中: BEGIN;
— 只有首次插入 RELEASE 流水成功时,才执行回补
UPDATE sku_stock SET available_stock = available_stock + ?WHERE sku_id = ?;COMMIT;如果订单服务和库存服务不在同一个数据库,就不要假设跨服务事务会自动保证一致性。可以采用事件表加可靠投递的方式:订单状态变更和待发送事件先落库,后台任务持续投递;库存服务消费后写入幂等流水,处理成功后记录消费结果。
定时补偿任务需要有明确的扫描条件,例如查找“支付超时但仍未关闭”的订单、“已关闭但没有 RELEASE 流水”的订单,以及“存在扣减流水但订单状态异常”的记录。补偿任务本身也必须支持重复执行,因为任务超时后重跑是常态,而不是例外。
检查项错误现象建议动作 订单已取消,无释放流水库存被长期锁定创建补偿释放任务 同一事件存在两条释放流水可能重复回补唯一索引拦截并告警 释放流水存在,库存未增加事务边界不完整核查事务和失败重试 库存与流水汇总不一致数据出现漂移暂停自动修复并人工确认 最终要建立一个可核对的公式:期初库存加回补数量,减去扣减数量,应当等于当前可售库存与已锁定、已售状态的合理汇总。
只监控库存是否小于 0 远远不够,因为库存丢失往往表现为“库存仍然非负,但实际可卖数量越来越少”。


读者评论
文章把并发控制和幂等控制区分得比较清楚,条件更新确实是单库库存扣减的基础。不过实际落地时,还需要结合订单状态和唯一索引,否则只能避免负库存,不能防止重复扣减。
对“先查再扣”的并发反例解释得很直观,尤其是库存为1时的时序分析。相比直接上分布式锁,先用受影响行数判断条件更新结果,确实更简单也更容易验证。
文中关于支付超时、库存回补和消息重复消费的讨论比较实用,说明库存问题不只是数据库减法。建议再补充事务消息或对账任务的示例,方便读者理解异常场景如何闭环。
文章没有把Redis预扣库存包装成万能方案,这一点比较客观。对于热点商品,缓存和消息队列能提升吞吐,但也会引入双写、回补和数据对账等额外成本,需要根据流量和一致性要求选择。