数据库存:架构师数据视角:用性能优化验证降低超卖风险
库存系统最危险的时刻,往往不是库存已经变成负数,而是压测报告写着“平均响应时间 8 毫秒、QPS 达到 5,000”,业务却无法回答一个更基本的问题:这 5,000 次请求中,到底有多少次扣减真正成功,是否存在重复扣减、漏扣和错误回补。我的判断是,防超卖不能只看库存字段最终是否小于零,而要同时验证扣减语义、并发性能、异常恢复和库存对账。
本文标题中的“数据库存”如果是笔误,业务语义应理解为“数据库库存”。这里讨论的不是普通库存台账,而是高并发订单、秒杀、渠道分销、多仓库存等场景下,如何用数据库设计和性能验证证明库存系统在压力下仍然可靠。
很多团队在库存系统评审时,第一反应是询问“每秒能处理多少请求”。这个问题当然重要,但它只回答了系统的处理速度,没有回答业务是否正确。一个系统可以拥有很高的吞吐量,也可能在重试、锁等待、缓存回写失败时产生错误库存。
我通常会把库存系统的验收拆成三组指标。第一组是性能指标,包括吞吐量、P95 和 P99 延迟、超时率;第二组是数据正确性指标,包括负库存次数、重复扣减次数、漏扣次数和对账差异;第三组是恢复指标,包括数据库故障、消息重复、服务重启和订单取消后的最终库存结果。
| 验收维度 | 必须回答的问题 | 不合格的典型表现 |
|---|---|---|
| 性能 | 目标并发下是否达到延迟和吞吐目标? | 平均延迟很好,但 P99 长时间超时 |
| 正确性 | 成功扣减数量是否与库存流水、订单数量一致? | 库存没有变负,但部分订单重复扣减 |
| 恢复 | 失败、重试、回补后是否能够重新对账? | 订单取消后库存回补两次或完全没有回补 |
| 可解释性 | 每一次库存变化能否追溯到业务事件? | 数据库库存与缓存不一致,却找不到变化来源 |

库存大于等于零是最直观的不变量,但它不是全部。假设初始库存为 100,系统成功创建了 100 个订单,数据库库存也是 0,表面看没有超卖;如果其中 3 个订单因为重复消费被扣了两次,另外 3 个订单实际没有扣减,那么库存数仍可能“碰巧正确”,但订单与库存关系已经失真。
因此,库存系统至少要守住以下不变量:
在实际架构评审中,我不会先问“要不要上缓存”或“要不要上消息队列”,而会按以下顺序判断:
技术组件不是库存正确性的起点,业务不变量才是。如果基础扣减语义没有设计清楚,增加缓存和队列只会让错误路径变得更长、更难追踪。
在普通电商流量中,用户请求通常分散在大量 SKU 上。库存表的不同记录被不同请求更新,数据库的行锁竞争相对有限。到了大促或秒杀时,流量会集中到少数热门商品,系统面对的不是“很多商品同时扣库存”,而是“几万请求争抢同一条库存记录”。
这会产生一个很容易被忽略的现象:SQL 已经使用了索引,扫描范围也不大,但响应时间仍然明显升高。原因不是查询慢,而是多个事务需要按照顺序更新同一行。索引解决的是定位问题,不能消除同一库存行的逻辑竞争。
我在做压测方案时,会刻意把 SKU 分布拆成均匀模型和热点模型。只测均匀分布,通常会高估系统容量;只看平均响应时间,又会掩盖少量请求在锁队列中等待数百毫秒甚至数秒的情况。

库存扣减通常不是一个孤立动作。业务代码可能在事务中先校验活动资格,再查询优惠,再创建订单明细,最后扣减库存。有些系统甚至会在事务持有期间调用营销服务、会员服务或支付预授权服务。
这种设计在低并发时不容易暴露问题,但它会把外部依赖的延迟传导到库存锁上。只要外部服务偶尔出现 500 毫秒延迟,同一热门 SKU 的后续扣减就会形成排队,连接池中的线程也会被占住。
我的经验是,库存事务应该只保护必须原子完成的数据库动作。资格校验、商品展示、营销试算和支付通知尽量放在锁外;对于必须在扣减前完成的校验,应采用快速、可控、可超时的方式,并明确失败后的补偿路径。
库存充足时,很多错误逻辑看起来也能正常运行。真正适合验证方案的,是库存即将耗尽的边界场景。例如库存为 3,瞬间发起 100 个并发请求,最终只能有 3 个请求成功,97 个请求明确失败;系统不能出现 4 个成功订单,也不能出现成功订单数量与库存流水不一致。
我会把“极限库存测试”单独列为压测场景,而不是混在普通压力测试里。因为它验证的是竞争条件和边界判断,关注点与验证系统平均吞吐量并不相同。
一个商品可能同时在自营商城、第三方平台、线下门店和分销渠道销售。如果所有渠道共享一个总库存,却没有明确的库存分配策略,就算单库扣减完全原子,也可能出现渠道可售库存之和超过物理库存的情况。
这类问题不是简单的 SQL 漏洞,而是库存模型没有定义清楚。必须先区分物理库存、锁定库存、可售库存、渠道配额和在途库存,再决定哪些字段允许实时扣减,哪些字段通过异步同步更新。

