系统重构期间,扣减一致性最容易被误判成“库存字段不能小于零”。但我在处理库存、额度和配额类系统迁移时,真正难查的事故往往不是负数,而是同一个业务请求被执行两次、旧服务和新服务各扣了一次、数据库已经提交但调用方因超时重复发起请求,最后订单、扣减流水和可用数量分别呈现出三个不同结果。因此,系统重构要保证扣减一致性,不能只增加一个事务或一把分布式锁,而要同时守住事实来源、原子扣减、业务幂等、事件投递、迁移切流和对账补偿六个环节。
数据库存:运维团队场景拆解:系统重构如何做到保证扣减一致性
我通常不会直接问“你们有没有使用事务”,而会先让团队把一次扣减拆开。因为“扣减成功”在不同系统里可能代表完全不同的事情:库存减少、额度冻结、订单创建、支付完成、发货确认,甚至只是消息已经写入队列。
对运维团队来说,扣减一致性至少需要覆盖以下五层:
如果只验证第一层,系统可能出现“库存数量看起来正确,但同一订单被扣了两次又补回一次”的情况。数字最终回到了原值,流水却已经无法审计,这仍然不是一致性。
在日常运行中,扣减链路通常只有一套实现,问题边界相对清晰。系统重构后,旧服务、新服务、数据同步程序、灰度网关和补偿脚本可能同时存在。此时最关键的问题不是“新服务的 SQL 写得对不对”,而是到底有几个入口拥有扣减权。
例如,旧库存服务仍然处理主流量,新库存服务接收灰度流量,迁移程序又按照订单变更同步一份数据。只要这三个角色中有两个可以直接修改库存,就必须回答以下问题:
如果这些问题没有明确答案,所谓“平滑迁移”往往只是把风险从一个时间点分散到了更长的时间窗口。
我判断一套扣减方案时,会要求方案负责人不看监控大盘,直接回答四个问题:
如果只能回答“我们有事务”“我们有消息队列”“我们有分布式锁”,却无法说明异常请求的结果,那么这套方案还没有形成闭环。

先看最常见的一类问题。用户提交订单后,应用服务调用库存服务扣减 2 件商品。库存服务完成数据库事务并返回成功,但响应在网关到客户端之间丢失。客户端认为请求失败,随后使用相同订单再次发起扣减。
如果库存服务只把“订单号”写入日志,而没有把它作为数据库层面的幂等键,第二次请求可能再次执行:
UPDATE stock SET available = available - 2 WHERE sku_id = 1001 AND available >= 2;
两次 SQL 都可能成功。库存从 10 变成 6,而业务方只认为用户买了 2 件。更麻烦的是,如果后续订单服务只收到一次成功响应,系统不会立即报错,差异可能要等到盘点或退款时才暴露。
这里有一个容易被忽视的判断:“请求重试”不是异常情况,而是分布式系统的正常运行状态。网络抖动、网关超时、连接池耗尽、服务重启和消息确认失败都会让调用方无法知道服务端到底是否已经执行。
系统重构时,团队常采用双写。旧服务处理请求后写旧库存表,新服务同时写新库存表,等一段时间观察两边数据是否一致。这种做法在复制型迁移中有一定价值,但它不等于两套业务逻辑都可以执行扣减。
如果旧服务和新服务都把“写入库存”当作真实扣减动作,订单请求可能形成如下链路:
更隐蔽的情况是两边初始库存并不相同。旧系统扣减成功,新系统因库存不足失败,双写程序为了“保持一致”又尝试补偿旧系统。最后可能形成一次真实业务对应两次扣减、一次回补的复杂记录。
很多讨论把库存扣减简化为“available 减 quantity”,但实际业务经常至少包含可用库存、冻结库存和已售库存三个状态。下单时可能只是冻结,支付成功后才转为实扣,订单取消后再释放。
如果重构时没有先统一这些字段的业务语义,就会出现看似技术正确、实际业务错误的迁移。例如,旧系统的“扣减流水”代表冻结,新系统的同名字段却代表支付后的实扣。数据同步完成后,两边数字可能完全相等,但它们表达的不是同一件事。
| 业务动作 | 可用数量 | 冻结数量 | 已售数量 | 常见误判 |
|---|---|---|---|---|
| 下单锁定 | 减少 | 增加 | 不变 | 被误认为已经完成销售 |
| 支付成功 | 通常不变 | 减少 | 增加 | 重复执行实扣 |
| 订单取消 | 增加 | 减少 | 不变 | 释放动作被重复执行 |
| 退款回补 | 按业务规则增加 | 不变 | 减少或保留历史值 | 直接改库存而不留反向流水 |
所以,重构的第一步不是选择数据库锁,而是建立“动作,状态,流水”的映射关系。没有这张映射关系,后面的对账只能证明数字相等,不能证明业务事实相等。

