# 11 · 数据一致性工程:在没有跨服务事务的世界里把数据弄对 > 一句话点题:**单机时代,一个 `BEGIN…COMMIT` 就能让「扣库存、记订单、发优惠券」要么全成、要么全败。一旦这三件事散落在三个服务、三个数据库里,那条让你高枕无忧的事务边界,就被网络无情地切断了。** 上一章讲了分布式为什么会乱、会丢、会分叉;这一章接着「at-least-once + 幂等」往下走,讲一套**在没有跨服务事务的世界里,依然把数据弄对**的工程手艺:Saga、Outbox、幂等、事件溯源、CQRS、契约演进。 --- > **🧭 这是进阶篇第 2 章。** [10 · 分布式系统的硬道理](10-分布式系统的硬道理.md) 摆出了「病理」——部分失败、没有全局时钟、共识很贵、exactly-once 是幻觉。本章是「临床」:**知道了会乱,那到底怎么治?** 上一章结尾埋的伏笔——「at-least-once 投递 + 消费端幂等 = 效果上的恰好一次」——正是本章所有手法的地基。 > > 这也是 AI 时代最考验人的一层。AI 几秒就能给你写出「乐观路径」的下单代码:扣库存、建订单、发消息,一路 `await` 到底。但它几乎从不主动给你补上「**第三步失败了,前两步怎么办**」。而那,恰恰是这一章的全部主题。 --- ## 一、为什么「跨服务事务」近乎奢望:2PC 的诱惑与代价 先回到 [04 · 十大核心架构模式](04-十大核心架构模式.md) 里微服务最大的那个痛:**一旦「每个服务一个数据库」(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 编排器做成了平台级基础设施。这里的编排与我们在agent系统里的router agent相似,编舞与handoff模式相似。 --- ## 三、双写难题:既改了数据库,又要发条消息,怎么不丢不重? 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](https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html),把 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 没记」的破绽,下次重试又扣一遍。 > **架构智慧**:**「重试」和「幂等」是一对必须成对出现的孪生兄弟。** 任何时候你在代码里写下「失败了就重试」,都必须先问一句:**被重试的那个操作,幂等吗?** 不幂等的操作配上自动重试,等于给系统装了一台「随机重复扣款机」。[支付系统](../templates/payment-system/README.md) 的幂等扣款、[通知系统](../templates/notification-system/README.md) 的去重限频,都是这套三件套的活样板;Stripe 等支付 API 把 `Idempotency-Key` 直接做成了对外的一等公民请求头,正是因为它们深知:**客户端的网络一定会重试,服务端就必须幂等。** --- ## 五、事件溯源:存「发生过什么」,而不是「现在是什么」 到这里,我们一直在「**存当前状态**」的世界里打补丁。现在来一次世界观的翻转——**事件溯源(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)**,从最近的快照往后放,而不是从盘古开天辟地放起。 > **判断要点**:**事件溯源是把双刃剑,绝不是「更先进所以更好」。** 它在「**审计/可追溯压倒一切、且业务天然就是一串事件**」的领域(账务、交易、订单状态机、[协同文档](../templates/collaborative-doc/README.md) 的编辑历史)闪闪发光;但若硬塞进一个普通的增删改查后台,你买到的全是「查询难、演进难、心智负担重」的代价,却用不上它的好处。**先问:我真的需要「过去每一刻的完整历史」吗?** 不需要,就老老实实存状态。 --- ## 六、CQRS:把「读」和「写」拆成两套模型 事件溯源遗留了一个难题——「事件流没法直接查」。解法是 **CQRS(Command Query Responsibility Segregation,命令查询职责分离)**。我们在 [04](04-十大核心架构模式.md) 提过它,这里往深里挖。 核心思想一句话:**写用一套模型,读用另一套模型,中间靠「事件 / 同步」把读模型喂新。** ``` 传统:读和写共用同一个模型、同一张表 —— 既要好写(规范化、强一致),又要好读(各种查询) 结果常常是「两头都将就」,一个复杂查询能拖垮整个写库。 CQRS:左右分家,各自做到最优 写侧(Command) 读侧(Query / 物化视图) ┌──────────────┐ 领域事件 ┌─────────────────────────────┐ │ 写模型 │ ──────────▶ │ 读模型1:订单列表(为列表页优化) │ │ 规范化/强一致 │ 投影更新 │ 读模型2:用户画像(为详情页优化) │ │ 只管「正确地写」 │ ──────────▶ │ 读模型3:搜索索引(为搜索优化) │ └──────────────┘ └─────────────────────────────┘ 写完只对写库强一致;读库由事件「投影」出来,稍微滞后 = 最终一致 ``` microservices.io 点破了 CQRS 在微服务里最实际的用途:当你「每个服务一个库」之后,**想做一个「join 了好几个服务数据」的查询,就没法直接 join 了**(数据分散在各家)。CQRS 的答案是:**建一个专门的读库(视图库),它订阅各服务发出的领域事件,把需要的数据预先「拼好、摊平」存进去**,查询时直接读这张现成的视图,又快又简单。 CQRS 的甜头和苦头: - **甜头**:① 读写可以**独立优化、独立扩展**(读多写少时,读侧疯狂加副本,写侧纹丝不动);② 一个写模型可以投影出**任意多个**专为不同查询定制的读模型;③ 复杂查询不再拖累写库。 - **苦头**:① **读模型是最终一致的**——你刚写完去读,可能还没投影过来,读到旧数据(经典的「刚下单却在订单列表里看不到」)。这必须在产品体验上正面处理;② 系统组件、数据冗余、运维复杂度都翻倍;③ 多了「投影」这条异步链路要保证不丢不乱。 > **判断要点(本节最重要)**:**CQRS 是「重武器」,绝大多数 CRUD 系统不该用它——那是过度设计的重灾区。** 它真正值得的场景很窄:**读写负载严重不对称、或读侧查询形态极其多样**(同一份数据要被列表、详情、搜索、报表四种完全不同的方式查)。一个朴素判断:当你发现「为了一个报表查询,不得不在核心写库上建一堆奇形怪状的索引,还拖慢了下单」时,CQRS 才开始回本。**不要因为「听起来很高级」就上 CQRS;它的最终一致和双倍复杂度,是实打实要还的债。** --- ## 七、Schema / 契约演进:呼应「数据最难改」 [05 · 数据与状态](05-数据与状态.md) 那句「逻辑好改、数据难改」,在分布式 + 事件驱动的世界里被放大到极致:**你的数据库 schema、你发出去的事件结构、你的 API 契约,一旦有别人(别的服务、攒了三年的老事件、还没升级的老客户端)依赖它,就再也不能想改就改。** 难点在于「**新旧必须共存**」。灰度发布、滚动升级期间,新版本和旧版本的代码、数据**同时在线**: ``` 滚动升级的中间态:新旧代码同时在线,新旧格式的数据同时存在 ┌─────────┐ 写新格式 ┌──────────┐ │ 新版本 │ ─────────▶ │ │ ◀── 旧版本还在读,它认识新格式吗?(要前向兼容) └─────────┘ │ 数据/消息 │ ┌─────────┐ 读旧格式 │ │ │ 旧版本 │ ◀───────── │ │ ◀── 新版本要读老数据,它认识旧格式吗?(要后向兼容) └─────────┘ └──────────┘ ``` - **后向兼容(backward)**:新代码能读懂老数据/老消息。 - **前向兼容(forward)**:老代码能读懂(至少能不崩地忽略)新数据/新消息。 而把数据库从旧结构安全迁到新结构,业界久经考验的套路叫 **expand-contract(先扩展,后收缩)**,又叫「平行变更」: ``` ❌ 危险做法:直接 RENAME / DROP 列 —— 部署的一瞬间,还没升级的旧实例集体崩溃 ✅ expand-contract 三步走(每一步系统都始终可用): ┌── Expand(扩展)──┐ ┌── Migrate(迁移/双写)──┐ ┌── Contract(收缩)──┐ │ 加新列/新表,不删旧的 │ │ 代码同时写新旧两份;后台 │ │ 确认无人再读旧字段后, │ │ 旧代码完全无感 │ │ 慢慢把存量数据搬到新结构 │ │ 才安全地删掉旧列/旧代码 │ └────────────────┘ └──────────────────────┘ └──────────────────┘ ``` 整个过程辅以**灰度迁移**:先放 1% 流量到新路径,盯着监控,没问题再 10%、50%、100%。这样即便新结构有 bug,炸的也只是一小撮,且能秒级回退到旧路径。 > **架构智慧**:**演进能力,本质是「永远不做一步到位的破坏性变更」。** 把一次危险的「原地手术」拆成「扩展 → 双写迁移 → 收缩」三步小手术,每一步都让新旧兼容、系统不停。这跟 [08 · 架构决策记录与演进](08-架构决策记录与演进.md) 一脉相承:**好架构不是一次设计对,而是能在不停机、不丢数据的前提下,一小步一小步安全地变。** 在事件溯源系统里这一条尤其要命——老事件你删不掉,新代码必须永远认识每一个版本的老事件,所以「事件 schema 怎么演进」要在第一天就想清楚。 --- ## 📌 真实案例:DoorDash 用 Cadence 给「丢事件」兜底 DoorDash 的配送业务(Drive)早期靠**事件驱动**串联派单流程:一个动作发个事件,下游服务监听后接着干。这正是第二节里「编舞式 Saga」的典型——低耦合、好扩展。但它精准踩中了本章的两根硬骨头: 1. **丢一个事件,整条流程就「卡死在半路」**:事件驱动是 at-least-once(甚至偶尔会丢),一旦某个关键事件没送达、或某个消费者处理到一半崩了,这单配送就停在了一个**没人推进、也没人补偿**的中间态——而这恰恰是第二、三节反复强调的「双写 / 部分失败」之痛。纯编舞的弱点也暴露无遗:**流程散落在各服务的监听器里,没人能一眼看清「这单到底卡在哪、为什么不动了」。** 2. **他们的解法:引入 Cadence 做「持久化工作流」兜底**。DoorDash 把 Drive 的配送创建流程放到了 [Cadence](https://github.com/cadence-workflow/cadence)(Uber 在 2017 年开源的工作流编排引擎,后捐给 CNCF)上,作为主事件流的**兜底**。Cadence 这类引擎的本质,就是把第二节的 **Saga 编排器做成了平台级基础设施**:它**把工作流的每一步状态都持久化下来**,某步失败就**自动重试**,进程崩了**从上次的检查点继续**,而不是从头再来或者干脆丢失。 > 教训精确对应本章:**纯编舞(事件驱动)在「步骤多、要可追踪、要兜底」时会力不从心;一旦某一步可能失败而无人补偿,你就需要一个「记得住进度、扛得住崩溃」的编排层。** 这正是「Saga 编排 + 幂等重试 + 持久化状态」三件套从「模式」走向「平台」的真实落地——也解释了为什么 Uber/DoorDash/Netflix 们不约而同地造或用了持久化工作流引擎(Cadence / Temporal)。 > > 📎 [DoorDash 工程博客:Building Reliable Workflows: Cadence as a Fallback for Event-Driven Processing](https://careersatdoordash.com/blog/building-reliable-workflows-cadence-as-a-fallback-for-event-driven-processing/) · [Uber 官方博客:开源编排工具 Cadence](https://www.uber.com/en-US/blog/open-source-orchestration-tool-cadence-overview/) > > 另一个值得记住的一手坐标:Jay Kreps 在 LinkedIn 写下的 [《The Log》](https://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying)——「**日志即真相**」这一思想,正是 Outbox/CDC/事件溯源共同的精神源头;LinkedIn 后来在 Kafka 上每天跑**数万亿条消息**,把「一条只增不改的日志作为系统间真相之源」从论文变成了行业基础设施([Confluent 数据](https://www.confluent.io/blog/why-confluent-largest-kafka-service-in-the-world/))。 --- ## 🤖 AI / vibe coding 视角 把这一整章接到当下,你会发现一个惊人的对应: > **AI agent 的「多步工具调用」,本质上就是一个分布式 Saga。** ``` 一个 AI agent 帮你「订一趟差旅」: ① 调订机票工具(扣钱、出票) ──▶ ② 调订酒店工具(扣钱、下单) ──▶ ③ 调租车工具 ✗ 失败 每一步都有真实副作用 每一步都可能超时/重复 失败了,前两步怎么办? 机票酒店已经订了! ``` - **每一步都有副作用、且可能失败** → 这就是 Saga 的步骤;失败时你需要**补偿**(退票、退酒店),而不是假装无事。 - **工具调用会超时、会被框架重试** → 这就是 at-least-once;所以**每个工具调用必须幂等**(同一个 `tool_call_id` 重复执行,不能真的扣两次钱、发两封邮件)。 - **agent 的长任务跑到一半进程重启** → 这就是部分失败;你需要**持久化它的状态**,能从断点续跑——和 DoorDash 用 Cadence 兜底是同一个道理。[AI Agent / 工作流平台](../templates/ai-agent-platform/README.md) 的长任务状态、检查点恢复,就是这一章的活样板;而 [支付系统](../templates/payment-system/README.md) 的幂等扣款,是 agent 调用支付工具时的最后一道防线。 而这恰恰暴露了 vibe coding 时代最深的那道坎: > **AI 几秒就能生成「乐观路径」——一串 `await tool_a(); await tool_b(); await tool_c();` 行云流水。但它几乎从不自带补偿、幂等、发件箱。** 你让它「写个下单流程」,它给你的是「三步都成功」的美好世界;你得自己追问:「**第三步失败了,前两步怎么补偿?这些调用幂等吗?发消息和写库是双写吗?**」——这些问题 AI 不会主动替你想,因为它优化的是「让 demo 跑起来」,而不是「让数据在失败中仍然正确」。 **而「让数据在失败中仍然正确」这层判断,正是这个时代人类架构师最该补、也最难被替代的能力。** 实现越来越廉价(Saga、Outbox、幂等的代码 AI 都能写),但「这里要不要补偿、容忍多大不一致、哪一步必须幂等」的**判断**,代价由你的业务承担——这条主线,和 [10] 一脉相承。 --- ## 🎯 随堂检验 --- ## 本章小结 - **核心论断**:微服务把「每服务一个库」之后,那条让你高枕无忧的跨服务事务边界就没了。**别想着把强事务做回来(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 · 为失败而设计:韧性工程](12-为失败而设计.md),我们从「数据正确」走向「系统不倒」:超时、重试、熔断、舱壁、降级、混沌工程——当失败注定会来,如何让系统优雅地弯腰,而不是轰然倒下。