数据库存:架构师一页讲清:数据校验与保证扣减一致性的关系
库存明明已经做了“库存是否充足”的校验,高并发下却仍然可能出现超卖、重复扣减,甚至订单成功而库存没有减少。问题通常不在校验条件写错,而在于“校验通过”与“真正扣减”之间存在一个并发时间窗口。数据校验负责判断请求是否合理,数据库原子更新负责让扣减动作成立,事务负责组合多个操作,幂等负责拦截重复请求,补偿机制则负责处理跨系统失败。把这些职责混为一谈,是扣减一致性问题长期反复出现的根源。
本文不把“校验、事务、锁、幂等”简单罗列成技术名词,而是从一次库存扣减到底经历了什么开始,拆解每个机制能保证什么、不能保证什么,以及在单库、缓存、消息队列和微服务场景下应该如何取舍。
数据校验的本质,是在某个时间点读取数据,并根据业务规则判断请求是否合法。例如,购买数量必须大于零,商品必须处于可售状态,账户余额必须足够,优惠券必须尚未使用。
这些判断非常必要,但它们描述的只是读取瞬间的事实。库存为 10,表示某一刻数据库中可用库存是 10;它并不意味着在当前请求完成更新之前,其他请求不会先消耗这 10 件库存。
因此,下面这句话只能说明前置条件成立:
“本次读取时,库存大于或等于购买数量。”
它不能直接推出下面这句话:
“本次请求一定能够安全完成扣减。”
扣减一致性关注的不是一次请求能否通过判断,而是多个请求同时到达、请求超时重试、服务中途崩溃、消息重复投递、缓存延迟和跨服务调用失败后,系统最终状态是否符合业务事实。
以库存为例,一致性至少包含几个层次:
在实际设计中,我通常用下面这张职责地图判断方案是否完整:
| 机制 | 主要职责 | 典型问题 | 不能替代的机制 |
|---|---|---|---|
| 数据校验 | 判断输入和业务条件是否合法 | 购买数量为负、商品已下架 | 不能单独解决并发竞争 |
| 原子更新 | 让条件判断与扣减尽量在一次数据库操作中完成 | 库存不足仍被扣减 | 不能单独处理订单和消息一致性 |
| 事务 | 保证同一事务内的数据库操作共同提交或回滚 | 库存扣了但流水没写 | 不能自动覆盖缓存和其他数据库 |
| 幂等 | 防止同一业务请求被重复处理 | 客户端重试导致扣两次 | 不能替代并发控制 |
| 补偿与对账 | 发现并修复异步或跨系统失败 | 订单与库存状态长时间不一致 | 不能掩盖错误的数据模型 |
最容易记住的一句话是:校验负责判断,原子更新负责扣减,事务负责组合,幂等负责去重,补偿负责恢复。

为了看清问题,不需要先讨论复杂的分布式事务。假设某个商品只剩 1 件,同时有两个用户提交购买请求,每个请求购买 1 件。应用代码大致如下:
库存 = SELECT available FROM stock WHERE sku_id = 1001;
if (库存 >= 1) {
UPDATE stock
SET available = available - 1
WHERE sku_id = 1001;
}在低并发环境下,这段代码经常表现正常,所以问题很容易被忽略。真正的风险出现在两个请求的执行时间交错时。
| 时间 | 请求 A | 请求 B | 数据库库存 |
|---|---|---|---|
| T1 | 查询库存,得到 1 | 尚未查询 | 1 |
| T2 | 通过库存校验 | 查询库存,得到 1 | 1 |
| T3 | 准备执行扣减 | 通过库存校验 | 1 |
| T4 | 扣减成功 | 准备执行扣减 | 0 |
| T5 | 请求返回成功 | 仍按之前结果继续扣减 | 可能为 -1 或出现错误覆盖 |
这里真正的问题不是“有没有做库存校验”,而是两个请求都基于同一个旧库存值做出了决定。校验本身没有错,错的是校验结果被当成了后续更新的永久依据。
有些团队遇到这个问题时,会直接提高事务隔离级别,甚至把所有查询都改成加锁查询。这样有时能够缓解问题,但不能把它当成万能修复方案。
如果业务仍然是“先查、应用层判断、再更新”,那么设计者必须明确以下问题:
只写一个 SELECT ... FOR UPDATE,却把支付、远程调用、消息发送都放在锁内,往往会把超卖问题变成锁等待、连接池耗尽和吞吐下降问题。
库存字段没有变成负数,也不代表扣减一致性已经做好。有些实现使用了类似下面的更新:
UPDATE stock SET available = available - 1 WHERE sku_id = 1001 AND available >= 1;
这类条件更新通常能避免库存被扣到不允许的下限,但它仍然可能存在其他业务错误:
所以,“不超卖”只是库存一致性的一个结果,不是全部结果。设计方案时应先定义业务到底要保证哪一种一致性,再选择技术机制。

