
很多年过去我依然记得那个深夜订单服务创建订单成功库存服务扣减库存失败但订单已经写入了数据库。用户在页面上看到“下单成功”仓库里却永远找不到这件货。那一晚之后我开始认真研究分布式事务也踩过了后面几年所有的坑。这篇文章我想把这些年积累的东西一次讲清楚。标题叫“从单体事务到 TCC、Saga 与最终一致性”听起来像一个学术清单但我想说的不是背诵概念而是这条演进链背后每个方案解决什么问题、引入了什么新麻烦、以及你在真实项目里该怎么选。无论你是正在处理“订单与库存分布式事务”的初级后端还是已经见过生产事故的团队负责人这篇内容应该都能给你一些参考。先说结论分布式事务没有银弹每条路都是拿一部分一致性换另一部分可用性。理解这条演进链本质是理解每个方案的取舍边界在哪里。1. 先聊单体事务为什么它能“一锤定音”很多人一上来就扎进 TCC、Saga却忽略了单体事务这个起点。这不对。不看懂单体事务的强与弱你根本理解不了后面所有方案要解决什么。1.1 单体事务的基石ACID 是如何工作的单体事务说的是在一个数据库实例内完成的事务。它的核心保证是 ACID原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。数据库实现原子性和持久性的底层利器是 undo log 和 redo log。undo log 记录“怎么改回去”用于事务回滚redo log 记录“怎么重放”用于宕机恢复。MySQL 的 InnoDB 引擎里一条 UPDATE 语句的执行路径是先写 undo log再改内存中的数据页同时写 redo logWALWrite-Ahead Logging最后在事务提交时把 redo log 刷盘。这套机制保证了即使在事务执行到一半数据库崩溃重启后也能通过 redo log 恢复已提交的数据用 undo log 把未提交的数据回滚掉。你可以把它理解成写日记每次做重要决定之前先把“我准备干什么”记录下来万一中途出事翻日记就能恢复现场。而隔离性靠的是锁机制。行锁、间隙锁、MVCC 多版本并发控制共同决定了事务之间的可见性。这里有一个新手常踩的坑把隔离级别当成纯理论没有意识到“读已提交”和“可重复读”在生产环境下的并发行为差异巨大尤其是间隙锁导致的死锁问题排查起来非常头疼。1.2 单体事务的边界它帮不了你的时刻单体事务再强也只在一个数据库实例里有效。一旦你的架构拆分成了订单库、库存库、用户库一个业务操作要跨两个库写数据单体事务就立刻失效了——因为 MySQL 的本地事务无法跨连接、跨库协调提交。这是分布式事务问题的根源原子性需要跨进程协调而跨进程协调就意味着网络通信网络通信最大的特点就是“会失败而且你不知道对方到底有没有成功”。我见过不少团队在这个阶段尝试“硬撑”。他们用分布式数据库中间件把多个库逻辑上合并成一个分库分表集群试图让一条 SQL 跨库执行然后继续用本地事务。这种方案在小规模流量下能运行但一旦涉及复杂的分片键设计查询性能、跨分片 join、分布式主键全都变成新的难题。更麻烦的是分库分表解决的只是“存储的分布”并没有解决“业务的跨服务调用”你依然要面对订单服务调用库存服务的那个网络不确定性。所以单体事务的边界在哪一句话在同一个数据库实例内的数据一致性它全能管跨了实例、跨了服务、跨了网络它只能靠边站。2. 分布式环境下的事务难题问题到底发生在哪一层理解了单体事务的边界下一个问题是分布式事务为什么这么难难在哪个层面我用一次真实的下单流程来拆解。2.1 分布式带来的三堵墙网络、状态与时钟想象一个最典型的“订单与库存分布式事务”场景。用户下单订单服务往订单库插入一条订单记录然后调用库存服务的接口扣减库存。两个操作必须同时成功或同时失败。第一堵墙是网络不确定性。请求从订单服务发出经过负载均衡、网关、物理链路到达库存服务。在这个过程中可能出现的失败包括TCP 连接超时、服务端处理超时后客户端才感知、响应包丢失但服务端其实已经执行成功。最棘手的就是最后一种——“响应超时”不等于“执行失败”你根本无法确定库存到底扣没扣。第二堵墙是状态隔离。订单服务有订单库库存服务有库存库谁也不认识谁。两个服务无法直接查看对方的缓存、锁和事务上下文。协调两个独立状态的变化必须引入一个第三者来裁决而这个第三者本身就是新的故障点。第三堵墙是时钟不同步。两台机器的系统时间不可能完全一致你用“时间戳先后顺序”来判断事件因果在很多分布式场景下是不可靠的。虽然日常业务里这一点表现得不明显但在设计分布式锁、幂等判断、事件排序时它会在你最不经意的时候给你来一下。2.2 CAP 与 BASE一个不得不提的底层视角讨论分布式事务绕不开 CAP。C一致性、A可用性、P分区容错性三个不可能同时满足。这里大家总在争论“到底舍弃 C 还是 A”我要说的是在分布式系统里网络分区不是“可能发生”而是“必然发生”所以 P 是必选项。你能选的其实是在分区发生时你是要返回旧数据AP还是拒绝服务等待恢复CP。BASE 思想是对 AP 这条路的一种实践总结Basically Available基本可用、Soft state软状态、Eventually consistent最终一致性。它强调不需要每时每刻都强一致只要系统在一段时间后能收敛到一致状态即可。分布式事务所有后面的方案本质上都是在“强一致”和“最终一致”这两个端点之间找位置。这里我想补充一个自己的理解事务方案的演进不是为了把“最终一致”变成“强一致”而是为了让“最终一致”变得可控。可控的意思是多久收敛、失败怎么恢复、有没有兜底机制。脱离这三个问题谈方案都是纸上谈兵。3. 2PC 与 3PC第一代分布式事务方案的功与过从单体迈向分布式最早被搬上舞台的是两阶段提交2PC。直到今天很多成熟的分布式事务框架依然保留着它的基因比如 Seata 的 AT 模式实际上就是改良版的 2PC 思路。3.1 两阶段提交的完整流程与角色分工2PC 有两个角色协调者Coordinator和参与者Participant。以订单服务与库存服务为例第一阶段Prepare协调者问所有参与者“你们能不能提交这个事务”参与者各自执行本地事务但不提交把资源锁住然后返回“我准备好了”或者“我做不到”。这个动作在数据库层面通常对应 XA 协议的 prepare 操作。第二阶段Commit/Abort协调者收到所有参与者的投票后做决策。如果所有人都准备好了就发 commit 指令让所有人提交只要有一个人没准备好就发 rollback 指令让所有人回滚。这套机制保证了分布式场景下的事务原子性。它的数学模型是清晰的逻辑是严谨的。3.2 2PC 的致命弱点协调者单点与阻塞但你在生产中很快就会遇到 2PC 的三宗罪。第一宗罪是同步阻塞。参与者在 prepare 阶段要锁住资源直到协调者发出第二阶段的指令。锁住多久取决于协调者多快做出决策。如果协调者处理慢甚至宕机了所有参与者的事务资源全部被锁死业务吞吐量直接归零。而且这个锁还是无法自行超时释放的——因为参与者不知道是否需要回滚它只能等。第二宗罪是协调者单点故障。协调者宕机不仅所有在途事务卡死更致命的是它宕机前如果已经发出了部分 commit而后续指令丢失就会出现“一部分参与者提交了另一部分回滚了”的不一致状态。这完全背离了事务的目标。第三宗罪是脑裂风险。当协调者恢复后它需要重新询问所有参与者的状态来恢复决策但如果部分参与者此时不可达它就无法判断事务的最终结果。那 3PC 解决了什么3PC 把两阶段拆成了 CanCommit、PreCommit、DoCommit 三个阶段引入了参与者超时机制允许参与者在等待协调者指令超时后自行决策中止事务。它降低了 2PC 的阻塞时间但代价是引入了新的状态复杂性。更重要的是3PC 依然没有解决“网络分区时各参与者自行决策可能产生不一致”的问题。所以业界很少看到 3PC 的大规模生产落地它更像一个理论上的演进节点。3.3 生产环境里的 2PC 实践XA、Seata AT 与全局锁在实际落地层面2PC 最常见的形态是 XA 协议。MySQL、Oracle 等数据库原生支持 XAJava 侧的 JTA 也基于 XA。你写一个跨库事务用 JTA 管理两个数据源底层就是 XA 协调者去调数据库的 prepare、commit、rollback。但 XA 有两个很明显的问题一是要求数据库层面必须支持 XA对 NoSQL、缓存、MQ 这类组件无能为力二是协调者通常是应用服务器必须长时间持有事务上下文资源开销大在高并发下扛不住。国内 Java 圈更熟悉的可能是 Seata 的 AT 模式。AT 模式的核心思路是框架自动记录事务执行前后的数据镜像undo_log在全局提交时把本地事务统一提交在全局回滚时根据 undo_log 反向补偿。它的优势是侵入性小基本不需要改业务 SQL代价是框架需要在数据层面加全局锁防止其他事务并发修改同样的数据这个锁在高并发写场景下很容易变成性能瓶颈。我用 Seata AT 模式在生产环境跑过一个中低并发的订单流程整体稳定但到了大促峰值全局锁引起的等待会导致接口 RT 暴涨。后来我把那个场景改成事务消息 异步补偿才真正消掉瓶颈。这个经验让我意识到2PC 系方案更适用于并发量可控、事务执行时间短的场景而不是高吞吐场景的银弹。4. TCC把事务拆到业务层的人工补偿艺术如果说 2PC 是数据库层面的通用方案那 TCC 就是业务层面的手工精修。TCC 的全称是 Try-Confirm-Cancel。它把每个事务操作显式地拆成三个动作Try、Confirm、Cancel。4.1 TCC 的三个阶段与一次下单实例还是以“扣库存”为例。假设库存服务的账户上有 100 件商品用户要买 3 件。Try 阶段库存服务冻结 3 件商品不是直接扣减而是把可用库存从 100 变为 97已冻结库存变为 3。这个操作不改变最终数据状态只是预留资源并且把预留信息持久化到一张冻结单里。Confirm 阶段如果订单服务、库存服务、账户服务都 Try 成功协调者就通知库存服务执行 Confirm。Confirm 把冻结的 3 件真正扣减掉可用库存 97不变已冻结库存 3变为 0同时把冻结单标记为已确认。Cancel 阶段如果任何一个服务的 Try 失败协调者通知所有已 Try 成功的服务执行 Cancel。库存服务把冻结的 3 件释放可用库存从 97 恢复到 100已冻结库存变为 0冻结单标记为已取消。你看TCC 是完全把数据操作的计算和控制权交还给业务方每个服务自己决定如何预留资源、如何确认、如何补偿。这给了你极大的灵活性你可以对缓存、MQ、NoSQL 等非数据库资源做事务控制这是 XA 做不到的。4.2 TCC 的三个经典难题空回滚、幂等、悬挂但灵活性都是有代价的。TCC 落地时你会撞上三个非常具体的坑。第一个坑空回滚。如果某个服务的 Try 方法因为网络超时没有执行但协调者判断事务失败向它发起了 Cancel那么这个 Cancel 就应该被正常执行因为它可能实际执行了 Try 只是响应丢了。但如果你处理的资源根本没有被冻结过Cancel 里的“释放冻结”操作就会把不存在的冻结单当成正常的去释放导致数据异常。解决方法是在业务表里维护事务状态Cancel 先查状态没有对应记录就视为“已回滚”不再执行后续动作。第二个坑幂等性。Confirm 和 Cancel 在网络重试时可能被调用多次。假设 Confirm 被调用了两次第一次扣减了冻结库存第二次如果没做幂等判断就会把已经扣完的冻结单再扣一遍变成负库存。解决办法通常是给事务表加唯一约束用事务 ID 做去重。第三个坑悬挂。当 Try 请求比 Cancel 请求慢已经 Cancel 了但 Try 才到达就会出现“不该预留的资源被预留了”的情况。这种悬挂问题最难排查因为它考验的是时序。业界常见做法是为每次事务生成全局唯一 ID在 Try 入口校验事务状态发现事务已 Cancel 就直接拒绝执行。4.3 TCC 到底适合什么场景TCC 适合的场景是短事务、高一致性要求、且你能控制每一个参与的业务的场景。比如资金类操作转账、退款、冻结、解冻这类操作天然就有“预扣”和“冲正”的语义跟 TCC 的模型非常契合。不适合的场景是长链路、涉及外部系统无法改造为 TCC 的情况。想象一下你的订单流程要调微信支付你不可能让微信支付跟着你玩 Try、Confirm、Cancel。对外部系统的调用只能用 Saga 那样的事件补偿或者干脆走最终一致性。我在设计 TCC 时踩过的最大的坑是“锁粒度”。Try 阶段如果直接把整行数据锁住并发量一大就会互相等待。后来我改成在 Try 阶段只锁定“冻结单据”这一行而不是锁业务主记录同时用乐观锁版本号来控制并发更新性能和正确性才同时保住。这个细节如果你只读理论文档永远想不到。5. Saga面向长事务的最终一致协奏曲当业务链路很长——比如一个下单流程涉及订单、库存、优惠券、积分、物流预占——2PC 和 TCC 都会因为锁的累积和业务流程的复杂度而变得异常笨重。这时候就该 Saga 上场了。5.1 Saga 的两副面孔编排与协同Saga 的核心思想是把一个长事务分解成一系列本地短事务每个本地事务完成后就释放资源并提交。整个过程不持有全局锁。如果某一步失败了就依次执行之前各步骤的反向补偿操作。Saga 有两种实现模式。编排模式Orchestration里有一个中央协调器它负责告诉每个参与者“现在该你执行了”“失败了现在该你补偿了”。事务的流程逻辑集中在一个地方好理解、好监控。协同模式Choreography没有中央协调器服务之间通过事件驱动A 执行完发事件B 监听到事件后执行失败时同样通过事件反向触发补偿。这种方式去中心化但流程散落在各服务里系统越大越难看清全貌。我在实际项目中更推荐编排模式尤其是团队新人多的时候。中央协调器虽然看起来多了一个组件但它给了你一个集中的地方做日志、做重试、做告警。协同模式的“事件链不可见”问题会让你在线排查问题时崩溃。5.2 反向操作的设计不只是减回去那么简单很多人以为 Saga 的补偿就是“把操作反过来执行”。比如扣了库存补偿就加上库存。但真实世界的补偿远没有这么简单。举一个我印象深刻的例子一个旅行预订系统Saga 流程是“订机票 - 订酒店 - 扣款”。第三步扣款失败时需要补偿前两步。订机票的补偿是“退票”。但退票不一定是原路退回全款它可能根据退票时间产生手续费。更复杂的是如果第一步机票已经出票、值机了补偿可能根本没法“取消”只能变成“改签”或者“挂起待人工处理”。这要求你在设计 Saga 时不仅要为每个步骤设计主操作还要设计一套“状态机”来描述每个步骤的中间态已开始、已成功、补偿中、补偿完成、补偿失败。任何一个补偿失败都需要有告警和人工介入通道。补偿操作的幂等性同样重要。补偿消息可能被重复投递补偿逻辑必须能容忍重复执行。我在做 Saga 状态机时会把每个步骤的执行结果落库用一个状态表记录“哪个步骤、什么状态、执行过几次”重复请求先查状态已经补偿过就直接返回成功。5.3 Saga 的不确定性哪些已提交的事务应该被撤销Saga 还有一个容易被忽视的问题事务的边界不是由数据库决定的而是由业务语义决定的。举个例子Saga 流程执行到第三步失败时第一步和第二步已经成功并“提交”了。从数据库角度看它们是持久化的但从业务角度看如果第三步无法完成第一步和第二步的结果对用户没有意义就需要被撤销。这个“撤销哪些已成功步骤”的判断必须由 Saga 协调器维护一个事务台账来管理。台账设计建议至少包含全局事务 ID、每个参与步骤的标识、每一步的执行状态、补偿状态、重试次数、最后更新时间。每一次状态变化都要有审计日志。出了问题你可以拿着全局事务 ID 从头到尾复盘。Saga 比较适合的领域是长流程、多系统、可容忍暂时不一致的场景。比如下单后经过审核、出库、配送整个过程持续几十分钟甚至几天这种长事务根本不可能用 TCC 的全局冻结只有 Saga 能承载。6. 本地消息表与事务消息异步化的最终一致性如果说 2PC 系是“强一致”的执念TCC 和 Saga 是“业务补偿”的艺术那“最终一致性”就是拥抱现实的选择既然强一致这么贵我就接受短暂的不一致通过消息机制让它最终收敛。6.1 本地消息表最朴素也最可靠最终一致方案本地消息表的思路特别朴素在一个本地事务里同时写业务数据和一条消息记录。比如订单服务在创建订单时在一个数据库事务里执行两条操作insert into order订单表和 insert into message消息表状态为“待发送”。因为这两条 SQL 在同一个库里走本地事务所以它们要么同时成功要么同时失败。这叫“业务操作与消息记录的原子性”。然后由一个定时任务定时扫描消息表里“待发送”的记录把消息投递到 MQ。投递成功后把消息状态改成“已发送”。消费方比如库存服务收到消息后执行扣库存逻辑执行成功后返回 ACK消息状态变成“已消费”。这个方案看起来简单但它有几个隐藏的坑。第一个坑定时任务扫表投递消息量和数据库压力成正比消息多了扫表就成了性能瓶颈。第二个坑消费方一定要做幂等因为消息可能被重复投递。你需要一张消费记录表或者用 redis setnx 做去重否则库存会被重复扣减。第三个坑一旦消费失败消息一直停留在“已发送”状态定时任务必须设置重试机制和最大重试次数的告警超过阈值进入人工处理队列。我见过很多团队做到一半就停在这里理由都是“定时任务扫表太土了”。土不土不重要它在没有引入 MQ 事务消息的团队里是性价比最高的方案。它不需要任何中间件额外的能力只要你有数据库和定时任务就能搭起来。6.2 RocketMQ 事务消息更优雅的同库保证RocketMQ 的事务消息把“本地消息表”的原子性保证挪到了 MQ 内部。它的工作流程是这样的发送方先把消息发到 RocketMQ此时消息处于“半消息half message”状态消费者不可见。发送方接着执行本地事务订单入库。本地事务执行成功后发送方向 MQ 发送 commit让半消息变成正常消息消费者可以消费了。如果本地事务执行失败发送方发送 rollbackMQ 把半消息删除。万一发送方在本地事务执行完但还没来得及发送 commit/rollback 时就宕机了怎么办这时 RocketMQ 会调用发送方提供的“回查接口checkLocalTransaction”询问这条消息对应的本地事务到底成功了没有。发送方查一下订单表如果订单存在就 commit不存在就 rollback。这就是事务消息最核心的可靠性保证。这个机制解决了“业务操作和消息发送”的原子性问题实现上比本地消息表更干净。但它同样要求消费方做幂等并且依赖 RocketMQ 中间件。如果你用的是 Kafka 或者 RabbitMQ默认没有事务消息能力要自己做或者退回到本地消息表。6.3 最终一致性方案的兜底手段与监控不要以为上了消息机制就万事大吉。最终一致性方案里最容易被忽略的一件事是消息链路里的数据对账。消息从发送到消费中间任何一环都可能出问题消息丢失、消费失败、回调失败、重复投递。你需要一套对账系统。我的做法是每天凌晨跑一个对账任务比对订单表里已完成但订单状态是“初始”的记录去消息表或者消费记录里查对应消息的处理状态找出不一致的数据手动修复。另外消息积压监控很重要。你需要在监控大盘里加上每个 Topic 的消费延迟曲线。一旦某个 Topic 消费延迟持续增长基本可以断定有消费端在报错立即触发告警而不是等到用户反馈才处理。最终一致性方案适合的场景是并发量高、实时性要求不高、对短暂的不一致容忍度较高的业务。比如订单超时取消、积分发放、短信通知。订单金额、库存这种核心主链路如果你能接受几秒的延迟最终一致性也够用如果要求必须实时强一致那还是别省这个事TCC 或 2PC 更合适。7. 演进链不是升级链方案选型的底层判断框架把这条演进链从头看一遍你会发现每往后走一步都是在用一致性换取扩展性和性能。很多人会把它当成一个“升级路径”单体事务不够用了就上 2PC2PC 不行那就上 TCCTCC 太复杂不如搞 Saga最后统一搞最终一致性。这个认知大错特错。7.1 先澄清一个同名概念的坑写这篇文章前我看到热搜词里“TCC”和“WDDM”被放在一起。这里必须提醒一句如果你搜索的是“大模型跑不动”“v100 显卡改成 TCC 模式还是 WDDM 模式”你其实进入了另一个完全不同的领域。显卡的 TCCTesla Compute Cluster模式是一种 GPU 计算模式和分布式事务的 TCCTry-Confirm-Cancel只是缩写撞车了。把这两个概念混在一起很容易让你在技术选型时抓错方向。做分布式事务选型请务必确认上下文是“事务”而非“显卡”。7.2 一张表看透怎么选我这里整理了一张选型对照表按业务特征来决策。维度2PC / XA / Seata ATTCCSaga本地消息表 / 事务消息一致性强度强一致强一致业务层保证最终一致最终一致事务时长短短长长锁资源全局锁高并发下严重预占用粒度可调不持有全局锁不持有全局锁侵入性低框架自动高需要三个方法中需设计补偿低消息模型跨外部系统不支持难支持支持支持实现复杂度低高中高低典型场景同构数据库短事务资金冻结、转账旅行预订、审核流订单超时、积分、通知如果你的业务要求实时强一致、事务又是短链路2PC 系最合适如果链路中涉及资源预占能明确拆成三个阶段TCC 最合适如果链路很长、跨系统很多、可以容忍最终一致Saga 最合适如果只是简单的“写业务数据 发消息”消息事务即可。7.3 我自己的选型经验与踩坑记录最后想说说这些年我的项目体会。最开始做电商订单时我迷信强一致直接上了 Seata AT。结果大促一来全局锁导致下单 RT 从 30ms 飙升到 500ms差点压垮数据库。后来我把订单主链路改成了“本地事务 消息通知”订单库存允许 3 秒内的不一致核心的金额操作才保留 TCC。那一次之后我对“演进链”的理解彻底变了。方案没有好坏只有合不合适。你发现没有所有分布式事务框架最终都在解决两件事一是让“主观上不确定”的操作变得可追踪二是把“补偿/重试”的过程做成闭环。无论选哪一个方案你都应该问自己三个问题这个操作失败后我怎么发现发现了怎么恢复恢复不了怎么兜底如果这三个问题都有明确答案那你的分布式事务方案就算合格了。至于用哪种模式那是你的口味问题。