分布式事务深水区:从 2PC 到最终一致性
第 9 章我们认识了 Seata 和 @GlobalTransactional,但那是"会用"。这一章回答更难的问题:为什么微服务下本地事务就不够用了?强一致和最终一致到底怎么选?补偿接口为什么要写三个坑的防御?分布式事务是微服务里最容易翻车的地方——不是因为它难写,而是因为它看起来很简单:加个注解好像就完事了。下面按"问题从哪来 → 有哪些解法 → 每种解法付出什么代价 → 什么场景选哪个"这条线讲透。
为什么本地事务在微服务里"突然"失效了
先说清楚失效的根因,不然后面所有方案你都记不住。本地事务的能力边界,是"同一个数据库连接的同一个事务"。只要写操作落在同一个连接里,数据库就能替你保证要么全成功、要么全回滚。一旦业务被拆成多个服务、每个服务连自己的库,这个边界就断了——两个数据库之间没有任何机制知道对方干了什么。
论本地事务的边界到底在哪
① 单体时代:下单要写订单表、扣库存表、扣账户表,三张表在同一个库里。开一个 @Transactional,Spring 拿到一个 Connection,三张表的操作都走它,数据库用一个 undo/redo log 保证原子性。你什么都不用管。
② 拆分之后:订单表在 order_db(order-service),库存在 stock_db(stock-service),余额在 account_db(account-service)。三次写操作落在三个不同的 Connection、三个不同的数据库进程上,它们之间没有共同的事务边界。
③ 于是出现"半成功":order_db 提交了,stock_db 扣了,account_db 因为余额不足失败。数据库不会替你回滚前两个——因为从数据库视角看,那两个事务早就提交完了、天经地义地成功了。
把你的代码"翻译"成数据库视角,一眼看出问题
很多人看到第 9 章 @GlobalTransactional 就以为"加个注解等于本地事务的体验"。实际上注解只是把协调逻辑自动化了,真正的一致性保证依然来自"每个参与者要写 undo_log / 补偿接口"这套额外机制。理解不了这一点,你就会在"为什么回滚了一半"的问题上反复卡住。
CAP 与 BASE:先想清楚你到底要什么
选分布式事务方案之前,必须先做一个业务判断题:这个业务,中间那几百毫秒的"不一致"能不能被用户看到、能不能被容忍?这就绕不开 CAP 和 BASE。
论CAP 的"3 选 2"是个常见误读
① CAP 是 C(一致性)、A(可用性)、P(分区容忍)。误读是"三个里挑两个"。正确的理解是:网络分区(P)在分布式系统里是必然发生的,不是你能选的选项。所以真正的取舍只在分区发生的那一刻:你选 C(拒绝服务、保证数据一致),还是选 A(继续服务、接受短暂不一致)。
② 平时不分区的时候,CA 都想要,没问题。CAP 讨论的是"出事的时候"。
③ BASE 是 CAP 的工程化答案:Basically Available(基本可用,可以降级、可以延迟)、Soft state(软状态,允许中间态存在)、Eventually consistent(最终一致)。核心思想:不消灭中间状态,而是保证中间状态最终一定会被收敛掉。
| 维度 | 强一致(CP 思路) | 最终一致(AP + BASE 思路) |
|---|---|---|
| 用户看到什么 | 要么全部成功,要么全部失败,不存在中间态。 | 可能出现"订单已创建、积分未到账"这类短暂中间态。 |
| 响应延迟 | 要等所有参与者 prepare 完成才能返回,延迟 = 最慢的那个。 | 本地事务提交即可返回,延迟由自己决定,通常几十毫秒。 |
| 可用性 | 任一参与者宕机/超时,整个业务不可用。 | 参与者宕机不影响主流程,补偿任务稍后重试。 |
| 实现复杂度 | 中间件承担大部分复杂度(XA 驱动、Seata XA)。 | 复杂度落到业务代码:补偿接口、幂等、状态机,必须自己写。 |
| 典型场景 | 资金划转、对账、库存重扣减等不容错的短事务。 | 下单、发券、加积分、通知、统计等长链路周边动作。 |
| 技术选型 | XA / Seata XA / Seata AT(弱化版强一致) | 本地消息表 / 事务消息 / Saga / TCC |
看这张表请记住一句话:绝大多数互联网业务属于右边那一列。"下单送积分"延迟 3 秒到账,用户根本感知不到;而为了这 3 秒去上一个 2PC,你付出的可用性和复杂度代价是几十倍。真正需要左边那一列的场景其实很少。
2PC / XA:教科书方案,工程上的噩梦
2PC(Two-Phase Commit,两阶段提交)是所有分布式事务方案的"祖师爷"。理解它的缺陷,你才能理解后面所有方案为什么长成那样。
| 阶段 | 谁在动 | 做了什么 |
|---|---|---|
| 阶段一 Prepare | 协调者(TM)→ 各参与者(RM) | 协调者问"你能提交吗"。参与者执行事务但不提交,把 undo/redo 信息写进日志、锁住相关资源,回复 Yes/No。此时资源已被锁住,别的请求会阻塞。 |
| 阶段二 Commit | 协调者 → 各参与者 | 如果所有人都是 Yes,协调者发 Commit,参与者提交并释放锁;只要有一个 No(或超时),发 Rollback,所有人回滚。 |
论2PC 的四个硬伤
① 同步阻塞:从 Prepare 到 Commit 之间,所有参与者都锁着资源。如果有人卡在阶段二之前,锁会一直不放,其他业务请求全部排队。这是 2PC 在生产上最致命的问题。
② 协调者单点:TM 宕机后,参与者会一直保持"已 Prepare 未 Commit"的悬停状态,资源被锁死,需要人工介入恢复。
③ 提交阶段的不一致窗口:部分参与者收到 Commit 并提交成功,另一些因为网络断连没收到,此时系统处于真实的不一致状态,且协调者也无法得知谁提交了。
④ 3PC 只是缓解,不是根治:三阶段(CanCommit / PreCommit / DoCommit)加入超时机制减少阻塞,但仍然顶不住分区,还会引入新的不一致窗口。工程上基本不用。
那 2PC/XA 就完全不能用吗?能用,但要挑场景:参与方少、事务短、性能要求低、且是内部系统。比如银行内部两个核心库之间的对账划转,可以接受。但把它用在"用户下单"这种高并发链路上,就是自找麻烦。
用一个能跑的例子看清楚 XA 的骨架:谁在协调,谁在等
XA 不是应用层能单方面实现的,它要求数据库提供 XA 接口。MySQL 的 InnoDB 支持,但一些 NoSQL、新式云原生数据库并不支持。而且挂上 XA 之后,连接池的复用会变得很麻烦——XA 连接在事务结束前不能随便还给别人用,池子容易被耗空。所以选 XA 前先确认两件事:参与库支不支持、连接池扛不扛得住。
TCC:把"预留"这件事做进业务里
TCC(Try-Confirm-Cancel)的思路换了个方向:既然数据库层面协调不了,那就在业务层面自己定义"两阶段"。不锁数据库的行锁,而是在业务表里"冻住"一份资源。
论为什么需要 Try 这个"预留"动作
先看错误做法:Try 阶段直接扣库存、扣余额,失败再"反向加回来"。这在低并发下能用,但有两个硬伤:一是中间状态对用户可见——用户查库存看到"被扣了",查余额看到"钱少了",客服电话立刻打进来;二是加回来不一定成功,如果撤销时库存已被别人买走,你就还不回去了。
再看正确做法:Try 只做"冻结"——库存表的 frozen_count 加 1,可用库存减 1,但商品并没有真正出库;余额表 frozen_amount 加上金额,可用余额减掉,但钱没有真正扣走。Confirm 才把冻结转成真正的扣减,Cancel 则是把冻结原路释放。预留的本质:先占住,但结果对外不可见。
| 阶段 | 语义 | 库存服务的具体动作 |
|---|---|---|
| Try | 预留资源 | update stock set count = count - 1, frozen = frozen + 1 where product_id=? and count >= 1。加 count >= 1 是防超卖的关键。 |
| Confirm | 确认扣减 | update stock set frozen = frozen - 1 where product_id=?。只减冻结量,因为可用量在 Try 阶段已经减过了。这一步必须幂等。 |
| Cancel | 释放预留 | update stock set count = count + 1, frozen = frozen - 1 where product_id=?。把 Try 占住的还回去。必须幂等,且要能处理"Try 压根没执行"。 |
| 坑 | 现象(怎么被触发的) | 防御手段 |
|---|---|---|
| 幂等 | 网络超时后协调者重试,Confirm/Cancel 被调用两次,库存被扣两次或加两次。 | 用 branch_id 做唯一键建事务控制表,或状态机加"仅允许从 FROZEN 流转到 CONFIRMED"的条件更新。 |
| 空回滚 | Try 请求因为网络原因根本没到,但协调者已经判定全局失败,发出 Cancel。Cancel 什么都没释放,却把库存加多了。 | Cancel 时先查事务控制表:没有 Try 记录就写入一条"空回滚"标记并直接返回,绝不做正向的资源操作。 |
| 悬挂 | Try 请求在网络里"迟到"了:Cancel 先到并执行完(还留了空回滚记录),之后延迟的 Try 才到达并成功冻结资源,这笔冻结永远没人来释放。 | Try 执行前先查事务控制表:如果已经存在 Cancel 记录,说明自己被悬挂了,直接拒绝执行 Try。 |
事务控制表 + 三个坑的防御骨架(这是 TCC 真正的工作量所在)
很多人以为 TCC 的难点是写那三个接口,其实真正难的是把业务拆成"预留 / 确认 / 取消"三段,并且每段都能对"重复调用、乱序到达、中途失败"免疫。这需要和产品、财务把每个中间态的语义对齐(比如"冻结的钱算不算用户的可用余额")。所以 TCC 通常只用在资金、库存这类对一致性要求极高、且团队有足够设计能力的核心链路上。普通业务用 TCC,属于用手术刀削苹果。
Saga:长流程事务的补偿链路
Saga 的思路更朴素:把一个大事务拆成一串本地事务 T1、T2、T3…,每个 Ti 都配一个补偿动作 Ci。任何一步失败,就反向依次执行 C(i-1)、C(i-2)…C1。它适合那种流程很长、参与方很多、但每一步都"可以撤销"的业务,比如旅行订票(机票 → 酒店 → 租车)。
论Saga 必须直面的一个事实:它没有隔离性
为什么:因为每个 Ti 都是立即提交的本地事务,中间状态对所有人可见。T1 扣了库存,T2 还没扣款,此时别人查询就会看到"库存少了但订单没付款"。
怎么应对:业务层面打补丁。常见三种做法——① 语义锁:加一个 status=PENDING 的中间状态,把"未完成"的记录排除在各种查询之外;② 可交换的补偿:让补偿动作尽早执行,缩短窗口;③ 业务容忍:明确告诉产品经理"这个数字会有几秒延迟",通常这就够了。
记住:Saga 换来的是可用性和吞吐,代价是把"一致性"这件事从数据库挪到了业务语义里。
| 对比项 | 编排式 Orchestration | 协同式 Choreography |
|---|---|---|
| 谁在指挥 | 一个中心协调者(状态机 / 流程引擎),它依次调各服务。 | 没有中心,每个服务完成本地事务后发事件,下一个服务监听并继续。 |
| 流程在哪里 | 集中在状态机定义里,一眼能看清全貌。 | 散落在各服务的事件处理器里,需要把所有服务拼起来才能看清。 |
| 耦合程度 | 各服务只依赖协调者,服务之间零耦合。 | 服务之间通过事件间接耦合,新增一步要改上游的发布逻辑。 |
| 可观测性 | 好。协调者天然记录了"现在走到第几步、卡在哪"。 | 差。出问题时要在多个服务的日志/链路里串联才能定位。 |
| 适用场景 | 步骤多、分支多、需要人工干预或可视化编排的长流程。 | 步骤少(3~4 步)、参与方稳定、团队习惯事件驱动。 |
| 代表实现 | Seata Saga(状态机 JSON 定义)、Temporal、Camunda | 事件总线 + 各服务自行订阅 |
编排式 Saga 的状态机骨架:正向走下去,失败就反向补偿
本地消息表 + 定时补偿:最土、最稳、最常用
如果你只想记住一个方案,就记这个。它解决的是最常见的一类问题:"主流程写库"和"通知别人"必须一致。核心技巧只有一个——把"要发的消息"和业务数据写进同一个本地事务。这样一来,"业务提交了"和"消息被记下来了"就成为一个原子操作,不可能只发生一半。
论它到底保证了什么,没保证什么
① 保证的是:业务成功 ⟺ 消息落库。这两件事在同一个本地事务里,数据库帮你保证原子性。不存在"订单创建成功但消息丢了"。
② 没保证的是:消息一定能"投递成功"且"只投递一次"。定时任务把消息发给 MQ,可能发成功但确认超时,于是下次扫描又发了一遍。所以这是"至少一次"语义,消费端必须做幂等去重。
③ 为什么说它最稳:因为它不依赖任何特殊中间件能力,只用到了"数据库事务"和"定时任务"这两个最基础的东西。MySQL + 一个 cron 就能跑通,任何团队都能掌握,出问题也好排查。
三步搭起来:建表 + 业务事务里写消息 + 定时补偿
定时补偿天生是"至少一次投递",重复消息一定会出现(网络抖动、MQ 重投、服务重启)。消费端必须用 biz_id 建一张去重表(或直接用业务唯一键):插入成功才继续处理,主键冲突说明这条已经处理过,直接确认消费。少了这一步,你迟早会遇到"用户被加了两次积分"或者"发了两次货",而且对账时极难定位。RocketMQ 的事务消息本质上就是把这个本地消息表做进了 MQ 内部,思路完全一样,只是不用你自己写扫描任务。
Seata 四种模式与 AT 的 undo_log 原理
Seata 是把上面这些方案"产品化"的一个框架,它提供了四种模式,分别对应不同的思路。不要按"哪个先进"来选,要按"哪个的代价你的业务付得起"来选。
| 模式 | 思路 | 业务侵入 | 一致性 | 适合什么场景 |
|---|---|---|---|---|
| AT | 自动补偿。代理数据源,自动记录数据行修改前后的镜像。 | 几乎为零,加 @GlobalTransactional 即可 | 最终一致 | 默认首选。普通业务、团队不想写补偿逻辑、能接受全局锁带来的少量性能损耗。 |
| TCC | 手动定义 Try/Confirm/Cancel 三个接口。 | 高,三个接口都要自己写 | 最终一致(可做到接近强一致) | 资金、库存等核心链路,对性能敏感且团队有设计能力。 |
| Saga | 状态机驱动的长流程 + 补偿链。 | 中,要写状态机定义和各步补偿 | 最终一致 | 步骤多、链路长的业务流程,如旅行预订、审批流。 |
| XA | 数据库原生 XA,两阶段提交。 | 低,但要求数据库支持 XA | 强一致 | 参与方少、事务短、明确需要强一致的内部系统。高并发链路不要用。 |
论AT 模式的 undo_log 是怎么做到"自动回滚"的
① 拦截前:Seata 用数据源代理拦住你的 SQL。执行 update stock set count = count - 1 where id = 1001 之前,它先查一次这行数据,把结果存成 before image(前镜像)。
② 执行后:再查一次这行,存成 after image(后镜像)。然后把前后镜像和业务 SQL 一起写进 undo_log 表,和业务数据在同一个本地事务里提交。这就是它能自动回滚的全部秘密——回滚所需的信息被"顺手"持久化了。
③ 回滚时:全局失败,TC 通知各分支回滚。分支拿 undo_log 里的前镜像,生成一条反向 SQL(update stock set count = 1 where id = 1001)执行回去。
④ 防脏写校验:回滚前,它先拿 after image 去比对当前库里的数据。如果发现不一致,说明这行在全局事务之外被别的事务改过了,此时不能直接回滚(否则会覆盖别人的修改),Seata 会抛异常并需要人工处理。这个校验是 AT 模式安全性的关键,也是它"最终一致"而非"强一致"的根源。
AT 模式落地:建 undo_log 表 + 业务侧只加一个注解
选型别拍脑袋,按这三问走
误解一:把它当银弹。看到跨库就上 @GlobalTransactional,结果每个请求都多两轮 TC 通信、每个分支都多一次 undo_log 写入、还可能拿全局锁。高并发下 QPS 掉一半。先问"这个操作真的需要一致性吗"——很多跨服务操作其实是"通知"性质,异步化即可,压根不需要事务。
误解二:补偿接口写得随便。补偿接口不幂等,是最常见的生产事故来源。全局事务回滚时,TC 会重试通知分支,如果 Cancel 或 restore 不是幂等的,就会出现"重复退款""库存加两次"。所有补偿接口的第一行代码,都应该是幂等校验。记住:能重试的东西,一定会被重试。
① 本地事务的边界是"一个数据库连接",跨库就断了,所以你必须自己补上一致性。
② 先做业务判断题(中间态能不能被看到),再选技术方案;多数业务落在"最终一致"这一列。
③ 2PC 阻塞且有单点;TCC 换来性能但要写三个幂等接口;Saga 换来可用性但牺牲隔离性;本地消息表最土最稳。
④ Seata AT 靠 undo_log 的前后镜像自动回滚,但它是"最终一致",不是强一致。
⑤ 所有补偿接口必须幂等,所有消费端必须去重——这两条是底线,不是优化。
本章面试题
Q1. CAP 里为什么说"P 是必选"?
参考答案
因为分布式系统里网络分区是客观必然(光缆断了、交换机抖动、容器重启),不是你能通过努力消除的选项。所以 CAP 的实质是:分区发生的那一刻,你在 C 和 A 之间选谁。选 C 就是宁可拒绝服务也不返回不一致数据(如 ZooKeeper 少数派不可用);选 A 就是继续服务但接受短暂不一致(如 Eureka、Nacos 默认 AP)。
Q2. TCC 的"空回滚"和"悬挂"分别是什么?为什么要同一个事务控制表解决?
参考答案
空回滚:Try 没到,Cancel 却先到了,Cancel 无事可做却可能误操作资源。悬挂:Cancel 先执行完,迟到的 Try 才到达并占了资源,从此无人释放。用同一张表的原因:两者本质都是"不知道 Try 到底有没有执行过"。用 branch_id 做唯一键把每次 Try/Cancel 都落一条状态记录,Cancel 靠"查不到记录就写空回滚标记"防误操作,Try 靠"发现已有 Cancel 记录就拒绝执行"防悬挂。一个账本同时解决两个坑。
Q3. Seata AT 模式回滚时,为什么要拿 after image 去比对当前数据?
参考答案
这是防脏写。全局事务执行期间,这行数据可能被别的、不属于本全局事务的本地事务改过。如果直接按前镜像回滚,就会把别人的修改覆盖掉,造成数据丢失。比对 after image 就是确认"从我改完到现在,这行没人动过";一旦发现不一致,就拒绝回滚并告警,交给人工处理。这也是 AT 只能做到最终一致的原因。
Q4. 本地消息表方案里,"消息落库"和"消息投递成功"分别靠什么保证?
参考答案
消息落库:靠本地事务。业务数据和消息记录在同一个 @Transactional 里提交,数据库保证原子性,不存在业务成功而消息丢失。消息投递:靠定时任务扫描 + 重试,这是至少一次语义,只保证最终会被投出去,不保证不重复。所以额外需要一个东西兜底:消费端幂等去重。三件事合起来才构成完整的方案。
Q5. 一个电商下单链路:扣库存、扣余额、发优惠券、加积分、发短信。你会怎么设计一致性?
参考答案
分层处理,不要一刀切。强一致核心:扣库存 + 扣余额是钱和货,用 Seata AT(或 TCC)保证"要么都成、要么都不成"。最终一致周边:发券、加积分、发短信属于通知性质,主事务成功后发一条 MQ 消息,各自异步消费 + 幂等,失败重试即可,不占主链路时间。要点:别把这五件事塞进一个全局事务——那会让主链路延迟、可用性都被最慢的环节拖死。
Q6. 为什么说"不是所有跨库操作都需要分布式事务"?举个反例。
参考答案
判断标准是"这一步失败会不会造成业务上不可接受的错误"。比如"下单成功后给用户发一条站内信"——即使站内信发失败,业务也是对的,用户不会因此受损,事后补发就行,这种叫通知,异步化即可,压根不需要事务。反过来"扣了钱没加积分"这种,用户会投诉,就属于需要保证的一致性范围。反例结论:把通知当事务处理,只会白白付出复杂度和性能代价。