数据库存:技术负责人落地路线图:从性能优化走向降低超卖风险
数据库性能变慢,通常先表现为接口超时、连接池耗尽、慢查询增加;但在库存、票务、酒店、课程名额和限量权益场景里,真正危险的并不是“慢了几秒”,而是系统在高并发下错误地认为还有库存。我曾参与过一类典型排查:商品详情页显示剩余 37 件,订单服务却在 90 秒内创建了 52 笔待支付订单,数据库没有明显宕机,平均响应时间甚至只从 80 毫秒升到 210 毫秒。问题最后并不在某一条 SQL,而在库存读取、锁等待、订单超时释放和重试机制之间形成了短暂的状态失真。
因此,数据库存储与性能优化不能只围绕“查询更快”展开。技术负责人真正要落地的是一条从容量、访问路径、并发控制、库存状态、订单生命周期到事后校准的完整路线。本文将以高并发库存扣减为主线,拆解哪些优化能降低超卖风险,哪些所谓优化反而会扩大风险,并给出可以分阶段执行的指标、架构和取舍方法。
数据库性能问题的核心指标,通常是吞吐量、响应时间、锁等待、连接使用率和磁盘 I/O。超卖问题的核心指标则是可售库存、已预占库存、已支付库存、已取消释放库存,以及这些状态之间是否满足业务约束。
两者相关,但不是同一个问题。一个查询从 200 毫秒降到 20 毫秒,确实可以减少请求堆积,却不能自动保证两个并发事务不会同时读取到同一个库存值。相反,一个带条件的原子扣减语句,即使单次执行需要 30 毫秒,也可能比“先查询、再更新”的 5 毫秒方案更安全。
我的判断原则是:凡是涉及库存扣减,都要先证明不会突破业务不变量,再讨论如何提升吞吐。所谓业务不变量,可以简单表述为:可售库存不能小于 0;已预占库存不能超过可售库存;同一个业务请求不能成功扣减两次;订单取消后只能释放一次。
| 问题类型 | 典型表现 | 优先治理手段 | 不能单独解决的问题 |
|---|---|---|---|
| 查询性能不足 | 慢查询增加、CPU 飙升、接口超时 | 索引、SQL、分库分表、读写分离、缓存 | 不能保证库存扣减的业务原子性 |
| 并发竞争激烈 | 锁等待、死锁、更新冲突 | 缩短事务、拆分热点、控制并发入口 | 不能替代幂等和状态机设计 |
| 库存状态不一致 | 库存显示与订单结果不一致 | 预占、确认、释放、对账、补偿 | 不能只靠缓存刷新解决 |
| 重复请求 | 用户重复点击、网关重试、消息重复投递 | 幂等键、唯一约束、幂等消费 | 不能只靠前端按钮置灰解决 |
在实际项目中,我会把风险拆成两条链路:一条是“请求能不能及时完成”,另一条是“库存能不能只被正确扣减一次”。前者可以用扩容、缓存和异步化改善;后者必须靠数据库约束、事务边界、唯一键和可重放的状态转换来兜底。

很多库存系统一开始只有一个字段:stock。后来业务增加了预售、锁库存、分仓、组合商品、渠道配额、退款和超时关闭,团队不断往表里增加字段,最终没人能准确解释某个数值到底代表什么。
我更倾向于先建立库存账本,再决定数据库表结构。至少要区分以下状态:物理库存、可售库存、预占库存、已支付库存、已取消待释放库存、损耗库存和渠道冻结库存。不同业务可以合并部分状态,但不能把“展示库存”和“扣减库存”默认为同一个概念。
例如,一件商品有 100 件物理库存,运营为线下渠道预留 20 件,已经有 15 件被用户下单但未支付,那么线上可售库存不应该直接显示为 85 或 100,而要根据渠道策略明确计算口径。如果这个口径不清晰,前端显示、下单校验、仓库出库和财务结算会各自使用一套数字。
库存扣减成功不能只看接口返回了 HTTP 200,也不能只看缓存中的数字减少了。真正可验证的成功至少包括:请求拥有唯一业务标识;库存状态发生了合法变化;订单与库存建立了关联;后续重复请求不会再次产生扣减;出现超时后可以查询最终结果。
因此,我通常会要求库存接口返回一个可查询的业务结果,而不是让客户端根据超时自行猜测。例如请求超时后,客户端带着业务幂等键查询“处理中、成功、失败或已过期”,而不是立即发起一个全新的扣减请求。
在秒杀、限量商品或票务销售中,一次购买通常经历以下步骤:用户读取商品信息;提交购买请求;校验资格和数量;预占库存;创建订单;支付;支付成功后确认库存;超时未支付则释放库存;退款或售后时再进行反向处理。
只要其中一个环节采用了不同的数据源,或者状态转换没有明确的幂等规则,就可能产生“库存少扣、重复释放、订单重复创建、支付成功但无库存”等问题。特别是缓存、消息队列和数据库同时存在时,系统不是只有一个库存值,而是有多个时间不同步的库存视图。
这条链路里,最容易被忽略的是第六和第七步。很多团队把释放库存当成“补丁”,把对账当成“运营报表”。在我负责过的系统里,真正让库存差异从每天数百笔下降到个位数的,不是再次更换缓存组件,而是补齐了释放事件的幂等约束和可重放对账流程。
数据库变慢不会直接制造超卖,但它会把原本很短的竞态窗口拉长。当库存检查和库存更新之间相隔 5 毫秒时,两个请求可能偶尔撞车;当连接排队、锁等待和网络重试把窗口拉长到数百毫秒时,撞车概率会显著增加。
更危险的是,调用方经常把超时当成失败,然后重试。第一次请求可能已经在数据库中成功扣减,只是响应没有及时返回;第二次请求再次进入时,如果没有幂等键,就可能造成重复扣减。于是,表面上看到的是“数据库慢”,根因却是“超时语义不完整”。
我在压测中经常观察到一种反直觉现象:P99 延迟从 600 毫秒升到 2 秒后,错误率未必同步升高,但重复提交比例会明显上升。因为用户和网关都更倾向于重试,而重试正是库存系统最怕的输入之一。

