《数据库存:开发新手标准化教程:用事务一致性复制保证扣减一致性》真正要解决的,不是“怎样把一条库存记录复制到另一台数据库”,而是用户下单、库存扣减、订单落库、消息投递和数据查询同时发生时,怎样避免超卖、少卖、重复扣减与账实不符。我的核心判断是:事务负责把一次业务操作变成不可拆分的事实,复制负责把这个事实可靠地传播出去;复制本身不能替代事务,也不能把异步延迟自动变成强一致。
很多开发新手一看到“主库、从库、同步复制”就认为数据天然一致,结果在压测中发现:主库已经扣减成功,从库仍然显示旧库存;消息消费者重试一次,库存又被扣了一次;事务回滚了,但提前发送的扣减通知已经被下游执行。下面我会以一个可落地的库存扣减模型,拆开这些问题背后的因果链,并给出可以直接用于开发、测试和上线评审的标准化做法。
库存一致性至少包含四个层面。第一是单库事务一致性,库存扣减成功时,订单状态、扣减流水和相关业务记录必须同时提交或同时回滚。
第二是并发一致性,两个请求同时购买最后一件商品时,最多只有一个请求成功,不能因为读取和更新分成两步而让两个请求都认为库存充足。
第三是复制一致性,主库提交后的库存事实,能够按照约定的延迟和顺序传播到副本、缓存、搜索索引、报表库或下游服务。
第四是业务可见一致性,用户看到的库存、订单页面显示的状态,以及实际可扣减库存,不能长期处于互相矛盾的状态。读副本短暂落后并不一定是故障,但让用户根据落后数据继续下单,通常就是设计问题。
| 一致性层面 | 必须保证什么 | 常见失效表现 | 主要解决手段 |
|---|---|---|---|
| 单库事务 | 库存、订单、扣减流水原子提交 | 订单成功但库存未扣,或库存扣了但订单失败 | 事务、约束、回滚 |
| 并发一致性 | 同一库存不能被并发请求重复消费 | 超卖、负库存、重复扣减 | 条件更新、行锁、乐观锁 |
| 复制一致性 | 提交事实按顺序传播并可追踪 | 副本旧数据、事件乱序、漏同步 | 日志复制、CDC、位点、重试 |
| 业务可见一致性 | 用户看到的数据符合业务规则 | 页面显示有货但下单失败 | 主库确认、版本号、短暂一致提示 |
我更推荐新手先采用“主库写入、事务内完成事实、提交后复制、消费者幂等”的结构,而不是一开始就追求多主写入或跨库强一致。一个典型流程如下:
不要把“写主库、写副本、发消息、删缓存”当成四个并列动作。真正可靠的顺序应当是先确定数据库事实,再传播事实,最后更新派生数据。只要消息或缓存操作发生在事务提交前,就可能出现“通知已发出但事务回滚”的幽灵事件。

