数据库存:技术负责人增长视角:用并发扣减放大保证扣减一致性
高并发扣减最危险的地方,不是数据库偶尔报错,而是系统在“没有报错”的情况下把最后一件商品卖给了两个用户。很多团队上线前压测的是吞吐量,上线后排查的却是库存差异、订单回滚和客服投诉。我的判断是:并发扣减首先是业务约束问题,其次才是锁和性能问题。真正可持续的方案,不是简单叠加分布式锁、缓存和消息队列,而是先把“资源不能被扣成负数、同一请求不能重复扣减、失败后必须可恢复”这些约束拆开,再分别交给数据库、事务、幂等和补偿机制处理。
在库存、账户额度、优惠券、票务座位、营销名额等系统中,“一致性”经常被当成一个笼统目标。但不同团队说的一致性,可能完全不是一回事。
如果业务只要求库存数字不能小于零,那么重点是保证单次扣减的原子性。如果业务要求订单创建成功时库存必须已经扣除,那么需要处理订单与库存的事务边界。如果业务允许订单先进入处理中,库存稍后确认,则可以采用消息队列和最终一致性。
因此,我通常会先把需求拆成四层:
一条条件更新 SQL,主要解决第一层问题;事务和行锁解决的是局部流程的一致性;幂等键解决重复请求;消息确认、补偿任务和对账机制则负责让系统从异常中恢复。把一条 SQL 的能力夸大成全链路一致性,是并发扣减设计中最常见的认知错误。
对于非极端流量的业务,我更倾向于从数据库条件更新开始,而不是直接把请求放进缓存预扣、分布式锁和消息队列。原因很现实:复杂组件增加的不是只有吞吐,也包括故障模式、运维成本、数据修复成本和排查难度。
一个足够清晰的最小方案,是把“库存充足”作为数据库更新条件的一部分:
UPDATE inventory SET available_quantity = available_quantity - :quantity WHERE sku_id = :sku_id AND available_quantity >= :quantity;
应用程序读取受影响行数。如果受影响行数为 1,说明这次扣减满足条件并成功执行;如果为 0,说明库存不足,或者请求条件没有匹配到目标记录。这里的关键不在 SQL 看起来简短,而在于判断和扣减被放进了同一个数据库更新动作中。
这与“先查询库存,再在程序中判断,最后执行更新”有本质区别。前一种方式把业务约束交给数据库在更新瞬间判断,后一种方式在应用层和数据库之间留下了可被并发请求插入的时间窗口。

技术负责人不能只问“系统能不能扛住峰值”,还要问峰值持续多久、热点是否集中、一次扣减是否影响多个资源、失败是否可重试,以及业务能否接受短暂的处理中状态。
例如,一个后台额度扣减系统每天有几万次请求,单个资源并发竞争不高,数据库条件更新和业务幂等可能已经足够。相反,一个热门活动只有一个 SKU,但瞬时有数十万请求争抢几百件库存,即使 SQL 完全正确,热点行锁也会让请求排队,数据库连接池和线程池可能先被拖垮。
所以,一致性方案的升级条件不是“大家都在用缓存”,而是当前方案已经被可观测数据证明存在瓶颈。没有锁等待、数据库 CPU、P99 延迟和库存差异数据,就不应该仅凭感觉引入更复杂的架构。
假设某个商品的可用库存只有 1。请求 A 和请求 B 几乎同时到达服务端,代码大致如下:
quantity = select available_quantity if quantity > 0: update available_quantity = available_quantity - 1 create_order()
在低并发测试中,这段代码很可能一直表现正常。问题是,A 查询到库存为 1 后,还没有完成更新,B 也可能查询到库存为 1。两次判断都通过,随后两个请求都执行扣减。
如果更新语句没有再次校验库存,有些数据库场景会产生负库存;如果更新语句使用了某种覆盖式写入,还可能出现“两个请求都返回成功,但库存只减少了一次”的覆盖问题。后者更隐蔽,因为数据库数字看上去没有负数,订单数量却已经超过实际库存。
很多开发者把问题归因于“数据库不够快”,但速度不是根因。即使查询只耗时1毫秒,只要查询和更新是两个独立动作,并发请求就有机会在中间插入。
这可以用一个简单时序表示:
| 时间 | 请求 A | 请求 B | 库存 |
|---|---|---|---|
| T1 | 读取库存为1 | 等待 | 1 |
| T2 | 判断库存充足 | 读取库存为1 | 1 |
| T3 | 准备执行扣减 | 判断库存充足 | 1 |
| T4 | 扣减成功 | 扣减成功或覆盖更新 | 0或出现差异 |
真正需要保护的不是“查询动作”,而是“库存满足条件时才允许扣减”这一业务约束。这个约束必须在并发竞争的瞬间仍然成立,而不是只在代码执行顺序看起来正确时成立。

