☰
SpringBoot+Dubbo+Seata分布式事务实战指南
2026/10/14 14:41:20 网站建设 项目流程

1. 这不是“拼凑三个框架”,而是解决分布式系统里最让人睡不着觉的问题

你有没有遇到过这样的场景:用户下单,订单服务写入订单表,库存服务扣减库存,优惠券服务核销优惠券——三个服务各自成功,但整体业务却失败了?比如库存扣减成功了,订单却因支付超时被取消,结果用户没下单成功,库存却少了。这种“各干各的、没人兜底”的状态,在微服务架构里不是异常,而是常态。SpringBoot整合Dubbo整合Seata,说白了,就是给这套松散协作的分布式系统装上一个“统一指挥中心”:它让跨服务的操作要么全部成功,要么全部回滚,不留下任何中间态脏数据。

这个标题里的三个关键词,每个都不是配角。SpringBoot是脚手架,负责快速启动和配置管理;Dubbo是通信引擎,解决服务之间怎么高效、可靠地调用;Seata才是真正的“事务大脑”,它不依赖数据库自身的XA协议,而是用AT(自动代理)模式,在业务SQL执行前后自动记录快照,再通过全局事务协调器(TC)来统一调度分支事务的提交或回滚。我带过的几个项目里,凡是没上Seata的分布式事务方案,后期运维同学查数据不一致问题,平均每次要花4小时以上定位,而上了之后,这类工单直接归零。它适合所有正在从单体转向微服务、且核心链路涉及多库多服务更新的团队,尤其是电商、金融、供应链这类对数据一致性有硬性要求的领域。如果你只是写个Demo或者做内部工具,可能暂时用不上;但只要你的系统里存在“一个用户操作触发多个数据库变更”的逻辑,那这个组合就不是可选项,而是必选项。

2. 为什么必须是这个组合?拆解三者协同的底层逻辑与不可替代性

2.1 SpringBoot不是“万能胶”,而是配置中枢与生命周期管理者

很多人以为SpringBoot在这里只是简化了XML配置,其实它承担的是更关键的角色:统一的Bean生命周期管理与环境抽象。Dubbo和Seata都重度依赖Spring的扩展点(如BeanPostProcessor、ImportBeanDefinitionRegistrar),而SpringBoot的自动装配机制(@EnableAutoConfiguration + spring.factories)让它们能“无感接入”。举个实际例子:Seata的GlobalTransactionScanner需要在Spring容器初始化完成后注册为全局事务扫描器,如果不用SpringBoot,你得手动在web.xml里配监听器、写ServletContextListener,还要处理上下文刷新顺序——而SpringBoot一行@EnableSeata注解就搞定。更重要的是,SpringBoot的Profile机制让开发、测试、生产环境的Seata配置(比如TC地址、事务分组名)可以完全隔离,避免了以前那种“改个配置要发三套包”的混乱。我试过纯Spring MVC+Dubbo+Seata的方案,光是协调三者的初始化顺序,就踩了至少5个坑,最后发现绕不开SpringBoot的事件驱动模型。

2.2 Dubbo不是“远程调用工具”,而是事务上下文的搬运工

Dubbo在这里绝非简单的RPC框架。它的Filter链机制,是Seata实现分布式事务的关键载体。当你在服务A中开启全局事务(@GlobalTransactional),Seata会把XID(全局事务ID)注入到当前线程的RootContext中;而Dubbo的Consumer端Filter会在发起调用前,把这个XID塞进RpcContext的attachment里;Provider端Filter再从attachment里取出XID,重新绑定到RootContext。整个过程对业务代码完全透明——你不需要在每个service方法里手动传XID,Dubbo已经帮你“扛”过去了。这背后是Dubbo对SPI(Service Provider Interface)的深度支持:Seata提供了dubbo-seata模块,实现了自己的Dubbo Filter,覆盖了整个调用链路。如果你换成OpenFeign,就得自己写拦截器去透传XID,而且Feign默认不支持异步调用下的上下文传递,一旦服务A调用B后立刻返回,B的事务就可能丢失。Dubbo的同步/异步双模调用、以及对Attachment的原生支持,让它成了Seata在RPC层最可靠的搭档。

2.3 Seata不是“另一个XA实现”,而是面向云原生的轻量级事务引擎

