
最近在帮一个电商团队做中台微服务改造第一个被拆出来的就是订单服务紧接着就撞上了分布式事务这面墙。以前下单、扣库存、加积分都在一个应用里一个Transactional就能把所有操作拴在同一个数据库连接上服务一拆订单服务连订单库库存服务连库存库互相之间只能靠网络接口通信下单成功后远程调库存扣减库存那边只要一超时或者一宕机两边数据就再也对不上了。这个场景在微服务架构里太典型了几乎所有做过服务拆分的人在某个版本迭代里都会被迫认真面对分布式事务。这篇博文不打算铺开讲理论我主要想聊聊在 Spring Cloud 体系里集成分布式事务的完整过程主流方案到底怎么选Seata 怎么一步步接进去回滚怎么验证以及生产环境里那些不踩一遍根本不会长记性的细节。1. 本地事务失效这件事为什么拆完服务就找上门了1.1 事务的边界本质上是数据库连接的边界先补一个最基础的认知后面所有方案都从这里长出来。单体应用里写TransactionalSpring 做的事情是在方法执行期间把同一个数据源连接绑定到当前线程上所有 SQL 都走这条连接最后统一 commit 或 rollback。事务的边界就等于连接的边界而连接就在这一个进程内所以本地事务能保证原子性、一致性、隔离性和持久性。但微服务拆分之后订单服务和库存服务分别部署各自连接各自的数据库。一次下单操作跨了两条连接、两个数据库、两个进程。你不可能让订单库的事务管理器去控制库存库的连接因为两者根本不在同一个内存空间里。这时候本地事务天然就“断裂”了。所以别把锅甩给微服务本质是“事务边界”和“服务边界”不再重合。解决思路只有两条要么引入一个跨越所有参与方的协调者要么换一种写入方式让一致性要求通过其他机制兜住。1.2 分布式事务真正要解决的三个问题一说到分布式事务第一反应是“要么都成功要么都失败”这是原子性。但实际做起来你会发现隔离性也同样棘手。举个具体例子订单服务先插入订单再远程调库存服务扣库存。订单插入成功了但全局还没提交此时如果有报表任务去扫订单表会看到一条“悬空”订单或者库存已经被扣了订单却还在生成中。这些中间状态如果被其他系统读到一样会造成脏数据。所以分布式事务通常要同时考虑三件事原子性所有参与方要么一起提交要么一起回滚。隔离性事务执行过程中的中间状态不能以不可控的方式暴露给其他事务。最终一致性在跨网络环境下强一致很难一直保持但可以通过重试、补偿、对账把数据最终拉回一致状态。这个“三角形”正好对应分布式领域最常聊的 CAP 和 Base 理论。强一致方案在网络分区时倾向于放弃可用性最终一致方案保留可用性用异步补偿去追最终状态。理解了这层取舍再去看后面那些方案谁适合什么场景基本一目了然。1.3 为什么“订单与库存”是分布式事务的经典场景只能说这个场景太有代表性了。订单和库存之间有一个强约束订单创建成功库存必须扣减库存不足订单就不能创建。它还是典型的高频短事务每次操作数据量不大但调用链长出问题的影响又直接——库存扣多会超卖扣少会造成资损。我们当时的主链路是订单服务创建订单 - 调用库存服务扣减库存 - 调用积分服务增加积分。如果不做一致性控制最坏的情况就是订单表里显示用户下单成功了库存表里扣减却失败了库存台账和实际账目永远对不上每次对账都要人工介入成本高到团队无法接受。把这条链路当作分布式事务的试点跑通之后其余业务基本可以照搬这套模式。2. 先别急着上框架主流分布式事务方案怎么选2.1 强一致路线2PC 与 XA 的先天约束先看最“硬”的方案。二阶段提交2PC解决的就是多库原子提交问题第一阶段协调者问所有参与者“能不能提交”每个参与者本地执行但不提交把资源锁住第二阶段所有人都说 OK协调者才发“正式提交”指令任何一个说不行全部回滚。XA 是 2PC 在数据库层面的标准实现比如 MySQL 的 XA 事务。理论上很完美强一致也没有业务侵入。但放到微服务场景里问题非常明显第一阶段要把资源锁住整个事务期间所有参与库的行锁、表锁都不能释放这在高并发下等于把吞吐量按在地板上摩擦另外 XA 要求数据库本身支持 XA跨异构资源比如一个 MySQL 一个 MongoDB根本没法用。所以你会看到XA 在单体多库场景里偶有使用但微服务架构下很少有人拿它当主干方案。2.2 TCC把“补偿”写进业务代码TCC 是 try-confirm-cancel 的缩写等于让业务自己实现三个方法Try 阶段做资源预检查比如检查库存并冻结Confirm 阶段真正执行比如扣减冻结库存Cancel 阶段做反向操作比如解冻库存。这种方式不依赖数据库事务甚至可以跨异构系统隔离性强是很多金融场景的首选。代价是侵入性极高。每接入一个业务你都要为它手写三套方法并且要处理幂等、空回滚、悬挂这些问题。比如库存不足时 Try 直接返回失败但如果网络抖动导致确认请求没到Cancel 又先到了你就得自己保证 Cancel 不能瞎执行。所以 TCC 能力强但开发量通常翻倍对团队的抽象能力和代码规范要求很高。2.3 Saga长流程多一点柔性补偿最灵活Saga 的核心思想是把一个长事务拆成一串本地事务每个本地事务都配一个反向补偿操作。比如下单成功后扣库存扣库存失败就执行“取消订单回补库存”的补偿。Saga 有两种编排方式一种是事件驱动每个服务完成后发消息给下一个一种是集中协调由一个编排中心统一调用。它比 TCC 更灵活适合跨多个系统、流程很长的业务比如订单-支付-物流因为它不要求所有参与方在同一时间段内“在线”天然支持重试和幂等。但 Saga 的一致性强度偏弱它没有全局锁中间状态会被其他系统看到补偿操作自身也可能失败需要重试机制和人工介入的兜底。对我们这种订单扣库存的场景来说Saga 略“重”了一点而且最终不一致的时间窗口比较长。2.4 消息驱动本地消息表与事务消息还有一条“老实”路线不追求实时强一致而是通过消息把一致性“推”过去。最常见的是本地消息表业务操作和一条消息写入同一个本地事务然后后台任务把消息发到 MQ消费者执行业务逻辑核心保证是“本地事务和消息发送的原子性”。另一条是事务消息典型实现是 RocketMQ。发送方先把消息发送到 MQ状态是半消息本地事务成功了再提交消息失败就回滚消息如果半消息一直没确认MQ 会回调发送方查询最终状态从而保证“本地事务和消息发布的最终一致”。这条路线适合“可以异步”的场景比如积分、通知、报表但对订单和库存这种需要即时返回扣减结果的场景就不太合适——用户下单时你就得告诉他有货没货总不能等他逛完一圈再异步扣。2.5 我们的选型结果为什么是 Seata 的 AT 模式综合来看团队当时的约束是Spring Cloud 技术栈、Java 开发、数据源主要是 MySQL、要求尽量低的改造量。在对比了一大圈之后我们选了 Seata 的 AT 模式。AT 模式相当于在 TCC 和 XA 之间取了个巧它用数据库 undo_log 表记录数据变更前后镜像框架自动生成反向补偿 SQL业务代码只需要加一个GlobalTransactional注解数据源交给 Seata 代理即可。方案一致性业务侵入性性能影响适用场景XA / 2PC强一致低但依赖数据库高全程锁资源单体多库、低并发TCC业务级强一致高手写三方法中金融账务、跨异构系统Saga最终一致高中长流程、可异步补偿本地消息表最终一致中低可异步、可削峰事务消息最终一致低低可异步、可削峰Seata AT最终一致数据层面强一致低注解加代理中多写 undo_log短事务、低冲突、MySQL 场景简化一下就是AT 模式用最低的代码侵入换来了数据层面的一致性特别适合订单扣库存这种对实时性有要求、事务又不长的场景。另外我还被问过好几次“为什么不用 RocketMQ 事务消息”判断标准其实很朴素业务现场是不是需要同步返回结果。下完单用户马上要知道有没有货这个就必须同步如果只是下单后发优惠券、发积分用户可感知不强那就该走事务消息。接下来就是实际操作了。3. 动手集成Spring Cloud Alibaba Seata 打通订单与库存3.1 环境与版本选择以我们当时的环境为例注册中心和配置中心都用 NacosSeata Server 用的 1.6.x服务端是 Spring Boot 2.x Spring Cloud Alibaba 2021.x。这里提醒一句Seata 版本迭代很快不同版本的配置项差异不小网上很多老教程里的配置在新版里已经改掉了建议以你本地 Maven 依赖对应的版本文档为准。我下面给的配置是基于 1.6.x 的常规写法。3.2 部署 TC Server全局事务的协调者Seata 架构里有个角色叫 TCTransaction Coordinator所有分布式事务的状态都由它记录和管理。它必须注册到注册中心这样各个业务服务才能发现它。如果你下载的是 seata-server-1.6.x 的压缩包解压后主要改conf/application.ymlserver: port: 7091 seata: registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP config: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP改完直接启动sh bin/seata-server.sh启动日志里能看到 TC 成功注册到 Nacos 的提示。注意如果你的 Nacos 有命名空间隔离业务服务配置里的 namespace 要和 TC 保持一致不然互相找不到。TC 单机部署可以用于开发联调但生产环境我建议至少起两个节点组成集群避免单点故障把整个链路的分布式事务全部打挂。3.3 业务服务接入依赖、配置、undo_log 表订单服务、库存服务、积分服务都需要引入 Seata 依赖。以订单服务为例pom.xml 里加dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId /dependency dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId version1.6.1/version /dependency然后在 application.yml 里配置事务分组seata: enabled: true application-id: order-service tx-service-group: order_tx_group registry: type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: public group: SEATA_GROUP service: vgroup-mapping: order_tx_group: default grouplist: default: 127.0.0.1:8091这里有个容易懵的点tx-service-group 是业务自定义的名称比如 order_tx_group它通过 vgroup-mapping 映射到 TC 集群名最后指向 TC 地址。新版本建议优先走注册中心自动发现老的 grouplist 写死地址的配置我一般只当兜底。业务库还要建一张 undo_log 表每个参与分布式事务的库都要有不能只建在主库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, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;数据源必须被 Seata 代理否则框架拿不到前后镜像。如果项目里用了连接池常见写法是这样Configuration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource) public DataSource dataSource() { return new DruidDataSource(); } Bean Primary public DataSourceProxy dataSourceProxy(DataSource dataSource) { return new DataSourceProxy(dataSource); } }Primary很重要MyBatis、MyBatis-Plus 这些框架默认注入的是唯一的 DataSource如果忘了加代理没生效整个事务链路就是“假的”分布式事务。3.4 用 GlobalTransactional 圈住整条链路配置做完业务代码改动其实非常小。订单服务里把原来普通方法升级成全局事务Service public class OrderService { GlobalTransactional(rollbackFor Exception.class) public void createOrder(String userId, Long productId, Integer count) { OrderDO order OrderDO.build(userId, productId, count, CREATED); orderMapper.insert(order); // 远程调用库存服务 inventoryFeignClient.deduct(productId, count); // 远程调用积分服务 pointFeignClient.add(userId, 100); } }库存服务里扣减接口保持普通本地事务但遇到异常必须向上抛PostMapping(/inventory/deduct) Transactional(rollbackFor Exception.class) public ResultBoolean deduct(RequestParam Long productId, RequestParam Integer count) { int rows inventoryMapper.deduct(productId, count); if (rows 0) { throw new BizException(ErrorCode.INVENTORY_NOT_ENOUGH); } return Result.success(true); }执行流程是这样的createOrder 方法一进入本地会向 TC 发起全局事务注册拿到一个全局事务编号 XID随后订单 Mapper 执行 insertSeata 通过数据源代理记录前后镜像并把一条分支事务注册到 TCFeign 调用库存服务时XID 会被放到请求头中传过去库存服务收到后把库存扣减的本地事务也注册成同一个全局事务的分支。全部方法正常结束TC 发起全局提交任何一个环节抛异常TC 就会向所有已注册分支发起回滚。另外注意GlobalTransactional默认只对 RuntimeException 回滚如果要覆盖受检异常必须像上面一样显式写rollbackFor Exception.class我们团队统一规定所有全局事务方法都这么写省得后面有人栽跟头。3.5 XID 是怎么在微服务间传播的这一步是最多人踩坑也不自知的地方。GlobalTransactional只在当前服务里生成 XID它要靠调用链传出去后续服务才能感知自己参与了全局事务。Spring Cloud Alibaba 的 Seata 集成在 Feign 环境下是默认处理好的会自动把 XID 放到 header 里。但如果你用 RestTemplate、WebClient 或者裸 HTTP 连接必须手动传递。手动传也不复杂核心就是给请求加一个头然后把这个拦截器挂到 RestTemplate 上Component public class SeataRestTemplateInterceptor implements ClientHttpRequestInterceptor { Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { String xid RootContext.getXID(); if (xid ! null) { request.getHeaders().add(RootContext.KEY_XID, xid); } return execution.execute(request, body); } }如果你用的是 OpenFeign官方集成默认已经做了这件事遇到问题先去抓包确认 header 里有没有 TX_XID八成能定位问题。3.6 验证一次真实回滚集成完之后不要急着上线先手动验证回滚链路。我的习惯是这样操作先把库存数量设置成 10第一次正常下一单确认订单表、库存表都正确变化然后把库存扣到 0再下一单让它触发“库存不足”异常接着立刻查数据库你会发现订单表没有出现新记录库存也没有被扣成负数。这就是全局回滚生效了。如果回滚没生效优先查三件事被调用的服务是否引入了 Seata 依赖请求头里 XID 有没有传到被调服务被调服务的业务库里有没有建 undo_log 表。这三项是 90% 集成失败的原因。4. AT 模式看起来简单坑全在细节里4.1 隔离级别默认可能读到未提交数据先说一个很多教程会回避的点。AT 模式的全局锁解决的是“写写冲突”也就是两个全局事务同时改同一行时第二个必须等第一个提交后才能执行。但“读写冲突”它不管普通 select 不会因为全局锁阻塞所以一个全局事务改了数据但还没提交时其他请求可能读到这个中间状态。Seata 的官方定位是默认隔离级别为读未提交。如果想提升到读已提交需要业务代码里把关键的查询改成select ... for update让查询也走数据库锁或者在 select 时配合GlobalLock注解。但这么做的代价是并发能力下降具体用不用要看业务对中间状态敏感度。对我们订单库存场景下单过程中的报表查询能容忍一瞬间的中间状态我们就没有全局加锁。4.2 undo_log 表回滚的命根子每次分支事务执行Seata 都会做三件事查询执行前镜像before image、执行业务 SQL、查询执行后镜像after image然后把这些镜像和分支信息写入 undo_log。回滚时就是拿着 after image 对比当前数据如果没有被其他事务修改就用 before image 反向生成 UPDATE 或 DELETE 回写。所以 undo_log 一旦缺失回滚直接失败。常见的高危操作有手动清空 undo_log、把 undo_log 表建到了错误的库、业务 SQL 和 undo_log 的写入不在同一个本地事务一般是因为数据源代理没生效。我见过线上事故有人觉得 undo_log 膨胀写了个定时任务每天晚上清表结果当天业务就出问题——因为还有长时间运行的全局事务没有结束。真要处理膨胀应该先排查长事务而不是简单清表。还有一个细节Seata 生成镜像时要通过主键定位记录所以参与分布式事务的业务表必须有主键无主键表会导致回滚时“找不到对应记录”的报错。4.3 全局事务超时与分支悬挂TC 对全局事务有时间限制默认 60 秒。超过时间还没提交TC 会单方面标记超时并触发回滚。但业务线程可能还在慢吞吞地执行后续分支才姗姗来迟。这个时候已经处于回滚流程的全局事务又收到了新的分支注册请求就会出现“分支悬挂”或者“空回滚”问题。应对措施有三条接口超时时间要小于全局事务超时时间避免线程还在跑但事务已经被判死远程调用要做超时控制快速失败不要把线程吊死被调接口要做幂等和空回滚保护即使分支已经不存在也能安全地返回成功。我们当时的做法是 Feign 统一配置连接超时 2 秒、读超时 5 秒所有参与分布式事务的接口都遵循这个底线明显减少了悬挂问题。4.4 远程调用的幂等分布式事务解决不了重复Feign 在超时后默认会重试如果第一次请求其实已经到达库存服务并扣减成功了只是响应丢了重试就会再次扣减最终库存被扣两次。AT 模式只能保证“事务内”原子性不能保证“请求只被处理一次”。所以被调接口一定要幂等。当时给库存服务加的幂等逻辑扣减请求带一个业务幂等号比如订单号加商品 ID 的哈希库存侧有一张幂等记录表唯一键就是幂等号。每次扣减先插幂等表插入成功才执行真正的扣减插入失败说明是重复请求直接返回上一次的结果。这样即使 Feign 重试、MQ 重投都不会造成重复扣减。4.5 吞异常是分布式事务的大忌还有一类问题是“看着很正常实则全盘失败”。比如有人觉得远程调用偶发异常在 catch 里打个日志就继续往下走方法最终正常返回TC 就会认为全局事务执行成功并提交。结果就是订单创建了库存扣减接口其实失败了数据对不上。这类问题的本质是全局事务的成败完全依赖方法有没有抛出异常。想让回滚生效异常必须按原样抛出去。如果是真正的业务失败如库存不足请转成明确的业务异常抛出如果是临时性故障需要重试应该在事务外重试而不是在全局事务内吞掉。5. 集成之后的“隐形工作”监控、性能与取舍5.1 全局事务的可观测性上生产后第一件事是把分布式事务纳入监控。TC Server 有控制台可以看到全局事务状态和分支事务列表但线上不会有人天天盯着控制台。我们要做的是把关键指标接到告警系统全局事务提交失败率、回滚率、平均执行耗时。Seata 的日志里有清晰的标记比如GlobalCommit、GlobalRollback、GlobalSession超时把这些关键字接进日志采集平台再做对应告警。还有一个排查技巧当业务反馈“数据丢了一条”时先去订单服务日志找 XID再拿 XID 去库存服务日志里搜很快就能定位某个请求是否加入了同一全局事务。如果库存服务日志里完全没有该 XID基本可以断定 XID 传播断了。5.2 AT 模式不是零成本性能损耗要提前评估AT 模式省的是业务代码不是性能。每笔分布式事务的实际开销包括分支事务额外写一条 undo_log事务提交时 TC 与所有分支节点做两轮通信全局锁的获取与释放热点数据上会形成排队。实测下来单次事务比原来多几毫秒到几十毫秒不等这个量级对大多数业务可以接受但如果是秒杀类的热点扣减锁等待就很扎眼。建议在上线前做一轮压测专门压热点商品的扣减链路。如果发现 TC 在大量锁等待就要考虑两个方向一是把热点库存拆成多个子库存分散锁粒度二是干脆把这个接口从强一致事务里摘出去用 TCC 或异步队列加库存预占来替代。没有一种分布式事务方案是免费的接入之前先把账算清楚。5.3 什么时候升级 TCC 或换方案我们现在的分工是订单创建加库存扣减这种“短、频、强约束”的链路继续用 AT账户余额变动、积分变动这类“资金相关、对中间状态敏感”的链路改用了 TCC纯异步可容忍延迟的通知、报表全部走 MQ 最终一致。这个组合在线跑了很久整体很稳。TCC 的升级时机我建议是出现以下情况再动AT 模式的锁冲突已经显著影响核心接口 P99业务要求强隔离不允许读到未提交数据事务里混进了不受你控制的第三方系统对方不能帮你建 undo_log。TCC 的重构量不小每个方法要补齐 Try/Confirm/Cancel 三套逻辑和幂等保护不值得为了“技术潮流”去换。5.4 最好的分布式事务是“没有分布式事务”最后一条经验可能是最值钱的一条尽量减少分布式事务的使用范围。我们最初把整条链路十几个服务间调用全都包进GlobalTransactional全局事务又大又慢稍微一个节点抖动整个链路一起失败故障半径比原来还大。后来做了一轮梳理把强一致要求高的核心链路保留分布式事务其余操作要么迁到服务内部执行要么改成 MQ 异步削峰系统稳定性和性能都明显提升。设计阶段能做的优化也很多。比如把“创建订单加扣库存”的强一致动作收敛到一个服务内处理库存扣减通过消息异步完成再靠定时对账兜底比如先本地落一张“待扣减表”后台任务批量同步到库存服务。这些都不是分布式事务方案但能把真正需要分布式事务的场景压缩得很小。我的原则是能用幂等和对账兜底就不用分布式事务必须用分布式事务优先选侵入性最小的 AT对中间状态敏感再考虑 TCC。这样接进来的分布式事务才是真的在解决问题而不是在制造新问题。