数据库存:技术负责人必看清单:用并发扣减推动提升查询性能
很多库存接口变慢,并不是数据库突然“性能不够”,而是一次扣减被拆成了查询、判断、更新、创建订单和重试等多个步骤。以一个限量商品接口为例,原本一次请求只需要完成库存扣减,却可能先查询库存,再把结果返回应用层,随后执行更新;当库存不足时,大量请求仍然完成了前置查询。我的判断是:并发扣减真正能优化的,不是所有查询,而是减少扣减链路中的无效读取、缩短事务路径,并把业务判断尽可能下沉到数据库的原子执行中。
但这并不意味着把“先查再扣”改成一条 UPDATE 语句,系统就必然获得数量级提升。热点商品可能带来行锁排队,连接池可能先于数据库 CPU 达到上限,缓存又可能制造库存展示与真实库存之间的偏差。技术负责人需要验收的,不能只是一条看起来更快的 SQL,而是一条能够压测、监控、重试、补偿和对账的完整扣减链路。
库存场景里经常出现一个概念混淆:原子扣减可以减少一次读取,但它不一定提升商品详情、订单列表、报表查询等其他业务的性能。它主要影响的是库存扣减接口的数据库往返次数、事务持续时间、锁竞争和连接占用。
如果技术负责人只看接口平均响应时间,很容易得到错误结论。一个接口平均耗时从 80 毫秒降到 55 毫秒,可能只是低并发下的收益;一旦热门商品的请求集中到同一条库存记录,P99 延迟仍可能从 200 毫秒升到 3 秒。真正需要同时观察的是请求吞吐、P95、P99、锁等待、连接池排队和扣减正确性。
| 观察对象 | 它回答的问题 | 不能单独说明什么 |
|---|---|---|
| 平均响应时间 | 整体请求的平均耗时是否下降 | 无法识别少量极慢请求和热点锁等待 |
| P95/P99 延迟 | 高分位用户是否遇到明显排队 | 不能直接说明数据库 CPU 是否是根因 |
| 数据库连接池等待 | 应用是否拿不到数据库连接 | 连接池空闲不代表 SQL 没有锁竞争 |
| 行锁等待时间 | 是否大量请求在争用同一库存行 | 无法单独判断索引是否设计正确 |
| 库存对账差异 | 扣减结果是否可靠 | 不能代替性能指标 |

较稳妥的基础实现,是把“库存是否足够”和“库存扣减”合并为一次条件更新。示例 SQL 如下:
UPDATE inventory SET available_stock = available_stock - :quantity, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_stock >= :quantity;
执行后通过影响行数判断结果。影响行数为 1,通常表示扣减成功;影响行数为 0,表示条件没有满足。但在真实系统中,影响行数为 0 可能对应库存不足、商品不存在、SKU 状态不可售或参数校验失败,不能直接把所有情况都返回成“库存不足”。
这条 SQL 的价值在于,应用不再需要先读取一个可能马上失效的库存值,再把计算结果写回数据库。判断条件和扣减动作由同一个数据库更新操作完成,数据库可以在自己的并发控制机制下完成校验与修改。
需要特别强调,原子条件更新不是“并发执行更多 SQL”,而是减少业务层不必要的 SQL,并缩小读取和写入之间的并发窗口。如果原有流程包含复杂订单创建、远程调用、日志写入和库存更新,那么真正应该优化的是事务边界,而不是简单地把更多步骤塞进一个事务。
这四个问题缺一不可。只追求速度,可能把库存一致性牺牲掉;只追求强一致,又可能把所有后续业务都放进长事务,导致数据库在高峰期被锁等待拖垮。
在我审查类似库存接口时,最常见的链路并不是一条查询加一条更新,而是下面这样:
其中前两次读取经常没有被纳入“扣减成本”统计,因为开发人员只统计了 UPDATE 的执行时间。但对数据库来说,每一次 SQL 都意味着连接占用、网络往返、解析或执行、事务上下文和结果传输。高峰期大量库存不足请求仍然完成前置读取,就会形成大量没有业务价值的数据库工作。
如果一个请求平均执行 2 次读取、1 次更新,峰值每秒 8,000 个请求,数据库至少需要处理约 24,000 次 SQL 操作,还没有计算订单状态、幂等记录和日志写入。把库存判断与扣减合并后,未必能让数据库完全脱离瓶颈,但可以先去掉一部分重复工作。