我在复盘这类系统时,发现最难处理的异常通常不是数据库里出现一个负数,而是多个系统对同一笔业务有不同判断。
例如,库存服务已经扣减成功,订单服务因为网络超时没有返回结果。客户端不知道订单是否创建成功,于是再次提交。如果没有幂等控制,第二次请求可能又扣减一次。又或者订单写入成功,扣减消息消费失败,用户拿到了订单号,但库存仍然没有减少。
还有一种经常被忽略的情况:缓存中的库存先被减少,数据库扣减因为死锁或连接超时失败。如果系统没有补偿,用户看到的库存会越来越少,但真实可售库存并没有同步变化。
这些现象说明,数据库原子更新只是“扣减动作正确”的必要条件,不是整个交易闭环的充分条件。
这套思路同样适用于很多非电商业务:
场景不同,业务语义不同,但共同问题都是:多个请求同时尝试修改有限资源,系统必须在竞争中保住不可破坏的约束。
事务能够保证一组操作的提交和回滚边界,但事务本身不等于串行执行。不同数据库、不同隔离级别和不同锁使用方式,会产生不同结果。
如果只是把“查询库存”和“更新库存”放进普通事务,却没有使用合适的锁,也没有在更新时加入库存条件,并发事务仍可能各自读取到相同的库存快照。事务可以保证每个事务内部的动作要么全部提交、要么全部回滚,却不一定自动阻止两个事务同时通过业务判断。
更准确的说法应该是:事务解决原子提交问题,锁和条件更新解决并发竞争问题。二者相关,但不能互相替代。
悲观锁可以让一个事务先锁住目标行,其他事务等待锁释放。对于需要在事务内读取、校验并修改多个字段的场景,它非常有价值。
但悲观锁会把并发请求转化为排队请求。锁持有时间越长,等待越严重。如果事务中还包含远程调用、复杂计算、订单写入或日志处理,锁可能被长时间占用。
我更关注三个指标:锁等待时间、事务持续时间和热点行集中度。只看数据库平均响应时间,很容易掩盖少数热点资源的长尾问题。一个接口平均耗时20毫秒,并不代表最后一件库存被争抢时的P99也是20毫秒。
乐观锁通常通过版本号或当前值判断更新是否仍然有效。例如,只有版本号仍为10时才允许更新为11。如果受影响行数为0,应用程序可以重试。
这种方式适合冲突概率不高、失败请求可以快速重试的业务。但在热点 SKU 或热门名额上,失败会集中发生。假设1000个请求争抢一个资源,只有一个请求成功,其他999个请求如果各自重试三次,数据库将额外承受近3000次更新尝试。
因此,重试必须有上限、退避策略和业务失败结果。库存不足时继续重试没有意义,版本冲突时也要区分是资源已被抢完,还是短暂竞争导致。
分布式锁可以协调多个服务实例,但它本身并不自动带来业务幂等,也不能替代数据库约束。如果客户端拿到锁后执行扣减,服务在提交数据库事务前宕机,锁是否释放、库存是否已经变化、重试是否重复扣减,都需要额外设计。
更麻烦的是,分布式锁通常有租约时间。业务执行超过租约后,第二个请求可能获得锁,而第一个请求仍在继续写数据。若没有令牌校验、 fencing token 或数据库层的最终约束,锁的“互斥”可能只是表面上的。
我的经验是:如果单库单资源可以用条件更新解决,就不要为了体现架构复杂度而先引入分布式锁。只有当资源跨越多个数据库、多个服务或多个进程边界,且确实存在需要协调的临界区时,才讨论它的必要性。
缓存适合承接高频读和部分削峰,但缓存中的“剩余数量”不一定等同于数据库中的真实可售状态。只要存在数据库写入失败、消息丢失、服务重启、缓存淘汰或重复消费,就需要重新确认数据。
如果把缓存扣减成功直接当成订单成功,系统会把缓存可用性误认为业务事实。正确的设计通常要区分“请求已受理”“库存已预占”“订单已确认”和“库存已释放”等状态。

