Seata AT模式实战:微服务分布式事务原理与落地
1. 为什么分布式事务是微服务绕不开的一道坎1.1 从单体到微服务事务问题是怎么冒出来的早些年做单体应用的时候事务这件事其实挺省心的。一个Transactional注解往 Service 方法上一贴Spring 的DataSourceTransactionManager就帮你把本地数据库的 ACID 兜住了。下单、扣库存、扣余额全在一个数据库连接里要么全成功要么全回滚干净利落。但业务一旦拆成微服务这套逻辑就彻底失效了。订单服务有自己的库库存服务有自己的库账户服务也有自己的库。每个服务只对自己那个库负责本地事务管不到别人家的数据。这时候一个下单请求跨了三个服务订单写成功了库存扣失败了账户还没动——数据就歪了。这就是分布式事务要解决的核心问题跨多个服务、多个数据源的操作如何保证最终的一致性。注意我说的是“最终一致性”不是“强一致性”。这个区别很关键后面会展开讲。1.2 分布式事务的几种主流解法以及为什么我最终选了 Seata业内解决分布式事务的路子其实不少我大致梳理一下常见的几种两阶段提交2PC强一致但性能差、协调者单点、阻塞严重互联网高并发场景基本不用。TCCTry-Confirm-Cancel业务侵入性强每个操作都要写三个方法补偿逻辑得自己扛适合金融级场景但开发成本高。本地消息表 / 事务消息靠消息队列做最终一致实现相对简单但需要额外的消息表和定时补偿任务。Saga 模式长事务拆成一串本地事务每个都配一个补偿操作适合流程长的业务但补偿逻辑复杂。Seata AT 模式对业务几乎无侵入靠全局锁和 undo log 做自动回滚开发体验最接近本地事务。我选 Seata 的 AT 模式理由很直接业务代码改动最小。你只需要在发起方加一个GlobalTransactional注解剩下的脏活累活 Seata 帮你干了。对于大多数中小团队来说这是性价比最高的方案。TCC 那种要写三个方法的模式团队里但凡有一个人补偿逻辑写错了排查起来能让你怀疑人生。1.3 Seata 的三大角色用大白话讲清楚Seata 的架构里有三个核心角色官方文档写得比较抽象我用一个生活场景给你翻译一下。假设你要在网上买一台电脑涉及下单订单服务和扣款账户服务TCTransaction Coordinator事务协调者相当于一个“裁判”。它独立部署负责协调整个全局事务的提交或回滚。所有分支事务都要向它汇报。TMTransaction Manager事务管理者相当于“发起人”。它通常就是你的业务入口服务负责开启全局事务、发起提交或回滚指令。GlobalTransactional注解就是标在 TM 这一侧的。RMResource Manager资源管理者相当于“执行人”。每个参与事务的微服务都是一个 RM它管着自己那个库的连接和 undo log负责执行分支事务并向 TC 汇报状态。一次完整的流程是这样的TM 向 TC 申请开启全局事务拿到一个全局唯一的 XIDXID 通过服务调用链路传递到各个 RM每个 RM 执行自己的本地事务把 undo log 记下来然后向 TC 注册分支所有分支都成功了TM 通知 TC 提交TC 再通知各 RM 提交任何一个分支失败TC 就通知所有 RM 用 undo log 回滚。提示XID 的传递是 Seata 能串起整个链路的关键。它默认通过 HTTP header 或 Dubbo 的 attachment 传递如果你用的是自定义的 RPC 框架需要自己实现 XID 的透传否则分支事务注册不上全局回滚就会失效。2. Seata AT 模式的核心原理拆解2.1 一阶段业务 SQL 和 undo log 是怎么一起落库的AT 模式最精妙的地方在于一阶段。很多人以为 Seata 是“先执行业务 SQL再单独记日志”其实不是。Seata 通过代理数据源把这两件事放在同一个本地事务里完成。具体过程是这样的RM 拦截你的业务 SQL先解析 SQL 语义拿到涉及的表、主键、更新前后的镜像数据。然后在一个本地事务里先执行业务 SQL再往undo_log表里插一条回滚日志。这两步要么一起成功要么一起失败。undo_log 里存了什么主要是三样东西beforeImage更新前的数据快照回滚时用来还原。afterImage更新后的数据快照提交后用来做并发校验。XID 和 branchId标识这条日志属于哪个全局事务的哪个分支。我举个例子。假设账户表里 id1 的记录余额是 100业务要扣 30。Seata 会记录 beforeImage 为{id:1, balance:100}执行UPDATE account SET balance balance - 30 WHERE id 1再记录 afterImage 为{id:1, balance:70}。回滚的时候它就用 beforeImage 把数据还原成 100。这里有个细节值得注意beforeImage 是在业务 SQL 执行前查的afterImage 是在执行后查的。而且查询用的是SELECT ... FOR UPDATE加了行锁防止在快照和更新之间有其他事务插进来改数据。这个设计保证了镜像数据的准确性。2.2 二阶段提交为什么快快在哪二阶段提交的时候RM 收到 TC 的提交指令做的事情非常简单异步删除 undo_log。因为业务数据在一阶段就已经落库了二阶段不需要再动业务数据只是清理日志而已。这就是 AT 模式性能好的根本原因。相比 2PC 那种二阶段还要持有锁、还要协调提交的方式Seata 的二阶段提交几乎是“零成本”的。删除日志这个操作可以异步做甚至失败了还能重试不影响业务数据的正确性。但这里有个坑我得提醒你undo_log 表必须和业务表在同一个库。因为一阶段要把业务 SQL 和日志写入放在同一个本地事务里如果分在不同库本地事务就保证不了了。我见过有人把 undo_log 单独建在一个公共库结果回滚的时候各种诡异问题排查了半天才发现是这个问题。2.3 二阶段回滚undo log 是怎么把数据还原的回滚比提交复杂得多因为要处理并发和脏写的问题。RM 收到回滚指令后会做这么几件事首先根据 XID 和 branchId 找到对应的 undo_log。然后拿 beforeImage 和当前数据库里的数据做比对。如果当前数据和 afterImage 一致说明没有其他事务改过这条数据可以安全回滚直接用 beforeImage 覆盖。如果当前数据和 afterImage 不一致说明在全局事务执行期间有别的线程改了这条数据这时候就要看配置了。Seata 提供了两种策略通过client.undo.dataValidation参数控制配置值行为适用场景true默认校验不一致时抛异常回滚失败对数据一致性要求高宁可失败也不脏写false跳过校验强制用 beforeImage 覆盖允许覆盖但可能丢失其他事务的更新我个人的建议是保持默认的 true。虽然回滚失败会带来麻烦但至少你知道出问题了能人工介入。如果设成 false数据被悄悄覆盖了你可能几个月后才发现对不上账那才是灾难。回滚完成后undo_log 同样会被删除。如果回滚过程中 RM 挂了TC 会不断重试直到成功或者达到重试上限。3. 从零搭建 Seata 实战环境3.1 版本选型和组件准备版本兼容性是 Seata 落地最容易踩的坑没有之一。SpringCloud Alibaba、Seata、Nacos 三者之间的版本必须对得上否则启动就报各种莫名其妙的错。我用的这套组合是经过实测比较稳的组件版本说明Spring Boot2.3.12.RELEASE别用太新的2.4 有配置加载顺序变化SpringCloud Alibaba2.2.7.RELEASE对应 SpringCloud HoxtonSeata1.4.21.4.x 系列比较成熟Nacos1.4.2注册中心和配置中心二合一MySQL5.7 或 8.08.0 注意驱动和时区配置注意Seata 1.4.2 之后版本号跳到了 1.5、1.6API 有些变化。如果你是新项目可以直接上 1.6.x但网上大部分教程还是 1.4.x 的遇到问题搜起来方便。我选 1.4.2 主要是求稳。3.2 Seata Server 的部署和配置Seata Server 就是 TC需要独立部署。下载解压后主要改两个文件。第一个是conf/registry.conf配置注册中心和配置中心的类型。我用 Nacos 做两者registry { type nacos nacos { application seata-server serverAddr 127.0.0.1:8848 group SEATA_GROUP namespace cluster default } } config { type nacos nacos { serverAddr 127.0.0.1:8848 group SEATA_GROUP namespace } }第二个是conf/file.conf配置存储模式。生产环境一定要用 db 模式把全局事务会话信息存到数据库否则 TC 重启后事务状态就丢了store { mode db db { datasource druid dbType mysql driverClassName com.mysql.cj.jdbc.Driver url jdbc:mysql://127.0.0.1:3306/seata?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai user root password 你的密码 } }然后需要在数据库里建三张表global_table、branch_table、lock_table。这三张表的建表语句在 Seata 的script/server/db/mysql.sql里直接执行就行。配置推送到 Nacos 这一步很多人会漏。Seata Server 从 Nacos 读配置你得先把config.txt里的配置项推到 Nacos 上。官方提供了脚本但 Windows 下跑起来有点麻烦我一般是手动在 Nacos 控制台建一个seataServer.properties的配置把关键项填进去。3.3 业务库的 undo_log 表准备每个参与全局事务的业务库都要建一张undo_log表。注意是每个业务库不是只在 TC 的库里建。建表语句如下CREATE TABLE undo_log ( id bigint(20) NOT NULL AUTO_INCREMENT, branch_id bigint(20) NOT NULL, xid varchar(100) NOT NULL, context varchar(128) NOT NULL, rollback_info longblob NOT NULL, log_status int(11) NOT NULL, log_created datetime NOT NULL, log_modified datetime NOT NULL, ext varchar(100) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid,branch_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8;这个表平时是空的事务执行期间会有数据提交或回滚后会被清理。如果你发现这个表里堆积了大量数据说明有事务卡住了得去查 TC 的日志。4. 业务代码改造与全局事务落地4.1 依赖引入和配置项详解每个参与事务的微服务都要引入 Seata 的 starterdependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId /dependency然后在application.yml里配置 Seataseata: enabled: true application-id: order-service tx-service-group: my_tx_group service: vgroup-mapping: my_tx_group: default registry: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 group: SEATA_GROUP这里有几个点容易搞错。tx-service-group是你自定义的事务分组名vgroup-mapping把这个分组映射到 TC 的集群名。TC 那边registry.conf里配的cluster default所以这里映射到 default。如果对不上启动时会报“no available service”的错误。application-id建议和spring.application.name保持一致方便在 TC 日志里定位是哪个服务。4.2 发起方加 GlobalTransactional 的正确姿势全局事务的发起方也就是 TM 那一侧在业务入口方法上加注解GlobalTransactional(name create-order, rollbackFor Exception.class) public void createOrder(OrderDTO orderDTO) { // 1. 创建订单 orderService.create(orderDTO); // 2. 扣减库存 storageService.deduct(orderDTO.getProductId(), orderDTO.getCount()); // 3. 扣减余额 accountService.debit(orderDTO.getUserId(), orderDTO.getAmount()); }rollbackFor Exception.class这个一定要加。默认情况下GlobalTransactional只对RuntimeException和Error回滚受检异常不回滚。业务里如果抛了受检异常全局事务不会回滚数据就歪了。这个坑我踩过排查的时候一脸懵。另外name属性给全局事务起个名字方便在 TC 的日志和监控里识别。别小看这个线上出问题的时候日志里一堆 XID有个可读的名字能省不少事。4.3 参与方什么都不用加吗这是 AT 模式最爽的地方参与方的方法上不需要加任何注解。只要它被 TM 通过 RPC 调用到并且 XID 正确传递过来了Seata 就会自动把它纳入全局事务。但有个前提参与方的数据源必须被 Seata 代理。SpringCloud Alibaba 的 starter 会自动帮你做这件事前提是你的数据源是通过 Spring 容器管理的。如果你在代码里手动new了一个 DataSource那 Seata 代理不到分支事务就注册不上。我见过一个案例某同学在工具类里自己创建了数据源结果那个库的操作死活不回滚。查了半天才发现是数据源没被代理。所以记住一句话数据源交给 Spring 管别自己造。4.4 一个完整的下单场景代码演示我把上面那个下单场景的完整代码贴一下方便你直接抄。订单服务TM 侧Service public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Autowired private StorageFeignClient storageFeignClient; Autowired private AccountFeignClient accountFeignClient; Override GlobalTransactional(name create-order, rollbackFor Exception.class) public void create(OrderDTO orderDTO) { // 本地事务创建订单 Order order new Order(); order.setUserId(orderDTO.getUserId()); order.setProductId(orderDTO.getProductId()); order.setCount(orderDTO.getCount()); order.setAmount(orderDTO.getAmount()); order.setStatus(0); orderMapper.insert(order); // 远程调用扣库存 storageFeignClient.deduct(orderDTO.getProductId(), orderDTO.getCount()); // 远程调用扣余额 accountFeignClient.debit(orderDTO.getUserId(), orderDTO.getAmount()); } }库存服务RM 侧Service public class StorageServiceImpl implements StorageService { Autowired private StorageMapper storageMapper; Override public void deduct(Long productId, Integer count) { // 普通本地事务不需要加 GlobalTransactional Storage storage storageMapper.selectByProductId(productId); if (storage.getCount() count) { throw new RuntimeException(库存不足); } storageMapper.deduct(productId, count); } }注意库存服务这里抛了RuntimeException这个异常会沿着调用链传回订单服务触发全局回滚。订单服务的本地事务回滚库存服务的本地事务也回滚账户服务如果已经执行了也会回滚。5. 踩坑实录与常见问题排查5.1 全局锁冲突并发下单时的死锁问题AT 模式在一阶段会申请全局锁这是为了保证全局事务之间的隔离性。但全局锁也带来了并发问题。场景是这样的两个全局事务同时操作同一条库存记录。事务 A 先拿到全局锁事务 B 拿不到会重试。如果事务 A 执行时间很长事务 B 一直重试最终可能超时失败。更麻烦的是死锁。事务 A 锁了库存表的 id1想锁账户表的 id1事务 B 锁了账户表的 id1想锁库存表的 id1。两个互相等死锁了。Seata 的全局锁默认重试 30 次每次间隔 10ms可以通过client.rm.lock.retryInterval和client.rm.lock.retryTimes调整。但调参数只是缓解根本解法是减少全局事务的粒度。能拆成多个小事务的别塞进一个大事务里。全局事务越长锁持有时间越久冲突概率越大。提示如果业务允许可以在查询库存的时候用SELECT ... FOR UPDATE提前锁定把冲突暴露在业务早期而不是等到更新的时候才发现。这样至少能快速失败而不是卡在那里重试。5.2 undo_log 表膨胀和清理策略正常情况下undo_log 表在事务提交或回滚后会被清理。但如果你发现表里数据越来越多通常是这几种情况事务卡住了TC 联系不上某个 RM或者 RM 一直回滚失败日志删不掉。异步删除失败二阶段提交时删除日志是异步的如果 RM 在删除前挂了日志会残留。脏数据手动改过数据库导致 undo_log 和实际数据对不上。排查的时候先看log_created时间超过一天的基本可以判定是异常残留。然后拿 XID 去 TC 的global_table里查看这个全局事务是什么状态。如果是Begin状态一直不变说明 TC 也在等得看 RM 的日志。清理的话可以写个定时任务删除log_created超过 7 天的记录。但删之前一定要确认对应的全局事务已经结束了否则删了日志回滚就做不了了。5.3 常见报错速查表我把实际运维中遇到的高频报错整理成一张表方便你快速定位报错信息可能原因解决方向no available service default foundTC 没启动或 vgroup-mapping 配错检查 TC 是否运行核对分组映射can not get cluster name in registry configregistry.conf 里 cluster 没配补上 cluster 配置Global lock acquire failed全局锁冲突减少事务粒度或调大重试参数undo_log table not exists业务库没建 undo_log 表在每个业务库执行建表语句XID not found in contextXID 没传递过来检查 RPC 框架的 header 透传branch rollback failed回滚时数据校验不通过查是否有并发修改检查 dataValidation 配置5.4 生产环境的几个保命建议最后分享几条我在生产环境总结的经验都是血泪换来的。第一TC 一定要集群部署。单点 TC 挂了所有全局事务都卡住。Seata Server 支持集群多个实例注册到同一个 Nacos 集群下TM 会自动负载均衡。第二全局事务别嵌套太深。A 调 BB 调 CC 调 D链路越长出问题的概率越大排查也越难。能扁平化就扁平化。第三关键业务加对账兜底。分布式事务再可靠也不能保证 100% 不出问题。资金相关的业务一定要有定时对账任务发现不一致及时告警和人工介入。技术手段是兜底不是万能。第四压测的时候重点测回滚。很多人压测只测正常流程回滚路径根本没跑过。结果线上一出问题回滚逻辑各种报错。我建议专门写个开关能强制让某个分支抛异常专门压测回滚路径。第五日志级别别开太细。Seata 的 DEBUG 日志量非常大生产环境开 DEBUG 能把磁盘写满。用 INFO 级别出问题的时候再临时调。这套东西我从最早的单体事务一路踩过来Seata 算是目前综合体验最好的方案了。但它不是银弹理解它的原理和边界比会用它更重要。尤其是全局锁和回滚校验这两个机制直接决定了你的业务能不能扛住并发。把这些搞明白了再去做技术选型和架构设计心里就有底了。