本地数据库事务可以保证同一个事务边界内的写操作要么全部提交,要么全部回滚。例如,扣减库存、写扣减流水、保存幂等状态放在同一个数据库中,使用本地事务通常是合理的。
但数据库事务无法自动覆盖另一个数据库、消息队列、缓存和外部支付接口。最典型的失败窗口是:库存事务已经提交,应用进程在发送消息前崩溃。此时数据库里有扣减,消息却不存在,下游永远不会收到库存变化事件。
专业判断不是“事务有没有用”,而是“事务到底覆盖了哪些事实”。如果方案图中用一个大框把数据库、消息和外部服务全部圈起来,却没有说明提交顺序和失败后的恢复方式,通常只是概念图。
分布式锁可以降低同一资源在同一时刻的并发修改,但它并不天然识别同一个业务请求。第一次请求拿锁、扣减、释放锁后,第二次重复请求仍然可以再次拿到锁并扣减。
锁还会引入新的运维问题:锁服务不可用怎么办,租约过期怎么办,业务执行时间超过锁时长怎么办,进程暂停后锁是否仍然有效,网络分区时两个节点是否可能同时认为自己持有锁。
对于单行库存扣减,我更倾向于先使用数据库条件更新和唯一业务约束,再根据冲突情况评估是否需要锁。能用数据库原子条件解决的问题,不要先引入跨服务锁。锁应该用于控制无法通过单条原子写解决的临界区,而不是作为所有一致性问题的默认答案。
双写至少存在四类差异:写入顺序不同、字段语义不同、异常处理不同和重试行为不同。即使两边最终库存数字相同,也可能出现一边已经生成流水,另一边只有汇总值;一边记录了取消,一边只记录了扣减。
如果必须双写,我建议把双写拆成“主写”和“复制”,而不是让两个系统都拥有独立业务决策权。一个系统负责决定是否允许扣减,另一个系统只接收带有原始业务号和版本号的事实事件。
重试只能解决暂时性故障,不能解决业务状态不确定。数据库连接超时后,事务可能已经提交,也可能尚未执行;此时直接重试扣减动作,最危险的不是失败,而是重复成功。
我会把异常分为三类:
真正可靠的重试,是对“状态查询”和“事件投递”进行重试,而不是无条件重复执行扣减动作。
事故发生后,最容易出现的操作是让 DBA 执行一条加回库存的 SQL。这个动作可能让汇总数看起来恢复,却会破坏流水、订单和审计关系。如果原始扣减其实没有成功,直接加回反而制造了新的虚增。
修复前至少需要查清:

所谓唯一事实来源,不一定是某张库存表,也可能是扣减流水加事件重放后的汇总。但必须有一个地方能够回答“这笔扣减到底是否发生过”。如果库存表可以被多个服务直接修改,且没有统一流水,运维团队就很难判断一次数量变化来自订单、退款、脚本还是人工操作。
我建议先画出以下三张图:
如果写入者地图中有五个入口,方案却只讨论一个库存服务的事务,那么方案覆盖率很可能不足 20%。这不是精确的行业统计,而是我在重构评审中使用的风险识别方法。
对于单库存项、单数据库的扣减,最基本的要求是把“检查数量”和“减少数量”合并到数据库可验证的写操作中。一个通用思路如下:
BEGIN; -- 1. 通过唯一业务号判断是否已经处理 SELECT status FROM deduction_request WHERE request_id = :request_id FOR UPDATE; -- 2. 如果不存在,则登记处理中状态 INSERT INTO deduction_request (request_id, sku_id, quantity, status, created_at) VALUES (:request_id, :sku_id, :quantity, 'PROCESSING', CURRENT_TIMESTAMP); -- 3. 只有可用数量足够时才执行扣减 UPDATE stock SET available_quantity = available_quantity - :quantity, stock_version = stock_version + 1, updated_at = CURRENT_TIMESTAMP WHERE sku_id = :sku_id AND available_quantity >= :quantity; -- 4. 必须检查受影响行数 -- affected_rows = 1:扣减成功 -- affected_rows = 0:库存不足或条件不满足 -- 5. 成功时写入扣减流水,并更新幂等状态 INSERT INTO deduction_log (request_id, sku_id, quantity, action, status, created_at) VALUES (:request_id, :sku_id, :quantity, 'DEDUCT', 'SUCCESS', CURRENT_TIMESTAMP); UPDATE deduction_request SET status = 'SUCCESS', updated_at = CURRENT_TIMESTAMP WHERE request_id = :request_id; COMMIT;
上面的代码只是结构示例,不能脱离数据库类型、索引设计和事务隔离级别直接复制到生产环境。真正需要验证的是:业务号是否有唯一约束,库存条件是否走正确索引,受影响行数是否被应用正确处理,事务异常时幂等记录是否会留下可恢复状态。
状态机比“几个 status 字段”更重要。以预扣库存为例,可以把订单和扣减状态拆分,但必须规定哪些状态允许转移:
| 当前状态 | 允许动作 | 目标状态 | 禁止动作 |
|---|---|---|---|
| 未处理 | 首次扣减 | 扣减成功或扣减失败 | 直接释放库存 |
| 处理中 | 查询执行结果、超时恢复 | 成功、失败或待人工复核 | 无条件再次扣减 |
| 扣减成功 | 支付确认、取消释放 | 已确认或已释放 | 再次执行原扣减 |
| 已释放 | 新业务重新下单 | 新的业务号重新处理 | 使用原业务号重复释放 |
状态转移的价值在于,它能把“重试”限制在可接受的范围内。一个已经成功的扣减请求再次到达时,系统应该返回第一次处理结果,而不是重新执行一遍业务动作。
并不是所有扣减场景都需要同样的实时性。支付前的库存预占通常要求在单个 SKU 上快速判断,订单状态和营销权益则可能允许几秒内最终一致。把所有链路都设计成同步强一致,会导致锁等待、级联超时和系统吞吐下降。
我通常用三个问题判断:
库存数量判断往往需要较强的原子性;库存变更通知、报表汇总和运营看板则可以通过可靠事件实现最终一致。不要用“最终一致”掩盖没有对账机制,也不要用“强一致”掩盖系统没有定义事实边界。

