我把一个下单操作拆成了"先调库存服务扣库存、再调订单服务建订单",本地测试一路绿灯,可上线后偶尔出现库存扣了、订单却没建成,钱货两空、数据对不上,排查半天才明白跨服务的操作根本没有我以为的那种原子性的深度复盘
这是一次让我对"要么全成功、要么全不做"这个我习以为常的保证,有了刻骨认知的事故。我做了个下单流程,涉及两个独立的微服务:库存服务和订单服务。我的代码很直白:第一步,调库存服务扣减库存;第二步,调订单服务创建订单。本地测试时两步都顺利,我便觉得这流程天衣无缝——下单嘛,扣库存、建订单,顺理成章。
可上线后,客服开始陆续收到投诉:有些用户反映"下单失败了,可库存/额度却被扣掉了";对账时也发现,库存的扣减记录和实际订单数对不上——有一批库存"凭空"少了,却找不到对应的订单。我把日志一翻,真相浮出水面:这些异常的请求,都是"第一步扣库存成功了,但第二步建订单失败了"(可能订单服务那一刻超时、或宕了、或网络抖了)。第一步已经实实在在地把库存扣了、并且提交了,第二步却失败了,而那扣掉的库存,没有任何机制把它退回来——于是系统就停在了一个"扣了库存、没有订单"的、半拉子的、不一致的状态里。我一直默认的"这两步要么都成、要么都不成",根本就不存在。
故障现场:第一步成功提交、第二步失败,中间没有回退
我把出问题的代码和时序还原出来,问题一目了然:
// 下单: 跨两个服务的两步操作
public void placeOrder(Order order) {
// 第一步: 调库存服务扣库存(这是一次独立的远程调用, 成功即【提交】)
inventoryService.deduct(order.getItemId(), order.getQty());
// ↑ 假设这一步成功了, 库存服务那边已经把库存扣了并提交, 木已成舟
// 第二步: 调订单服务建订单(另一次独立的远程调用)
orderService.create(order);
// ↑ 如果这一步失败了(超时/宕机/网络抖动) → 抛异常
// 可【第一步扣的库存, 已经提交了, 不会自动回退】!
}
// 结果出现的不一致状态:
// 库存: 已扣减(-1) ✓ 提交了
// 订单: 创建失败 ✗ 没建成
// → 库存少了, 却没有对应订单 → 数据不一致, 钱货两空 ✗✗✗
// 我以为的(错误假设):
// 这两步像一个数据库事务一样, 要么都成功、要么都回滚
// 实际:
// 两步是【两个独立服务的、各自独立提交的】操作,
// 没有任何东西把它们绑成一个"原子单元"; 第一步提交了就收不回了
看着"库存扣了、订单没了"这个对不上的账,我才彻底明白:我把"跨两个服务的两步操作",想当然地当成了像单个数据库事务那样、具有原子性(要么全做、要么全不做)的整体。可这两步,是两个独立服务上、各自独立执行并各自独立提交的操作——它们之间没有任何东西把它们捆成一个"同生共死"的原子单元。第一步在库存服务里成功提交后,木已成舟;第二步在订单服务里失败了,也无法自动让第一步回滚(它们是两个进程、两个数据库,本地事务管不到对方)。于是"一半成功、一半失败"的中间态,就成了一个真实存在、且无人收拾的烂摊子。我以为我有"全或无"的保证,其实我什么保证都没有。
第一件事:搞懂为什么"跨服务的多步操作"天然没有原子性
冷静下来,我去把"分布式事务与数据一致性"这一课认真补了,才明白这个"半拉子状态"的根源:
【为什么跨服务/跨库的多步操作, 不会自动"全或无"】
单机数据库事务的"原子性"从哪来:
- 同一个数据库, 由它的事务机制(日志、锁、回滚段)统一保证
- 一个事务里的多条语句, 要么一起 commit, 要么一起 rollback
- 这个"全或无"是【单个数据库内部】提供的能力
跨服务/跨库, 这个保证就【消失】了:
- 库存服务、订单服务是【两个独立进程, 两个独立数据库】
- 调库存服务 deduct() 成功 → 它在【自己的库】里提交了, 独立、已生效
- 再调订单服务 create() → 这是【另一次独立调用、另一个库的独立事务】
- 没有一个"上帝"把这两次独立提交, 绑成一个能一起回滚的原子单元
→ 第一步提交后, 第二步若失败, 第一步【无法自动撤销】→ 不一致
更糟: 网络是不可靠的, "第二步"可能:
- 超时(但其实成功了?) / 失败 / 调用方收不到响应
→ 你甚至不确定第二步到底成没成, 状态更难收拾
结论:
"多个独立操作要么全成、要么全不做"这种原子性, 在分布式下【不是免费的】,
跨服务时它默认【不存在】, 必须靠额外的机制(分布式事务方案)去构建。
常见方案: 最终一致性 + 补偿(Saga)、本地消息表/事务消息、TCC……
核心思路: 用"可补偿/可重试/可对账"换取"最终一致", 而非奢求强一致的全或无。
这一下点醒了我:我把"原子性(要么全做要么全不做)"当成了操作天然自带的属性,可它其实是单个数据库内部专门提供的一种能力;一旦操作跨出了单个数据库、散落到多个独立的服务和库上,这种能力就默认消失了——没有谁会自动帮我把几个独立的提交绑成一个原子单元。我却带着"全或无"的惯性,把两个独立服务的调用串在一起,以为它们会像本地事务那样同生共死,殊不知第一步一旦提交就覆水难收。不是这两步"应该"原子却没原子,而是跨服务本就没有原子性这回事,得我自己想办法去补上一致性。
第二件事:正解——用最终一致性 + 补偿(Saga)/可靠消息,而非奢求强一致全或无
找到根因,正解就清晰了:跨服务别奢求"强一致的全或无"(代价极高且脆弱),改用"最终一致性"——给每一步配上补偿动作(Saga:某步失败就反向补偿前面已成功的步),或用可靠消息/本地消息表把后续步骤异步、可重试地推进,并辅以对账兜底。让系统即使中途出错,也能最终收敛到一致。
// 错误: 两步直串, 第一步提交后第二步失败就成不一致, 无补偿
inventoryService.deduct(itemId, qty); // 提交了
orderService.create(order); // 失败 → 库存扣了订单没了 ✗
// 正解1: Saga —— 每步配补偿, 失败就反向补偿已成功的步骤
public void placeOrderSaga(Order order) {
boolean deducted = false;
try {
inventoryService.deduct(order.getItemId(), order.getQty());
deducted = true;
orderService.create(order); // 若这步失败 → 进 catch
} catch (Exception e) {
if (deducted) {
// 补偿: 把第一步扣的库存退回去, 让系统回到一致状态
inventoryService.compensateRefund(order.getItemId(), order.getQty());
}
throw e;
}
}
// 正解2: 可靠消息 / 本地消息表 —— 第一步和"发消息"在同一本地事务,
// 后续步骤由消费者异步、幂等、可重试地完成, 保证最终一致
@Transactional
public void placeOrderReliable(Order order) {
inventoryService.deductLocal(order); // 本地事务内
outbox.save(new Msg("ORDER_CREATE", order)); // 同一本地事务存消息
} // 提交后, 后台可靠地投递消息 → 订单服务幂等消费、建订单, 失败重试
// 关键配套: 补偿/重试都要【幂等】; 再加【对账】兜底, 定期扫出不一致并修复
这套做法的精髓,是承认"跨服务拿不到免费的强一致全或无",转而追求"最终一致":允许系统短暂处于中间态,但保证它最终一定会收敛到一致——靠补偿(失败就把已做的反向撤销)、靠可靠消息+幂等重试(让没做完的最终做完)、靠对账(兜底扫出漏网的不一致并修复)。这些机制都不奢求"瞬间全或无",而是用"可补偿、可重试、可对账"换取"终将一致"。不是去强行制造分布式下昂贵又脆弱的强一致,而是接受最终一致、并为达成它配齐补偿与兜底。
【跨服务保证数据一致, 几条原则】
1. 别奢求"跨服务强一致的全或无": 代价高(2PC 等)、性能差、还易卡死
2. 优先"最终一致": 允许短暂中间态, 但保证最终收敛到一致
3. Saga(补偿事务): 每步配一个反向补偿动作, 某步失败就补偿前面已成功的
4. 可靠消息/本地消息表/事务消息: 第一步与"发消息"绑在同一本地事务,
后续由消费者异步、幂等、可重试地完成
5. 一切补偿与重试必须【幂等】(可能被重复执行); 失败状态要能识别
6. 对账兜底: 定期扫描跨服务数据, 找出不一致(扣了没单等)并自动/人工修复
7. 想清楚: 我的多步操作跨了几个独立的提交边界?每个边界都可能"半路断"
第三件事:其他"误以为有原子性/一致性、其实没有"的同类坑
顺着"跨边界就没有自动的原子性"这条线,我把系统里同类的坑都排查了一遍,它们都源于"把单机的强保证,想当然地延伸到了分布式":
第一个,缓存与数据库的双写不一致。先写库再删缓存(或反之),中间一步失败,缓存和库就不一致了——和跨服务两步同理,双写没有原子性。要靠延迟双删、订阅 binlog 等保证最终一致。
第二个,调用超时却不知成没成。第二步超时,可能其实成功了、也可能失败了,你收不到结果。盲目重试可能重复执行,所以下游必须幂等。
第三个,分两次写两个库/两张表没用事务。哪怕在一个服务里,写两个不同的数据源若不在同一事务,也没有原子性,一样会半成功。
第四个,"先检查后执行"在并发下不原子。先查余额够不够、再扣款,这两步之间别的请求插进来,就超扣了——check-then-act 不是原子操作,要用锁/原子操作/CAS。
第四件事:单机事务 vs 跨服务,一致性从哪来
我把"单机数据库事务"和"跨服务多步操作"在一致性上的差别整理成一张表,这是我现在设计跨服务流程时的依据:
| 维度 | 单机数据库事务 | 跨服务/跨库多步操作 |
|---|---|---|
| 原子性 | 天然有(要么全成全回滚) | 默认没有,会半成功 |
| 谁保证 | 数据库事务机制(日志/锁/回滚段) | 没人自动保证,要自己构建 |
| 失败回退 | 自动 rollback | 不会自动退,需补偿动作 |
| 追求的目标 | 强一致(全或无) | 多数追求最终一致 |
| 实现手段 | BEGIN/COMMIT/ROLLBACK | Saga补偿/可靠消息/TCC/对账 |
| 必备配套 | — | 幂等、重试、对账兜底 |
这张表让我看清:单机事务的"全或无"是数据库白送的能力,而跨服务的一致性需要我自己花力气去构建——它不是默认存在的,是设计出来的。我把白送的当成了到处都有的,于是在跨服务的地方两手空空、栽了跟头。跨边界的一致性,从来都得自己买单。
第五件事:我对"跨服务多步操作"的几个想当然
这次事故,本质是我把单机事务的强保证,想当然地延伸到了分布式。把这些想当然列出来,每一条都值得警惕:
| 我曾经的想当然 | 事故教我的真相 |
|---|---|
| "扣库存、建订单两步,要么全成要么全不做" | 跨服务无原子性,第一步提交后第二步失败不会回退 |
| "一步失败,前面的会自动回滚" | 本地事务管不到别的服务/库,需自己写补偿 |
| "原子性是操作天生自带的" | 它是单机数据库内部提供的,跨边界就没了 |
| "调用超时就是失败了,重试就行" | 超时可能其实成功了,盲目重试要靠幂等兜底 |
| "分布式也能轻松做到强一致全或无" | 强一致代价高且脆弱,多数该用最终一致 |
| "本地测试都过了,流程就没问题" | 本地难触发中间失败;分布式异常上线才暴露 |
第六件事:设计跨服务多步操作时,我现在的自检习惯
现在每当我设计一个有多步的操作、或排查"数据怎么对不上、半拉子状态",我都会先按这张图问自己:
这张图的精髓,是"先数清这个操作跨了几个独立的提交边界;跨了边界就默认没有原子性、必须自己用补偿/可靠消息+对账去构建最终一致"。设计就给跨服务的每步配补偿或可靠消息、全程幂等、加对账兜底、排查就看半拉子状态是不是某步提交后下一步失败却没回退。这套习惯,让我从"以为跨服务也是全或无"变成了"跨边界就主动构建最终一致"——核心始终是:"要么全做要么全不做"的原子性是单个数据库内部专门提供的能力,操作一旦跨出单库、散落到多个独立服务/库/缓存上,这种能力默认就消失了,没有谁自动把多个独立提交绑成原子单元;第一步提交后第二步失败,第一步不会自动回退,系统就卡在半拉子的不一致态;正解是别奢求跨服务强一致全或无(代价高且脆弱),改用最终一致——Saga 补偿(失败反向撤销已成功步)、可靠消息/本地消息表(异步幂等可重试推进)、并用对账兜底,所有补偿重试都要幂等。
我立下的几条规矩
这场"库存扣了订单没建、数据不一致"的事故,换来了我做分布式系统时,刻进骨子里的几条铁律:
- "要么全做要么全不做"的原子性是单机数据库的能力;操作跨出单库到多服务/多库,它默认就没了。
- 跨服务的多步操作,第一步提交后第二步失败,第一步不会自动回退——会留下半拉子的不一致态。
- 别奢求跨服务的强一致全或无(2PC 等代价高、性能差、易卡);多数场景该用最终一致。
- Saga:给每一步配一个反向补偿动作,某步失败就补偿掉前面已成功的步骤。
- 可靠消息/本地消息表:第一步与发消息绑同一本地事务,后续由消费者异步、幂等、可重试地完成。
- 一切补偿和重试都必须幂等(可能被重复执行);再加对账定期扫出不一致并修复兜底。
- 设计多步操作先数清它跨了几个独立提交边界,每个边界都可能"半路断",都要想好怎么收敛。
附:我现在落地"可靠消息 + 幂等消费"最终一致的骨架
这是我现在做跨服务一致性时,最常用的"本地消息表 + 幂等消费"骨架——它把这次踩坑的教训(第一步与发消息绑同一本地事务、消费幂等可重试)固化成了一套可复制的结构,让"扣了库存却没订单"那种半拉子状态再不可能停留:
// 生产端: 业务操作与"发消息"放在【同一个本地事务】里, 共生死
@Transactional
public void placeOrder(Order order) {
inventory.deductLocal(order.getItemId(), order.getQty()); // 本地: 扣库存
outbox.insert(new OutboxMsg( // 本地: 同事务存消息
order.getId(), // 业务键, 用于消费端幂等去重
"ORDER_CREATE",
toJson(order)));
} // 二者要么一起提交、要么一起回滚 —— 本地事务保证了这一步的原子性
// 后台投递: 可靠地把 outbox 里的消息发到 MQ, 发成功标记已投递, 失败重发
// (这一步即使重复投递也没关系, 因为消费端是幂等的)
// 消费端: 幂等消费 —— 同一业务键只生效一次, 失败可安全重试
public void onOrderCreate(OutboxMsg msg) {
if (processed.exists(msg.getBizKey())) { // 幂等: 处理过就跳过
return;
}
orderService.create(fromJson(msg.getPayload())); // 真正建订单
processed.mark(msg.getBizKey()); // 记录已处理
// 任何中途失败 → 不标记 → MQ 会重投 → 再次进来, 因幂等而安全
}
// 兜底: 定时对账, 扫描"扣了库存却 N 分钟内没对应订单"的, 告警/补建/退库存
这套骨架把我这次的教训钉死在了结构里:生产端把"扣库存"和"记下要发的消息"绑进同一个本地事务(这一步有原子性,要么都成要么都回滚);后续建订单由消费端异步完成,且幂等——同一业务键只生效一次,所以投递可以放心重试;最后再用对账定时扫出漏网的不一致。这样,跨服务那个曾经"断了就收不回"的缝,被"本地事务保前半段原子 + 可靠消息推后半段最终完成 + 幂等保重试安全 + 对账保兜底"这一整套机制严严实实地缝住了。我用这套机制,亲手把单机数据库白送、而跨服务消失了的那份"一致性",重新建了起来。
写在最后
回头看,这场由"跨服务操作没有原子性"引发的"库存扣了订单没建"事故,真正教给我的,远不止"用 Saga 或可靠消息"这一个技巧。它让我对"我们在一个'受保护的、有人兜底的小环境'里待久了, 会把那个环境慷慨提供的种种保证(比如'一组动作要么全做要么全不做'), 当成天经地义、四海皆准的'世界规则'; 可一旦走出那个小环境、进入更大更松散的世界, 这些保证悄无声息地消失了, 而我们还浑然不觉地依赖着它们",有了一次刻骨的体会。我栽跟头,是因为我把'一个特定环境(单机数据库)慷慨提供的保证', 误当成了'操作本身固有的、走到哪都有的属性'——"多步操作要么全成、要么全回滚", 这个由数据库默默替我兜底的能力, 我用得太顺手、太久, 早已忘了它是'那个环境给的', 而非'天上掉的';当我把操作拆到多个独立服务上、走出了单个数据库这个'受保护区', 那个兜底的'上帝'就不在了; 可我仍想当然地以为它还在, 依然按'全或无'的假设写代码;于是第一步提交、第二步失败、无人回滚的半拉子状态, 就成了必然。这让我领悟到一个关于"保证、边界与依赖"的深刻认知:我们享有的很多'保证', 都是某个特定环境/系统/边界专门为我们提供的, 而非事物本身固有的; 这些保证在边界之内可靠又免费, 用久了就会被我们当成空气、忘记它的存在和来源;而危险恰恰发生在'跨越边界'的那一刻: 当我们把在'小环境'里养成的依赖, 带到一个'不提供这种保证的大环境'里时, 那个我们以为一直都在、其实早已消失的保证, 会让我们在毫无防备中跌入它消失后留下的坑;所以每当要把一件事'拆开、扩大、跨出原来的范围'时, 都要清醒地盘点: 我原来依赖的那些保证, 哪些是'那个小环境'给的?跨出去之后, 它们还在吗?不在了, 我得自己把它建起来吗?。这给了我一种看待"一切'把系统拆分、扩展、跨边界'之事"时的清醒:每当我把一个原本在单一边界内完成的事, 拆分到多个独立的部分去做时, 要追问"我原来仰仗的那些'理所当然'的保证(原子性、顺序、即时一致……), 有哪些其实是'原来那个边界'专门给的?现在跨了边界, 它们还成立吗?如果不成立, 我准备用什么机制把它重新建起来?"——清醒地识别每个保证的来源与适用边界, 在跨出边界时主动重建那些消失的保证, 而不是带着旧依赖盲目地走进新环境;"认清保证的来源与边界、跨界时主动重建消失的保证",是设计对分布式系统、也是做对一切'拆分与扩展'之事的关键。认清原子性是单机数据库给的而非天然属性、跨服务默认无原子性会半成功、要主动用最终一致去重建一致性——这,是我用一次库存扣了订单没建的事故,换来的、关于架构、也关于如何看待保证与边界的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次把一个操作拆到多个服务、顺手以为它还会"要么全成要么全不做"时,先想想"这原子性是谁给我的?跨了服务它还在吗?",并主动为最终一致配上补偿与对账,那我对着那批"扣了库存却找不到订单"的账折腾的大半天,就值了。
—— 别看了 · 2026