每一种扣减业务都应该先回答:到底哪个系统中的状态可以被认为是真实状态?如果数据库是库存真相源,缓存只能是加速层;如果账务系统是余额真相源,订单服务不能自行推断余额是否已扣。
真相源一旦不明确,后续的补偿就无从谈起。因为当缓存、订单库和库存库出现差异时,团队必须知道应该把哪边的数据修正到哪边,而不是简单选择“最后写入的数据”。
在设计文档中,我通常会要求画出一条状态链:
“库存不能为负”通常是强约束,不能依赖定时任务事后修复。因为一旦负库存已经被订单消费,后续修复可能涉及退款、取消订单和人工沟通。
“页面展示的剩余库存必须每秒都准确”则可能是弱约束。很多业务可以接受页面显示的是近似值,只要下单确认时能够以真相源为准。
如果把所有数据都按强一致处理,系统的成本会迅速上升;如果把本应强一致的数据都异步化,业务风险又会被隐藏。技术负责人的判断重点,是找出哪些约束必须在请求当下成立,哪些状态可以延迟确认。
乐观锁和条件更新在低冲突场景中通常表现很好,因为大部分请求只需要一次数据库尝试。热点冲突场景则不同,大量请求都在争抢同一行或同一小组资源,失败重试和锁等待都会放大数据库压力。
可以用一个简单的竞争率观察方案是否需要升级:
竞争率 = 更新失败次数 / 更新总尝试次数
重复请求率 = 幂等校验命中次数 / 请求总次数
锁等待占比 = 等待锁耗时 / 数据库事务总耗时
这些指标没有统一的绝对阈值,但趋势很重要。若竞争率长期升高,同时P99延迟和连接池占用同步上升,说明系统已经不仅是“库存逻辑正确”,还需要解决资源热点带来的承载问题。
库存不足不是系统异常,而是业务结果。版本冲突、重复请求、请求超时、消息重复消费,也不应该全部被当作500错误。
一个成熟的接口会区分:
系统真正的稳定性,不是让所有请求都成功,而是让失败可识别、状态可查询、结果可恢复。
每增加一个缓存、队列、锁服务或异步消费者,就增加一组需要监控和演练的故障场景。技术评审不能只写“吞吐提升”,还应该写清楚数据不一致时由谁发现、多久发现、如何修复,以及修复是否会影响用户已经创建的订单。
我建议把方案评估拆成四个维度:
| 评估维度 | 需要回答的问题 | 常见证据 |
|---|---|---|
| 正确性 | 能否防止超卖、重复扣减和非法状态 | 并发测试、唯一约束、库存流水 |
| 性能 | 峰值时锁等待和P99是否可接受 | 压测报告、数据库监控、连接池指标 |
| 恢复性 | 超时、宕机和重复消费后能否恢复 | 补偿任务、对账结果、故障演练 |
| 演进成本 | 业务增长后是否需要大规模重构 | 容量模型、组件依赖、运维人力 |

对于一次只扣减一个 SKU、一个额度或一个名额的业务,条件更新通常是最容易验证的方案。数据库更新的条件应尽量包含真正的业务约束,而不是只根据主键覆盖写入。
UPDATE resource_quota SET remaining = remaining - :amount, updated_at = CURRENT_TIMESTAMP WHERE resource_id = :resource_id AND remaining >= :amount;
应用层不能只判断 SQL 是否执行成功,还要检查受影响行数。很多数据库客户端在语句执行没有异常时会返回成功,但这不代表目标行真的被更新。
affectedRows = execute(updateSql, params) if affectedRows == 1: return "扣减成功" else: return "额度不足或资源状态不允许扣减"
同时,资源字段必须建立合理索引。对于按资源编号更新的场景,主键或唯一索引通常可以让数据库快速定位目标行;如果条件中包含状态字段,也要结合数据分布和执行计划判断是否需要联合索引。
如果一次请求扣减数量不是1,不能简单把判断写成“剩余数量大于0”。正确约束应该是剩余数量大于等于本次请求数量。
UPDATE inventory SET available_quantity = available_quantity - :quantity WHERE sku_id = :sku_id AND available_quantity >= :quantity AND status = 'ON_SALE';
这样可以避免库存还剩2件时,一个请求一次性购买5件却仍然通过条件。状态条件也很重要,因为资源可能已经下架、冻结或进入人工盘点状态,即使数字库存大于零,也不能继续扣减。
客户端重试、网关重试、消息重投和用户重复点击都会产生重复请求。仅依赖前端按钮置灰是不够的,服务端需要接收业务请求号,并在数据库中建立唯一约束。
CREATE TABLE deduction_record (
request_id VARCHAR(64) NOT NULL,
resource_id BIGINT NOT NULL,
quantity INT NOT NULL,
status VARCHAR(20) NOT NULL,
result_code VARCHAR(32),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (request_id)
);处理流程可以设计为:先尝试写入请求记录,写入成功后执行扣减;如果请求号已经存在,则读取历史处理结果,不再重复扣减。
但这里还有一个细节:请求记录写入成功后,服务在扣减前宕机怎么办?因此请求记录不能只有成功和失败两种状态,至少要能够表达“处理中”,并配合超时扫描、状态确认或补偿逻辑。
如果库存表和订单表位于同一个数据库,且业务要求“库存扣减成功才创建订单,订单创建失败则库存必须回滚”,可以考虑在一个本地事务中完成。
BEGIN; INSERT INTO deduction_record (request_id, resource_id, quantity, status, created_at, updated_at) VALUES (:request_id, :resource_id, :quantity, 'PROCESSING', NOW(), NOW()); UPDATE inventory SET available_quantity = available_quantity - :quantity WHERE sku_id = :sku_id AND available_quantity >= :quantity AND status = 'ON_SALE'; -- 检查 UPDATE 受影响行数 -- 受影响为 0 时回滚并返回库存不足 INSERT INTO orders (order_id, request_id, sku_id, quantity, status, created_at) VALUES (:order_id, :request_id, :sku_id, :quantity, 'CREATED', NOW()); UPDATE deduction_record SET status = 'SUCCESS', updated_at = NOW() WHERE request_id = :request_id; COMMIT;
事务中不要执行远程支付、调用外部营销服务或等待用户操作。事务越长,锁持有时间越长,热点资源的排队越严重。跨服务动作应该通过明确的状态机和可靠消息衔接,而不是把远程调用硬塞进数据库事务。
有些订单同时扣减商品库存、赠品库存和营销额度。此时即使每个资源单独扣减都正确,也可能因为不同事务以不同顺序加锁而发生死锁。
例如,事务 A 先锁商品再锁赠品,事务 B 先锁赠品再锁商品,双方都可能等待对方释放资源。解决办法之一,是按照稳定规则排序资源,例如始终按资源类型和资源ID的字典序加锁。
同时,必须为死锁准备重试策略。死锁重试不是无限重试,应该限制次数,并在多次失败后返回可识别的系统繁忙状态,避免请求在数据库内部持续堆积。