下面使用一个典型的电商库存重构案例说明。该案例采用情景化数据推演,数据用于展示排查方法,不代表某家企业的线上统计。
旧系统采用订单服务直接修改库存表的模式,扣减流水写在订单库中。新系统计划拆出独立库存服务,并将库存、冻结、释放和扣减流水统一到库存库。迁移期间需要支持灰度流量,且不能因为切流导致库存重复扣减。
迁移前梳理出的系统约束如下:
团队先对旧系统做了三个维度的基线快照:库存汇总、订单扣减流水和异常订单。基线不是简单导出库存表,而是把每个 SKU 的当前可用数量与历史动作重新计算一遍。
对于某个 SKU,理论可用数量可以按以下方式核对:
理论可用数量
= 初始库存
已确认扣减数量
当前有效冻结数量
+ 已完成释放数量
+ 已审核回补数量
这条公式不能直接适用于所有业务,但它强迫团队明确每一种动作是否已经包含在当前库存字段中。实际核对时,必须排除已取消、已冲正和重复写入的流水,否则会把历史脏数据带入新系统。
基线校验后,团队将数据分为三类:可以自动迁移的数据、需要规则转换的数据,以及必须人工复核的数据。不要把历史异常一并迁移后再指望新系统自动修复。新系统的第一天就带着旧系统未解释的差异,后续所有对账都会变得困难。
迁移方案没有让旧服务和新服务同时做扣减,而是采用“单一主写、事件复制”的方式。灰度阶段,只有库存服务可以决定扣减是否成功;旧系统如果需要展示库存,只通过同步事件或只读接口获取结果。
扣减服务的核心表结构至少需要表达以下信息:
| 字段 | 用途 | 关键约束 |
|---|---|---|
| request_id | 标识一次业务扣减请求 | 唯一索引,不允许重复成功 |
| sku_id | 标识扣减对象 | 与库存主表建立关联 |
| quantity | 记录本次动作数量 | 必须大于零,单位明确 |
| status | 标识处理状态 | 限制状态转移路径 |
| stock_version | 辅助识别并发更新和迁移版本 | 更新时进行条件判断 |
| source | 标识订单、补偿或迁移来源 | 便于审计和排查 |
这里最重要的不是字段数量,而是每个字段是否有明确的写入者和生命周期。例如,补偿动作不能伪装成普通订单扣减,否则后续统计会把人工修复误认为真实销售。
很多团队把灰度理解成把 1% 的请求转到新服务,观察错误率后逐步增加比例。对于扣减系统,这还不够。灰度必须带有“业务号路由”和“结果校验”。同一个订单在重试时,必须始终路由到能够查询原始状态的系统,不能第一次走旧服务、第二次走新服务并分别执行。
我更建议按业务对象或订单分片进行灰度,而不是随机按请求灰度。原因是同一订单的创建、支付、取消和释放需要保持在同一套状态模型中。随机请求灰度会让一个订单的不同动作落到不同系统,排查复杂度会明显上升。
切流过程可以分成四个阶段:

总库存相等并不代表迁移成功。假设旧库有 SKU-A 100 件、SKU-B 50 件,新库有 SKU-A 90 件、SKU-B 60 件,总量都为 150 件。如果只对比全部库存总量,差异会被完全掩盖。
至少应按以下维度分组对账:
对账结果必须可分类。一个好的对账任务不应该只输出“差异 37 条”,而应该输出“重复扣减 12 条、缺失释放 8 条、字段映射异常 10 条、状态未知 7 条”。只有分类后,系统才能决定哪些可以自动修复,哪些需要人工介入。