假设库存只剩 1 件,同时有两个请求进入。请求 A 和请求 B 都先读取到库存为 1,随后分别在应用层计算出 0,再执行更新。如果更新语句只是把库存设置为应用层传回的结果,两个请求都可能认为自己扣减成功,最终形成订单数量大于真实库存的情况。
把两个操作放进事务可以改善部分问题,但事务本身不等于自动正确。隔离级别、锁类型、更新条件、事务持续时间和数据库引擎都会影响并发行为。尤其是事务中如果包含远程调用,锁可能被持有数百毫秒甚至数秒,后续请求就会在同一行上排队。
库存为零后,继续到达的请求已经不可能成功,但如果系统仍然让它们执行完整的库存查询、订单校验和事务流程,数据库压力不会因为库存耗尽而下降。相反,失败请求可能因为客户端重试、网关重试和消息重复消费继续放大。
因此,库存系统需要设计“快速失败”路径。例如使用短时缓存记录明显售罄状态,或者在接入层限制同一用户、同一 SKU 的重复请求。但这类措施只能减少无效流量,不能代替数据库最终扣减。缓存判断和数据库扣减之间仍然可能存在竞态。
10,000 个请求均匀分布在 10,000 个 SKU 上,和 10,000 个请求集中争抢一个 SKU,对数据库的影响完全不同。前者可能是普通索引访问,后者会把单行更新变成一个天然串行点。
我在方案评审中会先问三个问题:请求是否集中在少数热门 SKU?单个 SKU 的库存记录是否只有一行?业务是否允许把库存拆成多个可扣减单元?如果这些问题没有答案,直接讨论缓存、分库分表或消息队列,通常只是堆组件。
并发数只是同时在途的请求数量,不是数据库每秒能完成的有效事务数量。当数据库已经出现锁等待时,继续增加压测并发,往往只会增加排队请求和超时数量。
例如,单个库存行每次更新耗时 2 毫秒,理论上这一行的修改本身就存在串行化约束。把客户端并发从 500 提升到 5,000,不会让同一行在 1 秒内完成 2,500 次可靠修改,反而会积累更多等待事务。
索引只能帮助数据库更快定位目标记录,不能消除同一行被多个事务修改时的锁竞争。对于库存表,`sku_id` 命中唯一索引后,定位可能非常快,但更新、日志写入、事务提交和锁释放仍然需要时间。
另一个常见问题是索引条件与实际 SQL 不一致。开发人员以为索引存在就够了,却没有通过执行计划确认扫描行数、访问路径和锁定范围。尤其在数据分布变化后,原本合理的执行计划可能发生改变。
缓存适合做展示、预过滤和快速拒绝,但缓存扣减成功不等于数据库扣减成功。网络超时、进程崩溃、消费者重复消费和缓存过期,都可能让缓存结果与数据库结果出现偏差。
如果业务允许最终一致性,可以采用缓存预扣、异步落库和失败补偿;如果是支付前必须严格确认的库存,则需要数据库或可靠库存服务作为最终约束。关键不在于“是否使用缓存”,而在于谁是库存最终事实来源,以及失败后如何恢复。
大事务看起来更安全,却会延长锁持有时间。库存扣减、订单创建、支付请求、优惠计算、积分发放和通知发送如果都处于同一事务中,任何一个外部服务变慢,都可能拖住库存行。
更合理的做法通常是区分核心事务和后续流程。库存与订单关键状态需要明确一致性边界,通知、积分、统计和日志等非核心动作可以通过可靠事件或消息异步处理。这样做会增加补偿和对账工作,但能避免把外部依赖的延迟直接传导给数据库锁。
影响行数只能说明 UPDATE 是否匹配并修改了记录,不能自动告诉你“库存不足”还是“商品不存在”。如果接口把所有 0 行结果都当成库存不足,排障时就无法区分数据问题、参数问题和正常售罄。
在对外接口层,我建议保留明确的业务错误码,例如商品不存在、商品已下架、库存不足、重复请求和系统繁忙。必要时可以通过商品状态缓存或低成本读取区分场景,但不要为了返回一个更精确的错误码,又把高峰期所有失败请求重新变成复杂查询。

