第一次在项目里接触 Seata 分布式事务的时候第一个念头其实是抵触多一套中间件、多一张回滚日志表、多一个全局锁这到底值不值后来真正把一个订单系统从“一个库三张表”拆到“三个服务三个库”才理解 AT 模式存在的意义。它解决的正是本地事务覆盖不到、XA 又太重、TCC 又太麻烦的那段空白。标题里的“弥”字我的理解是“弥补”用 undo_log 弥补业务补偿代码用全局锁弥补隔离性缺口尽力把分布式事务做到对业务代码低侵入。这篇文章就把 AT 模式的工作原理、落地步骤、让我踩过好几次坑的细节以及它到底适合什么场景一次性说清楚。适合正在做微服务改造、遇到跨库数据不一致问题又不想把每个事务都写成 TCC 的开发者。1. 为什么分布式一致性问题绕不开AT模式到底解决什么1.1 一个订单扣库存的失败现场某电商系统的核心流程不复杂用户下单之后订单服务生成订单记录库存服务扣减商品库存账户服务扣减用户余额。这三个操作分别落在三个独立的数据库里。如果只给订单服务加上本地事务那么它只能保证订单表自己的写入不出问题无法约束库存服务和账户服务的操作结果。一旦扣库存成功、扣余额失败用户的钱没变少订单却建成了库存在系统中被无端占用最后只能靠对账脚本补救。这个“失败现场”是所有分布式事务方案真正要面对的问题多个数据源之间的状态变更要么一起成功要么一起回滚。跨服务调用没有共享的数据库连接也就没有传统意义上的“同一个回滚上下文”。每个服务各自提交了本地事务提交之后再去回滚另一个服务成本极高。这也是为什么一定要有“全局事务”的概念由一个独立协调者记录所有参与者统一决定提交还是回滚。我见过不少人一开始试图用“先扣库存再建订单失败就调用接口反向补偿”来解决问题。思路没错但业务代码里会塞满反向接口、补偿状态、重试逻辑而且补偿接口本身失败怎么办、消息序号怎么处理、接口重复调用怎么幂等每一条都是新的坑。AT 模式的思路不是让业务手写补偿而是从 SQL 层面自动记录变更前后的数据在需要回滚时自动生成反向 SQL。这也是它“弥合”分布式事务与普通编程习惯之间差距的关键。1.2 四种分布式事务方案对比AT的取舍优势分布式事务不是新话题XA、TCC、SAGA、AT 各有拥趸。我整理了一张选型对照表能比较直观地看出 AT 的位置。方案一致性类型侵入性性能损耗落地成本适用特点XA强一致低数据库原生支持高资源锁定时间长中并发量低、短事务、数据库支持完整TCC强一致高每个操作要写 Try/Confirm/Cancel中等高对性能敏感、业务可以拆幂等动作SAGA最终一致中需要写反向补偿低中高长流程、异步化、业务流程可正反执行AT最终一致默认读未提交低由中间件自动生成回滚 SQL中低低需要快速落地、业务SQL相对规范和可控从这张表能看出AT 最大的优势是侵入性低。对于已经写好的业务代码绝大多数情况下只需在入口方法加一个全局事务注解中间件会拦截受管的数据源分析 SQL 并生成前后镜像。相比 TCC 动辄给每个服务写三套接口的实现工作量AT 能省掉一大半开发时间。但低侵入不代表零代价。AT 的隔离级别默认是读未提交因为它做的是写实时、读不加锁。这个特点在并发场景下会带来脏读风险。后面我会单独讲怎么控制。另一个代价是每个事务都会额外写一份 undo_log数据量大的时候日志膨胀也需要关注。所以选 AT 之前一定要先评估数据规模、热点行和事务时长。1.3 AT模式的“弥合”设计思路“AT”的全称是 Automatic Transaction本质上是“自动化补偿事务”。它的设计思路很巧妙把分布式事务分成两个阶段第一阶段让各个服务用本地事务完成业务操作并记录数据镜像第二阶段由一个协调者根据第一阶段的结果统一决定是提交删除日志还是按日志反向回滚。这中间有两个“弥合”动作很关键。第一个是用 undo_log 弥合了“业务代码没有写补偿逻辑”的空白中间件解析出 UPDATE 语句执行前的旧值和执行后的新值回滚时直接生成反向 UPDATE。第二个是用全局锁弥合了“分布式环境下并发覆盖”的问题同一行数据在多个分支同时修改时全局锁保证只有一个分支能持有写锁其他人必须等待防止回滚时旧镜像被别的数据覆盖掉。理解了这两个动作再去看 Seata 的源码或者日志就不会被一堆概念绕晕。这个模式核心要解决的不是“能不能回滚”而是“如何低成本地做到可回滚、可协调、不脏写”。很多开发者在调研 AT 时总纠结“它是不是强一致”其实它从一开始定位就不是强一致方案而是用自动化手段把最终一致性做得很顺手。选型时理解这一点很多纠结会迎刃而解。2. AT模式工作原理拆解从接口调用到Undo Log回滚2.1 三大角色与两个阶段的基本协作Seata 分布式事务里有三个角色TC 是事务协调者独立部署的 ServerTM 是事务管理器通常是我们业务代码里加了全局事务注解的入口方法RM 是资源管理器对应每个数据库的数据源代理。整个协作关系可以用一句话概括TM 开启全局事务RM 注册分支事务TC 统一协调二阶段处理。一次典型的 AT 流程是业务入口方法被GlobalTransactional注解命中后TM 会向 TC 申请一个全局事务 ID也就是 XID。这个 XID 会通过调用链传递到下游服务。下游服务在执行 SQL 时数据源代理会检查当前链路里有没有合法 XID有的话就把这次数据库操作作为一个分支事务向 TC 注册。所有分支都执行完后TM 再向 TC 发起全局提交或全局回滚的请求。我用生活化类比解释一下TC 像会议主持人TM 是发起会议的部门RM 是各个执行人。会议发起人把议题编号发下去每个执行人干完活向主持人汇报结果。主持人收集到所有执行结果后拍板“全部通过”还是“全部推倒重来”。所有执行人必须听主持人的最终指令。分布式事务也是一样中间件保证的是“最终统一决策”而不是每个服务各自为政。2.2 undo_log表结构与前后镜像AT 模式要求每个参与事务的业务数据库里都有一张undo_log表。这张表是整个回滚机制的数据基础。表里最核心的字段是rollback_info、log_status、branch_id、xid。其中rollback_info存储的是 JSON 结构化数据记录了这个分支事务修改数据的beforeImage和afterImage。当业务执行一条 UPDATE 语句时Seata 的代理数据源会先查询出受影响行的原始记录作为前镜像然后执行 SQL再把 SQL 执行后的记录查出来作为后镜像。前后镜像都保存到 undo_log。真正提交分支事务时业务数据和 undo_log 会放在同一个本地事务里提交这样就保证了“哪怕本地提交了undo_log 也一定存在”。回滚的时候Seata 会读取 undo_log 中的 afterImage先和当前数据库里的数据比对。如果完全一致说明这行数据没有被其他未经管理的事务修改过就可以根据 beforeImage 生成反向 SQL 恢复旧值。如果比对发现不一致说明出现了脏写或者数据被绕过代理直接改掉此时中间件不敢盲目回滚只能记录错误并抛出异常留给人工介入。这个校验机制是 AT 模式安全性的重要底线。2.3 全局锁与隔离级别背后的代价在分布式事务中要回滚就必须知道数据在提交前的原始状态但如果多个分支事务同时修改同一行数据原本的 beforeImage 可能已经被别人改过回滚就会把别人提交的数据覆盖掉。Seata 解决这个问题的方式是引入一个全局行锁这个锁由 TC 统一管理全局事务提交或回滚完成时才会释放。全局锁带来的直接效果是写并发被串行化。两个事务同时去 UPDATE 同一行后到的人拿不到锁只能重试等待。所以在 AT 模式下一个全局事务持锁时间越长其他事务排队等待的时间就越久。这提醒我们不要把耗时的远程调用、外部 API 请求塞进全局事务方法里否则锁的持有时间会失控。隔离级别是 AT 模式最容易被误解的地方。由于普通 SELECT 不会去获取全局锁所以一个事务在未提交时修改的数据可以被其他事务的普通 SELECT 读到默认隔离级别实际是读未提交。如果业务要求读已提交可以在查询语句上使用SELECT FOR UPDATE让读操作也走全局锁或者通过GlobalLock注解控制查询走 Seata 的锁定逻辑。代价是并发能力进一步下降所以需要业务自己权衡。2.4 提交和回滚的真实时序我以“订单服务建单 库存服务扣库存”为例展开提交时的时序。TM 开启全局事务后订单服务和库存服务分别在本地执行 INSERT 和 UPDATE 语句同时各自写入 undo_log。此时三个库的本地事务都还没有提交。TM 向 TC 发起全局提交TC 检查到所有分支事务都正常完成就会广播“提交”消息。每个 RM 收到消息后把本地事务真正提交并异步删除自己的 undo_log。这里要注意分支事务的本地提交并不是在业务代码执行完立即发生而是等到 TC 通知之后才提交确保二阶段句柄还在。回滚时序则完全相反。当任意一个分支事务失败TM 向 TC 发起回滚TC 广播“回滚”消息。成功执行过 SQL 的分支 RM 会读取各自的 undo_log通过 afterImage 比对后生成反向 SQL把数据恢复到 beforeImage 的状态。整个流程里业务代码不需要手动写回滚逻辑所有补偿动作都由数据源代理完成。有一个细节很多文章不会写二阶段是异步化的Seata 支持批量删除 undo_log也支持异步提交分支事务。这样可以降低对主流程性能的影响但也会带来日志表短暂堆积的现象。如果压测时发现 undo_log 表增长很快要注意设置合理的清理策略并关注 TC 的并发线程参数不要一开始就盲目调大超时时间。3. 从零落地AT模式依赖配置与业务改造细节3.1 搭建可运行的最小工程落地 AT 模式的第一步是准备一个可运行的 Seata 事务协调者 Server。这个服务独立于业务服务负责管理全局事务和全局锁。本地开发时最简单的办法是直接启动默认的 file 配置版本把 Seata Server 跑起来业务服务通过 application.yml 里的事务分组指向它。生产环境一般会接入注册中心和配置中心但这个话题偏部署运维这里不展开。业务服务接入时我建议直接用 Seata 官方提供的 Spring Boot Starter。一个最小工程的 yml 配置大概是这样spring: application: name: order-service seata: registry: type: file config: type: file tx-service-group: my_tx_group另外要在file.conf里配置事务分组和集群名保证服务端和客户端的my_tx_group指向同一个 TC。事务分组可以理解为一个逻辑名它负责把业务服务和指定的 TC 关联起来。很多第一次接触 Seata 的人会在这里栽跟头服务端和客户端的分组名称不一致导致 TM 找不到 TC全局事务始终注册不进去。依赖方面把一个可运行的工程搭起来通常只需要引入seata-spring-boot-starter再根据实际情况添加连接池和数据库驱动。连接池建议使用带监控能力的成熟组件并单独配置初始化连接数避免在启动时因为连接不够导致数据源代理初始化失败。整体跑通之后可以在日志里看到Global transaction begin这样的关键字说明全局事务链路已经建立。3.2 业务代码里的注解与SQL注意事项AT 模式的入侵性主要体现在一个注解GlobalTransactional。这个注解要加在全局事务的入口方法上也就是发起跨服务调用的最外层方法。我见过很多人把注解加到被调用的服务方法上看起来也执行了但全局事务并没有真正覆盖整条链路因为入口并不是加注解的方法。使用注解有几个隐藏要求。方法必须是 Spring 管理的代理方法不能是同类内部自调用也不能是 private 方法。自调用时this.method()会绕过代理切面不会生效全局事务自然无法开启。如果遇到这类问题第一反应先检查调用链上是否有this.的痕迹。另一个要求是方法里的 SQL 尽量简单直观Seata 对部分复杂 SQL 支持有限比如某些数据库特有语法、批量 UPDATE 的跨表场景解析前后镜像时可能报错。业务改造上我建议把“可回滚”作为标准所有参与全局事务的表都要有主键主键策略要稳定避免无主键表或联合主键过于复杂。主键是 Seata 定位行数据的基础没有主键就无法准确生成 beforeImage 和 afterImage。如果表中有大字段、JSON 字段回滚信息序列化会变大也会增加日志存储压力。不是不能做而是要提前评估。3.3 连接池、多数据源和配置文件参数Seata 的 RM 通过数据源代理拦截 SQL所以接入 AT 模式时业务数据源必须被DataSourceProxy包装。如果项目使用了多数据源比如一个服务同时连接订单库和用户库那么每个数据源都要单独代理否则未被代理的数据源操作不会注册分支事务后续回滚时就会出现漏网之鱼。我曾经处理过一个故障订单服务能正常开启全局事务但某次回滚只回滚了订单表另一张资金流水表的数据没有恢复。排查到最后发现这个服务的数据源是自己封装的多数据源工具类其中一个数据源没有经过 Seata 代理。回滚时 Seata 只能操作它认识的数据源不认识的那条 SQL 就漏掉了。这个错误的隐蔽性很高因为大部分时候事务都正常只有异常回滚时才暴露。连接池参数也需要配合调整。AT 模式在二阶段提交前会持有数据库连接如果连接池的maxPoolSize设置过小事务并发一高就会出现拿不到连接的状况。尤其是长事务场景建议适当调大最大连接数并设置合理的空闲超时时间。配置文件里还有几个参数值得关注client.rm.lock.retryInterval决定全局锁获取失败后的重试间隔client.rm.lock.retryTimes决定重试次数。这两个参数要根据业务并发量调节重试太久会让请求响应变慢重试太短又容易误报锁冲突。3.4 性能与参数调优的方向AT 模式的性能损耗主要集中在 SQL 解析、前后镜像查询、undo_log 写入和全局锁竞争几个环节。以我跑过的压测数据来看简单订单写入场景下AT 相对本地事务通常会有 10% 到 30% 的额外耗时具体取决于 SQL 复杂度和锁竞争程度。这不是一个定值需要用自己业务的真实 SQL 去压测。优化的方向第一是缩短事务范围。不要把缓存更新、文件上传、消息发送都放在全局事务方法内部事务之外的操作尽可能放到提交后执行或者改成由事务消息驱动。第二是减少热点行的并发修改。比如库存扣减可以把一个热点商品的库存拆成多个子库存行按业务键路由到不同行避免所有请求都去抢同一行的锁。第三是控制未提交数据的可见时间如果下游服务需要立即读取到刚写入的数据要考虑接一个短暂延迟或改用带查询条件的查询重建而不是把 SELECT 无脑加锁。另一个容易忽略的点是 undo_log 的清理。虽然 Seata 在二阶段提交后会异步删除日志但如果业务量很大删除操作也有延迟。日志表最好按天分区空闲时段自动清理历史分区。我记得有一次压测环境里 undo_log 表达到了几百万行导致 TC 查询回滚日志时出现慢 SQL整个事务链路的耗时明显上升。后来把日志表改成按日分区、定期归档问题才稳定下来。4. 高频踩坑与问题排查实录4.1 全局事务没生效的常见原因全局事务不生效是最容易让新人困惑的问题因为代码编译通过、接口调用正常只有发生异常时才发现该回滚的地方没回滚。根据我的排查经验第一优先级看日志。正常开启全局事务时业务日志里一定会有Global transaction begin和对应的 XID 打印。如果日志里根本没有这行说明注解切面就没进来。常见原因无非几种注解加在了非入口方法上方法被同类其他方法直接调用导致代理失效tx-service-group配置不一致导致 TM 找不到 TC客户端与服务端版本不匹配方法不是 public。排查时按照从“切面是否进入”到“XID 是否传递”再到“分支是否注册”的顺序很快就能定位。要注意跨服务调用时 XID 需要通过 RPC 请求头传递如果自己封装了 HTTP 客户端却没有把tx-xid这个头透传到下游那么下游服务根本不会注册分支事务。我见过一个很典型的案例网关层先调用了订单服务订单服务内部再通过线程池异步调用库存服务。由于 Seata 默认把 XID 存在当前线程上下文中线程池里的子线程拿不到 XID导致异步任务里的 SQL 没有受到全局事务约束。解决办法要么避免在全局事务方法里使用线程池要么使用 Seata 提供的上下文传递工具在提交任务前把 XID 手动塞进去。这类问题排查起来比较费劲所以最开始设计接口时就要约定好调用模式不要随意引入异步。4.2 全局锁等待和死锁怎么查全局锁等待是 AT 模式在高并发下最常见的异常日志里通常会看到Global lock acquire failed或Lock key wait timeout。出现这个异常说明当前分支事务想修改的数据行正被另一个尚未结束的全局事务持有写锁。等待超时后客户端会放弃抛出异常业务如果没做好重试就会感知为接口失败。排查全局锁冲突可以先看 TC 侧日志看看当前哪些全局事务保持活跃状态同时结合数据库侧的信息检查是否有长时间未结束的事务。很多全局锁堆积其实是业务代码导致的长事务方法里做了耗时的远程调用或者事务中有大量数据处理逻辑导致全局锁一直被占着不释放。碰到锁等待时不要把retryTimes盲目调大。调大只是延后失败根本问题没有解决。正确的做法是先分析业务逻辑是否有可优化的空间比如把大事务拆成小事务、把热点行改成批量预扣或异步对账模式。如果确实需要高并发扣减热点库存AT 模式并不是最优选择TCC 或 Redis 预扣减加异步落库反而更合适。死锁则通常发生在多个事务以不同顺序更新多行数据时。比如事务 A 先更新订单再更新库存事务 B 先更新库存再更新订单两个事务互相等待对方的全局锁最终超时。全局死锁的排查比单库死锁更复杂因为涉及多个服务、多个库、多个 XID。建议从一开始就在事务方法内统一加锁顺序所有事务都按照“订单→库存→账户”的顺序访问数据可以从根源上减少死锁。4.3 脏读、乱回滚、日志膨胀的处理脏读问题我在前文提到过默认隔离级别是读未提交。如果业务严格要求读已提交不要指望 Seata 自动帮你解决。一种做法是在关键查询语句上使用SELECT FOR UPDATE它会触发全局锁判断确保读到的是已提交且不被回滚的数据。另一种做法是改造成最终一致性架构让读流程接受短暂的延迟。乱回滚属于比较严重的问题。回滚时 Seata 会比对 afterImage 与当前数据是否一致不一致就会拒绝回滚并告警。造成不一致的原因主要是有非 Seata 代理的 SQL 修改了数据DBA 手动修过数据定时任务直连数据库更新了同一行事务方法内部混用了不同数据源。解决方案不是去绕过校验而是把所有业务写路径统一接入代理数据源并建立数据库变更规范。线上一旦出现校验失败优先人工核对数据再决定是手动补偿还是放行。日志膨胀其实是个可预防问题。我见过的最夸张的情况是一个批量导入接口一次性处理了上万行数据每条数据都生成对应的 undo_log一次事务产生了 GB 级别的回滚日志。处理过后总结出几条经验批量任务尽量拆条或分段提交大事务拆分后每条事务的 undo_log 会小很多日志表要设置合理的分区和清理策略定期巡检 undo_log 表的增长速度。AT 模式本身不是洪水猛兽但如果不控制事务粒度再好的机制也会被压垮。4.4 与MQ异步化、缓存的补充协同有些团队在引入 Seata 之后会把消息发送也放进全局事务方法里希望做到“数据库操作和消息发送一起成功或失败”。这个想法很自然但 AT 模式管不到消息队列。全局事务提交之后MQ 消息可能已经发出去如果后续分支回滚消息却无法撤回就会出现业务数据回滚了但消费者已经按消息执行了的情况。正确做法是至少保证“先提交事务再发消息”并在消费者端做好幂等。更稳妥的做法是引入事务消息机制先发一条半消息事务提交后再把消息确认投递回滚则删除半消息。这样消息的投递时机就和全局事务结果绑定在一起了。很多消息中间件都支持事务消息使用起来比在业务代码里强行 try-catch 发消息要可靠得多。缓存的一致性也需要合理设计。把写缓存放在事务内部一旦事务回滚缓存已经更新成新值也会造成数据不一致。我通常建议先更新数据库全局事务成功提交后再删除相应缓存让下次读取时回源数据库重建缓存。删除缓存本身可能失败所以要结合延迟双删或消息重试兜底。这类问题虽然不属于 Seata 本身但在实际项目里它们总是紧密绑在一起提前想清楚能少踩很多坑。5. AT模式之外什么场景该换方案5.1 不适合AT的几个典型场景虽然 AT 模式落地方便但它不是万能药。第一个不适合的场景是超长事务。一个全局事务里包含十几次跨服务调用整个链路耗时会非常长全局锁的持有时间也会很长数据库连接、回滚日志、重试机制都会被拖垮。这种长流程更适合 SAGA 或 TCC把长事务拆成多个步骤每一步单独管理用流程编排和人工补偿来保证最终结果。第二个不适合的场景是热点行超高并发写。典型例子是秒杀系统的库存扣减所有请求都在同一行库存上做减法。AT 模式的全局锁会把写并发彻底串行化吞吐量很难打上去。这种场景更适合直接操作 Redis 预扣库存之后异步落库或者用 TCC 把 Try 阶段设计成只做资源预占Confirm/Cancel 再真正执行。热点行场景的首要目标是并发吞吐而不是简单回滚能力。第三个不适合的场景是强一致读需求很高的金融系统。AT 默认读未提交虽然可以用SELECT FOR UPDATE提升隔离级别但这样会进一步牺牲性能。对账要求极高、每一步都要求读到绝对最新和已提交状态的系统更适合使用支持标准 XA 的数据库方案或者在架构层面就按标准事务设计尽量把跨库操作限制在一个数据库内部。选型时不要因为 AT 简单就硬套需求不一致时强行使用会在后续项目迭代里不断还债。5.2 技术选型上的个人建议在我的实际项目经验里技术选型没有标准答案关键是把业务场景量化。首先看事务长度一个事务从开启到结束需要多少时间、涉及几次服务调用。长事务优先考虑 SAGA 或异步化。其次看并发压力同一行数据的写并发有多高高到多少 QPS 会打满。高并发热点行优先考虑 TCC 或预扣减。再看团队成本团队对分布式事务的熟悉程度、能否接受较高的手工补偿代码量。如果团队主要用 Java、业务场景是标准订单流程、数据量中等AT 模式通常是性价比最高的入门选择。组合使用多个方案也是常见做法。比如订单主链路使用 Seata AT 保证核心一致性库存热点使用 Redisson 分布式锁加本地事务做预扣最终库存差异通过定时任务对账长流程积分活动则使用事务消息和本地事件表实现最终一致。分布式事务不是非此即彼的单选题而是围绕业务一致性要求设计的多选方案。最后说一句掏心窝的话AT 模式我踩过坑也用它救过火。它最让人舒服的地方是让团队几乎不需要写补偿代码最让人警惕的地方也是“看起来太简单”。建议每个准备上 Seata 的项目先画一张事务链路图把每个参与分支的表、SQL、锁顺序都列清楚再决定是否使用 AT。实践几次之后你对分布式事务的理解会比看十篇原理文章都深。
阅读完成 · 觉得有帮助?