单个 SKU 或单个账户的扣减,优先考虑数据库条件更新。基本原则是:让数据库在一次写操作中完成条件判断和数值变化,再用影响行数判断结果。
UPDATE account_quota SET available_quota = available_quota - :amount, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE account_id = :account_id AND available_quota >= :amount;
这条 SQL 的正确性依赖几个前提:account_id 能准确定位一行,条件字段不会因为类型转换导致错误比较,事务隔离级别满足业务要求,应用代码不会在影响行数为零时把结果误判为数据库异常。
对于库存表,还应确认商品规格、仓库、批次和租户等维度是否都在唯一定位条件中。只用 sku_id 作为条件,却在业务上存在多个仓库,是非常常见的“SQL 看起来原子、业务上仍然扣错”的问题。
悲观锁可以通过查询后更新的方式保护同一行,但事务必须尽可能短。最忌讳的是拿到库存行锁后调用支付服务、物流服务或远程接口。远程调用一旦延迟,锁会长时间占用,最终形成锁等待、连接池耗尽和级联超时。
我通常把远程动作放在数据库事务之外,用状态和事件衔接。例如,事务内完成“库存冻结”和“待确认事件”写入,事务提交后由异步消费者完成后续动作。这样做牺牲了一部分即时性,却可以把数据库锁的生命周期控制在毫秒级或一个短事务范围内。
乐观锁通过版本号或更新时间判断记录是否被其他请求修改。它适合冲突率可接受、单次业务逻辑较短的场景。但在热门商品或高竞争账户上,版本冲突会导致大量重试,重试又会进一步增加数据库压力。
因此,乐观锁需要配套以下设计:
应用层先查询“是否处理过”,再决定是否执行,并不能完全避免并发重复请求。两个请求可能同时查到“不存在”,随后同时插入或扣减。因此,业务号必须在数据库建立唯一约束。
CREATE UNIQUE INDEX uk_deduction_request
ON deduction_request(request_id);唯一约束并不能替代状态机,但它能把重复业务从“重复扣减”变成“插入冲突或查询已有结果”。这会显著降低事故的破坏性,也让问题具备可定位性。
扣减 SQL 的索引设计通常要保证两件事:第一,能够快速定位具体库存对象;第二,幂等检查能够快速找到业务请求。库存条件 available_quantity 大于某个数量,是否适合作为索引条件,要结合数据库执行计划和数据分布判断,不能简单认为“给每个字段都加索引”就会更快。
索引过多会增加写入成本,尤其是高频扣减表。我的实践习惯是先保留业务唯一索引、主定位索引和必要的状态扫描索引,再通过真实压测和执行计划调整,不在生产高峰期临时添加索引。

有些系统只保存一个 request_id,表示请求已经处理过,却不保存处理状态和业务结果。第二次请求到达时,系统知道它重复了,却不知道第一次是成功、失败还是处理中,只能返回模糊错误。
更可用的幂等记录至少应包含:
当同一业务号再次到达时,可以按状态返回:成功则复用原结果,处理中则查询或稍后重试,明确失败则返回失败原因,未知则进入状态确认流程。幂等的目标不是拒绝重复请求,而是让重复请求得到与第一次一致的业务结果。
如果扣减和消息投递分属两个系统,最难处理的故障窗口是数据库已经提交,但应用在发消息之前崩溃。Outbox的核心做法是在同一个本地事务中写入业务数据和待发送事件,之后由独立投递程序发送事件。
BEGIN; UPDATE stock SET available_quantity = available_quantity - :quantity WHERE sku_id = :sku_id AND available_quantity >= :quantity; INSERT INTO deduction_log (request_id, sku_id, quantity, status) VALUES (:request_id, :sku_id, :quantity, 'SUCCESS'); INSERT INTO outbox_event (event_id, event_type, aggregate_id, payload, status) VALUES (:event_id, 'STOCK_DEDUCTED', :request_id, :payload, 'PENDING'); COMMIT;
投递程序扫描 PENDING 事件并发送到消息系统。发送成功后更新为 SENT,发送失败则按照退避策略重试。由于发送动作仍然可能重复,消费者必须使用 event_id 或 request_id 做幂等处理。
Outbox不能保证消息只发送一次,它更准确的价值是让“业务事实已经提交但事件完全丢失”的概率显著降低,并且让待发送事件成为可观测、可重试的数据库记录。
如果消费者收到库存扣减事件后要更新订单状态,就不能只依赖消息系统的消费确认。消息确认表示消息已经被消费者处理到某个阶段,不一定代表订单状态写入成功。
消费者可以采用本地消费记录、业务唯一约束或条件状态更新:
UPDATE order_item SET stock_status = 'CONFIRMED', updated_at = CURRENT_TIMESTAMP WHERE order_item_id = :order_item_id AND stock_status = 'RESERVED';
当影响行数为零时,不能直接判定失败。可能是订单已经确认,也可能是订单已经取消,必须读取当前状态再决定是否重复处理或进入人工复核。
补偿不是“发现少了就加回,发现多了就扣掉”。每一个补偿动作都要说明方向、依据和停止条件。例如,冻结释放只能针对当前仍处于有效冻结状态的订单执行;已经支付并发货的订单不能因为一条延迟取消消息而直接释放库存。
一个可运维的补偿任务应具备以下属性:

接口成功率只能说明请求有没有返回预期 HTTP 响应,不能说明库存、流水和订单是否一致。扣减系统至少需要同时监控业务结果、数据库结果、消息结果和修复结果。
| 监控类别 | 建议指标 | 发现的问题 | 建议处理动作 |
|---|---|---|---|
| 请求层 | 重复业务号率、超时率、重试率 | 调用方或网关重复请求 | 查询幂等状态,限制无效重试 |
| 数据库层 | 影响行数为零、锁等待、死锁、版本冲突率 | 库存不足或并发控制异常 | 区分业务失败和基础设施故障 |
| 消息层 | 待投递事件、消费延迟、重复消费率 | 事件积压或消费者幂等不足 | 重试、降速或暂停下游动作 |
| 业务层 | 订单与扣减差异、库存负数、异常释放 | 状态模型或业务链路不一致 | 进入对账和补偿流程 |
| 恢复层 | 待补偿任务量、人工复核耗时、重复修复率 | 自动恢复能力不足 | 优化规则并补充审批边界 |
我尤其关注“重复业务号率”和“待补偿任务量”这两个指标。前者通常能提前暴露调用链路的重试问题,后者则能反映系统是否把不确定状态堆积给了运维人员。
实时校验用于拦截明显非法动作,例如可用库存小于零、同一业务号出现第二次成功状态、释放数量超过冻结数量。实时校验不能替代全量对账,但可以缩短故障暴露时间。
定时对账用于按 SKU、账户、订单和流水核对差异。执行频率取决于业务风险,高价值额度系统可能需要分钟级对账,普通运营库存可以按小时或天执行。
全量对账适合迁移完成、数据库切换、重大版本发布和长期异常治理。全量对账需要考虑数据库负载,通常采用只读副本、分片扫描、时间窗口和增量标记,避免直接在主库上执行大范围聚合。
当业务人员反馈“订单只买了一件,但库存少了两件”,不要先改数据。我的排查顺序通常如下:
这个顺序看起来比直接查库存表慢,但它能避免“修复一个数字、破坏一条事实链”的二次事故。排查的核心不是找到一条异常 SQL,而是还原一次业务动作的完整时间线。
固定使用“差异率超过 1% 就报警”并不专业。对于低价值、可快速补偿的积分扣减,1% 可能只是需要观察;对于高价值账户,即使一笔差异也可能必须立即阻断相关交易。
阈值设计应至少考虑:

如果扣减、扣减流水和幂等记录都在同一个数据库中,业务动作只涉及一行或少量关联行,优先采用本地事务、条件更新和唯一业务约束。此时不建议为了“看起来先进”直接引入分布式事务或外部锁服务。
落地重点是:
如果扣减后需要更新订单、营销权益、仓储系统或运营报表,应把“库存扣减事实”和“下游通知”分开处理。数据库事务负责保存事实,Outbox或可靠事件负责让下游最终收到通知。
此时需要接受一个现实:下游状态在短时间内可能不同步。解决方式不是强行让所有系统同步提交,而是让每个中间状态可查询、可重试、可对账,并在超过时限后进入补偿队列。
热点对象的主要问题可能不是一致性,而是竞争导致数据库被大量请求拖垮。条件更新依然需要保留,但还要叠加请求排队、限流、分片库存、预分配或分桶等策略。
这里要谨慎处理“分桶库存”。它可以降低单行竞争,但会让总量汇总、失败回收和迁移对账更复杂。只有当团队具备足够的监控和回收机制时,才适合采用。
长时间并行时,最重要的是定义主写源和兼容期限。建议把旧系统降级为只读或事件消费方,避免两边都能修改核心数量。若确实无法立即关闭旧写入,应增加写入代理或统一门面,把扣减权收敛到一个入口。
同时,所有新旧链路都必须携带同一个业务号、事件号和数据版本。没有统一关联键,后续只能依赖模糊的时间和数量匹配,无法准确判断一条记录是否重复。
不要把“迁移”和“历史治理”混成一个不可控项目。可以先将历史数据分层:正常数据直接迁移,规则明确的数据转换迁移,无法解释的数据进入隔离区。新系统只接收已经通过规则校验的业务事实。
隔离区中的数据需要保留原始记录、异常原因、处理结论和处理人。这样既不会污染新系统,也不会因为清理历史数据而失去审计依据。
对于允许异步处理的业务,可以采用事件驱动和补偿机制,但必须设定“未知状态最长允许时间”。例如,扣减事件超过 5 分钟未确认进入告警,超过 30 分钟自动升级,超过 2 小时禁止继续扩大影响范围。
最终一致不是没有时限的一致。没有时限的“最终”,在运维上等同于没有承诺。
| 优点 | 缺点 | 适用条件 |
|---|---|---|
| 实现简单,故障边界清晰,数据库原子性强 | 只能覆盖同一数据库事务范围 | 扣减和流水位于同一数据库,事务短且下游可异步 |
| 便于查询和审计 | 高并发热点行可能产生锁竞争 | 单对象扣减,冲突率可接受 |
这是我最常推荐的起点。只要业务边界允许,就先把核心事实收敛到本地事务中,再通过事件通知外部系统。
| 优点 | 缺点 | 适用条件 |
|---|---|---|
| 系统解耦,能够承受下游短时不可用 | 需要处理重复消费、积压和状态延迟 | 下游可以接受最终一致 |
| 故障可以重试,异常记录可查询 | 对账和补偿建设成本较高 | 团队具备事件追踪和运维能力 |
它的关键不是消息队列本身,而是消息是否具备业务身份、消费者是否幂等、积压是否有上限、异常是否能够进入人工可见的队列。
| 优点 | 缺点 | 适用条件 |
|---|---|---|
| 可以协调多个数据库或服务的部分提交关系 | 性能、可用性和故障排查成本较高 | 跨库强一致要求明确且基础设施成熟 |
| 业务编排边界较集中 | 长事务、网络分区和参与者异常较难处理 | 参与者数量有限,事务时间可控 |
分布式事务不是系统重构的默认答案。对于库存扣减这种高频核心动作,我更关注它是否会放大锁等待和故障半径。很多场景采用本地事务记录事实,再以可补偿事件连接下游,反而更容易运维。
| 优点 | 缺点 | 适用条件 |
|---|---|---|
| 迁移过程可以持续比对新旧数据 | 两套写入逻辑容易产生顺序和重试差异 | 已经明确单一主写源,另一侧只做复制 |
| 便于灰度和回退 | 回退时需要处理已发生的新系统业务 | 有版本号、业务号和差异表支撑 |
双写最适合做数据复制,不适合让两套服务各自做业务决策。这个边界一旦模糊,迁移就会变成两套系统同时争夺库存事实。