参数校验只能阻止数量为零、负数、格式异常或明显超限的请求。例如,购买数量为 2,而库存只有 1,这类请求在进入数据库前就可以被拒绝。
但如果两个请求都购买 1 件,单个请求看起来都合法,组合起来却超过了剩余库存。这个问题属于并发一致性,不是输入合法性问题。
我的判断方式很简单:如果把请求一个一个顺序执行,代码完全正确;一旦并发执行就可能错误,那么重点就不应继续放在参数校验,而应转向原子更新、锁、版本号和事务边界。
事务能够保证同一数据库连接中一组操作的提交边界。例如,库存扣减和库存流水写入处于同一个本地事务中,库存扣减成功但流水写入失败时,可以整体回滚。
但事务不等于并发控制。两个事务都可能先读取到库存 1,然后按照各自的判断继续执行。最终结果取决于数据库的更新方式、隔离级别、锁行为和提交顺序。
更重要的是,事务通常不能自然覆盖以下动作:
因此,更准确的表述是:事务保证它覆盖范围内的数据库操作具有原子提交边界,但不自动保证整个业务链路一致。
悲观锁适合资源竞争强、冲突代价高,并且业务能够接受锁等待的场景。它的价值是让多个事务不能同时修改同一行,避免并发更新失控。
但是,锁是否有效取决于锁的对象、范围和生命周期。常见问题包括:
在高并发库存场景中,我通常优先考虑“带条件的原子更新”,只有当扣减前后必须读取并校验多项关联状态时,才评估是否需要显式锁。
缓存适合承接高频读取和流量削峰,但缓存中的数字不一定就是最终账本。若先在缓存中扣减,再异步写数据库,系统必须面对进程重启、消息丢失、重复消费和数据库写入失败。
缓存预扣并不是不能使用,而是需要配套设计:
缓存可以优化扣减路径,但不应在没有恢复机制的前提下承担唯一事实来源。
幂等解决的是同一业务动作被执行多次时,结果仍然只生效一次。例如,客户端发起扣库存请求后没有收到响应,于是使用相同请求号重新发送。系统应识别这是同一笔业务,而不是新的扣减。
但两个不同请求号同时购买最后一件商品,并不属于幂等问题。即使每个请求都有唯一编号,系统仍然必须在库存层面进行并发控制。
| 场景 | 是否属于幂等问题 | 重点机制 |
|---|---|---|
| 同一请求因超时被重试两次 | 是 | 业务请求号、唯一索引、幂等记录 |
| 两个用户同时购买最后一件 | 否 | 条件更新、锁或乐观并发控制 |
| 同一消息被消费两次 | 是 | 消费幂等、去重表、状态判断 |
| 库存扣减后订单写入失败 | 不完全是 | 本地事务、可靠消息、补偿和对账 |