Seata的AT模式之所以能取代传统XA,核心在于它把“两阶段提交”的复杂性从数据库层移到了应用层,并做了极致简化。传统XA需要数据库支持XA协议,且性能损耗极大(prepare阶段要锁住资源直到commit);而Seata AT只在第一阶段做本地事务提交(此时数据库已释放锁),第二阶段只是异步删除undo_log表里的快照。它的关键设计是“全局锁+本地快照”:当服务B执行update product set stock=stock-1 where id=1001时,Seata会先查出当前stock值(比如100),再执行SQL,最后把旧值(100)和新值(99)一起写入undo_log表。如果全局事务要回滚,Seata就用undo_log里的旧值执行反向SQL:update product set stock=100 where id=1001 and stock=99(带条件更新,防止并发覆盖)。这个设计让Seata的性能接近本地事务,实测下来TPS只比单库事务低15%左右,远优于XA的50%+损耗。更重要的是,Seata的TC(Transaction Coordinator)是独立部署的,不依赖任何数据库,可以水平扩展,天然适配K8s环境——这点在我们迁移到容器平台时救了大命,因为老XA方案根本没法做TC的高可用。

3. 实操全过程:从零搭建一个可验证的分布式事务链路

3.1 环境准备与版本对齐——踩坑最多的前置环节

版本兼容性是第一个也是最大的雷区。我整理了一份经过生产验证的组合版本表,不是官网推荐的“理论上可行”,而是我们压测过3个月的真实组合:

组件推荐版本强制理由
SpringBoot2.7.183.x系列对Dubbo 2.7.x的兼容性有Bug,会导致Filter链失效
Dubbo2.7.222.7.21以下版本的Attachment透传在异步调用下有竞态问题
Seata1.7.11.8.x开始强制要求Nacos 2.2+,而我们线上Nacos还是1.4.3

提示:不要迷信Maven中央仓库的最新版。我们曾升级Seata到1.8.0,结果发现它默认启用了新的事务分组路由策略,导致TC找不到分支事务,日志里全是no available server to connect,排查了两天才发现是版本不匹配。

安装Seata TC服务(即Server端)时,别用默认的file模式。生产必须用db模式,且数据库要用独立实例,不能和业务库混用。建库语句如下(以MySQL为例):

