11 · 数据一致性工程:在没有跨服务事务的世界里把数据弄对
一句话点题:单机时代,一个
BEGIN…COMMIT就能让「扣库存、记订单、发优惠券」要么全成、要么全败。一旦这三件事散落在三个服务、三个数据库里,那条让你高枕无忧的事务边界,就被网络无情地切断了。 上一章讲了分布式为什么会乱、会丢、会分叉;这一章接着「at-least-once + 幂等」往下走,讲一套在没有跨服务事务的世界里,依然把数据弄对的工程手艺:Saga、Outbox、幂等、事件溯源、CQRS、契约演进。
🧭 这是进阶篇第 2 章。 10 · 分布式系统的硬道理 摆出了「病理」——部分失败、没有全局时钟、共识很贵、exactly-once 是幻觉。本章是「临床」:知道了会乱,那到底怎么治? 上一章结尾埋的伏笔——「at-least-once 投递 + 消费端幂等 = 效果上的恰好一次」——正是本章所有手法的地基。
用一个你天天遇到的场景把本章串起来:网购下单。 它背后是「扣库存、建订单、发优惠券」三件事。单机时代一个数据库事务就能保证它们「要么全成、要么全败」;可一旦这三件事分到了三个服务、三个库,那条让你睡得着觉的事务边界就被网络剪断了——扣了库存、建了订单,优惠券却没发出去,数据就「半拉子」了。本章这一整套手艺(Saga、Outbox、幂等……),解决的都是同一个问题:没有了那条万能的事务边界,怎么把数据重新弄对。
这也是 AI 时代最考验人的一层。AI 几秒就能给你写出「乐观路径」的下单代码:扣库存、建订单、发消息,一路
await到底。但它几乎从不主动给你补上「第三步失败了,前两步怎么办」。而那,恰恰是这一章的全部主题。
一、为什么「跨服务事务」近乎奢望:2PC 的诱惑与代价
先回到 04 · 十大核心架构模式 里微服务最大的那个痛:一旦「每个服务一个数据库」(database per service),一个业务动作横跨多个服务,你就再也没有一条能把它们框在一起的事务边界了。
单体时代(一条事务搞定):
┌──────────────────────────────────────┐
│ BEGIN │
│ 扣库存 ──┐ │
│ 建订单 ──┼─ 同一个数据库、同一个事务 │ 要么全成
│ 发优惠券 ─┘ │ 要么全败
│ COMMIT │
└──────────────────────────────────────┘
微服务时代(三个库,谁也框不住谁):
┌─────────┐ ┌─────────┐ ┌─────────┐
│库存服务 │ │订单服务 │ │营销服务 │
│ DB_A │ │ DB_B │ │ DB_C │
└────┬────┘ └────┬────┘ └────┬────┘
扣成功 建成功 ✗ 挂了
──────────────────────────────────▶
库存扣了、订单建了,优惠券没发出 —— 数据「半拉子」了那能不能把这三个库重新框进一个事务?这就是「分布式事务」想干的事,最经典的协议叫两阶段提交(2PC,Two-Phase Commit):
阶段一(投票):协调者问所有参与者「你准备好提交了吗?」
协调者 ──prepare──▶ [库存] [订单] [营销]
◀── yes / yes / yes
阶段二(提交):全 yes 才发 commit;有一个 no 就全部 rollback
协调者 ──commit ──▶ [库存] [订单] [营销]听起来很美,代价却大到让大厂在核心链路上避之不及:
- 同步阻塞、长时持锁:从「投票」到「提交」之间,每个参与者都得锁住相关数据、把资源攥在手里干等。只要有一个参与者慢,所有参与者一起被拖住——在高并发下,这些锁会迅速堆积成灾。
- 协调者是单点:阶段二的 commit 命令发出去一半,协调者宕了——有的参与者收到了 commit、有的没收到,剩下的参与者攥着锁,进退两难(in-doubt),只能死等协调者复活来下最终命令。
- 跟可用性天生对立:2PC 要求所有参与者都在线、都点头才能往前走。这等于把多个服务的可用性「串联」了起来——任何一个掉线,整个事务就卡死。这恰恰是 [10] 里 CAP 最不愿看到的:为了强一致(C),把可用性(A)赔了进去。
架构智慧:2PC 不是「不能用」,而是「用错地方代价极高」。在单个数据库内部、或紧耦合的少数资源之间(比如一个数据库的多个分片),2PC/XA 仍有它的位置;但横跨多个独立服务、还要扛高并发时,2PC 的同步阻塞和协调者单点会让它崩塌。所以业界的主流答案不是「把事务做得更强」,而是**「干脆放弃跨服务的强事务,改用一套能容忍中间态的最终一致方案」**——这就是接下来 Saga 的舞台。
二、Saga:把一个大事务,拆成「一串本地事务 + 失败时的补偿」
既然没法把多个服务框进一个事务,那就换个思路:把大事务拆成一串小的本地事务,每个小事务只在自己服务的库里跑(本地事务是可靠的)。失败了,不靠「回滚」,而是反向跑一串「补偿动作」把已经做的撤回去。 这就是 Saga 模式(Chris Richardson 在 microservices.io 上把它系统化了)。
microservices.io 给的经典例子是「下单不能超过客户信用额度」:订单和客户在不同的库里,没法用一条 ACID 事务卡住,于是拆成 Saga——
正常路径(每步都是一个本地事务):
① 创建订单(pending) ─▶ ② 扣减库存 ─▶ ③ 冻结信用额度 ─▶ ④ 订单转 confirmed
某一步失败时,反向跑补偿(撤销已完成的步骤):
③ 冻结额度失败 ✗
◀── 补偿②:把库存加回去
◀────── 补偿①:把订单标记为 cancelled最关键、也最容易被新人误解的一点:补偿不是数据库回滚。
回滚(rollback):事务还没提交,数据库帮你「当无事发生」,干干净净。
补偿(compensation):上一步「已经提交、甚至已经对外产生了副作用」,
你删不掉历史,只能再做一个「反向操作」去抵消它。- 钱已经打给商家了 → 补偿不是「假装没打」,而是发起一笔退款(一条新的、反向的交易记录)。
- 邮件已经发出去了 → 补偿不是「收回邮件」(收不回),而是再发一封「订单已取消」的更正邮件。
- 库存已经扣了 → 补偿是把库存加回去(但此刻可能已经有别人买走了,这就是补偿的固有麻烦)。
架构智慧:Saga 用「语义上的撤销」换来了「不用跨服务锁」。代价是你必须正视中间态:在第③步还没跑完时,系统真实地处于「订单已建、库存已扣、但还没确认」的尴尬状态——这段时间叫语义锁定期。你得在业务上回答:这时候用户看到的是什么?能不能取消?会不会被别人看到这条「半成品」订单?Saga 把一致性从「数据库帮你保证」搬到了「业务流程帮你保证」——省了锁,但多了你要操心的状态机。
编排(Orchestration)vs 编舞(Choreography):谁来指挥这串步骤?
Saga 怎么把这一串步骤串起来,有两种风格,这是本节最重要的取舍:
编排(Orchestration):有个「总指挥」发号施令
┌────────────┐
│ Saga 编排器 │ ① 命令→ 库存服务 ──回执→┐
│ (中心大脑) │ ② 命令→ 订单服务 ──回执→┤ 它记录「现在走到第几步」
│ │ ③ 命令→ 营销服务 ──回执→┘ 失败就反向发补偿命令
└────────────┘
优点:流程一目了然、易监控、补偿逻辑集中。 缺点:编排器是新的核心组件,易变「上帝服务」。
编舞(Choreography):没有总指挥,各服务听「事件」各自响应
库存服务 ──发「库存已扣」事件──▶ 订单服务 ──发「订单已建」事件──▶ 营销服务
│发「优惠券已发」
优点:无中心、低耦合、各服务自治。 缺点:流程「散落」在各处,没人能一眼看清全貌,补偿链路难追踪。| 编排(Orchestration) | 编舞(Choreography) | |
|---|---|---|
| 控制流 | 集中在编排器,看得见全貌 | 分散在事件里,靠事件「接龙」 |
| 耦合度 | 服务只跟编排器对话,彼此解耦 | 服务靠事件解耦,但隐含「谁监听谁」的暗线 |
| 可观测性 | 强:一处就能看到「卡在第几步」 | 弱:流程散落,排障像拼图 |
| 适合 | 步骤多、补偿复杂、要强监控的关键长流程(订单、支付、入职) | 步骤少、参与方少、追求松耦合的轻量流程 |
| 风险 | 编排器膨胀成「上帝服务」 | 步骤一多就变成「事件意大利面」,没人讲得清全流程 |
判断要点:步骤少、链路短,用编舞;步骤多、补偿复杂、要能一眼看清「现在走到哪、为什么卡住」,用编排。 一个朴素的经验法则:当你发现「要靠翻好几个服务的日志才能拼出一笔订单到底发生了什么」时,就该上编排器了。这也是为什么 Uber、DoorDash 这类公司会专门做持久化工作流引擎(下面案例会讲)——本质就是把 Saga 编排器做成了平台级基础设施。
三、双写难题:既改了数据库,又要发条消息,怎么不丢不重?
Saga 也好、事件驱动也罢,背后都压着一个极其普遍、又极其阴险的问题——双写(dual write):
一个服务处理完请求,通常既要改自己的数据库(建了订单),又要对外发一条消息/事件(通知下游「订单建好了」)。这两件事,落在两个不同的系统里(数据库 + 消息中间件),没有一条事务能同时框住它们。
于是无论你把谁放前面,都有一个失败窗口:
方案 A:先写库,再发消息
写库 COMMIT ✓ ───(此刻进程崩溃 / 网络抖动 / 消息中间件正好挂了)───▶ 消息没发出
结果:数据库说「订单建好了」,但下游永远收不到通知 → 丢消息
方案 B:先发消息,再写库
发消息 ✓ ───(此刻写库失败 / 回滚)───▶ 库里没这条订单
结果:下游收到「订单建好了」,但数据库里根本没有 → 幽灵消息这个问题之所以阴险,是因为它在测试环境几乎不出现(本地一切顺利),却在生产的尾部概率里持续制造「数据库和下游对不上」的灵异事件。Red Hat、Confluent 等都把它列为事件驱动架构的头号陷阱。
解法:事务性发件箱(Transactional Outbox)。 核心思想优雅得近乎狡猾——既然「发消息」没法和「写库」放进一个事务,那就别真的发消息;改成往同一个库里的一张 outbox(发件箱)表插一行,这一行和业务数据用同一个本地事务一起提交。 这样「业务变更」和「我要发的消息」原子地同生共死。然后让一个独立的「投递员」去读这张表,把消息真正发出去。
┌─────────── 同一个本地事务(原子)───────────┐
│ INSERT 订单(orders) │
│ INSERT 待发消息(outbox) ← 消息也落进库里 │ 要么都成,要么都败
└────────────────────────────────────────────┘
│ 事务提交后
▼
┌──────────────┐ 读 outbox 新行 ┌──────────────┐
│ 消息投递器 │ ───────────────▶ │ 消息中间件 Kafka │ ──▶ 下游
│ (Relay) │ 发出后标记已发 └──────────────┘
└──────────────┘投递员读 outbox 有两种做法,microservices.io 称之为:
- 轮询发布(Polling Publisher):定时
SELECT那张表里「还没发」的行,发出去、标记已发。简单直接,缺点是有轮询延迟和额外查询压力。 - 事务日志拖尾(Transaction Log Tailing)= CDC(变更数据捕获):不去查表,而是直接读数据库的事务日志(binlog / WAL),一旦 outbox 表有新行提交,立刻捕获并发出。这就是 Debezium 干的活——它甚至专门做了一个 Outbox Event Router,把 outbox 表的变更直接路由成 Kafka 消息,零轮询、保顺序。
CDC 的精髓:数据库的「事务日志」本身就是一条「发生过什么」的真相流。
写库 ──▶ binlog / WAL(本就为复制和恢复而存在)──▶ Debezium 拖尾 ──▶ Kafka
「日志即真相」—— Jay Kreps《The Log》的核心思想(本章案例会讲)判断要点:只要你的系统里出现「改了库、又要发消息/事件」,就几乎一定要用 Outbox,而不是天真地写完库接一行
kafka.send()。 后者是 vibe coding 时代 AI 最爱生成、也最容易埋雷的「乐观路径」。注意 Outbox 给你的保证是 at-least-once(投递员可能在「发了但还没标记已发」时崩溃,导致重发)——所以它必须和下一节的幂等消费配套,缺一不可。
四、幂等:分布式正确性的基石
上一章说透了:传递层只能做到 at-least-once(不丢但可能重)。Outbox 也是 at-least-once。重试更是处处都在。这意味着「同一个操作被执行两次」是分布式里的常态,不是异常。 让系统在「被重复执行」时仍然正确,这个性质就叫幂等(idempotent)。
幂等的定义:同一个操作,执行 1 次和执行 N 次,对系统状态的影响完全一样。
天生幂等: SET 余额 = 100 ← 设成 100,做几次都是 100 ✓
天生不幂等:余额 = 余额 + 100 ← 做两次就多加了 100,灾难 ✗(重复扣款/重复发钱)「加减」「发货」「发钱」「发消息」这类操作天生不幂等,而它们偏偏又是最不能出错的。怎么把它们改造成幂等?三件套:
① 幂等键(Idempotency Key):每个操作带一个全局唯一的 ID
(如「订单12345的支付」这件事,key = pay_order_12345)
② 去重表(Dedup Table):消费端维护一张表,记下「这个 key 我处理过了」
③ 幂等消费者:来一条先查去重表 —— 见过?直接跳过返回上次结果;没见过?处理 + 记下 key
┌─ 收到消息(key=pay_order_12345)
│ 去重表里有这个 key 吗?
│ 有 ──▶ 已处理过,直接返回成功(不再扣第二次钱)
│ 无 ──▶ 在同一个事务里:执行扣款 + 写入 key,一起提交
└─注意第③步那个「在同一个事务里:执行业务 + 写入幂等键」——这又是一次「把正确性收进一个本地事务」的手艺,和 Outbox 异曲同工。如果你「先执行业务、再写 key」分两步,中间崩溃就会留下「钱扣了但 key 没记」的破绽,下次重试又扣一遍。
架构智慧:「重试」和「幂等」是一对必须成对出现的孪生兄弟。 任何时候你在代码里写下「失败了就重试」,都必须先问一句:被重试的那个操作,幂等吗? 不幂等的操作配上自动重试,等于给系统装了一台「随机重复扣款机」。支付系统 的幂等扣款、通知系统 的去重限频,都是这套三件套的活样板;Stripe 等支付 API 把
Idempotency-Key直接做成了对外的一等公民请求头,正是因为它们深知:客户端的网络一定会重试,服务端就必须幂等。
五、事件溯源:存「发生过什么」,而不是「现在是什么」
💧 深水区(本节 + 下一节 CQRS 都可初读跳过,主线不依赖它们)。事件溯源和 CQRS 是两件「重武器」——绝大多数日常的增删改查系统都用不上,硬上反而是给自己添堵。所以这两节你读完只要记住一句:「它们是什么、以及什么时候才轮到我用」,会判断就够了,不必现在就掌握细节。下面给好奇的人讲透。
到这里,我们一直在「存当前状态」的世界里打补丁。现在来一次世界观的翻转——事件溯源(Event Sourcing):不存「账户现在余额是 100」,而是存下导致这个余额的一连串事件:「开户(+0)→ 存入 80 → 存入 50 → 取出 30」。当前状态(余额 100)不再是被直接保存的东西,而是把所有事件依次「重放」算出来的结果。
传统(状态存储):库里只有一个当前值,改一次就「覆盖」掉旧值
account.balance = 100 ← 你永远不知道它「曾经」是多少、为什么变成 100
事件溯源(只追加,从不覆盖):
[开户] [存入80] [存入50] [取出30] ← 一条只增不改的事件流
└──────────── 重放求和 ───────────▶ 当前余额 = 100这个思想你其实天天在用,只是没意识到:
复式记账(会计几百年的智慧):账本只增不改。记错了不能「擦掉」,
只能再记一笔「冲正」分录。当前余额 = 所有分录之和。
Git:仓库存的不是「文件现在长什么样」,而是一连串 commit(变更事件)。
`git checkout <某次提交>` 就是「重放到那个时间点」—— 这就是时间旅行。事件溯源买到的东西非常诱人:
- 完整审计:每一次状态变化都是一条不可变事件,天生就是一份完美的审计日志——金融、合规场景的刚需(「这笔钱到底怎么变成这样的?」永远有据可查)。
- 可重放、时间旅行:想知道「上周二下午三点这个账户什么状态」?把事件重放到那一刻即可。线上出了诡异 bug?把那串事件在测试环境重放,完美复现。
- 一份事件,多种解读:同一条事件流,今天用来算余额,明天可以拿来做风控特征、做数据分析——新需求不必改历史,只需写个新的「投影」去重新解读老事件。
但它的代价同样硬核,别被光环冲昏头:
- 查询变难:库里是一堆事件,你想查「余额大于 1000 的账户」?没法直接
WHERE——得先把事件重放成状态。这正是下一节 CQRS 要解决的问题(两者常常结对出现)。 - 事件 schema 演进是地狱:事件一旦写下就永不删改(那是真相)。可三年后你的事件结构变了,老事件还得能被新代码正确重放——这种「跨越数年的向后兼容」是事件溯源最难的工程挑战(第七节专门讲演进)。
- 重放成本:事件攒到几百万条,每次从头重放算当前状态会很慢——于是要定期存快照(snapshot),从最近的快照往后放,而不是从盘古开天辟地放起。(就像打游戏的存档点:不必每次都从第一关重打,从最近的存档接着玩就行。)
判断要点:事件溯源是把双刃剑,绝不是「更先进所以更好」。 它在「审计/可追溯压倒一切、且业务天然就是一串事件」的领域(账务、交易、订单状态机、协同文档 的编辑历史)闪闪发光;但若硬塞进一个普通的增删改查后台,你买到的全是「查询难、演进难、心智负担重」的代价,却用不上它的好处。先问:我真的需要「过去每一刻的完整历史」吗? 不需要,就老老实实存状态。
六、CQRS:把「读」和「写」拆成两套模型
事件溯源遗留了一个难题——「事件流没法直接查」。解法是 CQRS(Command Query Responsibility Segregation,命令查询职责分离)。我们在 04 提过它,这里往深里挖。
核心思想一句话:写用一套模型,读用另一套模型,中间靠「事件 / 同步」把读模型喂新。
打个比方:像餐厅把后厨和菜单展示分开。后厨(写侧)只管「把菜做对、库存记准」,按这个目标来组织;而摆在客人面前的菜单、点评墙、热销榜(读侧)只管「让人一眼看明白、点得快」,各做各的、各自优化。两边靠服务员(事件同步)来回传话保持大致同步——代价是菜单上的「售罄」标签可能比后厨慢半拍更新(最终一致)。
传统:读和写共用同一个模型、同一张表 —— 既要好写(规范化、强一致),又要好读(各种查询)
结果常常是「两头都将就」,一个复杂查询能拖垮整个写库。
CQRS:左右分家,各自做到最优
写侧(Command) 读侧(Query / 物化视图)
┌──────────────┐ 领域事件 ┌─────────────────────────────┐
│ 写模型 │ ──────────▶ │ 读模型1:订单列表(为列表页优化) │
│ 规范化/强一致 │ 投影更新 │ 读模型2:用户画像(为详情页优化) │
│ 只管「正确地写」 │ ──────────▶ │ 读模型3:搜索索引(为搜索优化) │
└──────────────┘ └─────────────────────────────┘
写完只对写库强一致;读库由事件「投影」出来,稍微滞后 = 最终一致microservices.io 点破了 CQRS 在微服务里最实际的用途:当你「每个服务一个库」之后,想做一个「join 了好几个服务数据」的查询,就没法直接 join 了(数据分散在各家)。CQRS 的答案是:建一个专门的读库(视图库),它订阅各服务发出的领域事件,把需要的数据预先「拼好、摊平」存进去,查询时直接读这张现成的视图,又快又简单。
CQRS 的甜头和苦头:
- 甜头:① 读写可以独立优化、独立扩展(读多写少时,读侧疯狂加副本,写侧纹丝不动);② 一个写模型可以投影出任意多个专为不同查询定制的读模型;③ 复杂查询不再拖累写库。
- 苦头:① 读模型是最终一致的——你刚写完去读,可能还没投影过来,读到旧数据(经典的「刚下单却在订单列表里看不到」)。这必须在产品体验上正面处理;② 系统组件、数据冗余、运维复杂度都翻倍;③ 多了「投影」这条异步链路要保证不丢不乱。
判断要点(本节最重要):CQRS 是「重武器」,绝大多数 CRUD 系统不该用它——那是过度设计的重灾区。 它真正值得的场景很窄:读写负载严重不对称、或读侧查询形态极其多样(同一份数据要被列表、详情、搜索、报表四种完全不同的方式查)。一个朴素判断:当你发现「为了一个报表查询,不得不在核心写库上建一堆奇形怪状的索引,还拖慢了下单」时,CQRS 才开始回本。不要因为「听起来很高级」就上 CQRS;它的最终一致和双倍复杂度,是实打实要还的债。
七、Schema / 契约演进:呼应「数据最难改」
05 · 数据与状态 那句「逻辑好改、数据难改」,在分布式 + 事件驱动的世界里被放大到极致:你的数据库 schema、你发出去的事件结构、你的 API 契约,一旦有别人(别的服务、攒了三年的老事件、还没升级的老客户端)依赖它,就再也不能想改就改。
难点在于「新旧必须共存」。灰度发布、滚动升级期间,新版本和旧版本的代码、数据同时在线:
滚动升级的中间态:新旧代码同时在线,新旧格式的数据同时存在
┌─────────┐ 写新格式 ┌──────────┐
│ 新版本 │ ─────────▶ │ │ ◀── 旧版本还在读,它认识新格式吗?(要前向兼容)
└─────────┘ │ 数据/消息 │
┌─────────┐ 读旧格式 │ │
│ 旧版本 │ ◀───────── │ │ ◀── 新版本要读老数据,它认识旧格式吗?(要后向兼容)
└─────────┘ └──────────┘- 后向兼容(backward):新代码能读懂老数据/老消息。
- 前向兼容(forward):老代码能读懂(至少能不崩地忽略)新数据/新消息。
而把数据库从旧结构安全迁到新结构,业界久经考验的套路叫 expand-contract(先扩展,后收缩),又叫「平行变更」。它的直觉就像在旧桥旁边先架一座新桥:先让两座桥并行通车(新旧字段共存),等车流都平稳走上新桥了,再拆掉旧桥——全程没有一刻是「断路」的。
❌ 危险做法:直接 RENAME / DROP 列 —— 部署的一瞬间,还没升级的旧实例集体崩溃
✅ expand-contract 三步走(每一步系统都始终可用):
┌── Expand(扩展)──┐ ┌── Migrate(迁移/双写)──┐ ┌── Contract(收缩)──┐
│ 加新列/新表,不删旧的 │ │ 代码同时写新旧两份;后台 │ │ 确认无人再读旧字段后, │
│ 旧代码完全无感 │ │ 慢慢把存量数据搬到新结构 │ │ 才安全地删掉旧列/旧代码 │
└────────────────┘ └──────────────────────┘ └──────────────────┘整个过程辅以灰度迁移:先放 1% 流量到新路径,盯着监控,没问题再 10%、50%、100%。这样即便新结构有 bug,炸的也只是一小撮,且能秒级回退到旧路径。
架构智慧:演进能力,本质是「永远不做一步到位的破坏性变更」。 把一次危险的「原地手术」拆成「扩展 → 双写迁移 → 收缩」三步小手术,每一步都让新旧兼容、系统不停。这跟 08 · 架构决策记录与演进 一脉相承:好架构不是一次设计对,而是能在不停机、不丢数据的前提下,一小步一小步安全地变。 在事件溯源系统里这一条尤其要命——老事件你删不掉,新代码必须永远认识每一个版本的老事件,所以「事件 schema 怎么演进」要在第一天就想清楚。
📌 真实案例:DoorDash 用 Cadence 给「丢事件」兜底
DoorDash 的配送业务(Drive)早期靠事件驱动串联派单流程:一个动作发个事件,下游服务监听后接着干。这正是第二节里「编舞式 Saga」的典型——低耦合、好扩展。但它精准踩中了本章的两根硬骨头:
- 丢一个事件,整条流程就「卡死在半路」:事件驱动是 at-least-once(甚至偶尔会丢),一旦某个关键事件没送达、或某个消费者处理到一半崩了,这单配送就停在了一个没人推进、也没人补偿的中间态——而这恰恰是第二、三节反复强调的「双写 / 部分失败」之痛。纯编舞的弱点也暴露无遗:流程散落在各服务的监听器里,没人能一眼看清「这单到底卡在哪、为什么不动了」。
- 他们的解法:引入 Cadence 做「持久化工作流」兜底。DoorDash 把 Drive 的配送创建流程放到了 Cadence(Uber 在 2017 年开源的工作流编排引擎,后捐给 CNCF)上,作为主事件流的兜底。Cadence 这类引擎的本质,就是把第二节的 Saga 编排器做成了平台级基础设施:它把工作流的每一步状态都持久化下来,某步失败就自动重试,进程崩了从上次的检查点继续,而不是从头再来或者干脆丢失。
教训精确对应本章:纯编舞(事件驱动)在「步骤多、要可追踪、要兜底」时会力不从心;一旦某一步可能失败而无人补偿,你就需要一个「记得住进度、扛得住崩溃」的编排层。 这正是「Saga 编排 + 幂等重试 + 持久化状态」三件套从「模式」走向「平台」的真实落地——也解释了为什么 Uber/DoorDash/Netflix 们不约而同地造或用了持久化工作流引擎(Cadence / Temporal)。
📎 DoorDash 工程博客:Building Reliable Workflows: Cadence as a Fallback for Event-Driven Processing · Uber 官方博客:开源编排工具 Cadence
另一个值得记住的一手坐标:Jay Kreps 在 LinkedIn 写下的 《The Log》——「日志即真相」这一思想,正是 Outbox/CDC/事件溯源共同的精神源头;LinkedIn 后来在 Kafka 上每天跑数万亿条消息,把「一条只增不改的日志作为系统间真相之源」从论文变成了行业基础设施(Confluent 数据)。
🤖 AI / vibe coding 视角
把这一整章接到当下,你会发现一个惊人的对应:
AI agent 的「多步工具调用」,本质上就是一个分布式 Saga。
一个 AI agent 帮你「订一趟差旅」:
① 调订机票工具(扣钱、出票) ──▶ ② 调订酒店工具(扣钱、下单) ──▶ ③ 调租车工具 ✗ 失败
每一步都有真实副作用 每一步都可能超时/重复 失败了,前两步怎么办?
机票酒店已经订了!- 每一步都有副作用、且可能失败 → 这就是 Saga 的步骤;失败时你需要补偿(退票、退酒店),而不是假装无事。
- 工具调用会超时、会被框架重试 → 这就是 at-least-once;所以每个工具调用必须幂等(同一个
tool_call_id重复执行,不能真的扣两次钱、发两封邮件)。 - agent 的长任务跑到一半进程重启 → 这就是部分失败;你需要持久化它的状态,能从断点续跑——和 DoorDash 用 Cadence 兜底是同一个道理。AI Agent / 工作流平台 的长任务状态、检查点恢复,就是这一章的活样板;而 支付系统 的幂等扣款,是 agent 调用支付工具时的最后一道防线。
而这恰恰暴露了 vibe coding 时代最深的那道坎:
AI 几秒就能生成「乐观路径」——一串
await tool_a(); await tool_b(); await tool_c();行云流水。但它几乎从不自带补偿、幂等、发件箱。 你让它「写个下单流程」,它给你的是「三步都成功」的美好世界;你得自己追问:「第三步失败了,前两步怎么补偿?这些调用幂等吗?发消息和写库是双写吗?」——这些问题 AI 不会主动替你想,因为它优化的是「让 demo 跑起来」,而不是「让数据在失败中仍然正确」。
而「让数据在失败中仍然正确」这层判断,正是这个时代人类架构师最该补、也最难被替代的能力。 实现越来越廉价(Saga、Outbox、幂等的代码 AI 都能写),但「这里要不要补偿、容忍多大不一致、哪一步必须幂等」的判断,代价由你的业务承担——这条主线,和 [10] 一脉相承。
🎯 随堂检验
- A先写数据库提交,紧接着调用发消息;两步都用重试包起来就够了
- B把订单和一条待发消息写进同一张库的两张表、用同一个本地事务提交,再由一个独立投递器读出来发送,下游做幂等消费
- C用一个跨数据库和消息中间件的两阶段提交事务,把两者强行绑定
本章小结
- 核心论断:微服务把「每服务一个库」之后,那条让你高枕无忧的跨服务事务边界就没了。别想着把强事务做回来(2PC 同步阻塞、协调者单点、与可用性对立);要换一套能容忍中间态的最终一致工程。
- Saga:把大事务拆成「一串本地事务 + 失败时的补偿」。补偿不是回滚(历史删不掉,只能反向操作)。编排(中心指挥、好监控)vs 编舞(事件接龙、低耦合):步骤多、要可追踪用编排,步骤少、求松耦合用编舞。
- 双写难题 → 事务性发件箱(Outbox):「改库」和「发消息」没法同事务,于是把「待发消息」也写进同一个本地事务的 outbox 表,再用轮询或 CDC(Debezium) 投递。它是 at-least-once,必须配幂等。
- 幂等是分布式正确性的基石:幂等键 + 去重表 + 幂等消费者。「重试」和「幂等」必须成对出现,否则自动重试就是「随机重复扣款机」。
- 事件溯源:存「发生过什么事件」而非「现在是什么状态」(类比复式记账、Git 历史)。好处是完整审计、可重放、时间旅行;代价是查询难、事件 schema 演进难、要靠快照。别因为「先进」就用,先问你是否真需要完整历史。
- CQRS:读写模型分离,读模型由事件投影、最终一致。值得的场景很窄(读写负载严重不对称、查询形态极多样);绝大多数 CRUD 用它就是过度设计。
- 契约 / schema 演进:后向 + 前向兼容,数据迁移走 expand-contract(扩展→双写迁移→收缩) + 灰度。演进力 = 永不做一步到位的破坏性变更。
- AI 时代主线:AI agent 的多步工具调用就是一个分布式 Saga,而 vibe coding 只给「乐观路径」、从不自带补偿/幂等/发件箱——「让数据在失败中仍正确」的判断,正是人类架构师要补的那一层。
承上启下:这一章我们学会了在没有跨服务事务的世界里把数据弄对——但「弄对」的前提,是系统得先扛得住失败。下一章(进阶篇第 3 章)12 · 为失败而设计:韧性工程,我们从「数据正确」走向「系统不倒」:超时、重试、熔断、舱壁、降级、混沌工程——当失败注定会来,如何让系统优雅地弯腰,而不是轰然倒下。
💬 评论