很多压测报告只展示每秒请求数和平均响应时间,却没有展示扣减失败原因、库存差异和锁等待。对于有限资源业务,吞吐量越高不一定越好,因为大量无效请求可能正在重复冲击数据库。
我更建议至少同时观察以下指标:
这些指标分别对应正确性、资源竞争和恢复能力。一个系统即使平均延迟很低,只要未知状态持续积累,也可能在峰值结束后出现大规模对账和退款压力。
下面是一组用于说明分析方法的情景模拟数据。它不是某个具体系统的实测结果,假设数据库版本、硬件配置、连接池大小和请求分布均保持一致,仅比较不同扣减路径在热点资源下的表现。
| 方案 | 平均延迟 | P99延迟 | 扣减失败识别率 | 库存差异 |
|---|---|---|---|---|
| 先查后扣 | 18毫秒 | 210毫秒 | 低 | 存在覆盖或超卖风险 |
| 条件更新 | 15毫秒 | 95毫秒 | 高 | 接近零 |
| 行锁事务 | 22毫秒 | 380毫秒 | 高 | 接近零 |
| 缓存预扣加异步确认 | 8毫秒 | 40毫秒 | 取决于补偿机制 | 可能出现短暂差异 |
这组数据最值得注意的不是哪个方案数字最好,而是指标之间存在取舍。行锁事务的平均延迟并不离谱,但P99明显升高,说明少数请求正在等待热点行。缓存预扣的接口延迟更低,但库存差异不再由一条 SQL 直接消除,而是转移到消息确认和补偿体系中。

真正有价值的压测,应该刻意制造资源不足、重复提交、网络超时和数据库锁竞争。否则测试出来的只是系统在理想条件下的写入能力。
我建议至少准备四类数据分布:
压测结束后,不能只看接口返回码,还要把扣减流水重新汇总,与库存表和订单表进行核对。压测的最后一步不是停止压测工具,而是证明资源状态没有被破坏。
本文涉及的示意数据用于解释方案取舍,不应被理解为任何数据库在所有硬件环境下的固定性能。真实压测至少要记录数据库版本、存储引擎、CPU和内存、磁盘类型、索引结构、事务隔离级别、连接池配置、请求大小、热点比例和数据量。
如果没有这些条件,直接写“支持百万并发”或“性能提升十倍”,不仅无法帮助决策,还会给后续容量规划制造错误预期。技术内容的可信度,不来自数字看起来多大,而来自数字的统计口径是否完整。
这类业务的典型特征是资源竞争不高、请求峰值可预测、库存和订单在同一数据库或同一服务边界内。优先方案是条件更新、本地事务、请求幂等和基础监控。
上线前需要完成以下工作:
这一阶段不建议默认引入缓存预扣和分布式锁。组件越少,越容易通过单元测试、集成测试和并发测试证明结果正确。
当请求量上升,但业务仍然可以接受几十毫秒到几百毫秒的处理时,可以在数据库正确性方案上增加队列削峰、异步通知和补偿任务。
需要注意,消息队列不是把一致性问题消除了,而是把同步等待改成了异步确认。此时必须建立消息唯一键、消费幂等、失败重试、死信处理和库存对账。
一个常见的处理方式是:数据库事务中完成扣减和业务流水写入,再由可靠消息通知订单或权益服务。消费者根据业务流水号做幂等处理,重复消息只返回历史结果,不重复执行扣减。
秒杀、限量活动或热门票务的难点,往往不是数据库每秒能写多少行,而是绝大多数请求最终都会失败,却仍然在争抢同一热点资源。
这时可以按顺序考虑:
缓存预扣适合“可以先排队、后确认”的业务,不适合必须在一个同步响应中完成账务确认的场景。对于余额、支付扣款等高风险业务,不能因为接口延迟压力就直接照搬库存活动的做法。

