做后端开发的这些年遇到分布式事务的场合不算少。只要你的系统拆成了多个服务、多个数据库面对一笔跨库写操作就一定会碰到一致性问题。面试的时候被问“分布式事务怎么解决”很多人第一反应就是Seata的AT模式。确实AT模式是目前落地成本最低、业务侵入最小的一种分布式事务方案尤其是在Spring Cloud、Dubbo这类微服务架构里几乎成了默认选项。今天我想从自己的实践经验出发把这个模式讲透它的核心原理是什么怎么一步步接入哪些坑是我真实踩过的以及它在什么场景下会失效。1. 为什么分布式事务绕不开Seata AT模式1.1 先搞清楚分布式事务在解决什么问题在单库单应用时代事务非常简单一个Transactional包住所有数据库操作要么全部成功要么全部回滚。可微服务化之后订单在订单库库存再独立的库存库用户在用户库。一个“下单”动作要同时扣库存、写订单、加积分涉及三个数据库、三套连接。此时单独靠本地事务根本无法保证一致性——订单写成功了库存扣减失败钱收了但货发不出去这就是分布式事务必须解决的问题。分布式事务的目标是让多个参与者数据库、消息队列、缓存等的操作达到“最终一致”。实现思路大体分成两派一派是强同步提交比如XA协议通过数据库层面对资源进行两阶段锁定另一派是补偿协调比如TCC、SAGA、消息事务靠业务代码来协调各个节点。Seata的AT模式本质上属于“自动化补偿”的思路但它又不像TCC那样要求你手写三个接口而是用拦截器和数据源代理把补偿逻辑自动生成出来。1.2 AT模式与XA、TCC、BASE的定位差异很多人容易把AT模式想成一种“万金油”。其实它的适用范围很明确适合数据库事务适合跨服务调用链路中每一步都是关系型数据库的场景。我们把它和XA、TCC放在一起对比就能看出它的位置。维度XA协议TCCSeata AT模式侵入性数据库层面支持业务代码无感知需要实现Try、Confirm、Cancel三个方法依赖注解和代理数据源业务代码基本无感一致性强一致同步阻塞最终一致靠补偿最终一致自动补偿性能锁持有时间长并发低无全局锁但开发成本高有全局锁性能介于两者之间接入成本依赖数据库XA配置复杂业务改造量大逻辑容易漏建表加依赖加注解几乎可快速接入典型场景单库或少量库、一致性要求极高并发高、业务明确可拆分大多数跨库微服务场景中小并发足够从这张表能看出AT模式赢在“性价比”。它不需要你为每个业务写补偿逻辑也不要求数据库支持XA。代价是它引入了全局锁在极端高并发下会有锁竞争。后续我会专门讲这个性能边界。2. AT模式核心原理一个对业务几乎无侵入的补偿方案2.1 全局事务与分支事务的协作模型要理解AT模式首先要建立两个概念全局事务和分支事务。一次分布式调用链比如“下单 - 扣库存 - 加积分”整体是一个全局事务。全局事务有唯一编号XID由Seata的事务协调器TC生成。而每个参与数据库操作的本地事务都叫分支事务。分支事务由业务所处的应用进程TM/RM注册到TC并绑定同一个XID。AT模式最核心的设计是每个分支事务按照“本地事务undo_log记录”的方式执行。也就是说在AT模式里并不是用数据库XA接口去预占资源而是在每个参与库上执行的是一次普通本地事务。只不过这个本地事务除了你要执行的业务SQL还额外写了一条undo_log日志用于后续回滚。这样就把一个复杂的两阶段提交问题拆分成了“在各节点各自提交失败时统一逆向补偿”。2.2 两阶段提交在AT模式里是怎么落地的一阶段提交大家应该听说过。在AT模式里一阶段不是立即提交业务数据而是把业务SQL放进一个本地事务里执行同时把数据变更前后快照也存到undo_log表然后直接提交这个本地事务。提交后数据已经对外可见了吗从数据库层面看是的业务数据已经变了。但从全局一致性角度看还在可控范围内因为TC上记录了这个全局事务持有的全局锁其他AT全局事务如果也要修改这些行会被阻塞。二阶段则分成两种情况全局事务成功时TC通知所有分支参与者删除undo_log释放全局锁整个流程就算完成全局业务抛异常时TC通知各分支发起回滚每个分支读到自己的undo_log根据before image构造“反向SQL”把数据改回去这就是回滚逻辑。整个过程业务方无需手写补偿Seata的RM资源管理器在底层完成了。2.3 数据快照与undo log回滚的关键我对一个刚接触Seata的朋友说过你把undo_log理解成数据库的“后悔药”就行。每一次业务数据被修改前AT模式会先查询这条数据原样存成before image执行完业务SQL之后再把修改后的数据查出来存成after image。这两份镜像连同分支事务ID、全局XID一起通过代理数据源写入同一个本地事务里。注意写入undo_log和执行业务SQL必须在同一个数据库事务内否则日志与数据不同步后面回滚就乱了。回滚时Seata会做一次“防脏写校验”把当前数据库里的数据与after image对比。如果一致说明这个数据没有被其他事务改过可以安全地用before image生成反向UPDATE或DELETE语句恢复数据如果不一致说明产生了脏写Seata会放弃自动回滚并把该分支事务标记为log_status1提示人工介入。这个机制看起来简单但实际生产环境一旦出现脏写处理起来非常痛苦后文我会结合故障案例展开。-- undo_log 表结构MySQL版本不同数据库略有差异 CREATE TABLE IF NOT EXISTS undo_log ( id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT increment id, branch_id BIGINT(20) NOT NULL COMMENT branch transaction id, xid VARCHAR(128) NOT NULL COMMENT global transaction id, context VARCHAR(128) NOT NULL COMMENT undo_log context,such as serialization, rollback_info LONGBLOB NOT NULL COMMENT rollback info, log_status INT(11) NOT NULL COMMENT 0:normal status,1:unable to rollback, log_created DATETIME(6) NOT NULL COMMENT create datetime, log_modified DATETIME(6) NOT NULL COMMENT modify datetime, UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINE InnoDB AUTO_INCREMENT 1 DEFAULT CHARSET utf8mb4 COMMENT AT transaction mode undo table;2.4 全局锁与写隔离并发安全的基础AT模式默认的隔离级别是“读未提交”。这听起来有点可怕但结合全局锁之后情况并没那么糟糕。Seata引入了一把逻辑上的全局锁当一个分支事务修改某一行数据时它会在TC上注册该行的全局锁。其他全局事务如果也要修改同一行必须先尝试获取这把锁获取不到就进入等待或重试。这样能保证不同全局事务之间不会互相覆盖数据这就是写隔离。但要注意这个全局锁是逻辑锁不依赖数据库的行锁。如果一个非Seata管理的普通事务直接修改了同一行AT模式的全局锁根本拦不住。因此在接入AT模式时要严格要求当前数据表只被Seata管理的应用访问或者至少所有写操作都走同一个微服务。否则一旦出现“旁路更新”脏写概率极高回滚时就会丢数据。3. 从0到1接入Seata AT模式实操步骤与配置要点3.1 部署TC并初始化全局事务会话表第一步是准备好TC也就是Seata Server。我推荐使用1.5.0以上版本注册中心可以选Nacos也可以用文件配置。这里不展开部署细节只提醒几个关键点TC侧需要一个可靠的存储默认支持文件、数据库、Redis三种模式。生产环境建议使用数据库模式把全局事务会话持久化到global_table、branch_table和lock_table三张表里避免TC重启后全局事务信息丢失。启动前还需要调整内存参数因为TC会缓存全局锁和事务会话。小项目默认配置能跑但并发稍微上来后堆内存得给足我习惯至少给到1G。另外要注意TC与业务服务的时间同步虽然Seata没有强制要求严格对时但超时判断依赖时间戳偏差太大会导致误判超时回滚。3.2 引入依赖并配置数据源代理客户端接入时如果是Spring Boot项目直接加seata-spring-boot-starter。这里的核心是数据源代理。AT模式之所以能自动解析SQL、生成undo_log前提是数据源被Seata的DataSourceProxy包装了一层。所以配置里要确保Seata能够拿到你的DataSource而不是直接操作业务数据源。以目前主流版本为例yml配置大致如下seata: enabled: true application-id: order-service tx-service-group: my_test_tx_group service: vgroup-mapping: my_test_tx_group: default grouplist: default: 127.0.0.1:8091 registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP username: nacos password: nacos一个很容易踩的坑如果项目中同时使用MyBatis、多数据源、动态数据源一定要保证Seata代理的是最终路由到的数据源。尤其使用dynamic-datasource时需要给每个业务数据源单独配置代理或者使用它提供的Seata集成包。否则事务管理器和AT分支注册会拿到不一致的DataSource导致分支事务无法生效。3.3 在业务代码中开启全局事务在调用链的最顶层方法上也就是整个分布式事务的发起者加上GlobalTransactional注解。典型例子是订单服务调用库存服务和积分服务那么订单服务里的createOrder方法就是全局事务的入口。Service public class OrderService { GlobalTransactional(rollbackFor Exception.class) public void createOrder(OrderCreateDTO dto) { orderMapper.insert(dto.toOrder()); stockService.deductStock(dto.getProductId(), dto.getQuantity()); pointService.addPoint(dto.getUserId(), dto.getAmount()); } }注意rollbackFor一定要配否则默认只回滚RuntimeException很多业务里抛的自定义异常是Exception子类不配就会导致事务“假成功”。另外被调用的服务方法本身最好不要再加GlobalTransactional只需要保持正常的本地事务Transactional即可。全局事务的XID会通过RPC框架的钩子自动传递到下游服务如果不传递下游就成了独立事务。3.4 验证一阶段提交与二阶段回滚接入完成之后不要急着上线先在测试环境做一次“破坏实验”。最简单的方法让stockService.deductStock里故意抛一个异常然后观察数据库。正常情况下订单表里的数据会被插入后又被回滚删除库存表也不会变化。同时查看undo_log表回滚成功的记录通常已经被清理。再验证成功路径看看undo_log里是否残留日志。理论上二阶段提交后日志会被删除但如果看到大量残留可能是清理失败的信号。常见原因包括TC异常、网络中断、客户端版本与TC版本不兼容。日志残留不影响业务数据但会持续占用存储空间并且这些历史记录无法区分哪些是已提交的长期下来会误导排查建议定时清理。4. 一次真实的回滚故障排查AT模式实战避坑4.1 问题现象全局事务回滚了但数据没恢复有一次在某个模拟订单系统里库存服务超时全局事务按预期回滚。下游检查时发现库存数据被改了原本扣减100件回滚后库存数量少了50件。更奇怪的是undo_log里该分支记录已经被标记为log_status1Seata的控制台也给出了“脏数据无法自动回滚”的提示。这个现象的根本原因在于扣减库存的执行流程里业务代码在调用库存扣减前先做了一次状态更新然后又有一个后台的普通事务去修改了同一行的库存数量。AT模式的全局锁没有拦住这个后台任务因为它不在全局事务上下文里也没有通过Seata代理的数据源。于是回滚时当前数据与after image不一致Seata判定为脏写拒绝执行自动补偿。4.2 排查思路从undo log到脏写源头那天排查过程很有意思。我先看了全局事务日志发现TC上分支回滚失败的原因确实是Client RM rollback failed。然后对比库里当前数据和undo_log中after image的二进制内容确认数据已经被第三者修改。接着查后台任务发现是一个定时对账任务它在同一张表上做了“先将库存调整为可用数量再扣减预占数量”的两步操作中间没有任何分布式事务控制。定位到根因后我立刻给该表的所有写操作加了访问规约要么全部走统一库存服务要么业务侧接入Seata代理禁止出现“旁路更新”。同时把update语句改为条件更新例如update stock set available available - ? where product_id ? and available ?让并发问题的暴露更早而不是等回滚阶段才发现脏写。这一步很关键因为AT模式的补偿能力再强也无法处理已经污染的现场。4.3 常见问题速查表脏读、全局锁失效、超时下面这张表是我在维护AT模式项目期间整理的高频问题基本覆盖了日常90%的坑。现象可能原因解决思路全局事务一直超时回滚分支事务执行时间超过timeout调大client.rm.timeout或全局事务超时参数下游服务报xid not foundXID传递链路断开比如用了异步线程或消息队列检查RPC透传配置异步分支改用独立事务或消息事务回滚时出现脏写提示存在非Seata管理的事务修改同一行禁止旁路更新改为条件更新并用分布式锁兜底本地事务无法启动未正确代理DataSource检查seata.enabled和数据源配置undo_log表不断增大二阶段提交清理失败或回滚失败排查TC连接、客户端版本定时清理确认已完成的日志批量更新性能骤降大范围更新导致全局锁竞争严重拆分批次减少单行锁持有时间这里面最容易被忽略的是全局锁失效。我见过一个项目全局事务范围非常大一次要处理上千行数据每个分支持有锁的时间超过几秒并发情况下其他全局事务全部卡住紧接着一批超时回滚反而导致了更严重的不一致。所以AT模式对事务范围的敏感度比想象中高得多。5. AT模式会遇到的性能边界与调优经验5.1 全局锁竞争对吞吐量的影响AT模式引入的全局锁本质上是一个集中式的并发控制点这个设计一定会带来性能损耗。当两个全局事务同时操作一行数据时后一个分支要等前一个分支完成二阶段释放锁后才能继续。如果前一个全局事务链路很长比如调用三个服务每步都做网络IO锁占用时间就会很长。我用一个简单例子估算过假设一行库存数据的全局锁平均持有时间50毫秒那么同一行数据的理论并发上限就是1秒/0.05秒20TPS。如果业务要求100TPS那么AT模式直接就不够用。这时候要么放弃对同一行的实时热点操作把库存预分配到多个子库存要么换TCC模式把锁定粒度放到业务上例如在Try阶段不真正扣减而是冻结部分库存Confirm阶段再落库。提前算清楚这个瓶颈比上线后加机器更有意义。5.2 减少无谓的分布式事务范围很多团队接入AT模式后喜欢把一个大方法整体包上GlobalTransactional甚至把查询操作也放进去。这是典型的过度使用。全局事务的范围应该严格限制在“必须保证一致性的写操作链路”。查询不需要参与分布式事务因为它是读取可能已提交的快照不会造成数据不一致。还有一类场景不适合AT长期占用外部资源比如在全局事务内调用第三方支付接口并等待回调。这会拉长全局锁持有时间让整张表都跟着卡住。我建议的做法是先执行本地数据库操作并提交再异步发送支付消息通过消息回调更新订单状态把“支付结果”与“库存扣减”解耦。如果实在要强一致也要把外部调用放到事务的最后并缩短超时时间减少锁占用。5.3 连接池与事务超时参数调整AT模式每一阶段都会额外查询前镜像、后镜像这意味着相比普通SQL每条被代理的SQL至少多出两次查询。如果业务表数据量大或者SQL没有索引这个额外开销会被放大。所以接入AT模式前最好给所有会被代理更新的表加上合适的索引。否则一阶段生成before image时全表扫描会让数据库压力瞬间飙高。连接池方面DataSourceProxy和底层实际数据源是两层连接连接池大小配置要考虑全局事务内部的多数据源调用。常规情况下把最大连接数调大20%~30%是合理的。超时参数也需要联动timeout控制全局事务超时默认60秒client.rm.reportRetryCount控制分支注册重试次数client.tm.commitRetryCount和rollbackRetryCount控制TC通知提交/回滚的重试次数。在弱网环境下重试次数过低会遇到“回滚没生效”的假象过高又可能影响性能我一般把回滚重试设为5次提交重试设为3次。还有一个容易被忽略的点批量操作SQL的解析支持。AT模式对SQL语法有解析限制像INSERT INTO ... ON DUPLICATE KEY UPDATE、REPLACE INTO这类写法在某些版本中可能解析异常或生成错误镜像。生产代码里尽量避免这种写法或者改成先查询再逐条更新。数据库字段方面TEXT、BLOB这类大字段虽然能放入undo_log但序列化开销很大如果表里必须有大字段最好拆表或用冗余字段避免纳入全局事务的更新范围。从我自己实际维护过的几个项目来看Seata AT模式非常适合作“从单体拆成微服务”的过渡期数据一致性方案。它不需要大规模改业务代码能让团队快速落地最终一致。但它的天花板也很明显全局锁的集中式调度决定了它不适合超高并发热点行更新也不适合非数据库资源的事务协调。我个人通常的建议是中小规模并发、跨库写操作直接上AT模式省心可靠高并发热点写、需要极致性能的场景再去研究TCC或SAGA。理解了这个模式的边界你才不会在压测的时候对着监控图一筹莫展。最后提一个实用技巧日常运维时多做一次“模拟回滚”的演练让开发同学清楚看到脏写发生在哪一步这样等真出问题的时候大家心理都有底。
阅读完成 · 觉得有帮助?