最常见的代码流程是先执行查询,得到 available_stock,然后在应用层判断库存是否大于购买数量,判断通过后再执行更新。问题在于,查询和更新是两个独立动作,中间存在竞争窗口。
两个请求同时读取库存 1 时,都可能得到“库存充足”的结果。即使后续更新语句是正确的,如果没有把条件重新放回更新动作,应用层之前做出的判断仍然可能已经过期。
-- 风险较高:读取和扣减之间存在时间窗口 SELECT available_stock FROM inventory WHERE sku_id = :sku_id; -- 应用层判断 available_stock >= :quantity 后再执行 UPDATE inventory SET available_stock = available_stock - :quantity WHERE sku_id = :sku_id;
这并不意味着所有“先查询再更新”的代码必然超卖。如果查询和更新被放进正确的事务,并且查询使用了适当的锁,结果可能是正确的。但它对事务隔离级别、锁类型和异常处理的依赖更强,代码审查和压测成本也更高。
事务能够保证一组数据库操作的原子提交或回滚,但它不能自动让业务请求幂等,也不能自动阻止消息重复消费。一次订单请求如果因网络超时被客户端重试两次,数据库可能会执行两次合法事务;每个事务本身都成功,但业务结果已经错误。
因此,我会把事务和幂等分开评审。事务回答“这一组操作是否一起成功”,幂等回答“同一个业务动作被执行多次时是否只产生一次效果”。两者缺一不可。
缓存预扣常用于承接高并发流量,它可以把大量“库存已经售罄”的请求挡在数据库之外,也可以减少热门库存行的直接竞争。但缓存扣减成功后,数据库写入可能失败;消息可能重复投递;服务可能在扣减缓存后立即重启。
所以缓存库存更适合作为高并发入口或预扣层,而不应在没有持久化、对账和补偿能力的情况下被直接视为唯一事实源。是否让缓存承担最终库存事实,取决于业务能否接受短暂不一致,以及是否具备可重放的库存流水。
消息队列擅长削峰、异步解耦和失败重试,但它不会自动解决库存竞争。假设系统先把下单请求全部放进队列,再由消费者扣库存,那么用户看到的“下单成功”可能只是消息入队成功,而不是库存扣减成功。
这种模式需要明确订单状态机,例如“待确认”“库存锁定成功”“库存不足关闭”“待支付”。如果产品不接受下单后再告诉用户库存不足,就不能简单地把关键扣减动作全部异步化。
库存扣减 SQL 通常按照 SKU 查询。如果 SKU 字段没有索引,确实可能造成扫描和锁范围扩大。但当执行计划已经稳定使用唯一索引,性能瓶颈往往转向热点行锁等待、事务长度、连接池排队或日志写入。
我见过一些系统在锁等待明显时继续添加索引,结果索引数量增加了,写入成本和维护成本也增加了,热点 SKU 的等待却没有明显下降。调优前应该先查看执行计划、锁等待和事务耗时,而不是把“加索引”作为默认答案。
平均值会掩盖长尾。比如 99%的请求在 10 毫秒内完成,1%的请求因为锁等待达到 2 秒,平均值仍然可能只有 30 毫秒。但那 1%的请求可能恰好是大促最关键的热门商品请求。
库存系统至少要同时观察 P50、P95、P99 和最大延迟,并把延迟分解为应用排队、数据库执行、锁等待和外部调用四部分。只有这样,才能判断慢在哪里。