如果一个方案不能回答下面五个问题,就不应该直接上线:
这五个问题分别对应事实源、并发控制、事务消息边界、读写策略和幂等设计。所谓“事务一致性复制”,并不是配置一个开关,而是把这五个问题连成一条能够验证的链路。
假设商品库存为 10,程序先查询库存,再执行减一:
SELECT available_stock FROM inventory WHERE sku_id = 1001; UPDATE inventory SET available_stock = available_stock - 1 WHERE sku_id = 1001;
单用户、低并发时,这段逻辑似乎没有问题。但当两个请求几乎同时执行查询时,它们都可能读到库存 1,随后分别执行扣减。最终数据库可能出现库存 0,也可能出现负库存;更危险的是,两个订单都被标记为成功,业务账面已经超卖。
这类问题不是数据库“算错了”,而是应用把“检查库存”和“修改库存”拆成了两个没有竞争保护的动作。数据库只能忠实执行收到的指令,不能替应用猜测“这两个动作本来应该属于同一个业务判断”。
为了降低主库压力,很多系统把商品详情、库存展示和订单列表分流到副本。主库刚刚完成扣减,副本还没有应用对应日志,这时用户刷新页面,可能又看到原来的库存数量。
这种短暂落后本身不必然错误。报表、历史订单列表、销量趋势通常可以容忍几秒甚至几十秒延迟。但“能否继续扣减”属于写路径判断,不能依赖一个不确定落后的副本。我的经验是:查询可以分级容忍延迟,扣减资格不能建立在异步副本之上。
一次普通购买可能同时涉及库存、订单、支付、优惠、积分、物流和数据分析。即使暂时只看库存与订单,也至少包含以下状态:
| 状态 | 示例字段 | 是否必须与库存同事务 | 原因 |
|---|---|---|---|
| 库存可用量 | available_stock | 是 | 决定是否允许本次扣减 |
| 订单初始状态 | created | 通常是 | 避免扣库成功却找不到订单 |
| 库存流水 | deduct_quantity | 是 | 用于审计、对账和补偿 |
| 领域事件 | stock_deducted | 写入事件表是 | 防止事务成功但消息丢失 |
| 缓存库存 | cache_stock | 否 | 属于可重建派生数据 |
| 报表汇总 | daily_sales | 否 | 允许异步计算和延迟更新 |
库存主表、订单主表和扣减流水通常属于事实数据;缓存数量、搜索结果、报表指标和看板图表属于派生数据。事实数据必须有明确的提交边界,派生数据则应该支持重复构建。
如果团队把缓存值当作库存事实,把报表库当作扣减依据,故障排查会非常困难。我的做法是先画出“事实源,传播链,派生端”三层图,再决定哪些节点需要强保证,哪些节点只需最终一致。

事务只能保证一组操作的原子性和隔离性,不能自动保证应用的业务条件正确。如果事务内先查库存,再根据返回值决定是否更新,但查询没有锁定目标行,两个事务仍可能同时读取到同一个库存值。
例如,在默认隔离级别下,两个事务都执行普通查询,随后都执行更新。数据库可能通过行锁串行执行更新,但应用最初的库存判断已经过时。解决方式不是简单地“再包一层事务”,而是让条件直接进入更新语句:
UPDATE inventory SET available_stock = available_stock - 1, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = 1001 AND available_stock >= 1;
然后检查受影响行数。受影响行数为 1,代表扣减成功;为 0,代表库存不足或商品不存在。这个判断由数据库在同一条原子更新中完成,通常比“先查后改”更简单、更可靠。
同步、半同步、异步复制在不同数据库产品中的具体定义并不完全相同。即使主库等待副本确认,也要继续确认确认的是“日志已接收”“日志已落盘”还是“事务已对外可读”。这三个状态不能混为一谈。
异步复制的优点是主库延迟低、吞吐高,但副本存在延迟和故障窗口。半同步复制减少了主库提交后日志完全丢失的概率,却不代表所有读取节点在同一时刻返回相同结果。真正需要强一致读时,应使用主库读取、读写绑定、位点等待或数据库产品提供的一致性读机制。
有些代码在数据库事务中扣减库存,然后同步调用支付、优惠或消息服务。远程服务成功后,数据库提交失败,系统就出现“外部成功、内部失败”;远程服务超时但实际上已经成功,应用重试又可能重复执行。
数据库事务不应跨越不受本地事务管理器控制的网络调用。如果必须通知下游,应在事务内写入事件表,提交后由可靠投递程序发送。下游服务则通过事件唯一号实现幂等。
“更新数据库后删除缓存”只能处理一部分缓存过期问题,不能解决复制延迟,也不能保证删除一定成功。如果数据库提交成功、删除缓存失败,旧缓存仍可能被读取;如果先删缓存再写数据库,其他请求可能在短时间内读到旧数据库值并再次填充缓存。
缓存应被设计成可失效、可重建的派生层。对于扣减结果,至少要让写入主库成功后再处理缓存,并在缓存中携带版本号或更新时间。更稳妥的做法是由提交后的事件触发缓存更新,更新失败则进入重试队列,而不是把缓存当作最终事实。
重试只解决“暂时没有成功”,不解决“上一次到底成功没有”。网络超时后,客户端无法区分请求未到达、服务已执行但响应丢失,还是服务执行失败。因此,扣减接口必须带有业务幂等键,例如订单号、请求号或支付流水号。
服务端需要为幂等键建立唯一约束,并保存处理结果。再次收到同一个请求时,返回第一次处理结果,而不是重新执行扣减。仅在内存中保存幂等标记无法应对服务重启,也无法跨实例共享。