一致性事故的难点经常不是没有日志,而是日志无法关联。建议让每条请求、数据库流水、事件和补偿任务至少共享以下关联维度:
如果一个异常只能靠“某个时间点附近的日志”进行模糊搜索,说明可观测性设计还不够。理想状态是,运维人员拿到一个订单号,就能在几次查询内定位请求、事务、流水、事件和补偿记录。
“保证扣减一致性”本身不是方案,只是目标。可执行的方案必须进一步说明:谁可以扣、扣什么、依据什么判断、重复请求怎么办、消息失败怎么办、迁移期间谁是主写、差异如何发现、异常如何修复。
我更愿意把一致性定义为一个可验证的闭环:
如果团队正在准备系统重构,我建议不要先开技术选型会,而是先完成一次“扣减事实盘点”。把所有写入入口、业务动作、状态字段、幂等键、消息事件和人工脚本列出来,再选取一笔真实订单进行端到端回放。
如果连一笔订单都无法完整回答“什么时候创建、何时冻结、何时实扣、消息是否发送、取消如何释放、最终库存如何计算”,那么此时最应该投入的不是更复杂的中间件,而是补齐业务流水和对账能力。
系统重构真正要迁移的,不只是表结构和代码,而是“谁有权改变业务事实”这条权责边界。只要扣减决策权唯一、每次动作可追踪、异常状态可恢复,数据库事务、消息机制和运维监控才会共同构成可靠的一致性体系。

