分布式事务这个话题做过微服务的人基本都绕不开。我最早接触这块是在一个订单、库存、账户三个服务拆开的项目里本地事务管不住跨服务的数据一致性一笔下单请求走完订单表插入了、库存扣了、余额也减了结果中间某个环节抛异常数据就对不上。后来用上 Seata才算把这块理顺。这篇博文就围绕一个最典型的场景展开——订单服务发起GlobalTransactional联动库存和账户完成一次分布式事务把整个 demo 的搭建、原理、踩坑点讲透。如果你正在学 Seata或者手上有个跨服务一致性的活儿不知道怎么落地这个 demo 基本能覆盖你 80% 的疑问。我会从整体设计思路讲起再到核心注解的作用机制、三个服务的具体实现、异常回滚的验证方法最后把我实际调试时遇到的问题整理成速查表。全程按能直接抄作业的标准来写代码和配置都给到位。1. 整体设计与方案选型拆解1.1 为什么是订单服务当发起方分布式事务里第一个要想清楚的问题是谁来做事务的发起者。在这个 demo 里订单服务天然适合当这个角色。原因很直接——下单是整个业务链路的入口用户点一次提交订单后续的扣库存、扣余额都是这笔订单的从属动作。从业务语义上讲订单创建成功与否决定了后面两个动作该不该发生这个总控的位置非订单服务莫属。从技术角度看Seata 的 AT 模式要求有一个全局事务的起点由它来开启全局事务、注册分支事务、最终决定提交还是回滚。订单服务作为入口在方法上标注GlobalTransactional一进来就向 TC事务协调者申请开启全局事务拿到一个 XID这个 XID 会通过 RPC 上下文一路透传到库存和账户服务让它们的分支事务都挂到同一个全局事务下面。这个透传机制是 Seata 自动完成的前提是你用的 RPC 框架比如 Feign、Dubbo能被 Seata 的拦截器覆盖到。我见过有同学把发起方放在库存服务上结果订单已经落库了、库存回滚了数据照样不一致。所以发起方的选择不是随便挑的得跟着业务链路的真实起点走。这个 demo 里订单是起点那就订单发起逻辑最顺回滚边界也最清晰。1.2 AT 模式相比其他模式的取舍Seata 支持 AT、TCC、SAGA、XA 四种模式这个 demo 选的是 AT。为什么因为 AT 对业务代码的侵入最小。你几乎不用改原有的业务逻辑只要加个注解、配好数据源代理剩下的脏写回滚、快照记录、锁竞争Seata 都帮你处理了。对于订单、库存、账户这种标准的 CRUD 场景AT 是最省事的选择。TCC 需要你自己写 Try、Confirm、Cancel 三个方法业务侵入大适合那种对性能要求极高、或者资源没有本地事务支撑的场景。SAGA 适合长流程、需要补偿的业务。XA 则是强一致但性能开销大依赖数据库层面的支持。这个 demo 是教学性质的标准场景AT 的低侵入 自动回滚正好契合。不过 AT 也不是没有代价。它在第一阶段就把业务 SQL 执行了、本地事务提交了同时记录 undo log回滚日志第二阶段如果全局回滚就靠 undo log 反向补偿。这意味着在全局事务提交之前数据其实是已写入但可能被回滚的中间态靠全局锁来防止其他事务读到或改到这份数据。这个机制决定了 AT 适合读多写少、冲突不激烈的场景如果热点数据并发极高全局锁会成为瓶颈。这个 demo 里数据量小完全不用担心但真实生产环境要心里有数。1.3 三个服务的职责边界划分整个 demo 拆成三个服务订单服务order、库存服务stock、账户服务account。职责划分遵循谁的数据谁负责原则——订单服务只管订单表的写入库存服务只管库存扣减账户服务只管余额扣减。每个服务有自己独立的数据库互不共享这是微服务的基本要求也是分布式事务存在的前提。如果三个服务共用一个库那本地事务就搞定了根本用不上 Seata。订单服务作为发起方除了写订单还负责通过 Feign 调用库存和账户服务。调用顺序上我一般先扣库存再扣余额因为库存是稀缺资源先占住库存能减少超卖风险余额扣减失败的话库存还能通过全局回滚退回去。这个顺序不是硬性规定但符合业务直觉出问题时排查也方便。每个服务都需要接入 Seata 的客户端依赖、配置 TC 的地址、配置数据源代理。这三件事是三个服务共同的基础设施缺一个都跑不起来。下面章节我会逐个拆开讲包括依赖怎么引、配置怎么写、数据源代理为什么必须配。2. 核心机制与关键细节解析2.1 GlobalTransactional 到底做了什么很多人加了这个注解就以为万事大吉其实不清楚它背后干了什么出问题就抓瞎。这个注解的核心作用是在方法执行前开启全局事务、方法正常返回后提交全局事务、抛异常时回滚全局事务。具体来说方法被调用时Seata 的拦截器会先向 TC 发起begin请求TC 生成一个全局唯一的 XID 返回然后方法体执行期间所有被 Seata 代理的数据源操作、所有透传了 XID 的远程调用都会被注册为这个全局事务的分支方法正常结束拦截器向 TC 发起commitTC 通知各分支提交方法抛异常拦截器发起rollbackTC 通知各分支根据 undo log 回滚。这里有个关键点默认情况下只有 RuntimeException 及其子类、以及 Error 才会触发回滚。如果你在方法里 catch 了异常没往外抛或者抛的是受检异常Checked Exception全局事务是不会回滚的。这个坑我踩过——当时业务代码里 try-catch 吞掉了一个异常日志里看着报错了但数据没回滚查了半天才发现是异常没抛出去。解决办法要么把异常重新抛出要么在注解上显式指定rollbackFor Exception.class。GlobalTransactional(name create-order, rollbackFor Exception.class) public void createOrder(OrderDTO orderDTO) { // 写订单 orderMapper.insert(orderDTO); // 扣库存 stockFeign.deduct(orderDTO.getProductId(), orderDTO.getCount()); // 扣余额 accountFeign.deduct(orderDTO.getUserId(), orderDTO.getAmount()); }上面这段就是发起方的核心代码name是全局事务的名字方便在 TC 的日志里追踪rollbackFor指定所有异常都回滚稳妥起见建议都加上。2.2 XID 的透传链路为什么不能断XID 是全局事务的身份证从订单服务生成后必须一路传到库存和账户服务否则它们的分支事务挂不到同一个全局事务上回滚就无从谈起。Seata 的透传依赖 RPC 框架的上下文传递机制。用 Feign 的话Seata 提供了SeataFeignClient的自动配置会把 XID 塞进 HTTP Header 里带过去用 Dubbo 的话靠 Dubbo 的隐式参数传递。透传链路断掉的典型场景是你用了自定义的 HTTP 客户端、或者异步线程池里发起远程调用、或者中间经过了消息队列。这些情况下 XID 的 ThreadLocal 上下文丢了下游服务拿不到 XID就会当成一个全新的本地事务处理全局回滚时它不跟着回滚数据就脏了。这个 demo 用的是同步 Feign 调用链路是通的但你要知道边界在哪。提示如果你在业务里用了Async或者手动开线程池做远程调用务必确认 XID 有没有正确传递否则分布式事务会静默失效这种问题最难排查。2.3 数据源代理是 AT 模式的命门AT 模式能自动记录 undo log、自动加全局锁靠的是 Seata 对数据源的代理。你原始的 DataSource 会被 Seata 包装成一个代理数据源所有 SQL 执行都会经过代理代理在执行前查全局锁、执行后记录前后镜像before image / after image到 undo_log 表。如果数据源没被代理SQL 直接打到真实数据库Seata 完全感知不到回滚时也就没有依据。配置数据源代理有两种方式自动代理和手动代理。自动代理靠seata.enable-auto-data-source-proxytrue默认就是 trueSeata 会自动找到 Spring 容器里的 DataSource 并包装。手动代理则是自己写配置类用DataSourceProxy包一层。我建议新手先用自动代理省事如果项目里有多个数据源、或者用了某些特殊的连接池自动代理可能失效那就得手动配。每个参与事务的库都必须有undo_log表这是 AT 模式回滚的账本。表结构 Seata 官方给了建表语句字段包括分支 ID、XID、前后镜像、SQL 类型等。忘了建这张表回滚时会报Table undo_log doesnt exist事务直接失败。这个表在订单库、库存库、账户库三个库里都要建一个都不能少。3. 实操过程与核心环节实现3.1 环境与依赖准备先把基础设施搭起来。这个 demo 需要一个注册中心用 Nacos 或 Eureka 都行我用 Nacos、一个 Seata ServerTC 服务端、三个业务服务、三个独立的数据库。Seata Server 单独部署下载对应版本解压改一下registry.conf让它注册到 Nacos再改file.conf或数据库配置指定事务日志的存储方式生产建议用 db 模式demo 用 file 模式也行。三个业务服务的依赖基本一致核心是这几项dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId /dependency dependency groupIdio.seata/groupId artifactIdseata-spring-boot-starter/artifactId /dependency版本上要注意对齐Spring Cloud Alibaba 的版本和 Seata 的版本有对应关系版本错配是新手最容易翻车的地方启动就报类找不到或者方法不存在。我一般直接看官方文档的版本说明表按推荐的组合来。配置文件里要指定 TC 的地址和事务分组seata: 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 namespace: publictx-service-group和vgroup-mapping这两项要对应上vgroup-mapping里的值default是 Seata Server 里配置的集群名。这两处对不上客户端就找不到 TC启动时报no available service。三个服务的application-id各自不同但tx-service-group要一致因为它们属于同一个事务分组。3.2 三个服务的数据库与表结构每个服务一个库库名分别叫db_order、db_stock、db_account。业务表很简单订单表存订单信息库存表存商品库存账户表存用户余额。关键是每个库都要有undo_log表建表语句如下以 MySQL 为例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 CHARSETutf8;业务表我列一下核心字段方便你照着建表名关键字段说明t_orderid, user_id, product_id, count, amount, status订单主表t_stockid, product_id, stock库存表stock 为剩余库存t_accountid, user_id, balance账户表balance 为余额初始数据我一般给库存设 100、余额设 1000方便测试扣减和回滚。测试用例设计上正常流程扣 10 个库存、扣 100 元余额和库存都够事务提交异常流程在账户服务里人为抛个异常验证订单和库存能不能回滚回去。3.3 库存与账户服务的分支实现库存服务的扣减逻辑很直接一个本地事务的 update 语句Transactional public void deduct(Long productId, Integer count) { int affected stockMapper.deduct(productId, count); if (affected 0) { throw new RuntimeException(库存不足); } }注意这里用的是普通的Transactional不是GlobalTransactional。分支服务不需要开启全局事务它只需要在全局事务的上下文里执行Seata 会自动把它注册为分支。affected 0时抛异常这个异常会沿着 Feign 调用链传回订单服务触发全局回滚。这里有个细节库存不足的判断必须用 update 的影响行数来判断不能先 select 再 update否则并发下会有超卖问题。update t_stock set stock stock - #{count} where product_id #{productId} and stock #{count}这一条 SQL 就同时完成了判断和扣减是原子操作。账户服务的逻辑类似Transactional public void deduct(Long userId, Integer amount) { int affected accountMapper.deduct(userId, amount); if (affected 0) { throw new RuntimeException(余额不足); } }为了验证回滚我会在账户服务里加一个开关比如当 amount 等于某个特定值时故意抛异常模拟扣余额失败。这样订单和库存已经执行了账户抛异常全局事务回滚订单和库存的数据应该都退回去。验证的时候查三个库的数据订单表没有新记录、库存还是 100、余额还是 1000就说明回滚成功。3.4 发起方调用与全局事务验证订单服务的 Controller 收到下单请求后调用createOrder方法方法上标注GlobalTransactional。Feign 客户端定义如下FeignClient(name stock-service) public interface StockFeign { PostMapping(/stock/deduct) void deduct(RequestParam(productId) Long productId, RequestParam(count) Integer count); }调用顺序是先stockFeign.deduct再accountFeign.deduct。整个链路跑起来后怎么确认全局事务生效了我一般看三个地方一是 Seata Server 的控制台日志能看到全局事务的 begin、branch register、commit/rollback 记录二是各服务的日志里有没有打印 XID同一个全局事务下三个服务的 XID 应该完全一样三是直接查数据库正常流程数据都变了异常流程数据都没变。验证回滚的时候有个小技巧在账户服务抛异常之前先让订单和库存的 SQL 执行完并提交本地事务这样能真正验证已提交的本地事务被全局回滚这个 AT 模式的核心能力。如果异常在订单服务本地就抛了那库存根本没被调用验证不出分布式回滚的效果。所以异常点要放在最后一个分支服务里这是设计测试用例时的关键。4. 常见问题与排查技巧实录4.1 启动报错与连接问题速查实际搭 demo 的过程中启动阶段的报错占了大多数。我把高频问题整理成表方便对照排查报错信息原因解决办法no available service default found客户端找不到 TC检查 tx-service-group 与 vgroup-mapping 是否对应Seata Server 是否注册到注册中心Table undo_log doesnt exist库没建 undo_log 表三个库都执行建表语句can not get cluster name配置里集群名缺失registry.conf 和客户端配置的集群名要一致数据源代理未生效自动代理失效检查是否有多数据源改用手动 DataSourceProxy全局事务不回滚异常被吞或受检异常加 rollbackFor Exception.class确保异常抛出其中全局事务不回滚这个最隐蔽。我遇到过一次业务代码里用 try-catch 包住了 Feign 调用catch 里只打了日志没重新抛结果全局事务以为方法正常结束了直接 commit数据就脏了。后来改成 catch 里throw new RuntimeException(e)才解决。所以记住一条发起方方法里不要吞异常要回滚就得让异常冒泡出去。4.2 回滚不生效的排查思路回滚不生效按这个顺序查第一确认异常真的抛到了GlobalTransactional标注的方法外面没被拦截第二确认下游服务的 XID 和发起方一致日志里对比一下第三确认下游服务的数据源被代理了看启动日志里有没有DataSourceProxy相关的输出第四确认 undo_log 表里有记录回滚前应该有对应的 undo 数据第五看 Seata Server 的日志全局事务是 commit 还是 rollback如果 TC 那边显示 commit说明它压根没收到回滚信号。还有一种情况是回滚了但数据没变回去。这通常是 undo_log 里的前后镜像有问题比如业务 SQL 用了复杂的函数、或者表结构有特殊类型导致镜像还原失败。AT 模式对 SQL 有一定要求不支持复杂的多表关联更新、不支持某些数据库特性。遇到这种要么简化 SQL要么改用 TCC 模式自己写补偿逻辑。4.3 全局锁与并发冲突的处理经验AT 模式在第一阶段会持有全局锁防止其他事务修改同一行数据。demo 里并发低感觉不到但真实场景下热点行会成为瓶颈。我做过一个压测同一商品库存被高并发扣减大量请求卡在获取全局锁上响应时间飙升。缓解办法有几个一是尽量缩短全局事务的执行时间把非核心的远程调用挪到事务外面二是对热点数据做分片或者用队列削峰三是评估是否真的需要强一致有些场景最终一致就够了没必要上 AT。另外全局锁的获取有重试机制默认重试次数和间隔可以配。如果业务对失败很敏感可以适当调大重试次数但别调太大否则请求堆积更严重。这个参数在client.rm.lock.retry-interval和client.rm.lock.retry-times里配按实际压测结果调。4.4 实操心得与几个容易忽略的点分享几个文档里不常提但很实用的经验。第一GlobalTransactional和Transactional不要同时标在发起方方法上虽然不报错但语义上容易混淆全局事务已经包含了本地事务的语义重复标注没必要。第二Feign 的超时时间要设合理全局事务期间如果某个分支调用超时会触发回滚超时时间太短会误伤正常请求太长会拖慢整体响应我一般设 3 到 5 秒。第三测试回滚时记得把数据库的自动提交关掉的影响考虑进去有些连接池配置会影响本地事务的提交时机。还有一点Seata Server 的存储模式demo 用 file 模式重启后事务日志会丢生产必须用 db 模式把事务日志持久化。这个切换不难建好 global_table、branch_table、lock_table 三张表改配置就行但很多人 demo 阶段用 file上线忘了改出了故障没法追溯。我个人在实际操作中的体会是Seata 的 AT 模式上手快但会用和用对之间隔着对机制的理解。注解加下去只是开始真正决定成败的是 XID 透传、数据源代理、undo_log 这三件事有没有做到位。这个订单 demo 虽然简单但把这三个关键点都覆盖到了跑通一遍、再故意制造几次回滚失败去排查比看十篇原理文章都管用。后续如果想深入可以在这个基础上加消息队列的最终一致方案做对比或者把 AT 换成 TCC 感受一下侵入性的差异都是很好的延伸方向。