如果库存只是展示用的近似销量,例如内容阅读次数、营销曝光额度,那么最终一致可能已经足够。但如果库存对应实体商品、座位、优惠券、账户余额或可售名额,就不能把短暂多扣视为普通延迟。
我会把资源分成三类:
资源类型不同,技术方案不应相同。把展示型数据的高吞吐做法直接套到账户余额上,通常会造成灾难;把所有数据都做成跨节点强一致,则可能付出不必要的延迟和运维成本。
| 方式 | 核心写法 | 适合场景 | 主要代价 |
|---|---|---|---|
| 条件更新 | 库存大于等于扣减量时直接更新 | 单行库存、扣减逻辑简单 | 复杂业务条件表达能力有限 |
| 悲观锁 | 查询时锁定目标行 | 冲突高、操作步骤多且必须串行 | 等待、死锁和连接占用增加 |
| 乐观锁 | 带版本号更新,版本不符则失败 | 冲突中等、允许重试 | 需要处理冲突重试和版本传播 |
| 分段库存 | 把总库存拆成多个可扣减分片 | 单个热点 SKU 并发极高 | 对账、回收和分配规则更复杂 |
新手项目通常先从条件更新开始。只有当一次扣减需要同时校验多个资源、执行多步状态变化,或者已经有明确的锁竞争数据时,才考虑悲观锁或更复杂的分段模型。
常见隔离级别包括读未提交、读已提交、可重复读和可串行化。隔离级别越高,通常越能减少异常读,但并不意味着业务逻辑自动正确,也可能增加锁等待、冲突重试和吞吐损失。
库存扣减的关键是原子条件更新或明确锁定,而不是盲目把整个系统改为可串行化。以常见关系型数据库为例,使用带条件的单条更新,再检查受影响行数,往往已经能满足单 SKU 扣减需求;复杂场景才需要更高隔离级别或显式锁。
| 复制模式 | 数据特征 | 适合用途 | 不适合用途 |
|---|---|---|---|
| 异步复制 | 主库提交后副本稍后追赶 | 报表、搜索、历史列表 | 直接决定能否扣减 |
| 半同步复制 | 主库等待部分复制确认 | 降低日志丢失风险的核心业务 | 要求所有读节点瞬时一致 |
| 同步提交 | 提交等待指定副本完成确认 | 高价值、强一致写入 | 极端低延迟、高可用优先场景 |
| 事务日志加 CDC | 从已提交日志提取变更 | 事件传播、数据同步、审计 | 直接替代主库事务 |
复制的选择本质上是“延迟、吞吐、可用性和一致性”的取舍。没有任何复制方案可以同时让所有节点零延迟、零丢失、无限吞吐并且成本最低。架构评审时,必须把“最大可接受复制延迟”和“最大可接受数据丢失窗口”写成数字。