在成熟系统里,我通常会把数据按职责分为四类,而不是所有数字都塞进同一张库存表。
如果团队使用数据分析平台观察库存趋势,应该把它放在分析层,而不是让分析结果直接作为实时扣减依据。以九数云这类数据分析工具为例,它更适合连接订单、库存流水、支付和仓储数据,观察库存差异、超时释放率和渠道消耗趋势;实时扣减仍然要回到具备事务和约束能力的业务存储中。
我会特别关注分析口径是否统一。例如,报表中的“库存消耗”可能按订单创建时间统计,仓库却按出库时间统计,两者天然存在时间差。只有把事件时间、业务状态和统计口径写进数据字典,分析工具才不会把正常延迟误判成库存异常。
最常见的伪代码是:先执行查询,判断库存大于购买数量,再执行更新。单线程下它完全正常,高并发下却会出现两个请求同时读到相同库存,随后分别完成更新。
这段逻辑的问题不在于查询慢,而在于“判断”和“修改”不是一个原子动作。即使给查询加了缓存,甚至把缓存读取速度提升到微秒级,也无法从根本上解决并发下的竞争。
SELECT stock FROM inventory WHERE sku_id = ?; -- 应用层判断 stock >= quantity UPDATE inventory SET stock = stock - ? WHERE sku_id = ?;
更可靠的方式是把业务条件放进更新语句,至少让数据库保证只有满足条件的请求能够修改成功。
UPDATE inventory SET available_stock = available_stock - ?, reserved_stock = reserved_stock + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ? AND available_stock >= ?;
应用层必须检查受影响行数。受影响行数为 1,才代表本次预占成功;为 0,则可能是库存不足、商品状态不允许或版本冲突,不能简单当成数据库异常。
缓存适合承接读流量和快速拦截,但它有过期、淘汰、复制延迟、故障恢复和并发脚本执行等问题。只要缓存中的扣减结果没有可靠地落到权威账本,系统就可能出现缓存说“还有货”、数据库说“没有货”的分裂状态。
我不反对使用缓存扣减,反对的是没有定义缓存扣减失败后的处理路径。使用缓存作为第一道闸门时,至少要回答四个问题:缓存扣减成功后数据库写入失败怎么办;数据库写入成功后缓存更新失败怎么办;缓存重启恢复时从哪里重建;用户超时未支付时如何保证只释放一次。
如果这些问题没有答案,缓存不是性能优化,而是新增了一个不可追踪的库存账本。
读写分离对商品详情、订单列表和历史查询很有价值,但库存扣减前的可售判断如果读取了存在延迟的副本,就可能拿到旧值。尤其在主库刚刚扣减库存后,副本尚未同步,新的请求又从副本读到旧库存,随后继续进入扣减流程。
读副本并非完全不能用。我的做法是:展示页可以读副本或缓存;真正的库存预占必须由主库执行原子条件更新,或者由具备明确一致性保证的库存服务完成。不要因为副本查询平均只需 3 毫秒,就让它参与最终交易判断。
SELECT ... FOR UPDATE 可以保护行级并发,但它会把事务持续时间、锁竞争和连接占用一起带进业务流程。如果事务里还包含调用支付、发送网络请求、写多个远程服务,锁持有时间会迅速增长。
悲观锁适合库存热点不高、流程短、事务边界清晰的场景。它不适合把整个下单流程包住。我的原则是:锁住库存行只做必要的本地状态变更,订单创建、消息发送和支付通知通过可靠事件或事务消息衔接,避免在数据库事务内等待外部系统。
单纯记录“今天超卖 12 件”无法帮助团队定位问题。库存差异至少要拆成重复扣减、重复释放、支付确认丢失、订单创建失败、缓存与主库不同步、人工调整未入账和仓储回传延迟等类别。
如果所有异常都归类为“库存不一致”,技术团队只能不断加重试和补偿,最终可能把一个小问题变成重复处理风暴。每次补偿都必须带上原始事件 ID、处理次数、最后错误原因和当前状态。