不能直接这样判断。数据库事务可以保证事务范围内的操作具备原子性,但是否超卖还取决于扣减 SQL、并发控制、事务隔离级别、索引和业务入口。如果代码采用先查询库存、再在另一个事务中更新的方式,仍然可能出现并发覆盖。
更稳妥的做法是采用条件更新、检查受影响行数,并使用唯一业务号防止同一请求重复执行。
如果扣减对象位于同一个数据库,且业务动作能够在短事务内完成,优先评估数据库条件更新或行级锁。它们的事实来源和业务数据在同一处,排查边界更清晰。
只有当临界区确实跨越多个资源,或者业务需要协调数据库之外的动作时,才考虑外部锁。即便使用分布式锁,也不能省略幂等记录和数据库唯一约束。
先不要直接重试扣减。请求超时只说明调用方没有拿到结果,不说明服务端没有执行。正确顺序是根据业务请求号查询幂等状态和扣减流水,确认成功则复用结果,确认未执行才允许重新处理,结果未知时进入状态确认或人工复核。
最关键的是让一套系统拥有扣减决策权,另一套系统只复制已确认的业务事实。两边都可以写入并独立判断库存是否足够,是最危险的双写方式。
此外,新旧链路必须共享业务号、事件号和版本号,并建立对象级对账。只对比两边库存总量,无法识别 SKU 之间的差异和流水重复。
不建议直接改库存表。应先确认原始扣减是否成功、订单是否已经支付或发货、释放是否已执行,以及是否存在重复补偿。正确的修复应生成有独立编号的反向流水,并在修复后再次对账。
不是。最终一致必须有明确的最大允许延迟、告警阈值、升级机制和补偿时限。没有时限的最终一致无法被运维,也无法向业务方解释。
汇总表只能告诉你当前结果,不能解释结果是如何形成的。扣减、冻结、释放、回补和冲正都可能改变数量,缺少流水后,系统无法可靠识别重复动作和错误补偿。
对于高价值库存、账户额度或配额,流水不仅用于审计,也是幂等、对账、重放和故障恢复的基础。
当扣减事实需要通知多个下游,且下游允许短时间延迟时,可以使用可靠事件和消息队列解耦。消息队列不能替代数据库原子扣减,也不能天然保证只消费一次,消费者仍然需要幂等和状态判断。
如果团队还没有事件编号、消费记录、积压监控和补偿流程,贸然引入消息队列可能只是把同步错误变成异步错误。
分桶可以降低单行竞争,但会增加总量汇总、库存回收、迁移和对账复杂度。只有当单行竞争已经成为明确瓶颈,并且团队能够处理分桶分配、失败回收和异常对账时,才值得采用。
这三项验证覆盖了重复执行、结果未知和迁移并行三个最容易造成真实事故的场景。
我在重构一个库存扣减服务时,原实现是先查询可用库存,再在应用层判断是否足够,最后执行 UPDATE。压测时偶尔出现库存被扣成负数的问题,我想知道为什么单纯加事务还不够,以及数据库层到底应该怎么改。
这类问题的关键,不是有没有开启事务,而是“判断库存足够”和“执行扣减”是否在数据库层形成不可分割的动作。原先的先查后改流程存在时间窗口:请求A和请求B都读到可用库存为10,随后各自判断购买数量为8,最终可能都执行扣减。
我通常会把判断条件直接放进 UPDATE,例如:
UPDATE stock SET available = available - :quantity, version = version + 1 WHERE sku_id = :sku_id AND available >= :quantity;执行后不能只判断SQL有没有报错,还要检查受影响行数。影响行数为1,才代表扣减成功;影响行数为0,则需要进一步区分库存不足、版本冲突、记录不存在还是数据库异常。在一次隔离测试中,初始库存为100,并发发送500个扣减请求,每次扣减1到3件。先读后写的实现出现过负库存和扣减总量超过100的情况;
改成条件更新后,库存没有出现负数,但应用层仍然必须正确处理更新失败,否则用户可能看到“请求成功”而实际没有扣减。
方案并发控制效果需要额外关注的问题 先查询再更新风险较高存在竞态窗口 行级锁后更新较稳定锁等待、死锁和事务时长 条件更新适合单条扣减必须检查影响行数和索引 乐观锁适合冲突可控场景版本冲突后的重试策略 我的判断是:单SKU、单表、扣减逻辑简单时,优先采用条件更新配合合适索引,通常比在应用层堆叠分布式锁更容易验证。
只有当扣减需要跨多行、多表,且业务无法拆分时,才需要进一步评估锁、事务边界或更复杂的协调方案。
我遇到过一次请求超时:客户端没有收到响应,于是自动重试了一次。后来查数据库发现第一次请求已经成功扣减,第二次请求又产生了一条扣减流水。事务本身没有报错,但业务结果已经错了,我想知道这种情况应该如何从数据模型上解决。
事务解决的是一次执行过程中的原子性,不能判断两个请求是不是同一个业务动作。第一次请求可能已经提交,只是响应在网络中丢失;客户端、网关或消息消费者随后重试,数据库会把它们视为两次合法操作。我会为每次扣减建立业务幂等键,例如订单号加扣减类型,或者由上游生成全局请求号,并在扣减流水表上建立唯一约束。
典型字段包括业务号、扣减类型、SKU、数量、处理状态、创建时间和完成时间。处理流程不能只是“先查有没有,再决定要不要写”,因为两个并发请求可能同时查不到记录。更稳妥的做法是让唯一约束负责抢占幂等资格:第一个请求插入成功后继续扣减,第二个请求遇到唯一键冲突,再读取原记录并复用第一次处理结果。
请求状态重复请求的处理方式 处理中返回处理中,或短暂轮询,不应再次扣减 成功直接返回原成功结果 明确失败返回原失败原因,是否允许重试由业务决定 长时间处理中进入超时扫描和人工或自动补偿流程 在测试中,我连续发送相同业务号100次。没有唯一约束时,虽然每次数据库事务都正常提交,但可能写出多条流水;
加入唯一约束并统一处理冲突后,最终只保留一条有效扣减记录。这个结果说明,幂等不是代码里的一个 if,而是业务键、数据库约束、状态机和重试策略共同构成的闭环。需要特别避免“失败就无限重试”。数据库连接超时、死锁、库存不足和业务状态不允许扣减,处理方式完全不同。
建议只对明确的瞬时错误进行有限次退避重试,其余情况进入可查询的异常任务。
我们做系统迁移时曾考虑让旧服务和新服务同时写入,认为这样可以降低切换风险。但我越看越担心:如果旧系统写成功、新系统超时,或者两边字段定义不一致,最后到底谁的数据才算准?双写真的能保证一致吗?
双写本身不是一致性方案,只是一种迁移手段。它最多说明同一个业务动作被尝试写入两个位置,却不能保证两个位置同时成功、语义完全相同,更不能自动解决一边成功一边失败后的恢复问题。重构前,我会先确定唯一事实来源。
比如库存数量只能由库存服务的主库修改,订单服务只能发起扣减请求,缓存只负责读取加速,不能成为第二个可写库存源。没有这个边界,新旧服务越多,排查时越难判断哪一次扣减是真实业务事实。如果必须并行迁移,我更倾向于采用“单主写入加事件同步”,而不是让旧服务和新服务都直接写核心库存。
主写库在本地事务中完成扣减和事件记录,再由投递程序同步到新模型;新系统先做影子计算和差异记录,确认结果后再逐步接管流量。
迁移方式优点主要风险适用建议 双系统直接双写改造直观部分成功、顺序错乱、重复写入只适合低风险、可回放场景 旧系统主写,新系统订阅事实来源清晰存在同步延迟适合渐进式迁移 新系统影子计算先验证结果再切流需要额外校验链路适合扣减规则复杂的系统 停机全量迁移边界简单影响业务连续性适合可接受停机的低频系统 我会在切流前建立三组基线:库存主表总量、扣减流水汇总、订单状态汇总。
以某次测试为例,迁移后新旧模型的SKU数量、可用库存合计和流水笔数必须逐项核对,不能只抽查几条数据。切流时还要设置差异阈值、错误率阈值和回滚窗口,尤其要提前定义“已经在新系统成功扣减的数据,回滚后如何处理”。
真正可靠的迁移不是让两套系统永远保持实时完美一致,而是让写入权唯一、差异可见、失败可重放、回滚有边界。只要这四点做不到,直接双写通常会把风险从代码内部转移到运维排障阶段。
系统上线后,业务方只告诉我“库存和订单对不上”,但没有请求号、时间范围和具体SKU,排查时只能翻日志,效率非常低。我想建立一套真正能落地的监控和对账机制,并且想知道什么情况下可以自动补偿,什么情况下必须人工确认。
扣减一致性不能靠一次上线验收来证明,必须持续提供证据。至少要把请求号、业务幂等键、扣减流水号、数据库事务结果、消息投递状态和订单号串起来,否则出现问题时只能从时间和模糊日志中猜测。我建议把监控拆成三层。第一层是实时拦截,例如库存不能小于零、扣减数量不能小于等于零、同一幂等键不能出现多个成功状态。
第二层是分钟级或小时级指标,例如扣减成功数与订单成功数的差异、消息积压、重复消费和异常任务数量。第三层是定时对账,核对库存主表、扣减流水、订单和释放记录。
异常现象优先查询内容是否适合自动修复 同一业务号多条成功流水唯一约束、重试日志、消费记录通常先冻结重复记录,谨慎处理库存 订单成功但没有扣减订单事件、消息状态、库存服务日志业务允许时可重放事件 扣减成功但订单失败订单状态、扣减流水、支付状态确认无后续履约后再释放 库存出现负数并发请求、SQL影响行数、绕过接口的写入先止损,再人工复核 我的排查顺序通常是:先确认请求是否重复,再查幂等记录,然后查扣减流水和库存变更,接着核对消息投递与消费,最后查看订单和支付状态。
这个顺序是为了先判断“是否重复执行”,再判断“是否漏执行”,避免一看到数量差异就直接把库存加回去。补偿也不能简单理解为执行一条反向加库存SQL。如果原扣减已经触发支付、发货或权益发放,直接加回库存可能制造新的超卖。自动补偿只适用于边界清晰、没有后续履约、幂等键明确且补偿动作可重复验证的场景;
涉及支付、发货或退款时,应转人工审核。上线验收时,我会要求至少准备一份异常演练记录:模拟请求超时、消息重复、数据库提交成功但响应失败、消费者中途重启,并验证最终对账是否收敛。只有系统不仅能“正常扣减”,还能在异常后定位、重试和留下审计记录,才算真正具备运维意义上的一致性。


读者评论
文章把扣减一致性拆成数量、请求、状态、链路和恢复几个层次,比较符合实际运维排障经验。尤其是把超时重试视为常态,而不是偶发异常,这一点很有参考价值。
对系统重构中的双写风险分析得比较具体。新旧系统同时拥有扣减权确实容易造成重复执行,采用“主写加复制”的思路比简单双写更容易控制责任边界。
文中对事务、分布式锁和重试的局限讲得比较客观。不过实际落地还需要结合业务补充幂等表结构、对账周期和人工复核流程,否则异常恢复仍可能停留在原则层面。