在没有链路数据时,所有“数据库慢”的结论都不够可靠。建议给库存接口增加阶段耗时,包括参数校验、库存读取、库存更新、订单写入、外部调用、事务提交和响应排队。
每个阶段至少记录平均值、P95 和 P99,并增加 SKU 标识的脱敏分组,例如普通 SKU、热门 SKU、库存为零 SKU。只有这样,才能判断问题是所有商品都慢,还是少数热门商品把尾部延迟拉高。
读取慢,通常优先检查索引、条件选择性、返回字段和数据量;写入慢,需要检查更新索引数量、事务提交、日志写入和锁冲突;等待慢,则要看连接池、线程池、锁队列和外部服务。
原子条件更新主要能减少读取与应用层判断的成本,但如果真正瓶颈是单行锁等待,它不一定直接降低 P99。此时应该减少事务长度、降低无效重试,或者改变库存数据的物理分布。
不同业务对库存的容忍度不同。限量商品、票务座位、具有金融属性的额度扣减,通常不能接受超卖;内容权益、积分展示或营销资格预估,可能允许短时间最终一致。
| 业务类型 | 优先目标 | 更适合的扣减策略 | 主要风险 |
|---|---|---|---|
| 票务和座位 | 绝不重复占用 | 数据库原子更新或可靠库存服务 | 热点资源锁等待、超时释放 |
| 限量商品 | 库存正确与峰值稳定并重 | 原子更新加快速失败,必要时队列削峰 | 重试放大、订单补偿复杂 |
| 积分兑换 | 扣减正确、可审计 | 事务扣减加幂等流水 | 重复消费、对账压力 |
| 营销资格 | 高吞吐和用户体验 | 缓存预过滤加异步确认 | 短暂展示不一致 |
我不建议一开始就把库存系统改造成缓存、队列、分布式锁和分库分表的组合。更稳妥的顺序是:先保留现有数据模型,去掉先查再扣,确认索引和事务边界,再观察锁竞争和连接池变化。
如果单条原子更新已经达到业务目标,就没有必要为了追求架构复杂度而引入更多中间件。如果热点行成为明确瓶颈,再针对请求分布选择拆分库存、排队或预扣减。每增加一个组件,就要同时增加重试、幂等、补偿、监控和运维成本。

按购买数量扣减时,条件应直接约束可用库存,而不是在应用层读取旧值后计算新值:
UPDATE inventory SET available_stock = available_stock - :quantity, reserved_stock = reserved_stock + :quantity, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_stock >= :quantity;
这里的 `available_stock >= :quantity` 是防止库存变成负数的关键条件。`quantity` 必须在应用层校验为正数,并设置单次最大购买数量。否则,负数扣减可能被解释为库存增加,形成非常隐蔽的业务漏洞。
如果业务不需要同时维护 `reserved_stock`,不要为了“字段完整”而更新无关字段。一次更新涉及的字段越多、索引越多、触发逻辑越复杂,事务成本越高。库存表应该尽量承载扣减所必需的信息,非核心审计信息可以写入独立流水表或异步事件。
库存扣减请求必须携带业务幂等键,例如订单号、兑换单号或请求流水号。仅依靠库存值判断是否成功,无法防止客户端超时后重复提交。
BEGIN;
INSERT INTO inventory_deduction_record
(request_id, sku_id, quantity, status, created_at)
VALUES
(:request_id, :sku_id, :quantity, 'PROCESSING', CURRENT_TIMESTAMP)
ON CONFLICT (request_id) DO NOTHING;
— 只有首次插入成功,才执行库存扣减
UPDATE inventory
SET available_stock = available_stock - :quantity,
updated_at = CURRENT_TIMESTAMP
WHERE sku_id = :sku_id
AND available_stock >= :quantity;— 根据更新行数和幂等记录状态决定提交或回滚
COMMIT;
不同数据库的冲突写法不同,上面的 `ON CONFLICT` 只是示意,落地时应按照所使用的数据库语法改写。更重要的是,幂等记录与库存扣减必须处于清晰的事务边界内,不能出现“库存扣了,但幂等记录没有写入”或“幂等记录写了,但库存没有扣”的不可解释状态。
如果 `sku_id` 唯一,通常应建立唯一索引或主键约束。不要为了同时覆盖 `available_stock >= :quantity`,盲目建立复杂联合索引。库存值是高频变化字段,把它放进索引可能增加更新维护成本,是否有收益必须通过执行计划和压测确认。
EXPLAIN UPDATE inventory SET available_stock = available_stock - 1 WHERE sku_id = 10001 AND available_stock > 0;
执行计划之外,还要查看真实运行时的锁等待和扫描情况。某些数据库对 UPDATE 的执行计划展示方式与 SELECT 不同,不能只凭一条 EXPLAIN 输出作结论。应结合慢查询日志、数据库活动会话、锁监控和实际数据分布判断。
库存扣减事务内不应调用支付服务、优惠服务、短信服务或其他不受当前数据库控制的远程接口。外部服务的网络抖动会延长事务时间,锁等待会按请求数累积。
更常见的做法是先在本地事务中完成库存和订单关键状态变更,再通过可靠事件驱动后续动作。若订单创建与库存扣减必须强一致,需要在同一数据库内设计合理事务;如果跨越多个服务,则应接受最终一致性,并准备状态机、补偿任务和对账程序。