设计扣减流程之前,我不会先问“要不要上分布式事务”,而会先问三个问题:哪一个数字是最终账本?哪些状态必须同时成立?哪些短暂不一致是业务可以接受的?
例如,库存数量、订单状态和支付状态并不一定需要在同一毫秒完成一致。库存可以先锁定,订单进入待支付,支付成功后再完成最终确认。但如果库存已经扣减,系统却没有任何订单或锁定记录,这就是不可接受的事实丢失。
这个判断会直接影响方案:有些业务需要强一致,有些业务只需要最终一致,还有些业务需要的是“可追溯、可恢复”,而不是所有数据实时相同。
不变量是无论并发、失败和重试如何发生,都不能被破坏的业务规则。库存扣减常见的不变量包括:
一旦把不变量写清楚,技术方案就不会停留在“加一个锁”这种模糊层面。你会知道需要什么字段、什么唯一约束、什么状态机和什么对账口径。
一个系统如果同时把数据库、缓存、消息队列和报表库都当成库存事实来源,迟早会出现“每个地方看起来都有道理”的对账争议。
通常情况下,我会把数据库中的库存流水或账户账本作为事实来源,把缓存视为加速层,把报表视为分析层,把消息视为传播变化的通道。
这并不意味着数据库永远是最适合承受所有流量的组件,而是要明确:性能层可以暂时领先或落后,但事实层必须可验证、可回放、可修复。
如果一个方案无法明确回答这四个问题,即使代码中有事务、锁和消息队列,也不能认为它已经完成一致性设计。

入口校验的价值在于尽早拒绝不可能成功的请求,避免无效流量占用数据库连接。至少应校验商品编号、扣减数量、请求号和业务状态。
if (skuId == null || quantity MAX_PURCHASE) {
return reject("invalid request");
}
if (requestId == null || requestId.isBlank()) {
return reject("missing request id");
}这里的 MAX_PURCHASE 是业务上限,不是并发一致性控制。即使数量合法,也必须在写入数据库时再次检查资源是否足够。
对于单个 SKU 的可用库存扣减,优先考虑把业务条件放进 UPDATE:
UPDATE stock SET available = available - :quantity, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available >= :quantity;
应用程序必须检查影响行数,而不是只看 SQL 是否执行成功:
rows = execute(updateSql, params);
if (rows == 1) {
return success();
}
if (rows == 0) {
return reject("insufficient stock or invalid sku");
}
return fail("unexpected affected rows");“SQL 执行没有报错”和“扣减业务成功”是两回事。数据库驱动返回成功,只能说明语句被数据库接受;只有影响行数符合预期,才能说明目标记录满足条件并完成了扣减。
假设一次扣减请求的编号是 REQ-20260916-0001。系统可以在扣减流水表建立唯一约束:
CREATE TABLE stock_deduction_log (
id BIGINT PRIMARY KEY,
request_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,
UNIQUE KEY uk_request_id (request_id)
);不过,唯一约束只保证相同请求号不能重复落库。它不等于整条业务流程已经幂等,因为还需要定义重复请求读取什么结果:返回原来的成功结果、返回处理中,还是返回明确失败。
如果库存表、订单表和扣减流水表位于同一个数据库,可以把它们放入本地事务:
BEGIN;
— 先写入或确认请求幂等记录
INSERT INTO stock_deduction_log
(id, request_id, order_id, sku_id, quantity, status, created_at)
VALUES
(:id, :request_id, :order_id, :sku_id, :quantity, 'PROCESSING', CURRENT_TIMESTAMP);— 再进行带条件的原子扣减
UPDATE stock
SET available = available - :quantity
WHERE sku_id = :sku_id
AND available >= :quantity;— 影响行数为 0 时,整个事务回滚
— 影响行数为 1 时,写入订单或更新流水状态
UPDATE stock_deduction_log
SET status = 'SUCCESS'
WHERE request_id = :request_id;
COMMIT;实际代码还需要处理唯一键冲突、死锁重试、连接断开和事务超时。示例的重点不在于复制执行,而在于展示三个边界:请求去重、库存条件更新和同库操作的提交关系。
一个常见但危险的流程是:开启数据库事务,扣减库存,调用支付服务,等待支付结果,再提交数据库事务。这样做会让数据库锁的持有时间受网络、支付服务和用户行为影响。
更稳妥的方式通常是把业务拆成明确状态:
这不是把所有问题都交给异步,而是把“数据库必须共同提交的事情”和“可以通过状态机最终收敛的事情”分开。