余额扣减和商品库存有相似的并发特征,但风险等级不同。库存超卖通常可以通过取消订单、退款或人工补货缓解,余额重复扣款则可能直接造成资金损失和合规风险。
余额扣减应该使用明确的账务流水、不可变交易号、严格的幂等约束和可审计的状态变化。余额表可以作为当前余额的查询结果,但不能替代完整的借贷流水。
对于支付相关操作,不应只依赖缓存中的余额,也不应把客户端重试当成新的扣款请求。系统需要能够根据交易号查询最终状态:已扣款、未扣款、处理中或需要人工处理。
座位不是一个简单的数字库存。用户可能选择具体座位,座位还会经历可售、锁定、已售、释放和失效等状态。
如果用一个总库存字段代替座位明细,系统无法解释“为什么这个座位已经被谁占用”。更合理的模型是把座位作为独立资源,用唯一约束保证同一场次、同一座位不能重复确认,再通过锁定过期机制释放未支付座位。
这说明并发扣减设计不能脱离领域模型。当资源具有身份、状态和生命周期时,单纯的数量减法往往不够。
条件更新的优势是路径短、语义清晰、数据库可验证、便于测试。对于单资源扣减,它通常是最值得优先尝试的方案。
它的边界也很明确:不能自动处理跨服务事务,不能防止同一请求重复提交,不能解决缓存与数据库同步,也不能消除热点行上的锁竞争。
如果业务只需要保证单次扣减不超过可用数量,条件更新往往足够。如果业务还要求创建订单、锁定权益和发送通知全部同时成功,就需要继续设计事务边界和恢复流程。
悲观锁适合多个字段需要在同一事务内读取和修改,或者业务逻辑确实需要串行保护资源的场景。它的可理解性较强,发生竞争时数据库会明确表现为等待。
它的主要代价是锁等待、死锁和长事务。只要事务里包含远程调用或复杂业务判断,锁的持有时间就可能不可控。技术负责人需要为锁等待设置监控,并通过数据库慢日志和事务追踪定位真正持锁的代码路径。
乐观锁不会在读取阶段长时间占用锁,低冲突时吞吐通常较好。它适合失败后能够重新读取并快速计算的场景。
但高冲突场景下,失败重试会成为新的流量放大器。应用层还要处理重试后资源已经耗尽、请求顺序变化和结果超时等问题。乐观锁不是“无锁”,而是把竞争处理从等待转移到了失败判断和重试。
缓存预扣可以快速拦截明显的库存不足请求,消息队列可以平滑突发流量。这两种手段能够显著改善前端请求延迟,并降低数据库直接承受的峰值。
但它们引入了新的状态:缓存已扣、数据库未扣;消息已发送、订单未建;订单已建、扣减结果未知。所有这些状态都需要业务流水、重试策略、补偿任务和对账机制来解释。
| 方案 | 适合的业务条件 | 主要收益 | 主要代价 | 上线前必须验证 |
|---|---|---|---|---|
| 条件更新 | 单库、单资源、扣减逻辑简单 | 实现简单,边界清晰 | 跨服务能力有限 | 并发最后一件、受影响行数、索引 |
| 悲观锁事务 | 多个状态必须在同一事务内保护 | 流程一致性直观 | 锁等待和死锁 | 事务时长、锁等待、死锁重试 |
| 乐观锁 | 冲突较低,失败可重试 | 低冲突下吞吐较好 | 热点下重试放大 | 冲突率、重试次数、退避策略 |
| 缓存预扣 | 峰值高,允许异步确认 | 降低核心数据库峰值压力 | 最终一致和补偿复杂 | 消息可靠性、差异对账、缓存故障 |
| 分桶或分片 | 单资源极热,热点行成为瓶颈 | 分散写入竞争 | 汇总、回滚和查询更复杂 | 分配算法、桶耗尽、跨桶一致性 |

强一致方案的价值,是让用户在关键动作返回时就获得明确结果。它通常需要更严格的事务边界、更强的锁或更少的异步环节。
最终一致方案的价值,是允许系统用异步方式提高吞吐和削峰能力。它要求业务接受“处理中”,并且能够容忍短时间内的状态差异。
如果业务无法接受重复扣款,那么余额扣减更接近强一致和可审计要求。如果业务允许用户排队等待库存确认,那么活动抢购可以使用最终一致架构。选择哪种一致性,不是看技术方案是否流行,而是看业务能否承担状态延迟和失败补偿。
数据库CPU和接口QPS只能说明系统忙不忙,不能说明业务状态是否正确。扣减系统至少要建立三类对账关系。
如果这些关系无法通过查询或离线任务计算,系统即使短期没有事故,也很难在规模增长后快速定位差异。
建议把指标按请求入口、应用服务、数据库、消息和补偿任务分层采集。数据库锁等待升高时,需要知道是哪个资源、哪条 SQL、哪个事务和哪类请求造成的。
核心监控可以包括:
| 层次 | 指标 | 异常时说明什么 | 建议动作 |
|---|---|---|---|
| 请求层 | 重复请求率、超时率 | 客户端或网关可能在放大请求 | 检查幂等、超时和重试配置 |
| 应用层 | 线程池、连接池占用率 | 请求正在等待数据库或下游服务 | 缩短事务,限制并发进入核心区 |
| 数据库层 | 锁等待、死锁、P99 | 热点资源竞争或事务过长 | 优化锁顺序,拆分远程调用 |
| 消息层 | 堆积量、重复消费、失败率 | 异步链路处理能力不足或幂等缺失 | 扩容消费者,完善唯一业务键 |
| 补偿层 | 差异数量、恢复时长 | 系统已进入异常恢复阶段 | 暂停扩大流量,优先处理未决状态 |
测试用例不能只覆盖“库存足够时扣减成功”。我建议把资源数量故意设为1、2和10,分别模拟多个请求同时提交,验证最终成功数量是否严格受资源数量约束。
还要测试以下异常:
这些测试的结果应该形成可以长期保存的报告,而不是只在上线前临时执行一次。数据库版本、索引、事务配置和业务代码变化后,都可能改变并发行为。