下面用一个限量商品场景说明排查过程。假设商品库存为 10,000 件,活动开始后的 10 秒内收到 80,000 个扣减请求,其中 60,000 个请求集中访问同一个热门 SKU,其余请求分散在多个普通 SKU 上。
这种场景中,数据库可能并没有达到 100% CPU。普通 SKU 的查询很快,整体平均耗时也不一定异常;但热门 SKU 的库存行被持续更新,后续事务必须等待前一个事务释放锁。最终表现通常是:成功请求延迟升高,库存不足请求大量超时,客户端开始重试,数据库又承受额外流量。
在情景压测中,我会将方案拆成三个对照组,而不是直接比较一个“优化前”和一个“优化后”的总结果。
| 方案 | 核心路径 | 优势 | 主要短板 |
|---|---|---|---|
| 先查再扣 | 查询库存→应用判断→更新库存 | 改造直观,容易接入旧代码 | 往返次数多,读写窗口大,重试成本高 |
| 原子条件更新 | UPDATE 条件判断与扣减 | 路径短,正确性边界更清晰 | 热门 SKU 仍可能出现行锁排队 |
| 队列削峰后扣减 | 请求入队→消费者按节奏扣减 | 控制数据库瞬时写入,保护下游 | 用户不能立即知道最终结果,需要状态查询和补偿 |
情景观察显示,原子更新最先解决的是“无效读取”和“应用层读写窗口”,而不是热门行的物理串行化。队列削峰则把同步等待转成异步排队,能保护数据库,但会改变用户体验和业务时效。