库存通常是最容易被拿来解释一致性的场景,但库存也有不同模型。现货库存、锁定库存、在途库存和可售库存不一定是同一个字段。
如果系统只有一个 available 字段,最基本的扣减可以使用条件更新。但如果订单创建后需要等待支付,就不应直接把所有库存都视为最终销售完成,而应该区分“可用”和“锁定”。
| 业务动作 | 库存变化 | 需要防止的问题 |
|---|---|---|
| 创建订单并锁定 | 可用库存减少,锁定库存增加 | 同一库存被多个订单占用 |
| 支付成功确认 | 锁定库存转为已售库存 | 订单已支付但库存未确认 |
| 支付超时释放 | 锁定库存减少,可用库存恢复 | 重复释放导致库存虚增 |
| 订单取消 | 根据订单原始锁定数量释放 | 取消状态重复流转 |
这里的关键不是单纯把库存减一,而是让每次状态变化都有来源、有数量、有唯一业务号,并且不能越过状态机重复执行。
余额扣减与库存扣减看起来相似,但风险更高。库存多扣一件,可能造成履约问题;金额多扣一分钱,则可能引发对账、退款和合规问题。
余额字段不应使用浮点数保存金额。通常应使用定点小数类型,或者使用最小货币单位的整数保存,例如以分为单位。更新时还应带上余额充足条件:
UPDATE account SET balance_cent = balance_cent - :amount_cent WHERE account_id = :account_id AND balance_cent >= :amount_cent;
此外,余额表最好与资金流水表形成可校验关系。余额是当前快照,流水是变化证据。出现争议时,不能只看余额字段,而应核对流水是否能解释余额的变化。
优惠券通常不是简单的数值递减,而是“未使用、锁定、已使用、已过期、已释放”等状态变化。此时,用一个“库存大于零”的判断无法覆盖业务规则。
一个更接近真实业务的更新条件可能是:
UPDATE coupon SET status = 'LOCKED', locked_order_id = :order_id, locked_at = CURRENT_TIMESTAMP WHERE coupon_id = :coupon_id AND user_id = :user_id AND status = 'AVAILABLE' AND expire_at > CURRENT_TIMESTAMP;
影响行数为 1,表示优惠券被当前订单锁定;影响行数为 0,表示优惠券已经被使用、锁定、过期或不属于当前用户。这里的状态条件就是一致性边界的一部分。
额度扣减常见于授信、配额、积分和促销预算。它的难点往往不只是单个账户余额,而是多个维度的约束同时成立,例如个人额度、商户额度、活动总预算都必须足够。
当多个资源必须同时扣减时,单条 SQL 能否覆盖所有条件,会影响方案选择。如果所有资源在同一数据库且更新数量有限,可以在同一事务中按固定顺序更新;如果分布在多个服务,则需要明确部分成功后的补偿路径。

这是最适合优先做好基础一致性的场景,不需要一开始就引入复杂的分布式事务。
建议采用以下组合:
这套方案的优势是边界清晰、排查路径短、维护成本低。很多系统的问题不是技术能力不足,而是在单库能解决时过早引入缓存预扣和异步链路,反而增加了恢复难度。
当大量请求集中争抢同一行库存时,条件更新仍然可以作为底层正确性保障,但数据库可能成为吞吐瓶颈。
可以按以下顺序优化:
这里有一个重要取舍:吞吐提升通常意味着把请求排队、异步化或分散到多个库存桶,但分散之后会增加合并、回补和对账复杂度。不要只看接口每秒处理量,还要看失败请求、回补耗时和异常订单比例。
扣减完成后,如果业务立即查询库存,读请求可能被路由到存在复制延迟的从库。用户看到旧库存,并不一定表示扣减失败,而可能是读路径没有实现“读己之写”。
可采用以下策略:
读写分离解决的是读取压力,不会自动提升扣减一致性。写入仍应在事实数据库上完成,读副本只负责在业务允许的范围内提供查询服务。
缓存预扣适合流量突发、库存热点明显且业务能够接受异步确认的场景。它的前提不是“缓存比数据库快”,而是系统愿意为速度支付额外的状态管理成本。
至少需要建立以下链路:
如果业务无法接受短暂超卖、无法接受异步确认,或者团队没有能力维护对账与补偿,那么不建议仅因为追求峰值吞吐就采用缓存预扣。
当订单服务、库存服务、支付服务各自拥有独立数据库时,本地事务只能保证各服务自己的数据。此时应把业务拆成可追踪的状态流转,而不是假设一条远程调用链可以拥有单库事务的特性。
一种常见的可靠流程是:
这个模式的关键不在于消息队列本身,而在于“业务事实先可靠落库,再传播出去”。如果消息发送成功依赖应用在事务提交之后临时执行,一旦进程在两个动作之间崩溃,就会出现数据库已有变化但消息没有发出的间隙。