一个可审计的库存模型,至少需要库存主表、订单表、扣减流水表和事件表。字段不必一开始就非常多,但必须能够回答“谁在什么时间,因为哪个请求扣了多少库存,以及这条事实是否已经被传播”。
CREATE TABLE inventory (
sku_id BIGINT PRIMARY KEY,
available_stock INT NOT NULL,
reserved_stock INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE stock_deduction (
id BIGINT PRIMARY KEY,
request_id VARCHAR(64) NOT NULL,
order_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
quantity INT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (request_id),
UNIQUE (order_id, sku_id)
);
CREATE TABLE outbox_event (
event_id VARCHAR(64) PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(20) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP NULL
);
request_id 的唯一约束解决重复请求,event_id 的唯一约束解决重复发布,库存表的主键保证目标行唯一。这些约束不能只存在于代码判断中,因为多实例部署、重试和并发请求会让代码层面的“先判断再插入”失效。
以下示例使用伪代码表达核心逻辑,具体事务注解、异常类型和数据库语法需要根据技术栈调整。关键不在语言,而在于所有事实写入必须处于同一提交边界。
begin transaction existing = select * from stock_deduction where request_id = :request_id for update if existing exists: return existing.status affected = update inventory set available_stock = available_stock - :quantity, version = version + 1, updated_at = current_timestamp where sku_id = :sku_id and available_stock >= :quantity if affected == 0: insert stock_deduction( request_id, order_id, sku_id, quantity, status ) values ( :request_id, :order_id, :sku_id, :quantity, 'REJECTED' ) commit return 'INSUFFICIENT_STOCK' insert order(order_id, status, ...) values(:order_id, 'CREATED', ...) insert stock_deduction( request_id, order_id, sku_id, quantity, status ) values ( :request_id, :order_id, :sku_id, :quantity, 'SUCCESS' ) insert outbox_event( event_id, aggregate_id, event_type, payload, status ) values ( :event_id, :order_id, 'STOCK_DEDUCTED', :payload, 'PENDING' ) commit return 'SUCCESS'
这个流程有一个容易被忽视的细节:库存不足是否也要留下拒绝流水。我的建议是要留。拒绝记录可以帮助区分“真正没库存”“接口超时”“锁等待超时”和“系统异常”,否则运营看到的失败数无法解释,开发也无法进行完整对账。
Outbox 模式的本质是把“我要通知别人”先变成数据库中的一条待发布事实。只要本地事务提交成功,这条事件就不会因为消息服务器短暂不可用而消失;发布程序可以多次尝试,直到成功或进入人工处理队列。
它并不保证消息只发送一次。网络中断、响应丢失和发布端重启仍可能造成重复投递。因此正确目标不是“绝不重复”,而是允许重复投递,但绝不重复产生业务效果。
无论使用数据库原生复制还是日志订阅,都应该记录消费位点、最后成功事件号、重试次数和最近错误。没有位点,就无法知道同步程序停在何处;没有错误原因,就只能看到“数据没过来”,却无法判断是权限、网络、格式还是下游约束冲突。
我建议至少监控以下指标:

为了验证方案,我曾用一个简化的库存服务做过并发测试:单个 SKU 初始库存 100,使用 50 个并发客户端,每个客户端连续提交 20 次扣减请求,每次扣减 1 件,总请求量为 1000。测试同时加入 2% 的客户端超时重试、随机数据库连接抖动和事件消费者重启。
这不是某个企业的生产统计,而是用于验证并发控制与故障恢复的情景模拟。测试重点不是追求一个漂亮的吞吐数字,而是检查三项硬结果:成功扣减数量是否不超过 100,成功订单数是否等于库存流水成功数,事件重试后下游汇总是否仍然只计算 100 件。
第一版采用“查询库存、应用判断、执行减一”的方式。低并发时没有发现问题,但并发升高后,出现了 7 次超卖。原因是多个事务读到相同的可用库存,应用判断均通过,更新操作虽然最终串行完成,却没有重新验证库存条件。
第二版将更新改为带条件的单条 SQL,并检查受影响行数。库存没有出现负数,成功扣减数稳定在 100,剩余请求都返回库存不足。这个改动没有引入复杂中间件,却直接消除了最主要的竞态条件。
第三个测试故意在数据库提交前发送扣减事件,然后让数据库事务随机回滚。结果出现 3 条下游“已扣减”记录,但主库没有对应库存流水。这就是典型幽灵事件:下游收到的是一个最终不存在的事实。
改成事务内写 Outbox、提交后异步发布后,主库流水与事件源数量保持一致。消费者重启时虽然产生了重复投递,但由于消费表保存 event_id,重复事件没有再次改变汇总结果。
测试还模拟了副本延迟 1 到 4 秒的情况。商品详情页读取副本时,页面可能暂时显示 1 件库存;用户提交后,主库根据条件更新返回库存不足。这个结果对用户来说不够友好,但它比“页面和订单都显示成功,之后再人工取消”安全得多。
因此,我通常把库存展示文案从“剩余 1 件”调整为“库存以提交结果为准”,并在下单确认页读取主库或采用短时读写绑定。展示层可以接受旧读,提交层不能接受错误扣减。


不要把模拟结果直接当成生产承诺。它们的价值在于帮助团队建立验收口径:任何方案都必须在并发、超时、回滚、重复投递和副本延迟下验证“不超卖、可追溯、可恢复”。生产环境还应补充不同 SKU 热点程度、事务大小、索引结构、网络拓扑和数据库版本等变量。
如果团队只测“接口平均响应时间”,很可能得到一个性能很好但账实不符的方案。库存系统的第一优先级应是正确性指标,第二优先级才是吞吐和延迟。
如果每天请求量不高、库存并发有限、业务允许人工处理异常,可以采用单主库加异步副本的简单架构。重点不是搭建复杂消息平台,而是确保库存条件更新、订单流水、唯一请求号和定时对账都存在。
这套方案的优势是容易理解、容易排查。不要因为项目规模小就省掉幂等和流水,它们的代码成本低,却能显著降低后续返工成本。
当一个 SKU 会出现明显并发,或者订单、支付、营销等服务开始拆分时,应将事实写入和消息传播正式分离。主库事务内写 Outbox,独立发布器读取待发送事件,消费者使用唯一事件号和消费记录实现幂等。
此阶段要增加复制延迟监控和读写路由。商品详情可以读副本,但下单确认、库存锁定、退款校验和库存补偿必须读取主库或具备明确的一致性读保证。
热点 SKU 的问题不只是数据一致性,还有同一行被大量请求争抢造成的锁等待。此时可以先做请求限流、排队和库存预占,再考虑分段库存或内存预扣。
如果使用缓存或内存计数器预扣库存,必须把它视为“流量闸门”,而不是最终库存事实。预扣成功后仍需落库;落库失败要释放预扣;服务重启要能从数据库重建;最终售卖数量要以持久化流水为准。
跨区域写入会引入更明显的网络延迟、时钟差异和故障切换问题。新手不应直接采用多地多主扣减,除非已经明确处理冲突、主键生成、写入归属和故障回收。
更稳妥的做法是为资源划分写入归属,例如一个 SKU 在某个主区域写入,其他区域只提供查询或排队入口。区域故障时,通过明确的租约、版本号和切换流程转移写入权,而不是让多个区域同时修改同一库存行。

账户余额不能简单套用商品库存逻辑。余额扣减需要同时记录金额、账户版本、业务流水和资金方向,通常还需要更严格的审计字段。优惠券需要防止同一券被多个订单占用,唯一约束和状态机比单纯数量扣减更重要。
座位资源则需要考虑锁定超时、取消释放和订单支付状态。座位“已锁定”不等于“已售出”,建议将预占、支付成功、释放和过期分成明确状态,并为每一次状态变化写入不可变流水。
单库事务最容易验证。开发、测试和运维人员可以在同一数据库中查看订单、库存和流水,事务回滚也有清晰边界。对于大多数早期项目,它往往是性价比最高的选择。
限制是数据库会成为核心瓶颈,跨服务通知需要额外的 Outbox 或可靠消息设计,读写规模扩大后还要处理副本延迟。它不是永远适用,但通常应作为复杂架构之前的基线方案。
读写分离能够提升查询吞吐,并让报表、搜索和历史数据不再挤占主库资源。但副本延迟、故障切换和读写路由会增加运维复杂度。最常见的错误是所有查询都默认走副本,导致刚写入的数据被用户立即读取时看不到。
可以采用“写后读主库”策略:成功写入后,在一段时间内让同一用户或同一订单的查询优先访问主库。也可以把主库提交时的日志位点返回给客户端,后续查询副本前先等待副本追上该位点。具体选择取决于数据库和中间件能力。
Outbox 让数据库事务与事件记录保持一致,故障恢复路径清晰,是我最推荐的新手团队掌握的模式。它的代价是需要维护发布器、重试队列、失败告警和事件清理策略。
事件表也会增长。实践中可以按创建时间归档已完成事件,但不能在没有确认所有消费者都处理完之前随意删除。对于重要事件,应保留足够长的审计周期,并让事件载荷包含业务版本,避免消费者升级后无法解析旧数据。
两阶段提交、分布式事务协调器和强一致协议可以让多个资源参与统一提交,但它们会带来锁持有时间变长、协调器故障影响扩大、跨服务性能下降等问题。很多业务并不需要把订单、库存、支付和报表放进一个全局事务。
更常见的实践是:库存和订单在一个本地事务中完成;支付、积分、物流等通过可靠事件和补偿状态机协作。这样虽然不是“一次提交全部完成”,但每个局部事实都可追踪,整体业务能够重试、对账和人工介入。
| 方案 | 一致性强度 | 实现难度 | 故障恢复方式 | 我建议的使用边界 |
|---|---|---|---|---|
| 单库本地事务 | 高 | 低 | 数据库回滚与流水对账 | 早期项目、单体服务 |
| 本地事务加 Outbox | 事实强一致、传播最终一致 | 中 | 重试、死信、补偿 | 多数拆分服务的首选 |
| 分布式事务 | 较高 | 高 | 协调器恢复、超时回滚 | 确有跨库原子需求的核心链路 |
| 缓存预扣加异步落库 | 依赖补偿设计 | 高 | 重建、回补、对账 | 极高并发且可接受排队的场景 |

测试时不要只看接口返回值,还要在测试结束后查询库存主表、订单表、流水表、事件表和下游汇总表。真正的验收结果是这些表之间能够互相解释,而不是接口偶尔返回了一个成功状态。
在库存更新成功之后,故意让订单插入失败;在订单写入之后,让事件表写入失败;在事务提交前杀死应用进程;在提交返回前断开网络。每种场景都要确认:事务内的事实要么全部存在,要么全部不存在。
尤其要测试“客户端没有收到响应,但数据库已经提交”的情况。此时客户端重试必须返回第一次结果,不能再扣一次。这个场景经常被忽视,却是线上重复订单和重复扣减的重要来源。
不要只设置“复制服务正常”这样的模糊告警。建议把阈值写成可操作的数字,例如副本延迟超过 3 秒告警,超过 30 秒自动停止关键查询分流;Outbox 最老事件超过 60 秒告警,超过 5 分钟进入升级处理;库存对账差异大于 0 立即触发核查。
阈值不一定适合所有业务,但必须让值班人员知道什么时候可以等待,什么时候必须切换流量,什么时候需要停止自动重试。没有阈值,告警只是在描述现象,不能帮助决策。

唯一索引、非空约束、检查约束和外键能够阻断一部分非法状态,但不能替代业务监控。反过来,监控发现异常也不能阻止错误写入。可靠系统需要“数据库拒绝明显错误,应用处理业务分支,监控发现趋势变化,对账修复历史偏差”四层配合。
这一阶段不要急着引入缓存、分库分表和多活。只要单库事务链路还不能在并发测试中稳定通过,增加更多组件只会把问题藏到更深的位置。
重试策略建议使用递增退避,而不是每秒无间隔狂刷。因为下游故障时,持续重试会进一步压垮下游,形成“故障,重试,更大故障”的放大循环。
只有当监控显示主库读压力、热点行锁等待或复制延迟确实成为瓶颈时,才引入读写分离、缓存预扣、库存分段或队列削峰。每次优化都必须保留原有事实源和对账口径。
例如,增加缓存后,要回答缓存丢失能否重建;增加分段库存后,要回答分段之间如何回收和对账;增加队列后,要回答消息积压时用户看到什么状态。优化不是替换一致性,而是改变请求进入一致性核心区的方式。
开发新手最容易被“同步复制”“分布式事务”“高可用集群”等名词吸引,但库存扣减的第一原则其实非常朴素:先在一个明确的事务边界内写出唯一事实,再把事实传播给其他系统。只要事实本身不可靠,复制得越快,错误扩散得越快;只要事实可追踪,异步传播即使出现延迟,也仍然可以重试和恢复。
我对“事务一致性复制”的最终理解是三句话:库存判断必须在写入事实的地方完成;事件必须从已提交事实中产生;所有可能重试的环节都必须具备幂等和对账能力。数据库事务解决的是“这次操作是否成立”,复制解决的是“成立后的结果如何到达其他地方”,两者不能互相冒充。
下一步可以按照下面的顺序执行:
如果一个方案能在这些测试下保持“成功订单不超过库存、重复请求不重复扣减、事务回滚不产生幽灵事件、事件失败可以恢复、所有差异可以对账”,它才真正具备生产可用的扣减一致性。其他看起来更先进的技术,都应当建立在这个底座之上。
我刚开始做订单库存功能时,以为副本只是主库的一个“只读版本”,把库存查询和扣减前的判断都放到了副本上。测试时偶尔会发现主库库存已经变了,但副本返回的还是旧值,我想知道这会不会导致重复扣减,以及标准做法是什么。
库存扣减必须在主库执行,不能把副本查询结果当成最终扣减依据。副本的主要问题不是数据永远错误,而是异步复制时存在一个不可忽略的时间窗口:主库事务已经提交,副本还没有应用对应的复制日志。我在做并发测试时,将库存初始值设为 1,同时让副本人为延迟 200 毫秒。
请求 A 在主库扣减成功后,紧接着从副本查询库存,仍然可能读到 1。如果应用根据这个旧值再次判断“库存充足”,就会把一次读取延迟放大成重复扣减风险。
操作推荐节点原因 库存扣减主库写入和条件判断应由同一个权威节点完成 扣减结果确认主库或具备读写一致性保证的节点避免刚写入就读到旧状态 商品详情展示副本可接受页面展示通常允许短暂延迟 报表和统计副本查询压力大但不参与扣减决策 更安全的做法是把“判断库存是否足够”和“实际扣减”合并成一条主库更新语句:UPDATE stock SET available = available – ?
WHERE sku_id = ?AND available >= ?。然后根据受影响行数判断结果,返回 1 表示扣减成功,返回 0 表示库存不足或条件未满足。这里有一个容易被忽略的判断标准:副本可以用于展示库存,但不能用于决定是否允许扣库存。
只要某次读取会触发扣减、支付、发货或取消等状态变化,就应优先读取主库,或者使用数据库和中间件明确提供的会话级读写一致性能力。
我以前认为把订单创建和库存扣减放进事务后,所有一致性问题就解决了。后来遇到请求超时,数据库里明明已经扣成功,但客户端重试又扣了一次,所以我想弄清楚数据库事务的边界到底在哪里。
事务能保证其覆盖范围内的操作具备原子性,但它不能自动识别重复请求,也不能替应用处理网络超时、消息重复投递和副本延迟。真正可靠的库存扣减,通常需要“事务 + 条件更新 + 幂等”三个部分同时成立。
例如订单表和库存表位于同一个数据库中,可以在一个本地事务里完成以下操作:先写入扣减流水,再执行库存条件更新,最后更新订单状态。任一步失败,事务都可以回滚,这解决的是同一事务边界内的原子提交问题。但如果数据库已经提交,应用在返回响应前发生网络超时,客户端无法知道这次操作究竟成功还是失败。
此时客户端重试同一个请求,如果系统没有业务幂等号,就可能再次执行库存扣减。我更建议把订单号或 request_id 作为唯一业务标识,并建立唯一约束。例如扣减流水表可以设置 UNIQUE(request_id)。处理请求时,先根据 request_id 查询已有流水;
如果已经是成功状态,就直接返回原结果,而不是重新执行扣减。
机制主要解决的问题不能单独解决的问题 数据库事务同一事务内的原子提交和回滚重复请求、跨服务协调 条件更新库存不足时禁止继续扣减网络超时后的结果确认 幂等约束同一业务请求只成功一次副本复制延迟 对账补偿发现并修复已发生的业务差异实时阻止所有错误 所以,“加事务”只是第一步。
我的判断是:如果需求中出现“用户可能重试”“消息可能重复”“接口可能超时”,就必须在事务之外补上幂等设计,否则事务越可靠,重复请求造成的错误反而越容易被稳定写入数据库。
我在测试两个请求同时购买最后一件商品时,发现先查询库存再执行扣减很容易出现并发问题。现在我看到条件更新、SELECT FOR UPDATE 和版本号都有解决方案,但不确定新手项目该优先使用哪一种。
新手实现单行库存扣减时,我通常优先选择带库存条件的原子更新,而不是先查询再更新。它把“库存判断”和“库存减少”交给数据库在一条语句中完成,代码短、锁持有时间相对可控,也更容易通过受影响行数判断结果。
危险写法通常是先执行 SELECT available FROM stock,判断结果大于 0 后,再执行 UPDATE stock SET available = available – 1。两个请求可能同时读到库存 1,随后分别执行更新。如果更新语句没有再次检查库存条件,就可能出现扣减逻辑失控。
更合适的基础写法是:UPDATE stock SET available = available – 1, version = version + 1 WHERE sku_id = ?AND available >= 1。
两个请求同时竞争时,只有满足条件并成功更新的请求才算扣减成功,应用必须严格检查 affected_rows,而不能只看 SQL 是否执行没有报错。
方案适合情况主要代价 条件更新单行库存、扣减逻辑简单复杂业务流程需要额外状态设计 悲观锁需要在事务内读取后执行多步判断锁竞争和长事务风险较明显 乐观锁并发冲突可接受,允许有限重试冲突后需要重新读取和处理失败 悲观锁的典型方式是 SELECT … FOR UPDATE,适合必须先读取多项数据再决定扣减的场景,但事务不能夹杂远程调用,否则锁会被长时间占用。
乐观锁则依赖 version 条件,更新失败后进行有限次数重试,不能写成无限循环。我的选型顺序是:单 SKU、单次扣减先用条件更新;需要读取后执行多步数据库操作时考虑悲观锁;冲突不频繁且业务允许重试时考虑乐观锁。无论使用哪种方式,都要配合幂等号,否则锁只能解决并发竞争,不能解决同一请求被重复处理。
我准备把订单和库存拆成两个服务,发现它们已经不在同一个数据库事务里。有人建议直接上 TCC,也有人建议使用可靠消息和对账补偿,我想知道新手项目如何判断,不希望为了一个扣减功能引入过重的架构。
先不要把“跨服务”直接等同于“必须使用复杂分布式事务”。我在设计这类流程时,第一步会先确认业务是否允许短暂的中间状态,以及库存是否必须在订单创建的同一瞬间完成最终确认。如果订单表和库存表仍然可以放在同一个数据库中,优先使用本地事务,通常比引入分布式事务框架更容易验证。
只有当服务边界、数据库边界和部署边界已经确定无法合并时,才需要进一步选择可靠消息、Outbox、TCC、Saga 或对账补偿。
方案适合场景新手最容易踩的坑 本地事务订单和库存同库、同事务边界误以为可覆盖远程服务 Outbox 或可靠消息数据库变更需要通知另一个服务只保证发出消息,却没有消费幂等 TCC业务可明确执行预留、确认、取消忽略空回滚、幂等和悬挂问题 Saga长流程且每一步都可补偿补偿动作失败后缺少人工处理路径 对账补偿业务允许最终一致只有补偿任务,没有差异监控和告警 以“下单扣库存”为例,如果库存服务收到扣减消息后最终才执行,那么订单可能先处于待确认状态。
此时必须定义清楚:库存扣减成功,订单何时变为成功;库存不足,订单如何关闭;消息重复,库存服务如何保证只扣一次;消息丢失,谁负责发现。我更推荐新手先画出状态机,而不是先选框架。至少要有“待处理、扣减成功、库存不足、处理失败、待补偿”等状态,并为每次状态变化记录业务号和时间。
这样即使采用最终一致性,也能知道差异发生在哪里、是否可以自动修复。最终的判断标准不是哪个方案听起来更高级,而是业务能否接受延迟,以及补偿是否真的可执行。不能补偿的业务动作、不能幂等的接口、没有对账能力的消息链路,都不适合仅凭“最终一致性”四个字上线。


读者评论
文章把“事务一致性”和“复制一致性”区分得很清楚,尤其是用条件更新替代先查后改这一点,对处理并发扣减很实用。不过实际落地时还要结合数据库隔离级别、锁等待和死锁重试策略一起验证。
比较认同把库存、订单、流水和事件写入同一事务,再通过事件表异步投递的做法。这个方案能避免事务回滚后消息已发出的情况,但还需要补充事件表清理、投递监控和长期积压处理,否则可靠投递仍可能变成新的运维负担。
文中关于副本延迟的提醒很有价值,展示库存和实际扣减资格确实不能使用同一套读策略。建议上线前增加复制延迟、重复请求、消费乱序和超时重试等压测场景,单看功能测试很难发现这些一致性问题。