队列减少的是瞬时数据库写入压力,不一定降低用户从提交到获得结果的总等待时间。对于需要立即确认库存的购买流程,队列可能让用户先得到“排队中”,而不是“扣减成功”。这是一种稳定性换实时性的取舍。
如果业务允许几秒级确认,队列可以是合理方案;如果用户必须在页面上立刻看到明确结果,优先应把同步链路做短,使用原子更新、快速失败和严格超时,必要时再对热点资源做更细粒度的容量控制。
压测结束后,不能只比较接口响应时间。应设置初始库存、成功扣减总量、订单成功总量、取消回补总量和最终库存之间的校验关系。
最终可用库存
= 初始库存
已确认扣减数量
+ 已完成回补数量
仍处于占用状态的数量
如果这个等式无法在允许的延迟窗口内成立,说明系统虽然可能更快,但业务账已经失去可解释性。技术负责人应优先修复幂等、状态机和补偿问题,而不是继续调大线程池或数据库连接数。
如果请求量不大,SKU 分布比较均匀,数据库 CPU、锁等待和连接池都处于安全范围,最合适的行动通常是最小改造:
这个阶段不建议为了“高并发架构”提前引入复杂中间件。系统规模尚未证明需要队列、分片或独立库存服务时,复杂度本身就是新的故障来源。
如果商品详情和库存展示请求远高于真实扣减请求,可以把商品信息和库存展示放到缓存或只读副本,但扣减仍回到具备最终约束能力的主库。
展示库存不必承诺绝对实时,接口可以明确“库存状态更新时间”或使用“有货、紧张、售罄”等区间化表达。这样既降低读取压力,也避免用户把一个瞬时展示数字误认为最终可购买数量。
当活动结束或商品售罄后,70% 以上请求仍然访问数据库,说明系统缺少失败路径优化。可以在短时间内缓存售罄状态,配合网关限流和同一用户请求去重,减少明显无效请求。
快速失败缓存必须设置合理过期时间,并在补货、订单取消回补或库存状态变化时主动失效。它的作用是减少无效访问,不是作为最后的库存扣减依据。
如果锁等待持续升高,且大多数请求集中到少数 SKU,第一步不是立刻分库分表,而是限制单个热点资源的进入速度。可以采用令牌、排队、请求合并或短时限流,避免数万请求同时在数据库前排队。
如果业务允许把一个商品的库存拆成多个库存桶,可以将 10,000 件库存分布到若干可扣减单元,减少所有请求争用一行的情况。但库存桶会增加汇总、回补、查询和对账复杂度,不能只看到并发分散的收益。
库存服务、订单服务、支付服务分属不同数据库时,不要假设一个本地事务能够覆盖全部步骤。应设计清晰的状态流转,例如“待确认、库存已占用、订单已创建、支付成功、已释放”。
每个状态都要定义超时和补偿动作。库存占用后订单创建失败,需要释放库存;订单创建成功但消息发送失败,需要依靠可靠事件表或重试机制补发;消费者重复收到消息,需要用业务幂等键避免重复扣减。

数据库原子扣减的优势是结果清晰、边界明确,适合不能超卖的场景。但只要多个请求更新同一条库存记录,就存在某种程度的串行化。你可以优化索引和事务,却无法让同一个数据单元被无限并行地安全修改。
因此,强一致方案的关键不是承诺“无锁等待”,而是把锁等待控制在可接受范围内,并通过限流、排队或库存拆分避免等待队列无限增长。
消息队列能够把数据库瞬时写入控制在消费者承载能力之内,但请求提交后不一定立刻知道扣减结果。用户需要查询处理状态,业务也需要处理消息延迟、重复、乱序和消费失败。
如果产品流程无法接受“稍后确认”,就不能仅以队列吞吐作为选型依据。技术方案必须把用户体验、库存确认时效和取消回补时限一起纳入评估。
缓存预扣的好处是响应快、能够拦截大量无效请求;代价是缓存与数据库之间存在同步窗口。进程崩溃、网络中断或回补失败,都可能造成缓存数量暂时不准确。
如果采用这种方式,至少需要准备以下机制:
把一条库存记录拆成多个库存桶,可以让并发请求分散到不同记录,但“剩余库存是多少”不再是读取一行数据就能得到的结果。展示库存需要汇总,扣减失败需要尝试其他库存桶,订单取消还要回补到正确桶或统一回补池。
库存拆分适合已经确认单行热点是核心瓶颈,并且业务团队有能力建设对账、回补和监控体系的场景。对于普通商品库存,过早拆分往往得不偿失。

基线至少应包括测试数据规模、数据库版本、机器配置、连接池大小、索引结构、请求比例和并发模型。没有这些条件,任何“优化前后提升多少”的数字都很难复现。
我建议把测试数据分成三类:库存充足且 SKU 分散、库存不足请求占多数、请求集中到单个热门 SKU。三类场景分别对应正常吞吐、无效请求压力和热点锁竞争,不能用一个平均场景替代。
| 测试场景 | 建议观察指标 | 重点验证内容 |
|---|---|---|
| 库存充足、SKU 分散 | 吞吐、平均耗时、P95、数据库 CPU | 原子更新是否减少基础链路成本 |
| 库存不足率较高 | 失败响应耗时、SQL次数、连接池等待 | 快速失败是否减少无效数据库工作 |
| 单个热门 SKU | P99、锁等待、阻塞事务、超时率 | 是否存在单行热点和排队放大 |
| 客户端重复提交 | 重复请求数、重复扣减数、幂等命中率 | 重试是否会造成重复库存变化 |
| 数据库短暂不可用 | 失败率、恢复时间、补偿成功率 | 故障时是否能够停止放大并恢复数据 |
不要一开始就把并发拉到业务宣传的峰值。可以按照 100、500、1,000、2,000、5,000 等阶梯增加,并在每个阶段保持足够时间,观察吞吐是否继续增长。
如果并发增加后吞吐不再增长,但请求数、锁等待和 P99 继续上升,说明系统已经进入排队阶段。此时继续增加并发没有测试价值,只是在测系统如何超时。
如果只监控数据库 CPU,很容易漏掉连接池耗尽和锁等待。数据库 CPU 只有 40%,并不代表请求不会排队;大量事务可能正在等待锁,CPU 反而没有机会充分利用。