库存异常是最终结果,往往已经晚于问题发生。更有效的告警应该设置在过程节点,例如同一请求号重复命中、锁等待持续升高、未知状态超过阈值、补偿队列持续堆积和数据库扣减成功但订单状态长期未确认。
告警还需要区分短时波动和持续恶化。活动开始后几秒内的锁等待可能是正常现象,但如果等待持续增长,同时连接池接近上限,就需要限流或降低进入核心扣减区的请求量。
业务早期最重要的是建立准确的资源模型和扣减规则。此时应该优先完成条件更新、唯一幂等键、扣减流水和库存对账。
如果在这个阶段就把缓存、队列和分布式锁全部引入,团队可能还没有足够的监控和故障处理能力,却提前承担了多套组件的状态一致性问题。早期架构最宝贵的不是极限吞吐,而是容易理解和容易修复。
随着商品详情、库存展示和活动页面访问增加,数据库可能先被查询流量拖慢。此时可以把适合缓存的数据放到缓存层,但下单确认仍然要回到可靠的扣减路径。
如果突发流量持续时间短,可以用队列削峰,让数据库按稳定速度处理。队列长度、消息延迟和用户等待时间必须纳入业务设计,否则只是把接口超时变成队列堆积。
当绝大多数请求集中到同一个资源时,单行更新会成为天然瓶颈。此时可以考虑库存分桶,即把一个资源的可用数量拆成多个逻辑桶,让请求分散写入不同记录。
分桶不是简单地把库存数字除以几个份额。系统必须处理桶选择、桶耗尽、回收、总量汇总和异常修复。例如,一个桶还有库存,但另一个桶已经耗尽,路由策略应该如何调整;补偿时如何判断是否重复释放;后台展示总库存时如何避免读到中间状态。
只有当热点行竞争已经通过监控和压测被证明是主要瓶颈时,分桶才有意义。否则它可能只是把一个容易理解的扣减问题,变成多个难以解释的局部库存问题。
异步架构的关键不是“把消息发出去”,而是让每一个状态都能被确认。生产者要知道消息是否写入可靠介质,消费者要知道重复消息如何判断,补偿程序要知道哪些状态可以安全重试。
可以为每次资源变更保留不可变流水,流水包含请求号、资源编号、变更数量、变更前后状态、来源事件和处理结果。当前库存是查询效率较高的汇总结果,流水则是审计和修复依据。
增长带来的不是单纯的流量增加,而是状态数量和异常组合数量增加。架构演进的目标,就是让这些新增状态仍然能够被追踪、解释和恢复。

