Skip to content

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 compatibility:新代码能读懂老数据/老消息。
  • 前向兼容 forward compatibility:老代码能读懂新数据/新消息,至少能不崩地忽略它们。

而把数据库从旧结构安全迁到新结构,业界久经考验的套路叫 expand-contract,先扩展、后收缩,也叫平行变更。它的直觉就像在旧桥旁边先架一座新桥:先让两座桥并行通车,新旧字段共存,等车流都平稳走上新桥了,再拆掉旧桥——全程没有一刻断路。

   ❌ 危险做法:直接 RENAME / DROP 列 —— 部署的一瞬间,还没升级的旧实例集体崩溃

   ✅ expand-contract 三步走(每一步系统都始终可用):
   ┌── Expand(扩展)──┐   ┌── Migrate(迁移/双写)──┐   ┌── Contract(收缩)──┐
   │ 加新列/新表,不删旧的 │ │ 代码同时写新旧两份;后台   │ │ 确认无人再读旧字段后,    │
   │ 旧代码完全无感        │ │ 慢慢把存量数据搬到新结构    │ │ 才安全地删掉旧列/旧代码   │
   └────────────────┘   └──────────────────────┘   └──────────────────┘

整个过程辅以灰度迁移:先放 1% 流量到新路径,盯着监控,没问题再 10%、50%、100%。这样即便新结构有 bug,炸的也只是一小撮,且能秒级回退到旧路径。

架构智慧:演进能力,本质是永远不做一步到位的破坏性变更。 把一次危险的原地手术拆成扩展 → 双写迁移 → 收缩三步小手术,每一步都让新旧兼容、系统不停。这跟 08 · 架构决策记录与演进 一脉相承:好架构不是一次设计对,而是能在不停机、不丢数据的前提下,一小步一小步安全地变。 在事件溯源系统里这一条尤其要命——老事件你删不掉,新代码必须永远认识每一个版本的老事件,所以事件 schema 怎么演进要在第一天就想清楚。


📌 真实案例:DoorDash 用 Cadence 给丢事件兜底

DoorDash 的配送业务 Drive 早期靠事件驱动串联派单流程:一个动作发个事件,下游服务监听后接着干。这正是第二节里编舞式 Saga 的典型——低耦合、好扩展。但它精准踩中了本章的两根硬骨头:

  1. 丢一个事件,整条流程就卡死在半路:事件驱动是 at-least-once,甚至偶尔会丢。一旦某个关键事件没送达、或某个消费者处理到一半崩了,这单配送就停在了一个没人推进、也没人补偿的中间态——而这恰恰是第二、三节反复强调的双写和部分失败之痛。纯编舞的弱点也暴露无遗:流程散落在各服务的监听器里,没人能一眼看清这单到底卡在哪、为什么不动了。
  2. 他们的解法:引入 Cadence 做持久化工作流兜底。DoorDash 把 Drive 的配送创建流程放到了 Cadence 上。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] 一脉相承。


🎯 随堂检验

🤔一个订单服务处理完请求,既要把订单写进自己的数据库,又要往 Kafka 发一条订单已创建事件给下游。怎样才能不丢不重?

本章小结

  • 核心论断:微服务采用每服务一个库之后,那条让你高枕无忧的跨服务事务边界就没了。别想着把强事务做回来;2PC 同步阻塞、协调者单点、与可用性对立。要换一套能容忍中间态的最终一致工程。
  • Saga:把大事务拆成一串本地事务,失败时再补偿。补偿不是回滚,历史删不掉,只能反向操作。编排是中心指挥、好监控;编舞是事件接龙、低耦合。步骤多、要可追踪用编排,步骤少、求松耦合用编舞。
  • 双写难题 → 事务性发件箱 Outbox:改库和发消息没法同事务,于是把待发消息也写进同一个本地事务的 outbox 表,再用轮询或 CDC,比如 Debezium,来投递。它是 at-least-once,必须配幂等
  • 幂等是分布式正确性的基石:幂等键 + 去重表 + 幂等消费者。重试和幂等必须成对出现,否则自动重试就是随机重复扣款机。
  • 事件溯源:存发生过什么事件,而非现在是什么状态,可类比复式记账和 Git 历史。好处是完整审计、可重放、时间旅行;代价是查询难、事件 schema 演进难、要靠快照。别因为先进就用,先问你是否真需要完整历史。
  • CQRS:读写模型分离,读模型由事件投影、最终一致。值得的场景很窄,主要是读写负载严重不对称、查询形态极多样;绝大多数 CRUD 用它就是过度设计。
  • 契约 / schema 演进:后向 + 前向兼容,数据迁移走 expand-contract,也就是扩展 → 双写迁移 → 收缩,再配合灰度。演进力 = 永不做一步到位的破坏性变更。
  • AI 时代主线:AI agent 的多步工具调用就是一个分布式 Saga,而 vibe coding 只给乐观路径、从不自带补偿/幂等/发件箱——让数据在失败中仍正确的判断,正是人类架构师要补的那一层。

承上启下:这一章我们学会了在没有跨服务事务的世界里把数据弄对——但弄对的前提,是系统得先扛得住失败。下一章,也就是进阶篇第 3 章 12 · 为失败而设计:韧性工程,我们从数据正确走向系统不倒:超时、重试、熔断、舱壁、降级、混沌工程——当失败注定会来,如何让系统优雅地弯腰,而不是轰然倒下。

💬 评论