
服务拆开以后“数据一致”这件事就从数据库单机问题变成了跨进程问题。这次花了两周时间把分布式事务中间件 Seat 从原理到落地完整啃了一遍线上不敢直接上先在本地搭了一套模拟链路把回滚逻辑跑通。这篇把核心概念、设计思路、实操步骤和排查经验都写出来给准备接分布式事务或者正在看这块源码的同学一个参考。开头先说明一个习惯我在项目里把这个中间件统一叫 Seat正式文档里大家通常按 Seata 来拼这里用代号只是为了学习笔记里方便区分不影响你看源码和配置。1. 分布式事务学习先搞清楚为什么需要 Seat 这个角色想理解分布式事务得先回到“事务为什么会失效”这个起点。单体时代订单、库存、账户都在同一个数据库里三步操作放进一个本地事务要么全成功要么全回滚数据库的 undo 日志能把中间状态擦干净。但微服务拆分后订单服务、库存服务、账户服务各自连接不同的数据库甚至用不同的数据库产品原本由一个数据库管理的一整套逻辑现在分散到多个物理资源上。此时没有任何一个数据库能看到全局状态也没法替其他库做回滚。1.1 本地事务解决不了的是跨资源协调很多人第一次接触分布式事务时会先想到“把多个 SQL 塞进同一个事务里不就行了”。在一个数据库连接里这么做没问题但拆库之后每个服务自己管理一套连接池服务之间的 SQL 完全隔离。举例一个下单场景里订单库先扣库存再扣用户余额。扣库存成功、扣余额失败此时订单库里已经写入了库存扣减记录但账户库没有任何变化。如果直接在订单服务里抛异常回滚只能回滚库存这条记录无法通知账户服务做任何动作。更麻烦的是两个库之间还有可能存在网络超时、服务重启等中间态这种“半成功半失败”的状态需要靠一个专门的角色来协调。Seat 这一类中间件给出的答案是引入一个独立的全局事务协调者它记录每个参与方的状态并在某一方失败时对所有参与方执行补偿操作。这样就把“跨库不可控”转成了“由协调者统一调度”。1.2 CAP、BASE 和“最终一致”的取舍要理解 Seat 的设计绕不开 CAP 理论。CAP 说的是分布式系统里一致性、可用性、分区容错性三者不可兼得。网络分区一定会发生所以 P 必须保留剩下的只能在 C 和 A 之间取舍。传统数据库追求的是强一致事务提交后立刻对所有读操作可见。分布式场景里如果强一致就必须在写入时锁住所有节点牺牲可用性。所以要选最终一致允许一段时间内数据不一致但通过补偿机制最终所有节点都会回到一致状态。BASE 理论里的“软状态”和“最终一致”就是 Seat 这类方案的基石。理解了这个背景再看 Seat 的工作模式就清楚很多。它做的事情可以概括为用一个全局事务号串联所有参与分支提交阶段尽量保证业务操作的原子性失败阶段通过补偿把已经执行过的分支恢复原状。它不是为了消灭分布式问题而是把分布式问题收敛到了协调者身上。2. 核心设计思路拆解Seat 的角色划分和三种事务模式Seat 的整体设计非常清晰核心就三个角色事务协调器、事务管理器、资源管理器。把这三个角色理解透看源码和配置都会顺畅很多。2.1 三个角色各司其职TC 管状态TM 发指令RM 管分支事务协调器TC是全局事务的大脑负责接收全局事务的注册、分支事务的注册记录事务状态并在全局提交或回滚时向各个分支下发指令。它本身不执行业务代码只维护一张全局事务状态表。事务管理器TM在业务方这一端通常由全局事务发起方使用。发起方通过注解或 API 开启一个全局事务拿到全局事务 XID然后这个 XID 会通过调用链传递到各个下游服务。TM 负责告诉 TC 我这个事务准备提交还是回滚。资源管理器RM是真正操作业务数据的角色。每一个参与全局事务的服务都是一个 RM它管理自己所在数据库资源的分支事务并向 TC 注册分支事务。RM 收到提交或回滚指令后执行对应的资源操作。为了便于记忆可以把它们类比成一个项目团队TM 是项目经理负责启动项目和最终拍板TC 是项目协调办公室负责跟进所有任务状态RM 是具体执行任务的人每个任务的结果都要上报协调办公室。业务代码只需要在入口处标记“这是一个全局事务”剩下的协调交给中间件。2.2 AT 模式全局锁加 undo_log 的补偿逻辑AT 模式是 Seat 最常用、也是侵入性最低的模式。业务开发者不需要写复杂的补偿代码只需要在调用入口加一个全局事务注解再配置好数据源代理即可。AT 模式的原理可以拆成两个阶段。第一阶段执行本地业务 SQL 时中间件会拦截 SQL生成快照并记录到 undo_log 表同时向 TC 注册分支事务。第二阶段TC 收到全局提交指令后各分支删除 undo_log 并释放全局锁如果收到全局回滚指令各分支根据 undo_log 里的前镜像数据逆向生成补偿 SQL把数据恢复到执行前的状态。这里的关键是全局锁。AT 模式在操作数据时会对涉及的行加一个全局锁防止多个全局事务同时修改同一行数据造成脏写。等等我这个描述可以再补充一个细节undo_log 表必须与业务表在同一个数据库里回滚操作才能在本地事务中完成保证了补偿本身的原子性。所以配置 AT 模式时数据源代理是必须的它负责在 SQL 执行前后自动写 undo_log。AT 模式适合的场景是单体应用向微服务演进过程中的平滑过渡尤其适合大多数 CRUD 操作不需要业务代码配合改造。但它的代价是引入了全局锁吞吐量会受一定影响高并发场景需要评估全局锁等待时间。2.3 TCC 模式把“预留”从业务系统中真正立起来TCC 模式对应的是 Try、Confirm、Cancel 三个阶段是业务侵入性很强但控制力最强的模式。简单说每个参与分支都要实现三个方法预留资源、确认提交、补偿回滚。TCC 的思路是在业务开始前先做资源预留。比如扣减余额时Try 阶段把余额从账户可用余额转到冻结余额Confirm 阶段把冻结余额真正扣走Cancel 阶段把冻结余额释放回可用余额。通过“冻结”这个中间状态避免了直接扣减后再回滚的麻烦。与 AT 模式相比TCC 不需要 undo_log也不依赖数据库全局锁它对数据库资源的占用时间更短更适合长事务或跨系统调用复杂的高并发场景。但缺点也明显每个接入方都要维护三套逻辑还要保证 Try、Confirm、Cancel 之间的幂等性。比如网络超时后Confirm 可能被重复调用多次必须保证第二次调用不会重复扣款。初次接触 TCC 的同学容易把“Try 里直接更新业务数据”当作预留。这是最常见的错误。Try 阶段如果直接把余额扣掉了Cancel 阶段再去加回来看起来也完成了补偿但这样会导致两个问题一是中间状态对业务方可见用户会看到余额变少但又未完成支付二是如果多个事务并发操作同一账户没有冻结逻辑就会出现负数余额和脏写。所以 TCC 的核心不是“我加了 try 方法”而是“预留状态是否真正隔离”。2.4 Saga 与 XA 模式长事务和强一致的边界场景Saga 模式适合流程长、链路多的业务。它把一个全局事务拆成多个本地事务每个本地事务完成后都更新状态如果某个环节失败则依次反向调用前面环节的补偿操作。Saga 没有全局锁也不需要预留资源因此对数据库压力很小适合像订票、长途物流这类长时间运行且必须保证最终完成的业务。XA 模式则是另一种极端它依赖数据库原生的分布式事务协议。通过数据库连接层直接做两阶段提交强一致适合对一致性要求极高、性能压力不大的场景。但 XA 对数据库版本有要求而且事务时间越长锁定的资源越久所以实际项目里用得不多。对比来看AT 适合快速接入TCC 适合高并发强管控Saga 适合长流程XA 适合强一致低并发的特殊场景。没有哪个模式绝对最优只有适不适合业务场景。刚开始学分布式事务时建议先从 AT 模式入手理解框架的补偿思想之后再去看 TCC 和 Saga否则容易被各种名词绕晕。3. 实操复盘用 Seat 跑通下单扣库存理论看再多不落地跑一遍总会觉得不踏实。我本地搭建了一套模拟链路包含订单服务、库存服务、账户服务和一个 TC 服务中间用 HTTP 调用串联模拟下单并支付的过程。3.1 搭建一整套能本地复现的模拟链路我用的技术栈是 Spring Boot 加 MyBatis-Plus数据库选择 MySQL三张业务表分别存放在三个独立数据库中。订单表、库存表、账户表各自独立模拟真实微服务的分库场景。先在所有业务数据库里各建一张 undo_log 表这是 AT 模式回滚的核心。建表语句可以参照官方文档注意字段要包含 branch_id、xid、context、rollback_info 等。这一步漏了启动时不会报错但真正发生回滚时会报“找不到 undo_log 表”非常坑。然后在每个需要参与全局事务的服务里引入依赖并把数据源配置改为代理数据源。核心配置如下seat: registry: type: file config: type: file tx-service-group: my_test_tx_group service: vgroup-mapping: my_test_tx_group: seat-server这里配置了两个关键点事务分组要统一所有服务的 tx-service-group 名称保持一致服务端注册地址要能被各个服务访问到。我本地直接把 TC 服务跑在默认端口业务服务通过本地网络访问。数据源代理也很关键。AT 模式必须让中间件拦截 SQL 才能生成 undo_log所以要把原来的数据源包装一层代理。我用的是默认的代理方式配置好后所有通过 MyBatis 执行的操作都会被接管。3.2 关键代码与执行流程全局事务入口放在订单服务的下单方法上GlobalTransactional(name create-order, timeoutMills 60000) public void createOrder(OrderRequest request) { orderMapper.insert(buildOrder(request)); stockClient.deductStock(request.getProductId(), request.getCount()); accountClient.deductBalance(request.getUserId(), request.getAmount()); }三个服务的方法都是普通业务方法。启动订单服务后调用下单接口会发现日志里出现了注册全局事务和注册分支事务的输出。这说明 TM 已经向 TC 注册了全局事务 XIDXID 也通过调用链传递到了库存服务和账户服务。走查一下成功路径库存扣减成功、余额扣减成功全局事务正常提交三个库的数据同时生效。从 TC 控制台看事务状态会从 Begin 变到 Commit。这里要注意提交不是三库同时提交而是 TC 下发提交指令后各自异步执行最终一致不等于同时一致。3.3 失败路径如何观察自动回滚为了模拟失败我故意在账户服务里抛了一个运行时异常。正常情况下订单服务会异常向上抛此时 TM 收到失败信号后向 TC 发起全局回滚请求TC 会给所有已注册分支发送回滚指令。此时去数据库里看库存和订单会发现数据已经恢复原状。中间最关键的是 undo_log 表不再是空表里面记录了前镜像和后镜像。从日志可以看到回滚阶段生成了反向 SQL把刚插入的订单和扣减的库存一并撤销了。这里有个值得注意的细节回滚发生在每个分支的本地事务里所以库存回滚和订单回滚是分库完成的中间存在一个时间窗口查询数据可能看到“订单回滚了但库存还没回滚”。这就是最终一致的表现。高可用场景下如果担心这个中间窗口影响业务可以配合状态机和定时对账来兜底而不是指望中间件把“瞬时一致”也帮你搞定。4. 常见问题与排查技巧实录纸上谈兵容易真跑起来还是会遇到不少坑。我把自己踩过的问题整理成一个速查表每条都是真实的操作记录。现象可能原因排查方向全局锁等待超时两个事务同时更新同一行查 TC 日志里的全局锁持有时间改小事务范围回滚时报找不到 undo_log 表业务库没建 undo_log或表名写错核对所有参与业务的数据库连接不要只看一个库数据源代理没生效配置了代理却仍用原生数据源确认连接工厂是否正确返回代理对象服务间 XID 未传递自定义了 Feign 拦截器或 HTTP 头被覆盖调用链中检查是否存在 tx-service-group 和 XIDConfirm 重复执行导致数据翻倍TCC 接口没有做幂等控制在 Confirm 和 Cancel 里用事务状态表判断是否已处理4.1 全局锁等待超时是最常见的性能瓶颈AT 模式有一个全局锁的概念作用是防止多个全局事务并发修改同一行。我在压测时模拟过十个线程同时扣减同一件商品库存很快出现了锁等待超时异常。核心原因是全局锁的持有时间很长。普通数据库行锁只在事务执行期间持有全局锁则从第一阶段开始一直持有到第二阶段结束。如果全局事务内部调用了外部接口锁的持有时间就是整个链路的时间。解决办法是把长事务拆短避免在全局事务里做耗时的远程同步调用。比如下单链路可以拆分出“预下单”和“确认支付”两步各走一个短事务而不是在一个事务里做全部事情。4.2 业务数据库没建 undo_log 表AT 模式要求每个参与分支的数据库都必须有一张 undo_log 表。我刚开始只在订单库建了表库存服务和账户服务都没建。结果测试正常因为第一次测试的失败路径只回滚了订单数据库存服务的回滚接口没有被触发。第二次模拟余额不足时库存回滚直接报了错误。排查过程比较曲折因为报错信息埋在回滚日志里不去翻数据库还真看不出来。所以建议上线前把建表脚本一次性下发到所有参与数据库不要靠手工逐个建。4.3 回滚后数据仍异常幂等和脏写问题AT 模式回滚依赖前镜像生成反向 SQL。如果业务代码在事务执行过程中修改了同一行数据比如先查余额再做计算再更新中间件对这条记录的前后镜像判断会失效。更麻烦的是如果任意的分支在本地事务里提交了一部分数据回滚时可能无法覆盖全部变更。实际场景里我会在代码层面尽量减少“先读再写”的复合操作尽量用单条 SQL 完成更新。分布式事务解决的是跨库一致性不是帮你在本地方法里兜底脏数据。这是理解中间件边界很重要的一点。4.4 数据源代理与连接池兼容问题很多项目用了自定义的数据源连接池或者包装过层的多数据源组件。这类情况下 Seat 的数据源代理可能拦截不到真实 SQL导致日志里看不到 undo_log 生成。有一次我排查了很久发现是项目里自己定义了动态路由数据源代理对象和目标对象没对上。解决办法是梳理数据源创建链路确保代理层包裹的是最底层的物理数据源而不是包裹在路由这一层。另外要注意 Spring 事务管理器使用的数据源与 Seat 代理后的数据源必须是同一个对象否则本地事务和全局事务会相互覆盖出现“回滚了但数据没变”的诡异现象。5. 踩过坑之后的一些个人体会这次分布式事务学习最大的收获不是记住了三个模式的名字而是明白了中间件解决的是“协调问题”不是“容错问题”。Seat 可以帮你把跨库回滚的流程自动化但它不能替代业务侧的幂等设计、状态机设计和对账机制。真正的高可用方案永远是多层次组合业务状态机保证流程可控幂等表挡住重复调用定时对账兜底数据遗漏中间件负责降低跨库操作的复杂度。另外刚开始学这类框架不建议直接在生产环境试更不建议一上来就接 TCC。先用 AT 模式把本地链路跑通弄明白 undo_log 和全局锁的作用再手动构造一次失败场景观察回滚日志这个过程比刷十篇理论文章都管用。我后来排查线上疑问时很多判断都来自这次本地实验建立的直觉。分布式事务的门槛不在概念而在对异常路径的熟悉程度多制造几种失败场景你才能真正把框架用稳。