我在评审扣减功能时,最先看的不是正常下单是否成功,而是四类边界测试是否有明确结果。
| 测试场景 | 预期结果 | 重点观察 |
|---|---|---|
| 库存为 1,两个请求同时购买 1 | 最多一个请求成功 | 影响行数、最终库存、订单状态 |
| 同一请求号重复提交 | 只产生一次有效扣减 | 唯一键、幂等返回结果、流水数量 |
| 数据库提交后响应前断开 | 重试可以识别原结果 | 请求状态、重复扣减、补偿记录 |
| 扣减成功后消息发送失败 | 消息可重试或进入待补偿状态 | 事件记录、投递次数、最终状态 |
如果测试只验证“库存充足时能够扣减”,只能证明功能路径可用,不能证明扣减链路具备一致性。
对于条件更新,影响行数是非常有价值的诊断信号。影响 0 行可能意味着库存不足,也可能意味着 SKU 不存在、状态不符合或参数条件错误。不能把所有 0 行都简单返回成“库存不足”。
建议在日志和指标中区分:
这些指标比单纯监控接口成功率更能解释一致性问题发生在哪里。接口成功率很高,但重复扣减和对账差异持续增加,仍然说明系统存在隐蔽风险。
库存快照适合快速读取,流水适合审计和恢复。对于一个 SKU,可以用以下思路核对:
理论库存
= 初始库存
+ 入库流水总量
成功扣减流水总量
+ 释放库存总量
盘亏调整总量;
这里的具体公式必须根据业务模型调整。例如锁定库存和已售库存分开记录时,不能把所有状态简单相加减。重点是每一类库存变化都要有明确的业务来源和唯一编号。
分布式系统不可能让所有异常都消失。更现实的工程目标是:异常发生后,系统能够发现、定位、重试、补偿,并且不会在恢复过程中产生新的重复扣减。
因此,测试时应主动制造以下故障:
每个故障都应有明确的最终状态,而不是依赖运维人员手工修改一行库存数字。

| 方案 | 优点 | 缺点 | 适用边界 |
|---|---|---|---|
| 先查再扣 | 代码直观,便于展示库存信息 | 存在竞态窗口,数据库往返次数更多 | 只能作为展示或预检查,不能单独承担最终扣减 |
| 条件更新 | 判断与写入靠近,逻辑简洁,适合单资源扣减 | 复杂多资源规则需要额外事务或状态设计 | 库存、余额、额度等单行资源变化 |
我的建议是:先查可以保留,但最终扣减必须再次由数据库条件保护。前置查询用于友好提示,条件更新用于最终裁决,两者不是互斥关系。
乐观锁通常在记录中增加版本号:
UPDATE stock SET available = available - :quantity, version = version + 1 WHERE sku_id = :sku_id AND available >= :quantity AND version = :version;
如果影响行数为 0,说明库存不足或版本已经变化,应用可以返回失败或重新读取后重试。
乐观锁适合冲突相对可控、失败后可以快速重试的场景。它不会长时间阻塞其他请求,但冲突集中时,重试会放大数据库压力。
悲观锁更适合必须串行化处理、且冲突失败代价很高的场景。它的代价是锁等待、死锁和吞吐下降,需要严格控制事务范围。
| 比较维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 冲突处理 | 冲突后失败或重试 | 访问前等待或排队 |
| 锁等待 | 通常较少 | 可能明显增加 |
| 实现复杂度 | 需要版本和重试策略 | 需要理解锁范围和事务生命周期 |
| 热点资源 | 冲突高时重试成本上升 | 容易形成排队和锁竞争 |
同步事务的优点是结果明确,调用方在返回前就知道数据库是否完成。缺点是链路长时响应时间增加,并且跨服务场景难以覆盖全部操作。
异步消息适合削峰、解耦和跨服务传播状态,但它引入了延迟、重复消费、消息积压和补偿问题。异步不是天然更可靠,只有当消息可持久化、可重试、可监控、可对账时,才真正具备工程价值。
选择标准不应是“同步老旧、异步先进”,而应看业务是否允许:
强一致适合金额扣减、关键配额和不能出现中间错误状态的核心账本。最终一致适合订单通知、搜索索引、报表同步和非关键展示数据。
但“最终一致”不是“不管它最后会自己好”。一个合格的最终一致方案必须回答:


把一次扣减从入口到结束完整画出来,包括参数校验、读取、写入、事务提交、缓存修改、消息投递和用户响应。尤其要标记每个可能失败的时间点。
很多团队只画“成功流程”,没有画“提交成功但响应失败”“消息成功但消费超时”“取消与支付同时到达”等异常流程。没有异常流程图,就无法判断补偿是否真实存在。
在代码仓库中搜索库存、余额、额度、优惠券等资源的读取和更新逻辑,重点检查它们是否跨越了应用层判断。对于单行数值扣减,优先评估是否可以改成条件更新。
如果业务确实需要先读取多个字段,也要确认最终写入是否带版本号、状态条件或锁保护。不能因为前面查过一次,就默认后面的更新仍然安全。
请求号不是为了方便打印日志,而是为了在超时和重试时回答一个关键问题:这次业务到底处理过没有?
请求号应贯穿订单、扣减流水、消息和补偿记录。只有各个环节使用同一个可关联标识,系统才能在发生差异时从一条订单追到一次扣减,再追到一条消息和一次恢复操作。
在引入缓存预扣、分布式事务或复杂消息编排之前,先用两个并发请求、一个重复请求和一次模拟故障验证现有实现。如果最基础的条件更新、影响行数判断和幂等边界都没有做好,架构复杂化只会把问题分散到更多组件。
最终可以按以下顺序推进:
数据校验当然重要,但它解决的是“请求是否值得继续处理”。如果系统把校验结果存放在应用变量里,再过一段时间才去更新数据库,那么这个结果随时可能过期。
扣减一致性的关键,是让资源条件与实际更新尽可能成为同一个受数据库保护的动作;当库存、订单和流水需要共同成立时,再用本地事务组合;当请求可能重试时,用唯一业务号和状态机去重;当链路跨越服务和消息系统时,用可靠事件、补偿和对账让状态最终收敛。
下一步不必先选某个热门组件。先拿系统中最容易出问题的一条扣减链路,回答四个问题:谁是事实来源、谁决定扣减成功、重复请求如何处理、失败后如何恢复。只要这四个答案清楚,技术方案通常不会偏离太远。
校验是前置判断,一致性是全过程约束;校验通过,只代表可以尝试扣减,只有原子更新、事务、幂等和补偿共同闭环,才代表系统真正有能力把资源扣对。
我在排查库存超卖问题时,最先怀疑的是库存校验条件写错,后来发现校验本身没有问题:两个请求都读到了库存为 1,并且都判断购买数量为 1,真正的问题出在“校验”和“扣减”之间存在时间窗口。想请教一下,数据校验和扣减一致性到底是什么关系?
先给结论:数据校验负责判断“这次请求是否满足条件”,而扣减一致性负责保证“多个请求并发、重试或失败时,最终结果仍然正确”。前者是业务判断,后者是并发控制和状态落地,两者不是同一个问题。
最典型的错误写法是先查询、再判断、最后更新:
SELECT available FROM stock WHERE sku_id = 1001;
if available >= quantity:
UPDATE stock SET available = available - quantity WHERE sku_id = 1001;假设库存只有 1,用户 A 和用户 B 几乎同时发起购买。A 在 T1 查询到库存为 1,B 在 T2 也查询到库存为 1;两边都通过了校验,随后分别执行扣减。此时,校验得出的“库存足够”只是某个瞬间的快照,并没有锁定后续更新。
环节解决的问题是否足以保证并发安全 参数校验数量是否大于 0、格式是否合法否 库存查询当前时刻库存是否足够单独使用时否 条件更新库存足够时才执行扣减是核心手段之一 事务多条数据库操作共同提交或回滚需结合具体边界 我的判断是:不要把“校验通过”当成“扣减成功”的依据。
真正的成功信号应该来自数据库更新结果,例如带条件的 UPDATE 影响 1 行,才说明这次扣减被数据库接受;影响 0 行,则应按库存不足、记录不存在或条件失效处理。
我曾经测试过两种实现:一种是先 SELECT 再 UPDATE,另一种是直接执行带有 available >= quantity 条件的 UPDATE。在并发请求从几十提升到几百后,前一种实现开始出现成功订单数和扣减流水数对不上,后一种则能通过影响行数稳定判断结果。
具体应该怎么写,原子更新又解决了哪些问题?
库存扣减的关键不是“有没有查库存”,而是“库存是否足够”和“库存减少”能不能由数据库作为一次受保护的更新动作完成。
推荐使用条件更新:
UPDATE stock SET available = available - :quantity, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available >= :quantity;执行后不要只根据应用层之前查到的库存做判断,而要读取 affected rows。影响 1 行,表示条件满足且扣减已经发生;影响 0 行,表示本次更新没有成功,业务层应返回库存不足、商品不存在或其他明确错误。这种写法的价值在于,判断条件和数值变更位于同一条数据库更新语句中。
数据库会在执行更新时重新判断 available >= quantity,而不是使用应用层几毫秒之前读到的旧值。对于同一条库存记录,数据库的行级并发控制会让竞争更新按数据库规则完成,而不是让多个应用线程各自基于旧快照做决定。
我通常会把两种实现放在压测中对比: 实现方式并发下的主要风险成功判断依据建议 先查再扣校验与更新之间存在竞态窗口应用层判断不建议单独使用 条件 UPDATE仍需处理重复请求和后续业务失败影响行数适合作为扣减核心动作 先加行锁再查询再更新锁等待、事务过长、死锁事务内查询结果适合复杂判断场景 但要注意,原子更新只保证库存这一行的扣减动作,不自动保证订单、库存流水、缓存和消息都成功。
若订单和流水必须与库存共同落库,应把它们放进合理的本地事务;若跨越多个服务,则需要可靠消息、幂等消费和补偿机制。
我一开始以为给扣库存方法加上事务,再加一把行锁,就能解决所有一致性问题。实际遇到客户端超时重试后,同一个请求被扣了两次;后来又发现消息重复消费也会造成重复扣减。想知道事务、锁、唯一约束和幂等机制到底各自负责什么,应该怎样组合?
这几个概念经常被混在一起,但它们解决的是不同故障面。我的经验是,先按“并发、重复、组合、跨系统”四类问题拆分,再决定技术手段,而不是看到扣减就统一加锁。
手段主要解决的问题典型边界 条件更新或乐观锁多个不同请求同时竞争资源冲突失败后需要明确返回或重试 悲观行锁控制同一资源的访问顺序事务过长会造成锁等待和死锁 本地事务库存、订单、流水共同提交或回滚通常只覆盖同一个数据库事务边界 唯一约束防止同一业务请求重复写入不能替代完整业务状态机 幂等机制防止网络重试、重复投递造成重复处理不能单独解决不同请求之间的竞争 例如每次扣减都携带 request_id,可以建立扣减流水表: CREATE UNIQUE INDEX uk_request_id ON stock_deduct_log(request_id);
处理流程可以是:先在事务内检查或写入 request_id,再执行库存条件更新,最后写入订单或流水。重复请求再次到达时,唯一约束或幂等记录会阻止第二次实际扣减,并返回第一次处理结果。这里有一个容易踩坑的地方:幂等键必须代表“同一次业务意图”,不能简单使用用户 ID 或商品 ID。
用户连续购买两次,应该产生两个不同请求号;同一个请求因超时重试,才必须复用原请求号。我的选型判断是:单库场景优先采用“条件更新 + 本地事务 + 业务请求号唯一约束”,只有在需要复杂读取、多个资源互相锁定时才考虑悲观锁。
锁并不是一致性的代名词,锁范围、索引命中情况、事务时长和异常回滚方式,往往比“有没有加锁”更重要。
我现在负责的系统已经拆成订单服务、库存服务和支付服务,库存扣减成功后,订单服务偶尔因为超时没有收到结果,缓存里也会短暂显示旧库存。我不希望直接上复杂的分布式事务,想知道不同场景下应该怎样判断方案,并且如何设计失败后的恢复流程?
先区分两件事:数据库内的一致性,和跨服务业务流程的一致性。单体系统中,订单、库存和流水如果位于同一个数据库,通常可以用本地事务解决提交边界;拆成多个服务后,任何一个本地事务都无法自动覆盖其他服务。
场景推荐起点需要重点防范 单库单服务条件更新 + 本地事务 + 唯一请求号重复提交、事务范围过大 高并发抢库存条件更新或乐观锁 + 限流削峰热点行竞争、失败重试风暴 订单与库存跨服务本地事务 + Outbox 或可靠消息消息丢失、重复消费、状态不一致 支付与库存强协调状态机、补偿,必要时评估分布式事务长事务、回滚边界、人工介入 一个比较稳妥的跨服务流程是:订单服务先在本地事务中写入待确认订单和待发送事件;
事件发送成功后,库存服务消费消息并用 request_id 做幂等校验,再执行条件扣减;库存服务返回成功或失败事件,订单服务据此推进状态。如果消息发送和本地事务分开执行,就会出现“数据库提交成功、服务宕机、消息还没发出去”的窗口。
Outbox 模式的做法是把业务数据和待发送事件写入同一个数据库事务,再由独立投递程序扫描未发送事件并重试。它不能让网络永远不失败,但能把不可见的丢消息风险变成可查询、可重试的记录。缓存问题也要单独处理。扣减主库成功后,从库或缓存短时间读到旧值,并不一定代表库存扣减失败。
对刚完成扣减的关键查询,可以暂时读主库;缓存更新则应采用删除缓存、可靠消息或订阅数据库变更等策略,并为异常情况保留对账任务。我不建议一遇到跨服务就直接引入复杂分布式事务。先确认业务是否真的要求强一致,再评估补偿成本:如果允许订单在几秒内处于“待确认”,可靠消息加状态机通常更易维护;
如果资金扣减等操作绝不能出现中间状态,才有必要进一步评估更强的协调方案。发布前可以用这五个问题做检查:扣减是否是条件原子更新?重复请求是否有业务幂等号?消息是否可重试且可追踪?缓存旧读是否被业务允许?异常后是否能通过流水和订单状态自动对账?这五个问题比单纯询问“有没有使用事务”更能判断方案是否可靠。


读者评论
文章把“校验通过”和“扣减成功”区分得很清楚,先查再扣的并发窗口用库存为1的例子解释得比较直观。
对条件更新、事务、幂等和补偿的职责划分较准确,尤其指出事务不能自动覆盖缓存、消息和外部服务,这一点对实际设计很有参考价值。
内容覆盖面比较完整,但缓存预扣、消息可靠投递和对账补偿部分仍偏原则性,若能补充具体表结构或伪代码,落地会更容易。
文中没有把行锁和高隔离级别当成万能方案,提醒关注索引、锁持有时间和死锁,比较符合高并发系统中的实际情况。
文章适合帮助开发人员建立一致性思维,不过不同数据库在条件更新、锁行为和隔离级别上存在差异,实施时还需要结合具体数据库验证。