分布式事务从ACID到TCC、Saga与最终一致性,一文讲透

发布时间:2026/10/6 21:07:31
分布式事务从ACID到TCC、Saga与最终一致性,一文讲透 做后端这些年我对“分布式事务”这四个字的感情很复杂。刚入行时它像是个玄学名词——订单库存这种一秒钟就能想明白的需求一拆成微服务就怎么都说不清白。后来踩的坑多了才明白分布式事务从来不是一个孤立的技术点而是一整条由痛点逼出来的演进链从单体事务到两阶段提交再到 TCC、Saga、最终一致性每种方案都回答了一个前任没回答好的问题。如果你也在为跨服务的订单与库存怎么保证数据一致性发愁或者单纯想把这些概念彻底串起来这篇文章就是为你准备的——我会按这条演进链从头讲一遍把每种方案的原理、代价和真实工程里那些文档不会写的坑都摊开说。1. 先把“单体事务”这件事看透ACID到底保护了什么1.1 仔细拆解ACID先弄清楚“确定性”从哪来很多人一上来就研究 TCC、Saga却连单体事务为什么可靠都没细想过。这就像没学过压强的人直接去设计水坝方案再好也立不住。所以我坚持从最基础的 ACID 讲起。ACID 四个特性里对分布式事务问题影响最大的是原子性Atomicity和持久性Durability但另外两个也不是摆设原子性一个事务里的所有操作要么全部成功要么全部不执行。数据库通过 undo 日志实现回滚——事务执行过程中记录修改前的值一旦失败按照 undo 日志把数据恢复到事务开始时的状态。一致性Consistency事务执行前后数据库要满足所有约束从一个合法状态到另一个合法状态。这个特性更多是业务规则和数据库约束的体现。隔离性多个事务并发执行时彼此不能读到中间状态。数据库靠锁和多版本并发控制MVCC实现。持久性事务提交成功后结果不能丢。数据库通过 redo 日志在提交时把变更刷入磁盘即使进程崩溃、机器断电也能在重启后根据日志恢复。用个生活化的类比几个人共用一个账本每个人都要在账本上写一笔要么全写成功、要么全不写。写了一半发现有人写错了就把整页撕掉重来。单人事务就是那个可以“撕页重来”的账本因为账本、笔、橡皮都在一个人手里。1.2 单库事务的保护边界以及它为什么必然被拆单体事务可靠但有个大前提所有数据、锁、日志、回滚点都绑定在同一个数据库连接和同一个数据源内。一旦数据分布在两个物理库、两台服务器甚至两个机房单库事务的“撕页重来”能力就消失了——你连这本账本都不完整了。这也是我会反复强调的一点所谓“分布式事务”本质上是在“无法保证单库 ACID 的环境里想尽办法找回之前那种确定性”。你得先有这个认知后面的方案才不会看晕。但现实是你几乎必然要拆。业务量大了数据库要分库团队大了服务要分工一个订单系统拆成订单服务、库存服务、账户服务各管各的库。于是原来一条事务里的三步操作变成了跨服务的三次远程调用。单体事务的保护边界就是从这里开始失守的。2. 服务拆分之后ACID是怎么一步步失守的2.1 三个最要命的“跨服务”变化很多人以为服务化之后问题只是“从本地调用变成了 HTTP/RPC 调用”这是大错特错。实际变化有三个每一个都直接摧毁单库事务的根基第一跨库事务没有共同的锁管理器。单库事务里事务 A 锁住一行库存记录时事务 B 会在锁上等待数据库统一调度。拆库之后订单库的锁管不到库存库的锁谁先提交、谁先回滚数据库之间互相不知道。第二网络调用会产生“无法确认的结果”。本地事务失败就是失败数据库马上告诉你。远程调用你只能拿到三种结果成功、失败、不知道。最难受的是“不知道”——库存服务扣减成功了响应报文却在路上丢了订单服务超时重试结果扣了两次或者库存服务扣减成功了订单服务却以为失败去回滚结果库存少了订单还在。这种不确定是分布式环境特有的。第三原子性彻底丧失。单库事务里只要有一个操作失败整体回滚一切如初。拆开之后订单服务调用库存服务成功调用账户服务失败订单服务想让库存回滚但库存已经变了除非你再写一个“反向操作”把它改回去——这就是后来所有“补偿”“回滚”方案的最初动机。2.2 CAP定理与BASE分布式事务方案的底层纲领这时候就需要搬出分布式系统最著名的定理。CAP 说的是在分布式环境下网络分区Partition是必然发生的物理事实一旦发生分区你只能在“一致性Consistency”和“可用性Availability”之间二选一。很多人把 CAP 记成“三选二”这是错的。网络分区不是“可能发生”是“一定会发生”所以 P 没得选。你能选的是分区发生时是优先保证系统可用、接受短暂的不一致还是优先保证一致、拒绝部分请求。这让分布式事务方案分成了两个方向优先 C 的方案两阶段提交、TCC、分布式锁。它们努力让数据在尽可能短的时间内保持一致代价是可用性下降、并发受限。优先 A 的方案Saga、本地消息表、事务消息、对账兜底。它们允许一小段时间内数据不一致但保证最终会收敛一致。这正是 BASE 理论说的“基本可用、软状态、最终一致”。理解这条光谱后你再看任何分布式事务方案首先该问的问题都不是“它好不好”而是“它愿意牺牲哪一边换取哪一边”。3. XA/2PC的三板斧协调者的权力与代价3.1 两阶段提交到底怎么运转XA 是 X/Open 组织定义的分布式事务规范它把参与方分成两类协调者Transaction ManagerTM和参与者Resource ManagerRM数据库就是 RM。整个提交流程分成两段准备阶段Prepare协调者向所有参与者发送 prepare 指令。每个参与者在本地执行事务操作写好 redo/undo 日志但不提交把资源锁住然后向协调者回复“我可以提交”或“我准备失败”。提交阶段Commit / Rollback协调者收集所有参与者的回复。如果全部返回“可以提交”就发送 commit各参与者正式提交只要有一个失败就发送 rollback各参与者回滚。这个设计解决了“不知道”的问题正常情况下要么所有数据库都提交要么都回滚不会出现“库存已扣、订单未建”的状态。在低并发、网络稳定、数据规模可控的场景里XA 确实管用。MySQL 的 XA 事务、Oracle 的 XA 都还能用不少银行和电信的老系统至今运行在类似的机制上。3.2 为什么2PC不够用3PC也没能救场2PC 有个致命弱点同步阻塞。prepare 之后参与者的事务一直挂着行锁、连接资源全都不释放直到收到协调者第二阶段指令。如果协调者在这时候宕机了参与者既等不到 commit 也等不到 rollback只能无限期阻塞。我曾见过一个事故协调者所在容器被误删数据库里一大批 prepare 状态的事务把热点表锁了好几个小时业务直接停摆。协调者单点只是问题的一半。另一个更恶性的问题是当协调者发出 commit 后网络异常导致某个参与者没收到已提交的全局事务在这个节点上就缺失了数据状态无法统一。2PC 在“正常情况”下可以提供强一致在“网络异常”下依然救不回来。有人提出 3PC把准备阶段拆成 CanCommit、PreCommit、DoCommit并引入参与者超时机制——等不到协调者指令时可以主动中止。但 3PC 并没有真正解决问题协调者在 PreCommit 之后宕机少数参与者会执行 commit其他参与者可能 abort全局不一致的窗口依然存在复杂度反而更高。所以工业界真正跳出 2PC 的下一步不是 3PC而是把“一致性”这件事交还给业务代码的 TCC 和 Saga。4. TCC让业务代码自己来下“事务裁决”4.1 TCC的三次调用以及订单与库存走一遍TCC 的核心思想很简单既然让数据库或中间件来保证全局原子性不靠谱那就别依赖它们了把一致性交给业务代码自己设计三个阶段的接口Try、Confirm、Cancel。Try资源预占和业务检查注意不是真正执行操作。比如扣库存Try 阶段是把可用库存转成冻结库存不是直接把库存减掉。Confirm真正执行业务。把冻结库存转成实际扣减生成出库流水。Cancel释放预占资源。把冻结库存加回可用库存。用一个订单与库存的实例走一遍用户下单订单服务发起全局事务调用库存服务的 Try库存服务把“可用库存 100”变成“可用库存 99 冻结库存 1”。随后账户服务 Try 冻结了 100 元。全部 Try 成功协调者开始 Confirm库存的冻结库存清零真正扣减 1账户冻结金额转实扣。如果账户 Try 失败协调者触发 Cancel库存的冻结库存释放回可用库存订单取消。整个过程没有全局锁没有长连接被锁定的只是业务概念上的“冻结额度”数据库本身并没有挂起一个跨库事务。提示TCC 落地的一个关键点是 Try 必须做“预占”而不是把正式操作提前做完。很多人写 TCC 时直接把扣减写在 Try 里Confirm 里又是一个扣减这是典型的伪 TCC最终数据必然错乱。4.2 TCC最狠的三个魔鬼细节空回滚、幂等、悬挂TCC 看似思路清晰工程上却有三个绕不过去的坑几乎每个初上手的团队都会踩空回滚。极端时序下Try 请求因为网络拥堵迟迟没到达库存服务而全局事务已经判定超时并触发了 Cancel。库存服务收到 Cancel 时发现自己根本没有这条 Try 记录。这就是“空回滚”。处理办法是维护一张分支事务记录表以全局事务 ID 和分支 ID 为唯一键。Cancel 到达时如果找不到 Try 记录就插入一条“空回滚”标记然后直接返回成功——既不能报错也不能把根本不存在的冻结库存释放一遍。幂等控制。Confirm 和 Cancel 都可能因为网络重发、消费者重试而执行多次。如果库存的 Cancel 被重复执行冻结库存会被重复释放最终就是超卖。解决办法是比如在分支事务记录表里记录状态Try 已执行、Confirm 已执行、Cancel 已执行、空回滚已执行。每次 Confirm 或 Cancel 进来先查状态同一个分支只能从 Try 到 Confirm、或者从 Try 到 Cancel 流转一次其余重复请求直接返回成功不执行业务逻辑。悬挂。更刁钻的时序全局事务先判定超时Cancel 先到了库存服务。库存服务发现没有 Try 记录打上“空回滚”标记。结果过了一会儿那个迟到的 Try 请求又到了。如果此时直接执行 Try就会把已经“宣告终止”的事务重新拉起后面再也没有 Confirm 或 Cancel 会来了冻结库存永远挂在那里。解决思路是Try 到达时先查分支事务记录如果发现已经有 Cancel 或空回滚标记就直接拒绝保证 Try 不会在 Cancel 之后生效。这三个问题解决得好不好直接决定 TCC 是可靠方案还是事故源头。框架终究只是骨架幂等器和分支状态表的设计必须写进业务代码里。4.3 TCC的落地方式和适用边界TCC 在 Java 生态里落地相对成熟Seata 的 TCC 模式、DTM 等框架都能让业务只写三个接口由事务管理器统一编排调用。但你要有清醒的认知TCC 不是给 Controller 加三个方法那么简单。每个参与方都要天然具备“预占 / 确认 / 释放”的业务语义。像库存、余额、优惠券这类资源型操作很适合而像“发送短信”“记录日志”“调用一次外部查询”这种没有明确资源预占语义的操作硬套 TCC 反而画蛇添足。TCC 的适用边界很清晰强实时、跨异构系统、并发有上限的短期操作。比如支付环节冻结余额、库存预占、优惠券锁定这类操作往往跟钱和资源直接相关用户又要求实时反馈TCC 很合适。但如果你让它去处理一个跨了十几个服务、可能长达数天的长流程后续的 Confirm/Cancel 编排会变得极其难维护那是 Saga 更擅长的领域。5. Saga既然做不到立刻一致那就保证最终能对上账5.1 Saga原初思想的现实映射Saga 最早来自 1987 年的一篇论文作者是 Hector Garcia-Molina。它的思路和 TCC 截然不同把一个长事务拆成一串本地事务每一步都真实提交如果后面某一步失败就对前面已经成功执行过的每一步做“反向补偿”。还是订单与库存的例子。正常流程订单服务创建订单并提交调用库存服务扣减库存并提交调用支付服务扣款并提交最后发放积分。如果“发放积分”失败了Saga 不会尝试去回滚整个事务而是执行一串补偿调用支付服务的“退款”补偿刚才的扣款调用库存服务的“释放库存”补偿刚才的扣减调用订单服务的“取消订单”补偿刚才的创建。每一步都是独立的本地事务都有真实提交也都有对应的反向操作。注意补偿操作不是把 SQL 倒过来执行。扣款的补偿是“生成一条退款流水并原路退回”扣库存的补偿是“把库存加回可用库存”订单创建的补偿是“把订单状态改成已取消”。补偿本身就是新的业务操作要单独设计、单独测试。5.2 编排式与协同式两种落地路线的比较Saga 落地有两条主流路线名字很唬人但思路很简单编排式Orchestration有一个专门的“编排中心”维护整个流程的状态机它知道第一步该调谁、下一个调谁、失败后该补偿谁。所有参与者只对编排中心暴露接口即可。协同式Choreography没有中心控制者每个服务完成自己的本地事务后向消息队列发布一个事件下一个服务监听事件并触发自己的操作。补偿也靠事件链驱动。服务之间完全解耦但流程逻辑散落在各个服务里一旦链路长了排查问题简直是噩梦。我把两者的差异整理成一个对比表对比维度编排式Orchestration协同式Choreography流程控制集中于编排中心状态清晰分散到各服务事件监听调用关系编排中心与各服务调用耦合服务间通过消息解耦故障排查可从编排中心日志查全链路需要追踪事件链路相对困难补偿控制编排中心统一触发可控性强补偿也依赖事件链易出现循环触发适用场景流程相对固定、参与方有限高度事件驱动、强调服务自治我的实践经验是大部分业务项目更适合编排式。原因很简单——可观测性和可维护性胜过一切。协同式看起来很“优雅”但当一次补偿链条跨五个服务你根本不知道当前补偿到哪一步了而编排中心可以随时查询流程状态在控制台重试某个失败步骤。5.3 Saga真实项目里的几个大坑Saga 让系统“愿意接受中间状态”这在技术上释放了很多限制但业务上会带来几个棘手的现实问题。第一个是中间状态可见性。用户可能已经看到订单“已支付”几分钟后又看到“已退款”产品经理必须接受这种状态流转并且在界面和文案上设计好。如果业务要求用户任何时刻都只能看到“一致”的状态Saga 就不适合你。第二个是补偿的幂等和最终性。补偿操作同样可能被重试执行也需要幂等。更麻烦的是补偿也可能失败——库存释放接口挂了、退款通道超时了。Saga 工程上绝对不能假设补偿一定成功必须为重试设计持久化的补偿任务表并让运维可以手动干预。第三个是长流程的超时。Saga 里的每个正向事务都是真实提交如果中间某一步长时间不返回流程就卡住了。要给每个步骤设置业务超时超时后触发补偿而不是无限等待。提示如果要用 Saga建议把所有正向步骤和补偿步骤都放进一个可查询的事务状态表记录当前处于哪个步骤、执行了几次、是否已补偿、终态是什么。这个状态表是线上排查故障的第一入口。6. 最终一致性的一条野路子本地消息表与事务消息6.1 本地消息表把“消息”和“业务”同一锅炖前面讲的 TCC 和 Saga 都还在努力“协调事务”而最终一致性方案的思路更进一步既然实时保住全局一致太难那就接受一个时间窗口靠异步把账对上。本地消息表是这中间最朴素、也最容易被低估的方案。原理很直接把业务操作和一条“待发送的消息”写在同一个本地事务里。比如用户下单时订单服务的本地事务里做两件事插入订单记录同时插入一条“扣减库存”的消息记录。两条数据同库、同事务要么同时成功要么同时失败。然后由一个定时任务扫描消息表把未发送的消息发到 MQ库存服务消费消息去扣库存。为什么这能保证“消息不漏”因为消息和数据是同一事务提交的。只要订单数据存在消息记录就存在定时任务就有机会把它发出去。发送失败就稍后重试消费者处理失败也继续重试。它解决了“业务操作成功但消息没发出去”这个最要命的问题。代价也显而易见消息表要侵入业务库扫描有秒级延迟表会膨胀要定期清理消费端还要自己做幂等。但它的优点是依赖极简单不引入额外的框架很多中小团队就是靠它稳定跑了好多年。6.2 事务消息RocketMQ把扫描定时任务搬进了BrokerRocketMQ 的事务消息本质上就是“把本地消息表搬到 Broker 端”免去你手动建表和扫描。它的流程是生产者发送一条half message半消息这条消息暂时对消费者不可见。生产者在本地执行真正的业务事务比如创建订单。本地事务成功发送 commithalf message 变成可见消息消费者开始消费本地事务失败发送 rollback消息被丢弃。如果 Broker 迟迟没有收到 commit 或 rollback比如发送方宕机了Broker 会主动向生产者发起回查check back生产者根据本地事务的真实状态返回 commit 或 rollback。这个设计的精妙之处在于消息的最终可见性与本地事务的成功与否绑定而本地事务和消息的“发送”又通过回调机制保持了一致。相比自建消息表你不用自己写定时扫描任务也不用担心消息表膨胀。但前提是你的 MQ 得支持事务消息目前 RocketMQ、RocketMQ 系和部分云厂商的 MQ 都能做到。6.3 最终一致方案必备的“对账与幂等”双保险我必须给所有想用最终一致性方案的人提个醒本地消息表和事务消息都只保证“消息不会丢”从来都不保证“消息不会重复”。消费者可能收到同一条消息两次可能因为消费超时被重新投递也可能因为上游重试收到重复的操作指令。所以上了最终一致方案消费端的幂等是硬性要求不是可选项。最常用的办法是在消费端用“业务唯一键 处理状态”做去重。比如库存服务记录“订单ID 扣减动作”的处理流水表重复消息来了先查流水发现已经处理过就直接返回成功。此外无论哪种方案我建议都给核心链路配一个定时对账任务定期扫描一段时间内的订单和库存流水把“终态不清”的记录捞出来人工处理。这不是多此一举而是分布式环境下最后的兜底防线。7. 方案选型不是技术选美聊聊我见过的正确与错误用法7.1 动手之前先回答三个问题每当有人问我“分布式事务到底该选 TCC 还是 Saga”我都会先反问三个问题很多人在这一步就已经想明白了一大半第一你真的需要分布式事务吗很多场景根本不需要。如果订单和库存还能合在同一个库里或者把某些写操作改成异步合并那就别引入任何分布式事务方案。少一个方案就少一大类故障。第二业务能容忍多长的不一致窗口支付金额、余额这种用户盯着看容忍度低偏向 TCC订单积分、消息通知这种晚几秒甚至几分钟能对上就行偏向 Saga、本地消息表。第三团队能维护哪种复杂度TCC 要求每个业务方都理解 Try/Confirm/Cancel 语义要处理空回滚、幂等、悬挂Saga 要求设计补偿流程和状态表消息方案要求消费端幂等和对账。没有对应的兜底能力选再“高级”的方案也是给自己埋雷。7.2 方案对比与选型参考把常见方案摆在一张表里方便你在评审会上直接翻方案一致性特点核心机制主要代价适用场景XA/2PC同步强一致协调者统一提交回滚长锁、低吞吐、协调者单点低并发跨库、局域网内强一致TCC业务层近似实时一致Try预占 Confirm/Cancel业务改造重三接口三状态资金、库存、优惠券等短操作Saga最终一致带补偿正向提交 反向补偿中间状态可见补偿逻辑复杂长流程、多服务、跨系统本地消息表/事务消息最终一致异步收敛同库事务 异步投递消费幂等必须做透对时间窗口不敏感的通知、积分、状态同步7.3 我见过的几类典型翻车现场这些年我见过不少项目在选型上栽跟头总结下来其实是几个共性问题最常见的是把 TCC 用在不该用的地方。比如做一个用户签到送积分本身完全可以用消息异步领取结果团队为了“体验一下新框架”上了 Seata TCC积分接口硬拆成三个方法游累了一批人。这类场景的业务量根本用不到 TCC 级别的保证。第二个是用了本地消息表但忘了消费幂等。这是我在不少团队里反复强调的事故点。消息表保证了“不丢”但库存服务消费时没有做按订单去重一次网络重放就把库存扣了两次结果大促前对不上账查了一周才发现是这种低级的重复消费。第三个是用 Saga 却要求用户永远看到一致状态。有个做生鲜电商的项目用户下单后看到“已支付”没过多久订单变成了“已取消并退款”客诉飙升。根源不是 Saga 有问题而是产品根本没有为中间状态设计体验。最终项目组临时加了状态提示文案才把体验问题压下去。还有一个共通的教训没有对账兜底。分布式事务方案做得再完整也会因为人工干预、极端网络、第三方接口异常留下少量“终态不清”的记录。没有对账脚本和人工处理流程这些记录就会变成定时炸弹。我经手的项目里凡是稳定运行超过一年的核心链路背后必有一套对账系统这是铁律。最后分享一点个人经验无论你最终选了哪个方案先做两件事——给所有关键写操作加上幂等键把“查询状态”和“提交动作”分开设计。我见过太多项目匆匆接入事务框架最后发现救不了“本来就不该依赖事务”的混乱逻辑。分布式事务的真正价值是让那些实在无法避免的跨服务写操作在牺牲一部分实时一致性的前提下把数据不确定性管起来。如果你真的理解了上文可以试着自己回答一个问题把下单流程拆成三个服务、不借助任何框架你会怎么设计才能保证库存不超卖、订单不丢失想清楚这个问题再看任何分布式事务方案都会轻松很多。