对单 SKU、单库存池的基础扣减,我通常优先考虑让数据库直接完成“判断并扣减”这一动作。核心是把库存充足条件放进 UPDATE 的 WHERE 子句,并通过受影响行数判断结果。
UPDATE inventory SET available_stock = available_stock - :quantity, locked_stock = locked_stock + :quantity, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_stock >= :quantity;
如果返回受影响行数为 1,说明该次扣减成功;如果为 0,则可能是库存不足、SKU 不存在或条件已经不满足。生产代码还应区分这些情况,避免把“商品不存在”误报成“库存不足”。
这种方式的价值在于,库存判断和扣减发生在同一个数据库更新动作中,减少了应用层判断过期的窗口。但它仍然需要事务、幂等和流水配合,不能把一条 SQL 误解为完整库存方案。
幂等不能只依赖应用内存中的标记,也不能只依赖请求方“保证不重试”。网络超时、负载均衡重试、客户端重复点击和消息重复消费都可能让同一个业务动作再次到达。
一种常见做法是建立库存扣减流水表,以业务扣减号作为唯一键。处理流程可以是:先尝试写入扣减流水,再执行库存扣减;如果唯一键已经存在,就读取原处理结果并返回,而不是再次扣减。
CREATE TABLE inventory_deduction (
deduction_id VARCHAR(64) NOT NULL,
order_id VARCHAR(64) NOT NULL,
sku_id BIGINT NOT NULL,
quantity INT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (deduction_id)
);— 业务上应保证 deduction_id 对同一扣减动作唯一
INSERT INTO inventory_deduction
(deduction_id, order_id, sku_id, quantity, status, created_at, updated_at)
VALUES
(:deduction_id, :order_id, :sku_id, :quantity, 'PROCESSING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);实际实现时,流水写入和库存更新应根据业务一致性要求放在同一事务中,或者通过可靠事件机制连接。更重要的是,必须定义“处理中”状态长时间不结束时如何恢复,不能让幂等表变成新的悬挂点。
事务不是越大越安全。事务越大,锁持有时间越长,外部依赖越多,失败和回滚路径越复杂。我会先识别必须原子完成的最小动作,再决定事务范围。
| 动作 | 是否通常放在库存事务内 | 判断理由 |
|---|---|---|
| 校验扣减流水唯一性 | 通常是 | 需要防止同一业务动作重复生效 |
| 更新可售库存 | 是 | 这是库存不变量的核心变化 |
| 写入库存流水 | 通常是 | 保证库存变化有可追溯来源 |
| 调用支付服务 | 通常不是 | 外部调用延迟不可控,容易延长锁持有时间 |
| 发送营销通知 | 通常不是 | 可通过事务消息或可靠事件异步处理 |
| 生成复杂报表 | 不是 | 分析查询不应阻塞交易库存更新 |
乐观锁通常通过版本号或条件字段检测并发冲突,适合冲突概率较低、失败后可以重试的场景。悲观锁则更直接地锁住记录,适合必须串行化处理、库存量小且业务逻辑需要读取后再决策的场景。
但热门 SKU 下,乐观锁的重试可能把冲突放大成请求风暴;悲观锁则可能增加等待和长尾。我的选择依据不是技术偏好,而是三个问题:热点比例是多少、冲突失败后能否快速结束、业务是否允许用户重试。

库存扣减变慢时,不能笼统地说“数据库性能不够”。我会把问题拆成四类:定位慢、等待慢、事务慢和资源慢。
这四类问题的优化手段完全不同。索引问题可以通过执行计划确认,锁等待要看数据库锁监控,事务问题要看链路耗时,资源问题则要结合数据库和主机指标判断。
当一个 SKU 变成热点时,分库分表不一定有效。如果同一个 SKU 的请求仍然最终落到同一个逻辑库存记录上,物理表拆开并不会自动消除竞争。真正的优化方向可能是库存分桶:把一个大库存拆成多个可独立扣减的桶,减少所有请求争抢同一行。
但库存分桶会引入新的问题。系统需要决定如何选择桶、如何处理某个桶提前耗尽、如何统计总可售库存,以及回补时回到哪个桶。它适合高热点、库存量较大且业务可以接受更复杂状态管理的场景,不适合所有普通订单系统。
如果一个库存事务从 10 毫秒缩短到 3 毫秒,意味着同一时间内锁被释放得更快,后续请求等待时间也会下降。反过来,如果事务中有一次 500 毫秒的外部调用,即使数据库扩容,热点行仍然只能排队。
我通常会先做以下检查:
连接池过小,请求会在应用侧排队;连接池过大,则可能让数据库同时处理大量等待事务,CPU、锁管理和日志写入压力一起上升。在热点库存场景中,增加连接数并不一定增加有效吞吐,反而可能增加上下文切换和锁等待。
容量测试应逐步增加连接池,而不是直接设置一个很大的值。观察有效吞吐、P99 延迟、数据库活跃连接和锁等待的变化,如果连接数继续增加但成功扣减量不再增长,说明瓶颈已经转移到数据库竞争或事务处理能力。

在库存已售罄的场景,大量请求继续进入数据库没有价值。可以通过缓存售罄标记、接口限流、活动资格预过滤等方式,把确定无效的请求挡在库存扣减之前。
但缓存不适合替代所有数据库判断。缓存可能过期,可能丢失,也可能因网络分区暂时无法访问。更稳妥的分工是:缓存承担快速过滤和流量削峰,数据库或可重放的库存流水承担最终可审计的库存变化。
压测前不要只写“验证高并发性能”。这个目标无法判断是否通过。更好的写法是:“初始库存为 1,000 件,发起 10,000 次扣减请求,其中请求包含重复扣减、超量扣减和随机超时,最终成功扣减数量不超过 1,000,库存不出现负数,扣减流水与成功订单能够一一对应。”
测试目标越具体,结果越容易解释。对于线上大促,还应增加 P99 目标、错误率上限和数据库资源上限。例如,要求 P99 不超过 300 毫秒、库存负数为 0、重复扣减为 0、对账差异为 0,并规定数据库 CPU 和锁等待不能持续超过预设阈值。
我建议至少设计四种模型,而不是只用工具默认的均匀随机请求。
| 流量模型 | 请求特点 | 主要验证目标 |
|---|---|---|
| 均匀模型 | 请求分散到大量 SKU | 验证常规查询、索引和整体吞吐 |
| 热点模型 | 大部分请求集中到少数 SKU | 验证行锁、事务长度和连接池上限 |
| 耗尽模型 | 库存很快接近零 | 验证边界竞争、失败响应和售罄标记 |
| 异常模型 | 超时、重试、重复消息和服务重启 | 验证幂等、回补、恢复和最终对账 |
这四种模型的价值不同。均匀模型帮助我们了解系统的基础容量,热点模型揭示真实瓶颈,耗尽模型验证库存不变量,异常模型则判断系统是否具备生产级恢复能力。
每一次压测请求都应该带有唯一的 request_id、order_id 或 deduction_id,并记录 SKU、购买数量、发送时间、响应状态和重试次数。否则测试结束后只能看到“有多少请求返回 200”,却无法确认哪些请求真正改变了库存。
对于重复请求,应该显式构造同一 deduction_id 被发送多次的场景。对于异常请求,应该在库存扣减成功后模拟订单创建失败,观察库存是保留为锁定状态、自动回补,还是进入人工处理队列。
压测完成后,不能只查询 inventory 表中的最终数字。至少要同时核对库存主表、扣减流水、回补流水和订单状态。基础恒等式可以写成:
期末可售库存
= 期初可售库存
成功扣减数量
+ 成功回补数量
± 已审核人工调整数量
如果系统还有锁定库存、占用库存和渠道配额,就要把这些状态分别纳入核对。一个常见错误是直接把所有“下单”都当成成功扣减,实际上订单可能只是创建成功,库存仍处于待确认状态。

数据库 CPU 达到 80%并不必然意味着超卖,库存没有负数也不代表性能合格。监控面板应把技术指标和业务指标放在同一时间轴上,例如把锁等待上升与 P99 延迟、成功扣减率、订单创建失败率进行关联。

对于普通电商、企业内部采购和并发规模可控的系统,我通常优先推荐数据库原子扣减、幂等流水和定期对账。它的优点是库存变化路径短,数据可追踪,故障排查和补偿相对直接。
它的限制也很明显:热门 SKU 会竞争同一行,数据库写入能力会成为上限。如果业务峰值并不高,却一开始就引入缓存、队列和分布式库存,系统可能因复杂度增加而承担更多一致性风险。
缓存预扣适合商品库存有限、无效请求比例高、热点流量集中的场景。比如库存仅有 1,000 件,却有 100,000 个用户抢购,数据库没有必要为每一个明显失败的请求承担完整事务成本。
但缓存预扣必须配套设计以下机制:
如果团队没有可靠的流水、补偿和对账能力,我宁愿采用吞吐较低但路径更短的数据库方案,也不会因为追求一个漂亮的 QPS 数字而把库存事实放进不可解释的缓存状态中。
消息队列可以把瞬时流量变成平滑消费,避免数据库被突发流量直接击穿。它特别适合异步通知、库存流水扩散、订单状态同步和非核心后续处理。
但是,队列会引入消费延迟和状态不确定性。产品必须明确用户在消息入队后看到什么状态。如果页面显示“下单成功”,库存扣减却还未发生,用户体验和售后规则就必须允许后续失败关闭。
| 方案 | 主要收益 | 主要风险 | 适合场景 |
|---|---|---|---|
| 数据库原子扣减 | 路径短、易审计、一致性清晰 | 热点行竞争、数据库写入有上限 | 普通交易、并发可控、库存准确性优先 |
| 缓存预扣 | 快速过滤无效请求、降低数据库压力 | 缓存与数据库漂移、恢复和回补复杂 | 热点明显、无效请求比例高的大促场景 |
| 消息队列削峰 | 平滑流量、异步解耦、便于重试 | 消费延迟、重复消息、状态机复杂 | 允许异步确认、后续处理链较长的系统 |
| 库存分桶 | 降低单行热点竞争、提高并行度 | 桶选择、回补和对账逻辑复杂 | 极高热点且库存量较大的商品 |

如果日常并发有限,SKU 分布相对均匀,库存准确性要求高,我建议从最小可靠方案开始:
这个方案的重点不是追求极高吞吐,而是让每一次库存变化都能解释。对于库存价值高、售后成本高的行业,简单可审计往往比复杂高并发更重要。
如果少量商品承接了绝大多数请求,应先统计热点比例,而不是凭感觉决定是否上缓存。可以从访问日志中计算:前 1% SKU 承担了多少请求、前 10 个 SKU 的扣减占比是多少、热点持续多久、库存是否在短时间内耗尽。
当热点比例较高时,可以采用“缓存售罄过滤 + 数据库最终扣减”的组合。缓存只负责挡住确定无效的请求,数据库仍负责带条件扣减和流水记录。这样做的优点是边界清楚:缓存优化入口,数据库守住库存不变量。
大促系统通常需要分层,而不是让所有请求直接到库存数据库。建议把请求链路拆成资格过滤、限流、库存预判、库存扣减、订单确认和后续补偿几个阶段。
需要特别注意“预判成功”与“扣减成功”的语义差异。页面可以提示“正在排队”或“库存确认中”,但不能把还没有完成库存扣减的请求直接标记为最终下单成功,除非业务已经定义了清晰的失败关闭规则。
对于秒杀库存分桶,应先做容量和恢复演练。分桶降低了单行竞争,却增加了桶状态、回补路径和全局库存统计复杂度。没有对账工具和补偿机制的团队,不宜为了理论吞吐量贸然采用。
多仓场景首先要解决的是库存分配,而不是数据库锁。系统需要定义订单选择仓库的规则、仓库库存是否允许共享、调拨中的库存如何处理,以及渠道配额超卖时谁拥有优先级。
我建议将库存分为物理库存、可用库存、锁定库存、冻结库存和在途库存,并为每种变化定义来源事件。数据库表结构越清晰,后续做数据仓库、经营分析和异常追踪时越不容易把不同口径混在一起。
如果系统已经出现过超卖,不要先急着重写架构。先保留现场数据,查明问题属于哪一类:
不同根因对应不同修复方式。只有在确认瓶颈和错误来源后,才能判断需要改 SQL、补幂等、重做状态机,还是调整缓存和消息机制。

“支持高并发”不是测试条件。容量目标至少应说明请求量、并发用户、SKU 数量、热点 SKU 比例、单次购买数量、库存规模和持续时间。
例如,一份可执行的测试定义可以是:库存总量 10,000 件,SKU 数量 5,000 个,热点 SKU 占请求量 60%,每个请求购买 1 至 3 件,持续压测 30 分钟,其中包含 5%的重复请求和 1%的随机超时。
这个描述仍然只是示例,不是通用性能承诺。数据库规格、事务隔离级别、索引设计、应用实现和部署拓扑都会改变结果。任何容量数据都必须绑定测试环境。
| 指标 | 建议验收方式 | 示例通过标准 |
|---|---|---|
| P95 延迟 | 按热点和非热点 SKU 分组统计 | 不超过业务设定目标 |
| P99 延迟 | 排除压测工具自身瓶颈后统计 | 不超过大促可接受长尾阈值 |
| 库存负数 | 检查库存主表和历史快照 | 必须为 0 |
| 重复扣减 | 按 deduction_id 聚合扣减流水 | 必须为 0 |
| 对账差异 | 订单、流水、库存三方核对 | 必须为 0,或全部有审核记录 |
| 异常恢复时间 | 模拟消息重复、服务重启和数据库短暂不可用 | 不超过业务规定恢复窗口 |
库存不足本身不是系统错误。库存为 0 时,系统应该快速、稳定地返回业务失败;真正需要重点关注的是数据库异常、连接池耗尽、锁等待超时、重复扣减和无法确认状态。
因此,压测报告要把业务失败和系统失败分开统计。把所有非 200 响应都算作错误,会误判系统;把所有响应都算作成功,则会掩盖库存不足处理失控的问题。

性能压测证明系统在一类流量下能运行,故障演练则验证系统在不完整链路下能恢复。至少应模拟:扣减成功但响应丢失、订单写入失败、消息重复消费、消费者重启、缓存不可用、数据库主库短暂不可写。
每种故障都要提前定义状态。例如扣减成功但响应丢失时,客户端重试必须依赖幂等号查询原结果;订单写入失败时,库存是立即回补还是进入待补偿状态;消息重复时,消费者如何判断这条消息已经处理。
库存主库适合完成扣减和事务,不适合承载复杂的跨渠道分析。架构师需要把库存流水、订单事件、回补事件和渠道配额变化同步到分析层,用于观察热点、缺货、异常回补和库存周转。
以九数云这类数据分析工具为例,如果企业已经将订单、库存、渠道和仓储数据汇总到分析平台,可以用可视化报表观察 SKU 热点集中度、库存周转、缺货损失和异常订单。但它适合做经营监控、趋势分析和异常发现,不能替代交易数据库中的原子扣减,也不能把报表层当成实时库存事实源。
这类工具的价值在于补足数据库监控看不到的业务上下文。例如数据库可以告诉我们某个库存行锁等待很高,分析层则可以进一步回答:这是哪个渠道、哪个活动、哪个时间段、哪类客户造成的,是否值得做渠道配额或库存预留。
不要只统计总订单量。更有价值的指标是 SKU 请求集中度,例如前 10 个 SKU 占全部库存扣减请求的比例、前 1% SKU 占用的数据库更新次数,以及热点持续时间。
如果请求量很大但高度分散,数据库可能仍然可以稳定处理;如果总请求量不算特别高,却有 80%的请求集中到一个 SKU,热点行反而更容易成为瓶颈。
一个实用的库存异常看板至少应包含以下模块:
这些指标不是为了做一张漂亮的报表,而是为了缩短从异常发生到定位根因的时间。技术团队如果只能看到“库存不一致 100 件”,却无法按订单、渠道和事件类型拆解,处理效率仍然会很低。

锁等待、慢查询和数据库 CPU 如果没有 SKU、渠道、活动和订单类型维度,很难直接转化为行动。建议在日志和监控中保留必要的业务标签,但要避免把完整用户隐私或大字段直接写入高频日志。
例如,可以记录 sku_id、warehouse_id、channel_id、activity_id、deduction_id 的哈希或内部标识,并在分析层关联业务名称。这样既能定位热点,又能控制日志体积和敏感信息暴露风险。
低并发系统最重要的是建立清晰的库存模型和可审计流水。直接数据库扣减往往足够,系统结构越简单,异常恢复越容易,业务人员也更容易理解库存变化。
过早引入多级缓存、分布式锁和异步队列,可能让一次简单的扣减变成多个状态之间的同步问题。复杂度本身也是故障来源,应当用真实压测结果证明基础方案不够,再进行升级。
中等并发系统通常不需要立即分桶或分片。先缩短事务、优化索引、隔离外部调用、控制连接池,并针对热点 SKU 做单独压测,往往可以获得更稳定的收益。
如果无效请求比例很高,可以增加售罄缓存和接口限流;如果订单后续处理较重,可以把通知、积分、营销记录等非核心动作异步化,但库存扣减和订单状态转换必须保持语义清楚。
高并发系统可能需要缓存预扣、队列削峰、库存分桶和异步确认。这些方案可以提高入口承载能力,但代价是状态更多、补偿更复杂、对账周期更长。
在这种场景中,架构评审不能只讨论“能不能扛住峰值”,还要讨论“故障时用户看到什么”“库存差异多久能被发现”“补偿是否会再次重复扣减”“人工处理是否有明确边界”。如果这些问题没有答案,方案仍然不完整。
药品、贵重商品、稀缺票券或供应严格受限的库存,通常不能为了提升吞吐而放宽库存一致性。即便系统需要排队,也应保证每一次成功扣减都可追溯,每一次回补都有来源,每一次人工调整都有审批记录。
这类业务可以牺牲部分实时性或用户等待时间,换取更低的库存错误成本。技术选型应从错误损失出发,而不是单纯比较某个组件的理论性能。

不要从买缓存开始。先画出库存从入库、可售、锁定、扣减、取消、回补到人工调整的状态变化,并为每一次变化定义唯一事件和业务来源。
如果团队无法解释“订单关闭后库存为什么增加”“支付成功但库存为什么还是锁定”“渠道库存为什么比总库存多”,说明库存模型还没有稳定,继续做性能优化的意义有限。
逐条检查库存扣减 SQL 是否带有库存充足条件,是否通过受影响行数判断成功,SKU 是否有合适索引,扣减流水是否有唯一业务键,重试是否会再次执行库存变化。
同时检查事务边界,确认事务中没有外部网络调用、长时间计算和不必要的查询。对数据库执行计划、锁等待和死锁日志建立基线,而不是等大促时才第一次观察。
先测均匀 SKU,再测热点 SKU,然后测试库存耗尽,最后注入重复请求、超时和服务重启。每个场景都要记录性能指标和业务正确性指标,并保留可复盘的请求标识。
压测报告中不要只写“通过”或“不通过”,应明确瓶颈发生在哪个层面。例如:“热点 SKU 下有效扣减吞吐在 3,500 次/秒后进入平台期,数据库锁等待占比超过 40%,库存正确性仍通过,但 P99 超过目标。”这种结论才能指导下一步优化。
| 检查项目 | 完成状态 | 责任角色 |
|---|---|---|
| 库存不变量和状态机已定义 | 待确认 | 架构师、产品负责人 |
| 原子扣减和幂等约束已实现 | 待确认 | 后端、数据库工程师 |
| 热点 SKU 流量模型已建立 | 待确认 | 性能工程师、业务运营 |
| P95、P99 和锁等待已纳入监控 | 待确认 | 运维、平台工程师 |
| 重复请求和消息重试已完成演练 | 待确认 | 后端、测试工程师 |
| 库存、订单、流水已具备对账机制 | 待确认 | 数据工程师、财务或运营 |
| 缓存、队列故障后的补偿方案已验证 | 待确认 | 架构师、运维工程师 |
并发请求下库存不能为负,同一个业务动作不能重复生效,订单、扣减流水和库存主表能够相互解释。这是库存系统的底线,不能因为追求吞吐而放弃。
在明确请求量、热点比例、库存规模和数据库配置后,系统能够达到目标吞吐和延迟。这里必须看 P95、P99、锁等待和有效成功扣减,不能只看平均响应时间或提交请求量。
发生超时、重试、消息重复、缓存失效或订单取消时,团队能够知道库存为什么变化,并且可以通过流水、日志和对账恢复正确状态。没有可追溯性,所谓“最终一致”很容易变成无人负责的人工改数。
我最终坚持的判断是:防止超卖的终点,不是把一条扣库存 SQL 写出来,而是用并发测试、数据库指标、库存流水和故障演练证明这条逻辑在真实压力下仍然成立。
下一步可以从一个最小场景开始:准备库存为 1 的 SKU,构造 100 个并发请求,其中包含重复 request_id、随机超时和一次服务重启,然后核对成功订单数、库存主表、扣减流水和回补流水。如果这四组结果不能完全对上,就不要急着讨论更复杂的缓存、队列或分片方案。先把库存不变量守住,再用数据证明性能优化确实降低了超卖风险。
我在设计库存扣减接口时,最初采用的是“先查询库存,确认充足后再执行 UPDATE”的写法。单线程测试完全正常,但并发压测时,我发现多个请求会同时读到相同的库存值;我想知道,问题究竟出在应用代码、事务隔离级别,还是数据库锁机制上?
“先查询、再更新”的风险不在于 SQL 写错,而在于库存判断和库存扣减之间存在时间窗口。假设库存为 1,请求 A 和请求 B 几乎同时查询,都读到库存充足;如果两个请求随后分别执行扣减,应用层的判断已经无法保证库存约束不被破坏。
更稳妥的基础写法,是把“库存是否足够”直接放进
UPDATE 的 WHERE 条件中,让数据库在一次原子更新中完成判断和扣减: UPDATE inventory SET available_stock = available_stock - :quantity, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_stock >= :quantity;执行后必须检查受影响行数。受影响行数为 1,表示扣减成功;为 0,则表示库存不足、SKU 不存在,或条件未满足。不要再依赖更新前查询到的库存值作为最终判断。不过,带条件更新并不等于完整的库存方案。
实际落地时还需要把订单号或扣减流水号作为幂等键,并在同一事务中记录扣减流水,否则客户端重试、网关重试或消息重复消费,仍可能造成重复扣减。
方案并发正确性主要风险 先查询再更新较弱判断与扣减存在竞态窗口 带条件原子更新较强仍需处理幂等、事务和回补 缓存直接扣减取决于补偿设计缓存与数据库可能出现漂移 我的判断是:库存扣减首先要保证语义正确,再讨论吞吐量。没有原子扣减和幂等约束,单纯增加缓存、连接池或消息队列,只是在更快地放大错误。
我曾经遇到过一个接口,压测报告显示平均响应时间只有几十毫秒,QPS 也达到了预期,但大促流量一上来仍然出现超时和库存对账差异。后来我才意识到,性能指标和库存正确性可能同时存在问题;一套完整的库存压测到底应该检查哪些指标?
库存系统的性能验收不能只看平均响应时间,因为平均值会掩盖热点请求、锁等待和长尾超时。尤其是在热门 SKU 场景下,绝大多数请求可能集中更新同一行,平均延迟看起来正常,P99 却已经不可接受。
我建议把压测指标拆成三组,并且在测试结束后进行数据核对: 指标类别重点指标判断意义 接口性能QPS、P95、P99、超时率、错误率判断用户请求是否稳定完成 数据库状态CPU、连接池等待、锁等待、事务耗时、慢查询定位吞吐下降的真正原因 业务正确性负库存、重复扣减、漏扣、回补差异证明库存结果是否可信 压测至少要覆盖三种流量模型。
第一种是 SKU 均匀分布,用来观察常规负载;第二种是热点 SKU,把大部分请求集中到少数商品,验证行锁竞争;第三种是库存即将耗尽的边界场景,重点检查并发请求是否只有合法数量能够成功。
压测结束后,应使用库存流水进行反向核对: 期末可售库存 = 期初可售库存 – 成功扣减数量 + 成功回补数量 ± 合法人工调整如果库存字段没有出现负数,但订单成功数、扣减流水和期末库存无法相互解释,这套方案仍然不能算通过。
我的经验是,库存压测的“一票否决项”应该是负库存、重复扣减和不可解释的对账差异,而不是某个漂亮的平均延迟数字。
我在压测普通商品时,数据库 CPU 和延迟都很稳定,但把请求集中到一个库存只有几百件的热门 SKU 后,锁等待迅速上升。这个 SKU 明明已经建立了索引,为什么更新仍然变慢?是不是应该直接分库分表或把库存全部放进缓存?
索引解决的是“如何更快找到目标行”,而热点行问题解决的是“多个事务如何竞争修改同一行”。当大量请求都更新同一个 SKU 时,即使索引能够快速定位记录,数据库仍然必须按照并发控制规则串行处理对同一行的修改,因此索引并不能消除逻辑上的资源竞争。热点行通常会表现为:索引命中率正常,但锁等待时间上升;
数据库连接池中的活跃连接增加;P95、P99 延迟明显高于平均值。此时继续添加索引,甚至盲目扩大连接池,可能只会让更多请求同时堆积在数据库前面。更合理的优化顺序,是先缩短事务和降低无效请求。不要在持有库存锁期间调用外部服务,也不要把营销校验、支付请求和复杂订单处理全部放进库存事务。
对于已经售罄的 SKU,可以通过售罄标记或短时缓存快速拒绝请求,避免每个无效请求都触发数据库更新。
优化手段适合解决的问题不能解决的问题 建立精准索引减少查找和扫描成本不能消除同一行的并发竞争 缩短事务降低锁持有时间不能改变热点集中程度 限流与售罄快速失败减少无效请求不能替代最终库存校验 库存分桶或分段分散部分热点更新增加汇总、回滚和对账复杂度 是否引入缓存、队列或库存分桶,取决于热点比例、峰值流量和一致性要求,而不是看到锁等待就直接升级架构。
我的判断是:如果基础的原子扣减、限流和事务边界都没有做好,分库分表只会把一个可观察的问题变成更难排查的分布式问题。
我在评估高并发秒杀方案时,看到很多设计都会先在缓存中扣库存,再通过消息队列异步写入数据库。这个方案确实能降低数据库瞬时压力,但我担心缓存扣减成功后数据库写入失败、消息重复消费或订单取消回补失败;这种架构应该如何判断是否值得采用?
缓存预扣库存主要解决的是流量承接问题,不是自动解决库存一致性问题。它可以让大量请求先在内存层快速失败,减少数据库被无效请求击穿,但缓存中的成功扣减仍然需要最终落到可审计、可恢复的数据链路中。最容易被忽略的是异常路径。例如缓存扣减成功后,服务在写数据库前宕机;消息已经消费但响应超时,客户端再次重试;
订单创建成功后取消,回补消息却重复投递。只要其中一个环节没有幂等和补偿,缓存库存、数据库库存和订单状态就可能出现三套不同结果。
采用缓存预扣时,至少要设计以下控制点: 环节必须具备的控制验证方式 缓存扣减原子操作、库存下限保护并发扣减后不得出现负数 消息投递可靠投递、重试、死信处理模拟消费失败和重复投递 数据库落库幂等键、状态机、扣减流水重复请求只产生一笔有效扣减 取消回补回补幂等、异常补偿、定期对账重复取消不能多回库存 我通常不会把缓存直接当作唯一库存事实源,除非团队已经具备明确的持久化、恢复、对账和人工处置能力。
对于普通交易库存,数据库原子扣减加幂等流水往往更容易审计;只有当无效流量和热点竞争已经成为主要瓶颈,并且业务能够接受异步状态时,才值得引入缓存预扣与消息削峰。最终验收也不能只看缓存 QPS,而要同时核对订单、数据库库存、缓存库存和扣减流水。
高并发方案真正的标准不是“请求进得快”,而是故障发生后仍能解释每一件库存的去向。


读者评论
文章把“高QPS”和“库存正确性”区分开来,这一点很实用。尤其是重复扣减、漏扣和回补失败,确实比单看库存是否为负更容易被忽略。
热点SKU导致单行锁竞争的分析比较贴近大促场景。压测时同时设置均匀分布、热点分布和极限库存测试,得出的容量结论会更可靠。
文中对事务与幂等的区分很清楚。事务只能保证数据库操作一起提交,无法避免网络重试或消息重复带来的重复扣减,这个提醒值得在设计评审中单独验证。
多渠道库存部分说明了库存模型的重要性。物理库存、锁定库存、可售库存和渠道配额如果没有明确边界,即使数据库扣减原子,也可能产生业务层面的超卖。