我会要求团队先画出库存和订单的状态机。技术方案如果没有状态机,通常会把“创建订单”“锁定库存”“支付成功”“释放库存”混成几个接口名称,却没有明确它们之间的合法转换。
一个简化的库存状态机可以是:可售库存减少,预占库存增加;支付成功后,预占库存减少,已售库存增加;订单超时后,预占库存减少,可售库存增加;退款完成后,根据履约阶段决定是否恢复可售库存。
每个状态转换都要明确触发者、输入事件、前置状态、目标状态、可否重复执行以及失败后的重试方式。只要一个事件可以被重复投递,就必须保证重复执行不会重复改变库存。
| 事件 | 前置状态 | 目标状态 | 库存变化 | 幂等依据 |
|---|---|---|---|---|
| 预占成功 | 订单待创建 | 库存已预占 | 可售 -n,预占 +n | 请求幂等键或预占单号 |
| 支付确认 | 库存已预占、订单待支付 | 订单已支付 | 预占 -n,已售 +n | 支付流水号 |
| 超时释放 | 库存已预占、订单待支付 | 订单已关闭 | 预占 -n,可售 +n | 订单号加释放事件号 |
| 退款恢复 | 已支付、未出库 | 退款完成 | 已售 -n,可售 +n | 退款单号 |
库存操作至少有三种常见模型。第一种是单字段原子扣减,适合简单商品;第二种是预占加确认,适合支付耗时较长、需要保留库存的订单;第三种是库存流水账本,适合需要审计、对账和多渠道分配的复杂业务。
我不会根据流行程度选模型,而会看四个条件:支付链路最长多久;订单取消比例是多少;是否存在多仓、多渠道和组合商品;出现异常后是否需要追溯每一次变化。
对于大多数交易系统,我更推荐“当前快照加不可变流水”的组合:库存快照用于快速判断和更新,库存流水记录每一次业务变化。快照坏了可以通过流水重建,流水丢失则很难解释库存为什么变成当前数值。
不要看到数据库 CPU 高就立即分库分表,也不要看到响应变慢就立即加缓存。技术负责人应该先把请求耗时拆成排队、连接获取、SQL 执行、锁等待、网络传输和下游调用几个部分。
如果 SQL 执行只占 15 毫秒,而连接池等待占 180 毫秒,那么优化索引不会产生明显收益;如果 SQL 执行占 400 毫秒,且执行计划因为隐式类型转换走了全表扫描,那么增加数据库节点也可能只是延缓问题。
我常用以下顺序做判断:

库存接口最危险的失败方式不是明确返回“库存不足”,而是返回超时、未知状态或重复处理。安全失败意味着:当系统无法确认本次扣减是否完成时,不继续扩大库存变化,而是进入可查询、可恢复的中间状态。
例如,数据库写入成功但响应丢失,服务不能直接让客户端重试创建新订单,而应通过幂等键查询原订单。消息消费失败时,不能把库存自动加回,除非能够确认之前的扣减已经完成且没有后续支付或发货事件。
未知状态必须有查询接口,失败状态必须有补偿路径,成功状态必须有唯一事实。这是库存系统区别于普通 CRUD 系统的关键。
下面的案例来自我参与的一类限量商品项目,数据经过脱敏和区间化处理,用于说明排查方法。系统日常订单量约 18 万单,活动期间峰值约 3200 次下单请求/秒,单个热门 SKU 的库存只有 5000 件。
系统最初采用“缓存展示库存加数据库订单表”的结构。下单时先读取缓存,判断库存大于购买数量;随后创建订单,再异步扣减数据库库存。开发团队认为缓存已经提前挡住了大部分流量,数据库压力并不大。
活动开始后的前 3 分钟,平均响应时间只有 110 毫秒,错误率低于 0.5%,监控看起来非常健康。但活动结束后对账发现:数据库库存少了 5007 件,已支付订单对应 4988 件,未支付或重复订单对应 19 件。真正的问题并不是超过库存很多,而是少量异常没有在交易链路中被及时阻断。
我们先停止让缓存值直接决定订单是否成功。缓存仍然保留,但职责调整为流量拦截和页面展示:当缓存显示为 0 时,可以快速拒绝请求;当缓存显示大于 0 时,只代表“允许进入库存服务进一步判断”,不再代表最终成功。
数据库新增库存快照和库存流水。库存快照记录当前可售、预占和已售数量;库存流水记录业务事件号、订单号、变更前数量、变更数量、变更后数量、事件类型和处理时间。
CREATE TABLE inventory_snapshot (
sku_id BIGINT PRIMARY KEY,
available_stock INT NOT NULL,
reserved_stock INT NOT NULL,
sold_stock INT NOT NULL,
version BIGINT NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE inventory_event (
event_id VARCHAR(64) PRIMARY KEY,
sku_id BIGINT NOT NULL,
order_id VARCHAR(64),
event_type VARCHAR(32) NOT NULL,
quantity INT NOT NULL,
before_available INT NOT NULL,
after_available INT NOT NULL,
created_at TIMESTAMP NOT NULL
);
这里的关键不是字段多,而是每次变化都有唯一事件号。只要事件号已经处理过,重复消息就不会再次改变库存。
原方案先读缓存、再创建订单、最后异步扣减,存在订单先成功而库存后失败的窗口。我们将核心扣减改成库存服务内的条件更新,并把预占事件写入流水。
BEGIN; INSERT INTO inventory_event ( event_id, sku_id, order_id, event_type, quantity, before_available, after_available, created_at ) SELECT ?, ?, ?, 'RESERVE', ?, available_stock, available_stock - ?, CURRENT_TIMESTAMP FROM inventory_snapshot WHERE sku_id = ? AND available_stock >= ?; UPDATE inventory_snapshot SET available_stock = available_stock - ?, reserved_stock = reserved_stock + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ? AND available_stock >= ?; COMMIT;
实际实现中,流水插入和快照更新需要结合数据库能力、唯一约束和受影响行数判断进行调整,不能机械照搬这段示例。核心原则是:库存扣减必须在同一个可靠的事务边界内完成,并且事件号能够阻止重复处理。
库存预占成功后,订单可以进入“待支付”;订单创建失败时,必须触发释放事件。支付成功时,预占转为已售;订单超时关闭时,预占回到可售。
我们没有把支付调用放进库存事务,而是采用本地事件表记录待发送事件,再由投递程序发送到消息系统。投递程序可以重复发送,消费端依靠事件号幂等。这样即使消息系统短时不可用,库存事务仍能先完成,后续事件可以重试。
需要注意的是,本地事件表不是万能的。它解决的是“数据库状态已变更但消息没有发出”的问题,不能解决消费者处理到一半宕机、外部支付回调顺序错乱等所有问题。因此,消费者仍需验证订单当前状态,并拒绝非法状态转换。
对账任务每天执行一次远远不够。高风险活动期间,我们增加了分钟级轻量对账和小时级完整对账。
对账发现差异后不能直接改库存字段。正确做法是生成一条调整事件,说明差异来源、审批人、原始依据和修正数量。否则,虽然数字暂时对上了,下一次审计时仍然无法解释变化过程。

改造后,我们没有只看平均响应时间,而是同时观察重复扣减、重复释放、库存负数、订单状态卡住和对账差异。压测采用固定库存、不同并发度和不同重试比例三组条件,结果如下表为区间化示意,重点在趋势而非某个项目的绝对值。
| 指标 | 改造前 | 完成原子扣减后 | 加入幂等与对账后 | 观察结论 |
|---|---|---|---|---|
| P99 下单延迟 | 2.4 秒 | 1.1 秒 | 0.9 秒 | 事务缩短和热点控制改善长尾 |
| 重复扣减率 | 0.31% | 0.08% | 低于 0.01% | 幂等键比单纯提速更能降低重复处理 |
| 库存负数次数 | 每万请求 4.6 次 | 0 次 | 0 次 | 条件更新直接守住库存下限 |
| 重复释放率 | 0.18% | 0.16% | 低于 0.01% | 释放事件需要独立幂等设计 |
| 对账差异发现时间 | 24 小时内 | 4 小时内 | 5 分钟内 | 对账频率决定风险暴露时间 |

如果当前系统已经存在超卖或库存差异,我不建议第一周就开始分库分表。第一阶段的目标是让团队知道系统到底发生了什么,先建立最小可用的事实层。
这一阶段的交付物不应该是一份“监控大盘截图”,而应是一张可追溯的事件链。任何一笔库存异常,都能通过订单号或事件号找到是谁在什么时间、以什么理由改变了多少库存。
对于最简单的单品库存,优先完成条件更新和唯一约束。数据库表上应有合理的主键和索引,扣减语句应包含 SKU、库存状态和库存下限条件。
此时不要急着引入复杂的分布式锁。分布式锁可能增加续期、失效、脑裂和锁服务故障等问题,而单行条件更新已经可以解决很多基本并发竞争。
建议至少进行以下测试:
当支付耗时较长,或者订单取消率较高时,单纯扣减可售库存会让库存长期处于“已扣但未支付”的状态。此时应引入预占模型,并为预占记录设置明确过期时间。
预占不是“先占着再说”。预占记录必须拥有订单号、SKU、数量、创建时间、过期时间和当前状态。释放任务按状态和过期时间扫描,不能只按照订单是否存在来决定是否释放。
释放过程要满足两个条件:第一,只有处于“已预占、待支付”的订单才能释放;第二,同一个释放事件只能成功一次。即使定时任务被重复触发,也只能有一次状态转换成功。
当所有请求都竞争同一个热门 SKU 行时,索引再好也无法消除物理上的写竞争。此时需要在不破坏库存准确性的前提下,减少单点热点。
常见方法包括分片库存、分桶扣减、活动库存预分配和入口排队。但每种方法都有边界。
我的经验是,热点治理不能只看峰值吞吐,还要看活动结束后的库存回收成本。如果为了把单点写入从每秒 3000 次拆到 30 个桶,却需要人工处理大量剩余库存,整体运营成本可能更高。

没有故障演练的库存架构,只能证明正常路径可用。至少要模拟数据库连接池耗尽、主库切换、消息重复、消息延迟、支付回调先到、订单创建后库存确认失败以及释放任务同时运行多个实例。
每次演练都应回答三个问题:用户最终能否查询到确定结果;库存是否突破业务不变量;系统恢复后是否能够自动收敛。不能自动收敛的异常,要明确人工介入入口和审批边界。
补偿任务也要有限速和熔断。如果每秒产生数千条异常事件,补偿程序不能不加限制地全量重试,否则会进一步压垮数据库。建议按事件类型、创建时间和失败原因分队列处理,并为重复失败设置人工审核状态。
如果商品库存充足,峰值请求不高,支付流程短,最适合采用关系型数据库中的单行条件更新,加上订单幂等键和库存流水。不需要为了理论上的极端并发,引入复杂的分布式库存服务。
这类场景最容易被忽视的风险是后台人工调整和退款流程。技术负责人应重点保证所有库存变化都经过统一服务,不允许多个后台系统直接修改库存字段。
限量商品、演唱会门票和稀缺课程名额属于典型场景。这里最重要的是条件更新、入口限流、幂等和热点控制。前端页面显示“仅剩 1 件”并不是承诺,只有库存服务原子预占成功才能形成承诺。
如果库存只有几百件,而请求达到每秒数万次,建议把大量请求挡在库存服务之前。资格校验、用户限购、设备风控和活动时间校验都可以前置,但最终扣减必须仍然由权威库存账本完成。
酒店房间、课程名额和预售商品经常需要给用户留出支付时间。此时直接把可售库存减少到已售,会让未支付订单长期占用库存;直接不扣库存,又会导致多个用户重复获得购买资格。
预占模型适合这类业务,但必须设定释放策略。例如预占 15 分钟后释放,支付回调在释放前到达则确认,支付回调在释放后到达则进入人工或自动退款流程。时间边界必须有明确优先级,不能依赖事件到达顺序。
多仓库存不能简单相加。某个仓库有货,不代表用户所在地区可配送;某个渠道有配额,也不代表其他渠道可以直接使用。技术方案需要明确可售库存是按仓、区域、渠道还是商品总量计算。
我建议把“库存所有权”和“库存可售性”拆开。库存所有权回答这批货属于哪个仓或渠道;库存可售性回答当前用户是否可以购买。两者混在一个字段里,后续几乎必然需要大量人工修正。
组合商品需要同时扣减多个子 SKU。最典型的错误是先扣减主商品,再逐个扣减子商品,某个子商品不足时只能回滚前面的操作。
如果数据库支持事务且涉及 SKU 数量可控,可以在同一个事务中按固定顺序锁定所有子 SKU,避免不同请求以不同顺序加锁。若组合关系复杂、库存分散,应该考虑预计算可售套餐数,或者建立专门的组合库存服务。
固定锁定顺序很重要。例如所有请求都按 SKU 编号升序处理,可以显著减少循环等待。顺序不一致时,一个请求先锁 A 再锁 B,另一个请求先锁 B 再锁 A,就可能形成死锁。
| 方案 | 优点 | 风险 | 适用场景 |
|---|---|---|---|
| 数据库条件更新 | 权威、可审计、实现直观 | 热点写竞争明显 | 中低并发、库存准确性要求高 |
| 缓存快速预扣 | 吞吐高、响应快 | 双写失败、恢复和对账复杂 | 高并发活动的前置拦截或临时库存闸门 |
| 分桶库存 | 分散热点、峰值吞吐高 | 桶不均衡、剩余库存回收复杂 | 少量热门 SKU 的强峰值活动 |
| 排队处理 | 系统稳定、数据库压力可控 | 用户等待,实时性下降 | 可接受等待、库存极少的场景 |
如果业务更担心“少卖”而不是“多卖”,可以采用保守策略:缓存只用于拦截,数据库作为最终扣减;如果业务更担心高峰期间服务不可用,可以增加缓存闸门和排队,但必须把缓存异常后的库存校准设计好。
强一致事务适合库存快照和库存流水这类必须同时变化的数据。订单、支付、营销权益和仓储系统之间,很难全部放进一个跨服务事务,通常需要通过事件最终一致地收敛。
这里的关键不是选择“强一致”或“最终一致”这个口号,而是划分边界:库存核心扣减必须在可控事务内完成;跨系统通知可以异步;最终一致必须有最大收敛时间和异常告警。
如果业务规定支付成功后 30 秒内必须完成库存确认,那么消息系统和补偿任务就需要提供至少低于这个时间的可观测保证。没有收敛时间承诺的“最终一致”,在用户看来就是“系统偶尔丢单”。
分库分表可以解决数据量、写入吞吐和单实例容量问题,但它不会自动解决库存热点。一件热门商品如果仍然落在同一个分片中,分片数量增加也没有意义。
在我看来,分库分表前至少要确认三件事:单库容量是否真的接近上限;慢查询和锁竞争是否已经通过基础优化排除;库存热点是否需要单独的分桶或队列方案。如果只是因为“未来可能增长”就提前拆分,团队会先承担路由、跨库查询、扩容和对账成本。

幂等不能只依赖应用代码中的缓存标记。缓存过期、服务重启或并发请求都可能让应用层判断失效。数据库应通过唯一索引保证同一个业务事件不能重复落账。
CREATE UNIQUE INDEX uk_inventory_event_id ON inventory_event(event_id); CREATE UNIQUE INDEX uk_order_request ON orders(user_id, request_id);
唯一约束触发冲突时,应用要把它解释为“已处理”还是“非法重复”,取决于业务语义。对于客户端重复提交,通常应返回第一次处理结果;对于同一个事件携带不同数量,则应报警,因为这可能意味着幂等键被错误复用。
乐观锁通过版本号判断数据是否在读取后被其他请求修改,适合冲突相对可控的场景。它可以帮助发现更新丢失,但冲突发生后必须有明确的重试、失败或排队策略。
UPDATE inventory_snapshot SET available_stock = available_stock - ?, reserved_stock = reserved_stock + ?, version = version + 1 WHERE sku_id = ? AND version = ? AND available_stock >= ?;
如果一个热门 SKU 每次更新都发生版本冲突,应用层无限重试会把数据库压力进一步放大。版本冲突率超过预设阈值时,应转入排队、分桶或限流,而不是让每个请求持续自旋。
库存系统常见的查询不只是按 SKU 查询,还包括扫描超时预占、查找待补偿事件、筛选某时间段的库存流水和按订单号定位状态。索引要围绕这些真实查询设计。
定时任务不要每次扫描全表。应采用固定批次、游标或按时间窗口分页,避免释放任务在高峰期间突然占用大量数据库资源。
数据库事务日志用于恢复和复制,库存流水用于解释业务变化。不能因为数据库有完整事务日志,就认为业务已经具备审计能力。事务日志通常不包含“这次变化对应哪个订单、为什么释放、是否由人工调整”等业务语义。
库存流水也不能代替数据库事务。它记录了事实,但如果快照更新和流水写入不在可靠边界内,仍可能出现流水有记录、快照没变化,或者快照变化但流水缺失。两者需要相互校验。
常规验收会关注 QPS、平均延迟、P99、CPU 和错误率,但库存系统必须额外加入业务正确性指标。否则,一个能承受每秒一万请求、却多卖 20 件的系统不能算成功。
我建议把指标分成四层:
这些指标需要放在同一时间轴上。比如某次发布后 P99 变差,同时重复请求增加、释放延迟变长,这比单独看数据库 CPU 更能说明风险正在形成。
以下规则可以作为自动化测试和线上告警的基础:
这些规则不应该只写在文档里。能放进数据库约束的放进约束,能通过自动化测试验证的加入测试,不能强约束的则建立监控和人工审批流程。
很多压测脚本会让所有请求使用不同用户、不同订单号,并且数据库始终返回成功。这无法模拟真实活动。真实流量包含重复点击、超时重试、库存不足、支付取消、回调乱序和消息重复。
一套更接近生产的压测至少要包含以下比例的混合请求:
| 请求类型 | 建议模拟比例 | 需要观察的结果 |
|---|---|---|
| 首次下单 | 60%-70% | 吞吐、库存扣减和订单创建耗时 |
| 同幂等键重试 | 10%-15% | 是否只生成一个有效结果 |
| 库存不足请求 | 10%-20% | 是否快速失败,是否产生错误扣减 |
| 取消和超时释放 | 5%-10% | 是否重复释放,释放后库存是否恢复 |
| 支付回调重复或乱序 | 3%-5% | 状态机是否拒绝非法转换 |

库存系统的实时链路负责“现在能不能卖”,分析链路负责“为什么卖成这样”。两者边界清晰,才能避免报表系统承担交易职责。
在分析层,我通常会关注以下问题:哪些 SKU 的库存差异集中发生在某个渠道;超时释放率是否在某个支付方式上显著偏高;库存热点是否集中在少数活动时段;哪个仓库的实物出库延迟最长;人工调整是否集中在某些运营人员或商品类别。
这类问题适合通过数据分析工具连接订单、库存流水、支付、仓储和用户行为数据,形成统一的指标口径。九数云等工具可以用于构建库存差异看板、渠道消耗分析和异常趋势追踪,但分析结果必须通过业务服务或审批流程回写,不能让报表查询直接修改库存。
第一个错误是把订单创建量当成库存消耗量。订单可能取消、支付失败或被风控拦截,只有经过明确状态确认的订单才能计入对应库存口径。
第二个错误是把库存快照的时点值与仓库流水的期间值直接相减。快照是某一时刻的状态,流水是时间段内的变化,必须统一时间边界和事件时间。
第三个错误是忽略补偿和人工调整。报表如果只统计主交易链路,会把补偿事件漏掉,最后看起来库存一直少于订单消耗,却找不到原因。
一个有价值的库存看板不应只是显示“当前库存 1280 件”。我更希望看到库存变化的原因、风险和下一步动作。
如果看板只能告诉团队“出现了问题”,却不能告诉团队“问题发生在哪个状态转换、影响多少订单、是否可以自动修复”,它仍然只是展示工具,不是技术决策工具。

数据库性能优化当然重要,但它只是库存系统可靠性的地基,不是全部答案。缓存可以降低读取压力,索引可以缩短查询时间,分桶可以分散热点,队列可以削平流量峰值;这些手段解决的是系统如何承接请求。
而库存安全依赖另一套更严格的设计:条件更新守住数量下限,唯一约束阻止重复处理,状态机限制非法转换,预占和释放覆盖订单生命周期,库存流水提供追溯,对账任务让异常最终收敛。
我最看重的不是系统在理想状态下能承受多少请求,而是请求超时、消息重复、支付乱序和数据库切换之后,库存是否仍然不会突破业务不变量。
如果只能先做一件事,我建议先建立“库存事件流水加幂等约束”。它未必让接口立刻变快,却能让团队第一次真正知道库存为什么变化,也能在出现异常时阻止问题继续扩散。数据库优化应该从这里出发,最终回到业务结果:少超卖、少人工修正、少未知订单,让每一次库存变化都能被解释、被验证、被恢复。
我们把库存查询接口从 180ms 优化到 35ms 后,原以为活动期间的超卖问题会自然消失,但压测时仍出现了库存扣减异常。我一直疑惑:数据库更快了,为什么多个请求还是会同时买到最后一件商品?
因为性能和正确性解决的是两类问题。性能优化回答的是“系统能否更快处理请求”,而超卖治理回答的是“多个请求同时到达时,谁有资格成功扣减库存”。如果业务仍采用“先查询库存、再执行扣减”的两步逻辑,数据库即使响应更快,也只是让更多请求更快地读到同一个库存值。
我在一次商品库存压测中对比过两种写法:库存初始值为 100,使用 500 个并发请求,每次购买 1 件。先查询再更新的逻辑在高并发下出现过扣减结果超过可售库存的情况;改成带条件的原子更新后,成功扣减数始终不超过 100,失败请求通过受影响行数识别。
方案库存判断方式主要风险适合定位 先查后扣应用层判断存在并发时间窗口不建议作为最终扣减逻辑 条件更新数据库原子判断仍需处理幂等和回补数据库层基础防线 缓存预扣减缓存层快速判断缓存与持久化数据可能不一致削峰和快速失败 基础 SQL 可以写成
UPDATE inventory SET available_stock = available_stock - :qty WHERE sku_id = :sku_id AND available_stock >= :qty;。技术负责人应根据受影响行数判断是否扣减成功,而不是相信此前查询到的库存数字。我的判断是:数据库调优应该先做,但不能把它当成防超卖方案。正确顺序是先建立原子扣减约束,再通过索引、热点行治理和连接池优化,让这条正确的路径能够承受峰值流量。
团队准备在热门商品上增加分布式锁,开发同事认为只要同一商品串行处理,就不会超卖。但我担心锁服务超时、锁提前释放,或者锁和数据库事务不在同一个边界时,问题反而更复杂,实际落地应该怎么排优先级?
通常应先做数据库原子扣减,再判断是否需要分布式锁。原因很直接:数据库条件更新是库存事实写入附近的约束,而分布式锁只是业务流程外部的并发协调工具。锁能减少同时进入某段代码的请求,却不能自动防止重复消费、异常重试或事务提交后的状态错乱。
我曾测试过一个热门 SKU 的扣减链路:加锁后,单个 SKU 的更新冲突明显减少,但锁等待时间在流量突增时迅速上升;当持锁逻辑包含订单创建和消息发送时,锁的持有时间从几十毫秒扩大到数百毫秒。更麻烦的是,锁超时后旧请求仍可能继续执行,单靠锁并不能证明扣减一定安全。
控制手段主要解决的问题不能替代的能力推荐优先级 条件更新库存不足时禁止扣减重复请求和库存回补第一优先级 业务幂等避免同一业务重复生效热点流量削峰第一优先级 分布式锁协调复杂并发流程最终数据约束按热点和流程复杂度引入 限流或排队减少进入核心链路的请求库存正确性容量不足时引入 更稳妥的执行顺序是:先用条件更新保护库存不小于零,再用订单号或幂等键防止重复扣减,最后只在确实存在非原子业务流程时增加锁。
锁的粒度应尽量小,持锁期间不要执行长耗时远程调用,并且必须定义锁服务不可用时是失败、排队还是降级。如果一个方案只能回答“我们加了锁”,却回答不了“锁失效后数据库是否仍然安全”,我不会把它作为上线前的核心保障。锁是协调器,不是库存事实源。
为了降低数据库压力,我们考虑把库存先放到缓存里,用户下单时直接在缓存中扣减,之后再异步写入数据库。这个方案看起来吞吐量很高,但我不确定缓存扣减成功而数据库写入失败时,库存应该以谁为准,如何避免少卖或超卖?
缓存预扣减适合削峰和快速失败,但不适合脱离持久化约束独自承担最终库存事实。它把数据库热点更新转移到了缓存,却没有自动解决缓存扣减成功、消息发送失败、消费者重复执行、缓存重启丢失等问题。在一次模拟测试中,我故意让缓存扣减成功后暂停数据库写入,并重复投递同一条扣减消息。
没有幂等记录时,同一订单可能被重复落库;增加业务幂等键和库存流水后,重复消息可以被拦截,但仍需要补偿任务处理“缓存已扣、数据库未记账”的悬挂状态。
场景缓存结果数据库结果必须处理的动作 正常扣减成功成功记录订单和库存流水 缓存成功后服务中断成功未写入可靠消息、重试和补偿 消息重复消费可能重复触发可能重复扣减以业务幂等键拦截 缓存数据丢失库存状态不可用可能仍有正确数据从持久化数据重建并暂停高风险操作 我更倾向于采用分层职责:缓存用于展示库存、活动预热和拦截明显无货请求;
数据库或专用库存服务负责最终扣减;库存流水记录每一次预占、确认、释放和回补;对账任务负责发现两套数据的差异。还要明确业务能接受多长时间的不一致。普通商品可能允许短暂延迟,但票务、限量权益或库存极少的商品,不能简单套用“最终一致性”。
如果无法定义差异发现时限、自动补偿方式和人工处理边界,就不应把纯缓存扣减直接用于核心交易链路。
我们的库存系统已经使用了缓存、消息队列和分布式锁,但出现问题时仍然很难定位:有时是重复扣减,有时是订单取消后库存没有释放。我想知道,预算和研发资源有限时,应该先做哪些事情,如何用数据证明治理真的有效?
我建议按“建模、约束、扩容、验证”四个阶段推进,而不是先采购或接入更多中间件。很多团队的问题并不是组件数量不够,而是没有定义可售库存、预占库存和已确认库存,也没有规定订单状态变化对应哪一次库存操作。
在实际排查库存差异时,最有价值的通常不是一张架构图,而是三份能够互相核对的记录:订单状态、库存主表和库存流水。如果只能看到库存字段从 10 变成 9,却无法回答是哪笔订单扣减、是否重试过、取消后是否释放,这套系统就缺乏可审计性。
阶段优先交付物验收重点 第一阶段:盘点建模库存口径、状态机、异常路径清单明确预占、确认、释放和回补时点 第二阶段:数据约束原子扣减、幂等键、库存流水库存不小于零,重复请求不重复生效 第三阶段:并发治理限流、缓存、排队或热点隔离峰值下锁等待、数据库负载可控 第四阶段:验证闭环压测、对账、监控和故障演练异常可发现、可补偿、可追溯 指标也要分成两组看。
性能侧关注库存扣减接口 P95、P99、数据库锁等待、事务回滚率和消息堆积;正确性侧关注超卖订单数、重复扣减拦截数、释放失败数、订单与库存流水差异数,以及异常发现到修复的时长。
上线前我不会只做“接口能否跑通”的压测,而会加入重复提交、消费者重启、支付回调延迟、订单超时释放、主从延迟和库存服务重启等故障场景。验收标准应包括:库存不出现负数、同一幂等键只产生一次有效扣减、取消订单能在规定时间内释放库存,并且对账能够解释每一笔差异。
如果资源有限,最先做的不是分布式锁或缓存,而是库存定义、原子扣减、幂等、流水和对账。这五项完成后,团队才有资格根据真实压测数据决定是否需要进一步引入削峰和串行化组件。


读者评论
把平均响应时间和库存安全拆开讲很实用。尤其是“先查询再更新”的问题,很多系统单测没问题,但并发下确实容易出错,受影响行数和唯一幂等键应该作为扣减成功的基本判断。
文章提到的 P99 延迟比平均值更值得关注。接口超时后第一次请求可能已经扣减成功,如果没有结果查询和幂等处理,简单重试反而会放大超卖风险,这一点对网关和客户端设计也有提醒意义。
库存账本、缓存状态、异步事件和分析数据分层的思路比较清晰。实际落地时,释放库存和对账往往最容易被忽略,建议再补充不同订单状态下的补偿时限及异常处理示例,会更方便团队执行。