先停止讨论分布式锁和缓存,把更新语句改成带业务条件的原子扣减,并补齐受影响行数判断。然后增加请求幂等和库存流水,验证并发争抢最后一件资源时,成功数量是否严格不超过初始数量。
如果连“成功扣减多少次、释放多少次、最终剩余多少”都无法从数据中计算出来,先补数据能力,再进行性能优化。
先定位延迟来自查询、锁等待、事务持有还是连接池排队。不要直接把所有请求改成异步,因为异步可能降低接口延迟,却把压力转移到消息堆积和用户状态查询。
如果热点行锁等待占比高,可以先做限流、失败快速返回和重试退避;如果读压力高,可以先优化缓存和查询链路;如果事务过长,应先移除远程调用和非必要写入。
优先检查请求号是否真正贯穿网关、应用、数据库和消息消费者。很多团队虽然生成了幂等号,但在服务内部重试时重新生成了新的请求号,最终仍然被系统当成两次独立业务。
幂等记录要有明确生命周期。已经成功的请求应返回原结果;正在处理的请求应返回处理中或查询入口;失败的请求要区分可重试失败和不可重试失败。
不要只执行一条“修正库存”的脚本。先按照订单、扣减流水、释放流水和退款记录重建事件顺序,找出差异产生在哪一个状态转换节点。
如果系统没有流水,只能从当前表反推,修复结果很可能无法审计。长期治理应让每一次资源变化都有唯一业务来源和可追踪处理结果。
大促前不要只做机器扩容。应该先建立容量模型:峰值请求数、有效扣减比例、热点资源比例、数据库单行竞争、消息消费速度和补偿任务处理速度。
同时准备降级策略,例如关闭非核心查询、限制重复提交、提前筛选资格、缩短无效请求路径和暂停高风险的实时联动。增长期间最有效的保护措施,往往是减少不必要的请求进入核心扣减区。
优先选择能够被团队完整掌握的方案。一个只有少数人理解、没有演练过补偿流程的复杂架构,实际可靠性可能低于一个简单但边界清楚的数据库事务方案。
技术选型必须把值班、监控、数据修复和故障复盘的人力算进去。系统不是上线即完成,扣减链路一旦承载真实交易,后续每次变更都需要能够回答“失败后会发生什么”。
有限资源业务的成功率受库存、额度或名额天然约束。高并发系统的目标不是让所有请求都返回成功,而是让真正成功的请求不超过资源上限,让失败请求尽快得到明确结果。
条件更新可以守住单次扣减的数值边界;事务可以保证局部操作的原子提交;幂等可以避免重试放大;消息和补偿可以处理跨服务状态;监控和对账则让系统在异常后有机会恢复。
如果一个方案只展示了“扣减 SQL”,却没有说明重复请求怎么办、订单失败怎么办、消息重复怎么办、缓存不一致怎么办,那么它只能算局部实现,不能算完整的生产方案。
如果一个方案只强调“支持多大并发”,却没有给出热点比例、P99延迟、锁等待、库存差异和恢复时长,那么它的性能结论也不够用于技术决策。
并发扣减的核心能力,是把不可破坏的业务约束下沉到可靠的数据操作中,再把跨系统的不确定性包进幂等、状态机、流水、补偿和对账闭环。这才是技术负责人真正需要关注的“增长视角”:系统不仅能够承接更多流量,还能在流量变大、链路变长、异常变多之后,继续保持可解释、可恢复、可演进。
如果你正在改造一个库存或额度系统,可以按以下顺序开始:
先把正确性做成可验证的事实,再用性能数据推动架构演进。这样的扣减系统,才是真正能够支撑业务增长的数据库存。
我之前一直以为,只要在代码里先查询库存、判断库存大于 0,再执行扣减,逻辑就已经足够严谨。直到我在一次库存为 1 的并发测试中发现,两个请求都读到了“有库存”,最后却都进入了下单流程,我才意识到问题并不在 SQL 是否执行成功,而在读取和更新之间存在竞态窗口。
超卖的根源通常不是数据库“算错了”,而是应用把“读取、判断、扣减”拆成了多个可被并发打断的步骤。库存为 1 时,请求 A 和请求 B 可能同时读到 1,随后各自判断库存充足,最终都尝试创建订单。
更可靠的最小方案,是把业务约束直接写进更新条件,让数据库一次完成扣减和校验:
UPDATE inventory SET available = available - :quantity WHERE sku_id = :sku_id AND available >= :quantity;应用层不要只根据 SQL 是否抛出异常判断结果,而要检查受影响行数。影响 1 行表示扣减成功,影响 0 行表示库存不足或条件不满足。
方案库存为 1 时的并发结果主要问题 先查询再更新多个请求可能同时通过判断存在竞态窗口,容易超卖 无条件减库存可能扣成负数数据库执行成功,但业务状态错误 带库存条件的原子更新通常只有一个请求成功仍需处理幂等、订单失败和重试 我更倾向于把条件更新视为“库存单行约束”的解决方案,而不是完整的库存一致性方案。
它能保证一次扣减不能突破可用库存边界,却不能自动保证订单创建成功、支付成功后库存状态正确,也不能阻止同一个请求因超时重试而重复扣减。上线前至少要验证三件事:库存为 1 时并发请求只能成功一次;扣减成功后订单服务宕机时库存可以查询和补偿;同一个业务请求重复提交时不会产生第二次扣减。
缺少后两项时,系统只能说“单次 SQL 正确”,还不能说“全链路一致”。
我在设计扣减接口时曾经考虑过给查询加行锁,直觉上感觉把资源锁住就不会出问题。但压测后发现,热点商品的请求会在同一行上排队,数据库锁等待变长,接口 P99 明显恶化,所以我想知道不同并发控制方式到底应该按什么标准选择。
锁不是越多越安全,而是要看你希望保护哪一个业务约束,以及冲突失败后的处理成本。对于“库存不能小于零”这种单字段扣减,带条件的原子更新通常比先查询再加锁更简单,因为它没有把库存判断暴露在应用层。
三种方案的差异可以这样判断: 方案适合场景优势代价 条件更新单 SKU、单次扣减实现简单,数据库直接校验约束难以覆盖复杂跨表流程 乐观锁冲突概率中等,失败可以重试低冲突时吞吐较好热点冲突会造成重试放大 悲观锁事务内必须连续读取和修改多条数据控制边界直观锁等待、死锁和长事务风险较高 一个常见误区是给库存查询加了行锁,却在事务中执行远程调用、创建复杂订单,甚至等待支付结果。
这样做虽然延长了保护时间,但也把网络延迟和业务处理时间转化成了数据库锁等待。我的判断标准是:如果只需要扣减一个库存字段,优先使用条件更新;如果需要基于多个字段进行校验,并且失败后可以安全重试,可以考虑乐观锁;如果多个数据项必须在同一事务内保持严格约束,才使用悲观锁,并把事务压缩到最短。
选型时不要只看平均响应时间,至少要同时观察冲突率、重试次数、锁等待时间、死锁数量和 P99 延迟。一次测试中,低冲突数据使用乐观锁通常很平稳,但当请求集中争抢同一 SKU 时,重试次数会迅速增加,此时继续重试并不会创造库存,只会把压力重新推回数据库。
我曾经遇到过一种很难排查的情况:库存表里的数字是对的,扣减 SQL 也没有失败,但订单服务因为超时没有创建成功,用户随后再次提交时又触发了一次扣减。这个问题让我怀疑,数据库层面的原子性和订单、库存之间的一致性,可能根本不是同一个层次的问题。
数据库原子扣减只能保证某一次数据更新满足条件,不能自动覆盖“扣库存、写订单、发消息、支付确认”这一整条业务链路。库存成功而订单失败时,数据库并没有能力判断这次扣减是否应该释放。因此,库存扣减接口必须具备业务幂等能力。
可以为每次扣减生成唯一业务请求号,并记录扣减流水: request_id 作为唯一键 首次请求:写入流水并执行扣减 重复请求:返回第一次处理结果 超时重试:先查询流水状态,再决定是否继续我建议把库存状态拆成“可用、预占、已确认、已释放”几个阶段,而不是只维护一个 available 数字。
这样订单创建失败时,可以执行释放预占;支付成功后,再把预占转为已确认,状态变化也更容易审计。
故障场景可能结果需要的机制 扣库存成功,订单创建超时库存被占用但订单未知状态查询、超时补偿 客户端重复提交同一购买意图被扣两次业务幂等键、扣减流水 消息重复消费释放或确认动作重复执行消费幂等、状态机校验 服务处理结果未知重试可能造成二次操作先查状态,再决定重试 技术负责人需要区分三种一致性:单条库存记录的原子更新、单库事务的一致性,以及跨服务流程的最终一致性。
把第一种直接宣传成“全链路强一致”,是库存系统最容易被忽略的认知错误。上线前我会做一组故障演练:扣减成功后立刻中断订单服务、重复投递同一条消息、让接口在响应前超时、让补偿任务执行两次。只有这些场景都能得到可追踪、可恢复的结果,库存数字才真正具备业务可信度。
我曾经见过一个看似正确的方案:用带条件的 SQL 防止超卖,功能测试完全通过,但活动开始后所有请求都集中更新同一个热门 SKU,数据库 CPU 不高,接口却大量超时。后来我才明白,正确的行更新仍然可能因为同一行上的串行竞争,成为系统吞吐的上限。
热点库存的问题不是 SQL 不正确,而是大量请求必须争抢同一份可变状态。即使每次更新都能保证一致性,同一个 SKU 的库存行也可能形成排队,最终表现为锁等待增加、连接池占满和 P99 延迟升高。
是否引入更复杂的架构,应按业务规模和一致性要求逐级演进: 阶段推荐方案适用判断新增风险 基础阶段数据库条件更新流量可控,扣减逻辑简单热点行竞争 削峰阶段队列串行或分段处理允许排队,优先保护数据库消息堆积和处理延迟 高峰阶段缓存预扣加异步确认突发流量远超数据库写入能力缓存、数据库和消息的最终一致 极端热点阶段库存分桶或分片单资源成为明确写入瓶颈汇总、回滚和分配逻辑复杂 我不建议一开始就把库存放进缓存并宣称解决了高并发。
缓存预扣只是把竞争位置从数据库转移到了缓存,同时增加了“预扣成功但落库失败”“消息重复消费”“缓存丢失后如何修复”等新问题。更稳妥的演进路径是先测出瓶颈:记录数据库行锁等待、扣减 SQL 的 P95/P99、热点 SKU 请求占比、连接池使用率和消息处理延迟。
如果只是索引缺失或事务过长,优化数据库比引入缓存更划算;如果瓶颈确实是单行写竞争,再考虑队列、分桶或预扣。最终决策不应只问“能承受多少 QPS”,还要问业务是否允许排队、是否允许库存展示近实时、失败后能否补偿、团队是否有能力维护消息和对账系统。
对于多数业务,先用条件更新、幂等和补偿建立正确闭环,再根据实测热点逐步放大,通常比一次性堆叠复杂组件更可靠。


读者评论
文章把并发扣减拆成数值、请求、流程和恢复四个层次,这个框架比较实用。尤其是强调条件更新只能解决局部问题,避免了把一条 SQL 过度神化。
条件更新 SQL 的思路清晰,适合库存竞争不太激烈的业务。不过实际落地时还要结合数据库类型、索引设计和受影响行数判断,不能直接照搬。
对“事务不等于串行执行”的解释很有价值。很多系统确实只是加了事务,却没有处理锁竞争、隔离级别和更新条件,最终仍可能出现业务层面的不一致。
文章没有盲目推荐缓存和分布式锁,而是先看热点、P99、锁等待和库存差异数据,这种按实际瓶颈逐步演进的思路更符合工程实践。
幂等、消息补偿和对账机制部分提醒得比较到位。库存扣减成功但订单超时、重复提交等问题,往往比单纯的负库存更难排查,也更需要完整的状态设计。