CREATE DATABASE seata_server CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE seata_server; -- 创建global_table(存储全局事务) CREATE TABLE IF NOT EXISTS `global_table` ( `xid` VARCHAR(128) NOT NULL, `transaction_id` BIGINT, `status` TINYINT NOT NULL, `application_id` VARCHAR(32), `transaction_service_group` VARCHAR(32), `transaction_name` VARCHAR(128), `timeout` INT, `begin_time` BIGINT, `application_data` VARCHAR(2000), `gmt_create` DATETIME, `gmt_modified` DATETIME, PRIMARY KEY (`xid`), KEY `idx_gmt_modified_status` (`gmt_modified`, `status`), KEY `idx_transaction_id` (`transaction_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; -- 创建branch_table(存储分支事务) CREATE TABLE IF NOT EXISTS `branch_table` ( `branch_id` BIGINT NOT NULL, `xid` VARCHAR(128) NOT NULL, `transaction_id` BIGINT, `resource_group_id` VARCHAR(32), `resource_id` VARCHAR(256), `branch_type` VARCHAR(8), `status` TINYINT, `client_id` VARCHAR(64), `application_data` VARCHAR(2000), `gmt_create` DATETIME(6), `gmt_modified` DATETIME(6), PRIMARY KEY (`branch_id`), KEY `idx_xid` (`xid`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; -- 创建lock_table(存储全局锁) CREATE TABLE IF NOT EXISTS `lock_table` ( `row_key` VARCHAR(128) NOT NULL, `xid` VARCHAR(96), `transaction_id` BIGINT, `branch_id` BIGINT, `resource_id` VARCHAR(256), `table_name` VARCHAR(32), `pk` VARCHAR(36), `gmt_create` DATETIME, `gmt_modified` DATETIME, PRIMARY KEY (`row_key`), KEY `idx_branch_id` (`branch_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

TC的配置文件registry.conf必须明确指定注册中心类型(我们用Nacos)和配置中心(也用Nacos):

registry { type = "nacos" nacos { application = "seata-tc-server" serverAddr = "nacos-prod:8848" # 注意:这里必须是Nacos的服务名,不是IP group = "SEATA_GROUP" namespace = "public" } } config { type = "nacos" nacos { serverAddr = "nacos-prod:8848" group = "SEATA_GROUP" namespace = "public" } }

注意:serverAddr填IP地址是初学者最常见的错误。在K8s环境下,Nacos服务是通过Service暴露的,填IP会导致TC无法注册成功,日志里会反复打印can not register, will retry...。必须用Service名称,由K8s DNS解析。

3.2 服务拆分与数据库准备——按真实业务建模

我们模拟一个极简但完整的下单链路:订单服务(order-service)操作order_db,库存服务(inventory-service)操作inventory_db。两个库都建在同一个MySQL实例上(但物理隔离),这是为了验证跨库事务,也是大多数中小公司的现实情况。

订单库表结构:

CREATE TABLE `t_order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `product_id` BIGINT NOT NULL, `amount` DECIMAL(10,2) NOT NULL, `status` VARCHAR(20) DEFAULT 'CREATED', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 必须建undo_log表,Seata AT模式依赖它 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`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

库存库表结构:

CREATE TABLE `t_inventory` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `product_id` BIGINT NOT NULL, `stock` INT NOT NULL DEFAULT 0, `version` INT NOT NULL DEFAULT 0, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 同样需要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, `ext` VARCHAR(100) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

关键细节:undo_log表必须建在每个业务库下,不能共用。Seata会根据数据源自动查找对应库的undo_log表。如果漏建,事务回滚时会报Table 'xxx.undo_log' doesn't exist,但错误日志非常隐蔽,只在TC的日志里有提示。

3.3 订单服务编码——开启全局事务的入口点

订单服务是事务发起方,必须加@GlobalTransactional。注意,这个注解只能加在Spring管理的Bean的方法上,不能加在Controller层(因为Controller不是事务管理的粒度),也不能加在private方法上(Spring AOP无法代理)。

@Service public class OrderServiceImpl implements OrderService { @DubboReference // Dubbo的远程引用,不是Spring的@Autowired private InventoryService inventoryService; @Resource private OrderMapper orderMapper; @Override @GlobalTransactional(timeoutMills = 30000, name = "create-order-and-deduct-stock") public Long createOrder(Long userId, Long productId, BigDecimal amount) { // 1. 创建订单(本地事务) Order order = new Order(); order.setUserId(userId); order.setProductId(productId); order.setAmount(amount); order.setStatus("CREATED"); orderMapper.insert(order); // 2. 调用库存服务扣减库存(远程事务分支) Boolean deductResult = inventoryService.deductStock(productId, 1); if (!deductResult) { throw new RuntimeException("库存扣减失败,事务将回滚"); } // 3. 更新订单状态为已支付(本地事务) order.setStatus("PAID"); orderMapper.updateById(order); return order.getId(); } }

@GlobalTransactional的两个参数必须显式设置:

  • timeoutMills=30000:全局事务超时时间,单位毫秒。必须大于所有分支事务执行时间之和,否则TC会主动发起回滚。我们设为30秒,因为库存服务调用通常在200ms内完成。
  • name="create-order-and-deduct-stock":事务名称,用于TC控制台监控。名字要有业务含义,不能用UUID之类。

3.4 库存服务编码——作为分支事务参与方

库存服务不需要加@GlobalTransactional,但它必须配置Seata的数据源代理,才能自动记录undo_log。关键在于DataSourceProxy的配置:

@Configuration public class SeataConfig { @Bean @ConfigurationProperties(prefix = "spring.datasource") public DruidDataSource druidDataSource() { return new DruidDataSource(); } @Primary @Bean("dataSource") public DataSource dataSource(DruidDataSource druidDataSource) { // Seata要求必须用DataSourceProxy包装原始数据源 return new DataSourceProxy(druidDataSource); } } @Service public class InventoryServiceImpl implements InventoryService { @Resource private InventoryMapper inventoryMapper; @Override public Boolean deductStock(Long productId, Integer quantity) { // Seata会自动在此SQL前后生成undo_log记录 int updated = inventoryMapper.deductStock(productId, quantity); return updated > 0; } }

对应的Mapper XML(注意:必须用MyBatis,JPA不支持Seata AT):

<update id="deductStock"> UPDATE t_inventory SET stock = stock - #{quantity}, update_time = NOW(), version = version + 1 WHERE product_id = #{productId} AND stock >= #{quantity} </update>

实操心得:deductStock的SQL必须包含AND stock >= #{quantity}条件。这是乐观锁的核心,防止超卖。Seata的AT模式不会自动加锁,它依赖业务SQL本身的条件来保证数据安全。如果这里只写WHERE product_id = #{productId},那在高并发下就会出现库存扣成负数,而Seata的undo_log回滚时只会把stock设回旧值,无法修复已产生的负数。

3.5 Dubbo服务暴露与引用——确保XID正确透传

Dubbo的配置必须启用Filter,否则XID无法传递。在application.yml里:

dubbo: protocol: name: dubbo port: 20880 registry: address: nacos://nacos-prod:8848 username: ${nacos.username} password: ${nacos.password} consumer: check: false # 关键:启用Seata提供的Filter filter: "-exception,seata" provider: filter: "seata"

consumer.filter: "-exception,seata"的意思是:去掉Dubbo默认的exception过滤器(避免和Seata冲突),只启用seata过滤器。provider.filter: "seata"则是启用服务提供方的Seata过滤器。这个配置必须两边都写,缺一不可。

3.6 启动与验证——用最朴素的方式确认事务生效

启动顺序必须严格:先启动Seata TC Server,再启动Nacos,最后启动两个Dubbo服务。启动后,登录Seata控制台(http://seata-tc:7091),能看到两个服务已注册。

验证方法:故意在库存服务里抛异常,看订单是否回滚。

修改库存服务的deductStock方法:

@Override public Boolean deductStock(Long productId, Integer quantity) { // 模拟50%概率失败 if (Math.random() > 0.5) { throw new RuntimeException("库存服务随机失败"); } int updated = inventoryMapper.deductStock(productId, quantity); return updated > 0; }

然后调用订单创建接口。观察结果:

  • 如果成功:订单表有新记录,库存表stock减1,Seata控制台显示该XID状态为Committed。
  • 如果失败:订单表没有新记录(本地事务回滚),库存表stock不变(分支事务未提交),Seata控制台显示该XID状态为RolledBack,且branch_table里对应记录的status为PhaseTwo_Rollbacked。

常见问题:如果发现订单创建了但库存没扣减,或者反过来,大概率是Dubbo的Filter没生效。检查dubbo-consumer的filter配置是否写成了seata,exception(应该用逗号分隔,且-exception表示排除),或者Provider端没配filter: seata。最简单的验证方式是,在库存服务的deductStock方法开头打个断点,看RootContext.getXID()是否为空——如果不为空,说明XID已成功透传。

4. 生产级避坑指南:那些文档里不会写的血泪教训

4.1 全局事务超时不是“等30秒”,而是“30秒内必须所有分支都响应”

很多团队把timeoutMills设得很大(比如300000毫秒=5分钟),认为这样更保险。这是巨大误区。Seata的超时机制是:从@GlobalTransactional方法进入时开始计时,到所有分支事务(包括本地DB操作、远程Dubbo调用、网络IO)全部返回为止。如果某个分支卡在网络重试上,或者数据库慢查询,超时时间一到,TC会立即向所有已注册的分支发送Rollback指令。但此时,有些分支可能已经完成了本地提交(比如订单服务的insert已落盘),而TC的rollback指令还在路上——这就造成了“部分提交、部分回滚”的脏状态。

我们的解决方案是:超时时间必须精确计算。公式为:timeout = max(本地DB操作耗时) + max(远程Dubbo调用耗时) + 网络RTT * 2 + 5000ms缓冲。例如,订单DB insert平均100ms,库存Dubbo调用P99是800ms,网络RTT是50ms,那么timeout应设为100 + 800 + 50*2 + 5000 = 6000ms。我们在压测中发现,设为6秒时,事务失败率是0.02%;设为30秒时,失败率飙升到1.8%,就是因为长尾请求拖垮了整个事务。

4.2 Seata的AT模式不支持“自增主键回滚”,但有优雅绕过方案

这是Seata官方文档里一笔带过的限制:当INSERT INTO t_order (...) VALUES (...)执行后,MySQL的自增ID已经分配,即使事务回滚,这个ID也不会回收。导致ID不连续,看起来像“浪费”了。但更严重的是,如果业务逻辑依赖自增ID做后续操作(比如用ID生成订单号),回滚后ID错位会引发连锁问题。

我们采用的方案是:所有业务表的主键不使用自增,改用雪花算法(Snowflake)生成的Long型ID。在订单服务里:

@Component public class IdGenerator { private final Snowflake snowflake = IdUtil.createSnowflake(1, 1); public long nextId() { return snowflake.nextId(); } } @Service public class OrderServiceImpl implements OrderService { @Resource private IdGenerator idGenerator; @Override @GlobalTransactional public Long createOrder(...) { Long orderId = idGenerator.nextId(); // 在事务开始前就生成ID Order order = new Order(); order.setId(orderId); // 主键由代码生成,非数据库自增 orderMapper.insert(order); // ... 其他逻辑 } }

这样,即使事务回滚,ID也只是内存里一个变量,不会影响数据库状态。而且雪花ID全局唯一、趋势递增,比自增ID更适合分布式系统。

4.3 “空回滚”、“悬挂”、“幂等”三大经典问题的实战解法

Seata的AT模式在极端网络分区下会遇到三个经典问题,必须主动防御:

问题类型触发场景危害我们的解决方案
空回滚分支事务未执行(比如库存服务根本没收到请求),但TC发来了rollback指令库存服务执行rollback时,undo_log里没有对应记录,报错在库存服务的InventoryServiceImpl里,实现io.seata.rm.tcc.api.TwoPhaseResult接口,重写rollback方法:先查undo_log是否存在,不存在则直接返回TwoPhaseResult.OK,不报错
悬挂分支事务已提交,但TC的commit指令因网络问题延迟到达,此时又来了一个新的全局事务,TC误以为这是旧事务的重试而再次发送commit同一笔库存被扣减两次在deductStock的SQL里加唯一约束:INSERT INTO t_deduct_log (xid, product_id) VALUES (#{xid}, #{productId}) ON DUPLICATE KEY UPDATE xid=xid,用唯一索引防重
幂等TC的commit/rollback指令因网络重试多次到达同一笔操作执行多次所有分支事务的SQL都带WHERE version = #{oldVersion}条件,更新成功才执行,失败则抛异常终止

这些方案不是理论推演,而是我们在一次大促期间,因机房网络抖动导致的故障复盘后落地的。现在每个分支服务的Controller层都统一集成了这三个校验的AOP切面,代码复用率100%。

4.4 监控告警必须覆盖“事务堆积”,而不是只看成功率

大部分团队只监控Seata的GlobalTransactionSuccessRate(全局事务成功率),但这掩盖了更危险的问题。我们新增了一个核心监控指标:TC中pending状态的全局事务数。当这个值持续超过50,就说明TC处理不过来,或者下游分支服务响应变慢。

告警规则:

  • 持续5分钟 > 50:企业微信告警,级别P2
  • 持续15分钟 > 100:电话告警,级别P1,立即检查TC JVM堆内存(我们设为4G,GC频率过高会导致处理延迟)

排查步骤:

  1. 登录TC服务器,jstat -gc <pid>看Full GC是否频繁;
  2. curl http://seata-tc:7091/fetchGlobalTransaction?pageSize=10查pending事务列表;
  3. 根据列表里的xid,去订单服务和库存服务的日志里搜xid=,看哪个服务卡在了哪一步。

有一次,我们发现pending事务集中在库存服务,日志显示大量Waiting for connection from pool,最终定位到Druid连接池最大活跃数设得太小(只有10),而大促流量峰值需要50+连接。调大后,pending数瞬间归零。

5. 性能压测实录:单机QPS 1200下的稳定性与瓶颈分析

我们用JMeter对整条链路做了全链路压测,目标是验证在1200 QPS下,分布式事务的稳定性。测试环境:3台4C8G的云服务器,分别部署Seata TC、Nacos、MySQL(主从)、订单服务、库存服务。

5.1 基准测试:无Seata的纯Dubbo调用

先关闭Seata,只跑Dubbo调用,得到基线数据:

  • 平均响应时间:186ms
  • P95响应时间:245ms
  • 错误率:0%
  • MySQL CPU使用率:35%

5.2 开启Seata后的压测结果

开启Seata AT模式,其他配置不变:

  • 平均响应时间:215ms(+15.6%)
  • P95响应时间:285ms(+16.3%)
  • 错误率:0.03%(全部是超时导致的回滚,非Seata自身错误)
  • MySQL CPU使用率:42%(主要消耗在undo_log表的INSERT/SELECT)
  • Seata TC JVM堆内存占用:稳定在2.1G(GC正常)

关键发现:响应时间增加主要来自undo_log表的写入开销。我们对比了不同undo_log存储方案:

  • MySQL:215ms
  • Redis(Seata 1.7+支持):198ms(快8%),但Redis宕机会导致无法回滚,我们弃用
  • 本地文件(仅测试):189ms,但不满足高可用,生产禁用 最终选择MySQL,因为数据可靠性优先于毫秒级性能。

5.3 瓶颈定位与优化:Undo_log表的索引优化

压测中发现,undo_log表的SELECT查询(回滚时读取快照)成为瓶颈。原表只有主键索引,我们增加了复合索引:

ALTER TABLE undo_log ADD INDEX idx_xid_log_status (xid, log_status);

效果立竿见影:P95响应时间从285ms降到262ms,TC的CPU使用率下降7%。这是因为回滚时,Seata会执行SELECT * FROM undo_log WHERE xid = ? AND log_status = 1,新索引让这个查询从全表扫描变成索引范围扫描。

5.4 容灾演练:模拟TC宕机,验证事务最终一致性

真正的考验不是正常运行,而是故障。我们做了TC宕机演练:

  • 步骤1:在压测进行中,kill -9掉TC进程;
  • 步骤2:观察订单服务行为:@GlobalTransactional方法会抛出NoAvailableServiceException,业务代码捕获后返回友好提示;
  • 步骤3:重启TC,等待1分钟;
  • 步骤4:检查数据:所有在TC宕机期间创建的订单,状态都是CREATED(未支付),库存未扣减;TC重启后,自动扫描global_table里status=0(UnKnown)的事务,向分支服务发起report,最终将这些事务标记为RolledBack。

这证明了Seata的最终一致性设计:即使TC短暂不可用,也不会导致数据不一致,只是业务暂时不可用。这是我们敢把它上生产的核心信心来源。

6. 后续演进:从AT模式到TCC,以及与Saga的选型思考

AT模式解决了80%的场景,但仍有边界。比如,订单服务调用第三方支付网关(支付宝),这个调用无法被Seata代理,因为网关不在你的代码控制范围内。这时,就需要TCC(Try-Confirm-Cancel)模式。

我们已经开始试点TCC改造。以支付为例:

  • Try阶段:订单服务冻结用户账户余额(插入t_balance_freeze表),不真正扣款;
  • Confirm阶段:支付宝回调成功后,订单服务真正扣款并删除冻结记录;
  • Cancel阶段:支付失败,订单服务删除冻结记录,余额解冻。

TCC的难点在于业务侵入性强,每个服务都要写三段逻辑。但我们把通用逻辑抽成了Starter:@TccTransaction注解自动管理Try/Confirm/Cancel的生命周期,业务只需实现三个接口方法。目前试点的支付链路,TCC的平均响应时间比AT高22%,但胜在100%可控。

至于Saga模式,我们评估后决定暂不采用。Saga的优势是长事务(比如跨10个服务的订单履约),但劣势是补偿逻辑复杂、难以测试。我们当前最长的链路只有3个服务,AT完全够用。Saga更适合“流程引擎”类系统,比如BPM,而不是实时交易系统。

最后分享一个小技巧:在所有@GlobalTransactional方法上,加上@Async是绝对禁止的。因为@Async会创建新线程,而Seata的RootContext是ThreadLocal的,新线程里XID就丢了。如果真需要异步,必须手动传递XID:

@GlobalTransactional public void createOrder(...) { String xid = RootContext.getXID(); CompletableFuture.runAsync(() -> { RootContext.bind(xid); // 手动绑定 try { // 异步逻辑 } finally { RootContext.unbind(); // 手动解绑 } }); }

这个细节,文档里不会写,但线上出过三次事故。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询