一个下单按钮,背后牵扯的不只是一个表格。
用户点击下单的瞬间,如果系统还没被拆分,事情很简单:订单表插入一条记录,商品库存表扣掉一个数量,账户余额表扣掉相应金额。这三条SQL在同一个数据库连接里,包在一个本地事务里,要么全部成功,要么全部回滚。就像一个人在家做饭,锅铲还在自己手里,一道菜做坏了立刻可以重做。
一旦把库存、订单、账户拆进了不同的服务和数据库,麻烦就不是“多写几个接口”的事了。你的代码要同时操作三个库,中间任何一次网络抖动、接口超时、服务重启,都会让数据停在“一部分改了,一部分没改”的状态。这也是分布式事务这个话题在服务化改造之后必然被翻出来的原因。这一篇聊的就是分布式事务的落地,主角是Seata,先从原理把骨架搭起来,再往下讲部署、验坑和选型边界。
1. 分布式事务为什么让团队头疼:一个下单场景就能看出分裂
1.1 拆库以后,本地事务的范围已经失效
回到那个下单场景。单体应用时代,订单、库存、余额三张表在同一个库甚至同一个实例里,Spring的@Transactional就够用:框架在一个线程里拿到同一个数据库连接,开启事务、执行三条SQL、提交或回滚,数据库的锁、日志、恢复机制全部兜底。这个模型之所以可靠,是因为所有资源都在同一个数据库的管辖范围内,数据库天然就是那个“唯一裁判”。
拆库之后呢?三个操作分布在不同服务、不同库。跨服务调用一旦发起,链路根本不在同一个“数据库上下文”里。很多人最初会用一个try-catch包住三个操作,觉得某个调用失败就抛异常,前面的操作自然能回滚。实际上根本滚不回去:你catch到的只是“远程调用返回了异常”,账户库那条已经执行并提交了的UPDATE并不会收到任何回滚指令。Spring的@Transactional只对同一个线程上、同一个DataSource上的操作有效,换了库、换了服务,它管不着。
1.2 分布式环境下没有“同一个时钟”
如果只是“数据库管不过来”倒还好,真正麻烦的是三个现实约束。
第一,网络是不可靠的。服务A调用服务B,B实际上处理成功并提交了,但A因为网络超时没有收到成功响应。A这边判定“可能失败”,准备做回滚;B那边数据已经落库。两边都不知道对方到底处于什么状态。
第二,节点会死。就算没有网络问题,如果库刚刚写入成功,协调进程、业务服务或者数据库本身在这个节骨眼上宕机,这笔事务的最终状态便没人知道。单机数据库靠redo log和undo log恢复,分布式环境下谁来做这个“恢复”?
第三,锁和事务是局部的。数据库的锁只能锁住自己库里的行,没有办法锁住另一个库正在被修改的数据。跨库场景下,你不能让数据库用一把“全局锁”把三个库的同一行数据全部锁死。所以传统单机事务里的ACID,在分布式场景下不能被简单平移,只能通过“协调”和“补偿”在业务层面逼近同样效果。
1.3 从强一致走向最终一致是必然
正因为这些约束,分布式事务的解决方案基本都有一个共同取向:不强求所有参与者“同时成功”,而是保证只要业务最终成功,每个库的状态都对齐;只要业务最终失败,每个库的中间改动都被撤销或补偿。这也就是大家常说的从强一致走向最终一致。
Seata的工程思路,正是沿着这个方向做的:全局事务仍然有一个明确的发起方和协调者,但每个参与者(分支)先各自提交本地事务,把改动和旧值记录下来,等到大家都“举手”之后,要么一起“落定”,要么根据记录逐一把改动补偿回去。理解了这一点,再看后面的TC、TM、RM和各种模式就不会晕了。
2. Seata的运作骨架:三个角色和一条贯穿全链路的XID
2.1 先认清三个角色,再回头对应业务
Seata把一次全局事务拆成三部分职责。
| 角色 | 全称 | 职责 | 在下单场景里的对应 |
|---|---|---|---|
| TC | Transaction Coordinator | 独立运行的协调者,记录全局事务和分支事务的状态,决定提交还是回滚 | 单独部署的seata-server进程 |
| TM | Transaction Manager | 全局事务的发起方,负责开启、提交或回滚全局事务 | 下单入口的Service方法 |
| RM | Resource Manager | 分支事务的管理者,向TC注册分支,执行本地事务的提交和补偿 | 订单库、库存服务、账户服务各自的数据源 |
这里最容易绕晕的是TM和RM。简单记:谁在最外层开启全局事务,谁就是TM;谁的业务SQL真正落在了某个数据库里,谁就是RM。同一个服务可能同时是TM和RM:下单Service开启全局事务,它自己也操作了订单库,那么它既是TM,又通过数据源注册成一个RM分支。
TC和TM、RM的通信,是全局事务能跑起来的神经线。TC一般不参与业务SQL,只存状态、做裁决,所以它独立于业务服务部署,也正因为独立,才能保证某个业务节点挂了之后,其他人还能找到“总开关”。
2.2 XID:一张贯穿调用链的“通行证”
一次全局事务开始后,TC会分配一个全局唯一的事务ID,也就是XID。这个XID会在整个调用链路上被透传:下单Service拿到XID,调用库存服务时把XID带到请求头,库存服务再调用账户服务时继续带下去。这样每个人都知道“我现在参与的是哪一次全局事务”。
XID的传播可以靠微服务框架的请求拦截器实现,比如在Feign调用前把XID写进Header。业务代码不需要手工传参。值得多说一句的是,如果你们用的是自定义RPC、Dubbo或者Spring Cloud OpenFeign,需要确认框架层是否已经帮你处理了这一步;没处理的话,全局事务在“第一跳之后”就断掉了,下游全部成了普通本地事务。
验证XID是否传到位,最直接的办法是看日志:上游日志出现一次全局事务ID,下游日志同样出现同一个ID,说明链路通。如果下游没有拿到XID,那么下游的写操作只是个本地事务,根本不会被Seata管住。这个问题会在后面踩坑章节重点说。
2.3 AT模式的两个阶段:先办事、留底稿、出事再改回
Seata最常用的AT模式,把一次全局事务分成两个阶段。
一阶段:业务SQL照常执行,但在执行前后,Seata会记录该行数据的前镜像和后镜像,写进一张undo_log表。随后分支事务向TC注册,并去申请一个全局锁,最后把本地事务直接提交。注意,这个阶段是真的提交了本地事务,不是锁住不提交。
二阶段:TC收到所有分支的结果后做裁决。如果全部成功,TC广播提交指令,各分支把对应的undo_log删掉即可。如果有一个分支失败,TC广播回滚指令,每个分支读取undo_log,根据前镜像生成反向SQL,把刚才的改动“改回去”,再删除undo_log记录。
这个设计的机关在于:分支事务既然已经提交,数据库层面就不可能再回滚它,只能靠补偿。undo_log里的前镜像就是“照片”,反向SQL就是“根据照片把场地还原”。这也是为什么AT模式要求参与事务的每张业务表、每个业务库具备undo_log表,否则二阶段没有依据可查。还要注意,undo_log必须和业务表放在同一个库里,因为一阶段写undo_log和执行业务SQL必须落在同一个本地事务里,才能保证“照片”和“动作”一起成功或一起失败。
2.4 为什么它比传统两阶段提交好用
传统两阶段提交的最大问题在于,一阶段会预占资源。也就是说参与者都在资源上加了锁,迟迟不提交,数据库承受巨大的锁压力和性能损耗。
AT模式把“预占+等待”改成了“先干活+留凭证”。本地事务立刻提交,数据库的锁很快释放,代价是换了一个更高层的协调锁和一份undo_log写入。全局锁虽然也会造成跨事务的写冲突等待,但比“把所有数据库行锁都攥在手里等最终裁决”要轻得多。对大多数跨库写操作来说,这个交换是划算的。
3. 四种模式怎么选:AT、TCC、SAGA、XA的定位和边界
3.1 “模式”到底指的是什么
前面说的TC、TM、RM,在任何模式下都一样,真正有区别的是“分支事务如何管理资源、二阶段怎么做”。Seata把这种差异封装成四种模式,业务团队根据一致性要求、开发成本、性能预期来选。顺序不固定,但多数团队都是先从AT开始,不够再往其他模式迁移。
3.2 AT模式:侵入最低,适合大多数跨库写入
AT模式最大的卖点是无侵入。业务代码基本不用改,你只要把数据源交给Seata代理,然后在入口方法加一个注解。前文的undo_log和反向SQL,都是框架替你处理的。
它的代价也在“自动”这两个字上:隔离性不如TCC,遇到高写冲突时全局锁会成为瓶颈。所以AT适合中低并发、跨库操作、业务流程相对固定的场景,比如下单、改单、订单状态流转这类。我自己在项目里用得最多的也是AT,因为它能在一个下午就把跨库订单流程接进去,业务方几乎感知不到改造成本。
3.3 TCC模式:把三个动作交给业务自己写
TCC的思路是把一次业务操作拆成Try、Confirm、Cancel三个方法。Try阶段负责检查和预留资源,Confirm阶段真正执行业务,Cancel阶段释放预留资源并撤销。这三个方法都得业务自己实现,所以开发量明显比AT大,但也换来了更高的控制力:资源什么时候锁、什么时候释放、怎么处理部分成功,都是你说了算。
TCC的场景通常是资金类、强隔离、高并发,比如账户余额预冻结、库存预占。要注意,TCC对幂等、空回滚、悬挂都有额外要求,上线前必须把这些边界测全,不然Cancel阶段很容易出现“扣了两次”或者“本来没成功却执行了回滚”的局面。
3.4 SAGA和XA:长流程编排与强一致的最后选择
SAGA把一个长流程拆成多个本地事务,然后通过状态机或事件编排,一步步执行;某个步骤失败时,反向调用已经执行过的步骤的补偿事务。它对执行时间长、步骤跨度大的流程比较友好,比如审批链路、订单履约全过程。但它的隔离性差,中间步骤的数据对外是可见的,如果业务要求“中间过程不能被人看到”,SAGA就不太合适。
XA走的是数据库原生事务协议,二阶段由数据库自己保证,强一致、隔离性强,但资源锁占用时间长,并发能力和吞吐量受限,而且不少数据库对XA的支持存在兼容细节。所以除了低并发、强一致场景里的老系统局部改造,新项目一般不建议一上来就选XA。
3.5 选型对照表
| 模式 | 侵入性 | 一致性 | 隔离性 | 性能/并发 | 开发复杂度 | 典型场景 |
|---|---|---|---|---|---|---|
| AT | 低 | 最终一致(补偿) | 全局锁约束,弱 | 中等,写冲突时有瓶颈 | 低 | 跨库写、中低冲突业务 |
| TCC | 中高 | 最终一致(业务补偿) | 业务可控,可强可弱 | 较好 | 高 | 资金、库存等强隔离场景 |
| SAGA | 中 | 最终一致(补偿) | 弱,中间状态可见 | 较好 | 高 | 长流程、多步骤编排 |
| XA | 低 | 强一致 | 强 | 一般,资源锁久 | 中低 | 低并发强一致老系统 |
我个人的选型习惯是:第一个想到AT,先用最小成本跑通;一旦发现业务对隔离、账务对账有硬要求,或者压测里全局锁频繁超时,再评估TCC;流程很长并且能够用状态机表达的,才考虑SAGA;XA除非紧急,否则不会主动推到新项目里。
4. 动手把Seata跑起来:TC部署、建表、写入口事务
4.1 准备环境与版本匹配经验
跑通Seata的全局事务,至少需要:两个以上业务数据库、一个注册中心(没有就用Nacos,本地起一个也很简单)、JDK环境、以及Seata的server包。我建议第一次做Demo时,就用项目里已有的注册中心,少引入一个组件。
版本上提醒一句:Seata和Spring Boot/Spring Cloud的版本匹配需要主动确认。我试过的比较稳的组合是Spring Boot 2.7.x配Seata 1.7.x/1.8.x;如果你用的是Spring Boot 3系列,就要专门查新版本的兼容说明,不要图省事直接拉最新。版本不匹配容易在启动阶段或者第一次调用时暴雷,后面踩坑章节会展开。
4.2 部署TC(seata-server)
从官网下载seata-server二进制包后解压,关键要改两个地方。
一是registry和config,指向你的注册中心/配置中心。下面是一段Nacos场景下的简化配置示例,具体字段在不同版本里略有差异,以你当前版本为准。
registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 namespace: "" group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: "" group: SEATA_GROUP二是store.mode。测试环境可以选file,把全局事务和分支事务状态直接落盘;生产环境建议用db,把状态存进数据库,需要建三张表:global_table记录全局事务,branch_table记录分支事务,lock_table记录全局锁。db模式比file模式更稳,也方便出问题时直接查库定位。
这里最容易埋坑的是事务分组。客户端侧会配置一个tx-service-group,比如my_test_tx_group,服务端侧也要配置同样的分组映射,客户端才会通过注册中心找到TC。两边字符串必须对得上,否则客户端一直报“找不到TC”。另外,注册中心的namespace、group也要保持一致,很多人配置对了一个忘了另一个,客户端就是连不上。
4.3 业务库补一张undo_log表
AT模式下,每个参与全局事务的业务库都要创建undo_log表。这张表必须和业务表在同一个库里,因为一阶段写undo_log和执行业务SQL必须在同一个本地事务里,才能保证“照片”和“动作”一起成功或一起失败。
CREATE TABLE IF NOT EXISTS `undo_log` ( `branch_id` BIGINT NOT NULL COMMENT '分支事务ID', `xid` VARCHAR(128) NOT NULL COMMENT '全局事务ID', `context` VARCHAR(128) NOT NULL COMMENT '上下文', `rollback_info` LONGBLOB NOT NULL COMMENT '回滚信息', `log_status` INT NOT NULL COMMENT '状态:0正常 1异常', `log_created` DATETIME(6) NOT NULL, `log_modified` DATETIME(6) NOT NULL, UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`) ) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT = 'AT模式回滚日志表';建好之后不用手工管它,Seata会自己写入和清理。
4.4 应用侧改三处地方
第一处,引入Seata的Spring Boot Starter依赖,版本和前面说的一样要对齐。
第二处,配置事务分组和注册中心信息。给一个常见的客户端配置片段:
seata: tx-service-group: my_test_tx_group 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 enable-auto-data-source-proxy: true >@GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class) public void createOrder(OrderCreateRequest request) { orderMapper.insert(OrderDO.from(request)); inventoryClient.deduct(request.getSkuId(), request.getCount()); accountClient.deduct(request.getUserId(), request.getAmount()); }验证方法很简单:把账户扣减后面的逻辑故意改成一个抛出RuntimeException的模拟失败,或者让账户服务在扣款成功后返回一个错误码。执行一次后去数据库核对——正常情况下,订单表没有多余记录,库存没有扣,账户余额也没有变;同时日志里能看到全局事务注册、分支注册、二阶段回滚的关键字。
如果你想看得更细,可以在账户扣款前打印XID,再在下游服务的SQL执行位置也打印XID,确认同一条事务ID贯穿了两跳调用。这个验证做完,基本的全局事务链路就算通了。
5. 压测和上线时容易翻车的五个细节:从现象到排查链路
5.1 全局锁等待超时:AT模式在写冲突面前并不万能
现象:日志反复出现GlobalLockWaitTimeout或类似“获取全局锁超时”的异常,事务自动回滚。
原因:AT模式一阶段虽然提交了本地事务、释放了数据库行锁,但在二阶段结束前,TC的lock_table里对这条业务数据的全局锁仍然存在。另一个全局事务想要修改同一行,就得等前面那把锁释放。并发一高,等待时间超过了阈值,就会报错回滚。这里要特别强调一下,本地锁释放了不代表锁没了,全局锁是在TC侧的另一把锁,很多人习惯性以为“提交了就没有锁了”,结果在批量任务里被全局锁打懵。
排查链路:先查TC的lock_table里是否残留锁记录;再看是不是某个全局事务长时间没有提交或二阶段一直没执行;最后评估业务本身的写冲突概率。批量跑批任务时这个坑尤其明显,几十个线程同时改同一批数据,全局锁会被瞬间打满。别一上来就调大锁等待超时,那是治标不治本,先看是不是业务并发模型的问题。
5.2 @GlobalTransactional自调用失灵
现象:在同一个类里,方法A调用方法B,B上面标了@GlobalTransactional,但日志里一点全局事务痕迹都没有,数据也没有被Seata管住。
原因:Spring AOP通过代理对象拦截注解,而类内部方法的自调用不会经过代理对象,就像一个公司内部人员直接走后门,没有经过前台,门禁当然没生效。
解决:把需要开启全局事务的方法单独放到一个Service类里,让外部调用它;或者通过注入当前类的代理对象再调用。自查时去日志里搜索transaction或xid关键字,没有出现就说明切面压根没被触发。
5.3 undo_log被污染,回滚失败
现象:二阶段回滚时,日志出现branch rollback failed,业务数据没有恢复到预期状态。
原因:一阶段提交后、二阶段回滚前,有人绕过Seata直接改了同一行数据,比如定时任务、报表库同步、DBA手动订正。undo_log里存的是旧的前镜像,回滚时发现当前数据和前镜像对不上,自然执行不了补偿SQL。
这个坑的教训是:凡是参与Seata全局事务的表,写入入口必须收敛到业务服务API,不允许侧面直改。如果还是发生了回滚失败,不要指望框架能兜底,只能查undo_log残留记录、人工核对数据后写补偿脚本。
5.4 XID没透传,下游分支全部“脱管”
现象:上游明明有全局事务,下游服务日志里却看不到同一个XID,下游的写操作只是本地事务。
原因:RPC框架没有配置XID透传。最常见的场景是Feign调用没有配置请求拦截器,或者调用链路里引入了异步线程,ThreadLocal里的XID在新线程中丢失了。
解决:同步RPC链路里配置统一透传,通常是一个RequestInterceptor,把RootContext.getXID()放进请求头。异步场景要特别小心:如果业务里用了线程池,需要在任务开始前手动绑定XID,任务结束后清理,否则子线程根本不知道自己属于哪次全局事务。消息队列场景也一样,消息从发送到消费跨了线程和进程,别指望Seata自动把XID带过去,消费端要么自行加入新事务,要么走本地消息表的方案。
5.5 版本不匹配与手动数据源代理问题
现象:启动时NoSuchMethodError,或者全局事务调用时报错;另外一些项目启动正常,但AT回滚不生效。
原因:Seata各版本之间API变化比较大,尤其是2.x对比1.x,配置项和类路径都有调整;手动创建DataSource或动态数据源时没有用DataSourceProxy包一层,Seata看不到真实SQL的上下文。
解决:锁定版本,先写最小Demo做回归再推进业务改造;数据源相关配置梳理一遍,只对参与全局事务的数据源做代理。把这两个问题整理进团队的技术文档,比临时百度强得多。
整理成一张速查表:
| 现象 | 常见原因 | 快速排查方向 |
|---|---|---|
| 全局锁等待超时 | 并发写同一行 | 查lock_table、事务持续时间 |
| 全局事务未开启 | AOP自调用/代理失效 | 日志搜xid |
| 回滚失败 | undo_log被外部修改污染 | 查undo_log残留、核对前镜像 |
| 下游未纳入事务 | XID未透传/线程池丢失 | 查上下游日志xid |
| 启动异常/回滚不生效 | 版本不匹配/数据源未代理 | 核对版本、检查数据源代理 |
6. Seata不是万金油:什么时候值得用,什么时候不如轻量方案
6.1 先问三个问题再决定是否上全局事务
不是所有跨服务调用都需要全局事务。我习惯在评审阶段先问三个问题:这笔操作频率高不高?失败后能不能靠重试、对账、补单恢复?业务是不是必须同步完成?
如果答案里有两个“是”,那么Seata才有上的必要。如果只是一个低频动作、失败后能靠定时任务对账修复,那引入全局事务纯属给自己找麻烦。全局事务不是免费的,它要多写undo_log、要来回复核分支状态、要维护TC集群,这些开销都必须算进成本里。
6.2 轻量替代方案常常更划算
对于“某件事做完了,顺便要通知另一个系统”这种场景,本地消息表加定时任务就够了:业务表插数据、消息表插消息,放在同一个本地事务里;定时任务扫描未发送的消息去推送,失败就重试。这个方案简单、可靠,也不需要一个独立的协调器。
如果流程是“先改业务,再发MQ”,MQ的事务消息也能派上用场:先发一个半事务消息,业务提交成功后再确认发送;业务回滚就删掉消息。这比把消息发送也纳入Seata全局事务要轻得多。
还有一类场景可以靠对账兜底:订单支付成功后,可能B系统更新积分偶尔失败,那就每天跑一次对账,把失败的补回去。业务上只要接受一小段不一致时间,完全没必要上分布式事务中间件。
6.3 什么时候Seata仍然是最稳的选择
反过来,当业务链路必须同步完成、多个参与方必须对结果达成一致,而且团队不想花精力自研补偿框架时,Seata依然是最务实的选项。尤其是AT模式,几乎不改造业务代码就能把跨库写纳入事务管理,对大多数中低并发业务来说,性价比很高。
6.4 搭好以后,别忘了故障演练
不管你选了哪种模式,上线前一定要做恶意故障演练。找一个压测环境,故意让某个下游服务在全局事务中间抛错,然后逐张表核对业务状态。这类“故意搞破坏”的测试能暴露很多平时不显现的问题:全局锁等待可能无感,回滚失败可能是悄悄发生的,XID丢失在紧急排查时才想起来已经晚了。
我自己的体会是,分布式事务的核心矛盾,从来不是“哪条SQL要不要回滚”,而是“在无法消除网络和节点故障的前提下,怎么让业务状态最终保持一致”。Seata的AT模式让我最受用的一个思路叫“补偿优于等待”——先干活,把状态记录下来,后面发现问题再对齐。每次遇到全局锁超时、回滚失败,只要沿着“全局事务开了没有、分支注册了没有、锁等在哪里”这个顺序排查,大多数问题都能很快定位。
最后分享一个小建议:不管团队选择哪个版本、哪种模式,都要把故障演练脚本保留下来,每次版本升级后重复跑一遍。只有在这种故意搞破坏的测试里,你才能真正确认Seata在你这个业务场景里靠不靠谱,而不是上线以后才发现全局事务根本没生效。