库存系统最容易被忽略的是“请求已经超时,但数据库其实已经成功”的情况。客户端看见超时后再次提交,如果没有幂等控制,就可能发生二次扣减。
应主动模拟以下故障:
每个故障都应有明确的最终状态和恢复路径。无法回答“这笔库存最终由谁负责释放或确认”的系统,即使压测吞吐很高,也不适合直接承载高价值库存业务。

对于大多数中小规模库存系统,第一阶段目标不是追求极限吞吐,而是消除最明显的结构性问题:先查再扣、长事务、无幂等、错误重试和缺少监控。
原子条件更新通常是投入产出比最高的改造之一。它能让库存判断与扣减更接近数据库的原子执行路径,减少一次往返,也让超卖风险更容易被约束。但它必须和索引、事务、幂等以及错误码设计一起落地,单独替换 SQL 的收益有限。
当库存不足请求占比很高时,继续让所有请求进入完整扣减流程并不划算。可以通过售罄状态缓存、请求去重、限流和入口校验减少无效请求,让数据库把资源留给更可能成功的请求。
快速失败不是拒绝所有不确定请求,而是对已经明确失败的条件尽早返回。对于库存状态不确定的请求,仍应回到最终扣减环节确认,不能用一个可能过期的缓存值替代真实约束。
当监控证明瓶颈来自同一 SKU 的行锁竞争时,继续优化普通查询意义不大。此时需要在实时性、吞吐和复杂度之间做选择:同步限流最简单,队列削峰更稳但结果延迟更高,库存拆分吞吐更强但对账和回补更复杂。
我更倾向于按照业务目标逐级升级,而不是一次性建设“全套高并发架构”。每一步改造都应有明确的触发条件,例如锁等待超过基线、P99 超过 SLA、连接池等待持续上升或热点 SKU 请求集中度达到某个阈值。
技术负责人可以在下一次迭代中直接安排以下工作:
最终要记住:并发扣减不是“让数据库更忙”,而是让数据库少做无效工作,并在必须修改库存时一次完成可验证的判断与扣减。真正成熟的方案,既能在高峰期保持合理延迟,也能在超时、重试、重复消费和服务故障后解释每一件库存去了哪里。
如果一条原子 UPDATE 已经满足吞吐和一致性目标,就不要为了架构复杂而继续叠加中间件;如果热点行已经成为明确瓶颈,就不要再用索引优化掩盖排队问题。先测量,后改造;先缩短链路,后治理热点;先保证可对账,再追求极限性能,这才是数据库存量扣减场景中更可靠的技术负责人决策顺序。
我最初也以为,把库存扣减改成并发执行,就能让数据库查询变快。但实际压测时发现,原子扣减优化的并不是所有查询,而是减少“先查询、再判断、再更新”这条链路中的无效往返。到底什么情况下它有效,什么情况下反而会因为热点锁竞争变慢?
并发扣减并不会直接提升商品详情、订单列表等无关查询的性能。它真正优化的是库存扣减链路:把库存判断和数量变更合并为一次具备原子性的条件更新,减少数据库往返、应用层判断和连接占用时间。
传统写法通常是先查询再更新:
SELECT available_stock FROM inventory WHERE sku_id = ?;UPDATE inventory SET available_stock = available_stock - 1 WHERE sku_id = ?;更适合高并发扣减的写法是:
UPDATE inventory SET available_stock = available_stock - 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = ?AND available_stock >= 1;我在一次限量商品压测中,使用同一张库存表、同一批数据和相同连接池配置,对比了两种实现。先查再扣的接口每次请求需要执行一次读取和一次更新,库存不足时仍然会产生查询;原子更新则只执行一次写操作,并通过影响行数判断扣减是否成功。
测试中更明显的变化不是平均响应时间,而是数据库每秒 SQL 次数和连接池等待时间下降。
实现方式单次请求数据库操作主要风险适合场景 先查再扣至少一次查询加一次更新往返多、并发窗口大低并发或需要复杂读取判断 原子条件更新一次条件更新热点行可能锁等待库存、额度、配额扣减 但不能把“单条 SQL”理解成“无限吞吐”。
当大量请求集中更新同一个 SKU 时,数据库仍然需要按顺序修改同一行,P99 延迟可能升高。因此,判断方案是否有效,要同时看 SQL 执行次数、P95/P99、锁等待、连接池占用和库存正确性,而不能只看平均耗时。
我遇到过一个很容易误判的场景:慢查询日志里没有特别夸张的 SQL,但抢购接口的 P99 延迟却持续升高。开发团队一度反复加索引,结果效果很小。后来才发现,真正拖慢请求的是多个事务同时更新同一条热门库存记录。应该如何区分这两类问题?
判断库存扣减瓶颈,第一步不是立即加索引,而是把“访问路径慢”和“同一行排队”分开观察。两者在监控上的表现不同:查询或更新计划有问题时,通常伴随扫描行数过多、CPU 或 IO 上升;锁竞争则更常表现为数据库资源未打满,但请求延迟和锁等待持续增加。
我通常会先建立四组基线:SQL 执行耗时、扫描行数、行锁等待时间、连接池等待时间。再把热门 SKU 和普通 SKU 分开统计。如果只有热门 SKU 的 P99 明显升高,而普通 SKU 正常,优先怀疑热点行;如果所有 SKU 都变慢,则应先检查执行计划、索引和数据库整体资源。
观察现象更可能的原因优先检查项 扫描行数明显偏高索引未命中或选择性差EXPLAIN、索引、数据分布 数据库 CPU 或 IO 接近上限执行成本过高或请求量过大慢查询、连接数、IOPS 热门 SKU P99 飙升同一库存行锁竞争阻塞事务、锁等待、事务时长 数据库不忙但接口排队连接池或应用线程池不足池大小、获取连接耗时、线程堆积 还有一个经常被忽略的因素是事务边界。
库存更新后,如果事务里继续写订单明细、记录营销日志或调用外部服务,库存行的锁可能被无关操作长期占用。我的处理原则是:核心扣减事务只保留必要的数据库动作,通知、积分、日志等非关键流程移到事务提交之后异步执行。如果锁等待明显而索引和 SQL 执行计划正常,就不要继续堆索引。
索引能减少定位记录的成本,却不能让多个请求同时修改同一行。此时应从缩短事务、限制重试、削峰或拆分库存单元等方向解决。
我以前写库存扣减时,习惯先把库存读到应用层,再计算扣减后的数值,觉得这样更直观。后来在并发测试中发现,多个请求可能读到相同库存,事务虽然提交成功,结果却并不符合预期。除了把判断放进 UPDATE 条件里,还需要注意哪些索引、参数和幂等问题?
原子扣减的核心不是把 SQL 压缩成一行,而是让“条件成立”和“数值变化”由数据库在同一个受控操作中完成。
典型语句如下:
UPDATE inventory SET available_stock = available_stock - :quantity, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_stock >= :quantity;应用层应根据影响行数判断结果:影响一行通常表示扣减成功,影响为零表示条件未满足。但影响为零不一定只有“库存不足”一种原因,也可能是 SKU 不存在、参数异常或请求已经被其他业务规则拦截,因此业务层要设计清晰的错误码和审计信息。索引方面,首先要确保 sku_id 能快速定位库存记录。
如果一个 SKU 在表中只能有一条库存记录,应使用唯一约束或唯一索引,而不是仅依赖应用代码保证唯一。这样既能减少扫描,也能避免并发初始化库存时产生重复记录。我建议把以下校验放在进入数据库前完成:扣减数量必须大于零,不能超过单笔业务上限,SKU 和订单号不能为空,重复请求必须携带同一个幂等键。
特别要防止负数扣减,因为一条看似正确的减法 SQL,遇到负数参数后可能变成库存增加。
设计点不推荐做法更稳妥的做法 库存判断应用层读取后判断放入 UPDATE 的 WHERE 条件 库存唯一性只靠代码检查唯一索引或唯一约束 重复请求仅依赖客户端不重试订单号或业务幂等键去重 失败识别所有影响行数为零都返回库存不足结合业务状态和约束区分原因 还要注意扣减与订单创建之间的关系。
如果库存扣减成功但订单创建失败,就必须明确采用同一事务、可靠消息、补偿回滚还是预扣减状态机。单条原子 UPDATE 只能保证库存这一步的并发安全,不能自动保证整个订单流程的一致性。
我见过一些系统只要遇到高并发,就同时加缓存、消息队列和分布式锁,结果链路变得更复杂,排查问题反而更困难。我更想知道一个实际的升级顺序:什么规模下只用原子扣减就够了,出现哪些信号后才值得引入更复杂的组件?
我的判断顺序通常是先优化数据库执行路径,再处理无效流量,最后才处理热点写入。因为缓存和消息队列解决的是分流、削峰和解耦问题,并不能替代数据库对最终库存的约束。如果基础 SQL 没有命中索引,直接加中间件只会把问题隐藏得更深。第一阶段适合使用原子条件更新加合理索引。
这个方案链路短、可观测性好,适合库存分布较均匀、热点 SKU 不集中、数据库写入仍有余量的业务。此时重点是控制事务长度、关闭无意义重试,并完善幂等和对账。第二阶段可以加入缓存做请求预过滤。例如库存已经明确为零时,缓存可以快速拒绝大量无效查询;商品详情和库存展示也可以从缓存读取。
但缓存中的“可售数量”不能直接视为最终扣减结果,数据库或可靠的库存服务仍应承担最终约束。第三阶段才考虑消息队列削峰。当瞬时流量远高于数据库可承受写入能力,且业务允许排队处理时,队列可以把突发请求转化为更平滑的消费速率。
代价是用户不一定能立即知道最终结果,系统必须处理重复消费、消息积压、消费失败和订单超时。如果锁等待长期集中在少数热门 SKU,即使数据库 CPU 尚未达到上限,也说明单行已经成为串行化瓶颈。此时可以把总库存拆成多个可扣减库存单元,按分片或库存桶分散更新压力,但必须增加库存汇总、回收、补偿和对账逻辑。
现象优先方案不应立即做的事 SQL 执行次数多、往返明显改为原子条件更新先引入分布式锁 大量库存为零请求反复进入数据库缓存预过滤、快速失败让缓存直接替代最终扣减 瞬时写入超过数据库承载能力队列削峰、限流无限扩大数据库连接池 少数 SKU 锁等待持续升高库存分桶或拆分库存单元继续堆叠普通索引 技术负责人上线前至少要确认四件事:是否有 P99 和锁等待监控,是否能识别重复扣减,数据库异常时是否有明确降级策略,以及库存账能否和订单账对账。
真正成熟的方案不是组件最多,而是在当前流量和一致性要求下,能够解释每一次扣减、发现异常并完成恢复。


读者评论
文章把“查询性能”和“扣减吞吐”区分开来很重要,原子条件更新主要减少读写往返,并不能解决所有热点锁竞争,验收时确实不能只看平均响应时间。
并发库存场景下,直接用影响行数判断结果并不充分,库存不足、商品不存在和状态不可售需要进一步区分,这一点对接口错误码和业务处理很有参考价值。
文中对缓存的定位比较客观。缓存可以用于快速拒绝和展示,但不能替代数据库作为最终库存事实来源,异步落库时还必须配套补偿、重试和对账机制。
把订单创建、远程调用等步骤放进库存事务,确实可能放大锁等待。拆分核心事务和后续异步流程更合理,但也意味着系统需要承担幂等、消息可靠性和最终一致性的复杂度。
文章的压测指标较完整,除了吞吐和平均耗时,还应关注P95、P99、连接池排队、行锁等待及库存对账。尤其是请求集中在单个热门SKU时,索引并不能消除单行竞争。