ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot整合JTA+Atomikos实现跨库分布式事务实战

SpringBoot整合JTA+Atomikos实现跨库分布式事务实战

最近一个项目要把订单库和库存库拆成两个独立的 MySQL 实例,业务上又要保证两边写入的原子性:订单落库成功,库存必须扣减;订单失败,库存不能少。这个需求很经典,很多人第一反应是上消息队列或者分布式事务中间件。但如果只是两个关系型数据库、并发量可控,SpringBoot + JTA + Atomikos 其实是一套很顺手的轻量方案。JTA 是 Java 官方的分布式事务接口,Atomikos 是它的实现,配合 XA 协议能做到跨库强一致提交。我用这套组合解决过实际问题,改造量比想象中小,下面把核心配置、代码和踩过的坑一次性说清楚。

1. 为什么需要 JTA + Atomikos:从跨库回滚说起

1.1 本地事务只能管一个库

很多项目一开始都是单库,Spring 的@Transactional配一个DataSourceTransactionManager,所有操作都走同一个连接,事务提交和回滚都很自然。但当你把订单库和库存库拆成两个库之后,问题立刻变了:一次业务操作需要往两个数据源分别写数据,也就是要同时拿到两个数据库连接。Spring 默认的事务管理器只会绑定一个DataSource,它根本不知道第二个连接属于同一个逻辑事务。

我见过不少团队在这个阶段的做法是“两个 DAO 各自开事务,先写库 A,再写库 B,第二个失败就手动补一个反向操作”。这种补偿式写法看起来能落地,实际上非常脆弱:你没法保证“补一个反向操作”自己不会失败,也没法保证在写 A 成功、写 B 失败的那一瞬间,外部系统不会已经读到了 A 的脏数据。一旦涉及并发和重试,几乎无法做到精确无误。

本地事务解决不了这个问题,是因为它不具备跨资源协调能力。要让两个 MySQL 数据源在同一个事务里做到要么都提交、要么都回滚,必须引入一个能同时管理多个资源连接的事务协调者,这就是 JTA 和 Atomikos 要干的活。

1.2 XA 协议与两阶段提交

JTA 定义了事务管理器、应用服务器、资源管理器之间的标准接口,而 Atomikos 是 JTA 规范的一个嵌入式实现。底层真正起作用的是 XA 协议,全称是 eXtended Architecture。XA 规定了一个全局事务由三个角色组成:应用程序、事务管理器 TM、资源管理器 RM。这里的资源管理器可以理解成数据库自己的 XA 事务分支。

XA 最核心的动作是两阶段提交。第一阶段叫准备阶段,事务管理器问所有数据库“你们各自能不能提交”,数据库执行完业务 SQL 后,把数据和 undo 日志准备好,但不真正提交,只是向事务管理器报告“我准备好了”。第二阶段叫提交阶段,如果所有数据库都回复“准备好了”,事务管理器就统一发出 commit 指令;只要有一个数据库回复失败,事务管理器就统一发出 rollback 指令。

用生活里的例子类比,就像一群朋友约饭,结账时大家先把各自的钱准备好并报数,只要有人说“我钱不够”,整单就取消,谁都不付款;只有所有人都报数成功,才一起付款。这个机制的优点是强一致,缺点是准备阶段会持有数据库资源锁,所以事务不能拖太长。

Atomikos 在这个方案里扮演的是事务管理器的角色,它不需要额外独立部署,直接嵌在 Spring Boot 应用里,通过UserTransaction和TransactionManager两个核心对象参与事务协调。你在业务方法上继续用@Transactional,Spring 把控制权交给 JTA 事务管理器,再由它协调各个 Atomikos 数据源分支。

2. 搭建环境前的关键认知

2.1 依赖选择:Spring Boot 内置 Starter 已经变了

如果你在网上搜老教程,会看到很多spring-boot-starter-jta-atomikos的写法。这个 Starter 在老版本 Spring Boot 里确实存在,配几个 properties 就能用。但到了较新的 Spring Boot 版本,官方已经把分布式事务 Starter 移出,维护重心也转移到各个厂商自研方案上。所以现在再建项目,我不推荐依赖那个 Starter,而是直接引入 Atomikos 官方库,手动装配。

下面是我常用的 Maven 依赖:

<dependency> <groupId>com.atomikos</groupId> <artifactId>transactions-jta</artifactId> <version>5.0.0</version> </dependency> <dependency> <groupId>com.atomikos</groupId> <artifactId>transactions-jdbc</artifactId> <version>5.0.0</version> </dependency>

版本号建议以 Maven 中央仓库最新的稳定版为准,不要盲目用最新大版本。如果你在 Spring Boot 3 项目里使用,还要注意包名变化:JTA 标准接口从javax.transaction迁移到了jakarta.transaction,Atomikos 新版也做了对应适配。整体配置思路不变,所以我下面的示例还是按比较常见的 Spring Boot 2.x 写法展示。

2.2 数据源类型必须认清:不是所有连接池都能进 XA

这是新手最容易踩的坑。很多人配置多数据源时,习惯用 HikariCP 或 Druid 建两个DataSourceBean,然后给每个 DAO 分别绑定。看着是两套独立数据源,但实际上每个连接池跟 JTA 事务管理器没有协作关系。JTA 管理器在提交或回滚全局事务时,需要让所有资源分支都接入 XA 协议,普通连接池连接做不到这一点。

正确的做法是使用 Atomikos 提供的代理数据源,也就是AtomikosDataSourceBean。它内部维护了一个符合 XA 规则的连接池,对外暴露的接口仍然是标准的javax.sql.DataSource,你的 MyBatis、JdbcTemplate 都可以正常使用它。唯一要注意的是,你给它设置的数据源必须是数据库驱动里的 XA 实现,比如 MySQL 的MysqlXADataSource,而不是普通的MysqlDataSource。

另外,因为我们要手动创建所有数据源,最好把 Spring Boot 的数据源自动配置关掉,否则应用启动时会再塞进来一个默认 HikariCP 数据源,容易导致 Bean 冲突或事务管理器找不到正确数据源。在启动类上加上:

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)

2.3 分布式事务不是万能的:想清楚场景再动手

JTA + Atomikos 能解决跨库强一致,但它不是什么场景都适合。我个人的判断标准有三条:

第一,资源都是关系型数据库,而且都支持 XA。MySQL、PostgreSQL、Oracle 这些主流的都支持,但 Redis、MongoDB、消息队列这些非 XA 资源,JTA 管不了。

第二,并发量不是特别夸张。两阶段提交在所有资源提交前,需要一直持有数据库锁和连接,事务时间一长,连接池和数据库锁竞争都会放大。如果系统是每秒几千甚至上万笔写入,我不建议直接用 JTA。

第三,业务要求是强一致,而不是允许短暂的不一致。比如账户余额和交易流水,必须严格同步,这种场景适合 JTA;如果是“先更新订单状态,再异步发通知,通知晚几秒没问题”,那就用本地事务加消息队列做最终一致更轻松。

3. 核心配置与关键参数详解

3.1 事务管理器与 UserTransaction 的装配

要让 Spring 的@Transactional走 JTA 事务,至少要向容器注册UserTransaction和TransactionManager,再把它们包装成 Spring 的JtaTransactionManager。Atomikos 提供了现成的UserTransactionImp和TransactionManagerImp,我们只需要把它们作为 Bean 暴露出去:

@Bean(initMethod = "init", destroyMethod = "close") public TransactionManager atomikosTransactionManager() { return new TransactionManagerImp(); } @Bean public UserTransaction userTransaction() { UserTransactionImp userTransactionImp = new UserTransactionImp(); userTransactionImp.setTransactionTimeout(60); return userTransactionImp; } @Bean public JtaTransactionManager transactionManager() { JtaTransactionManager jtaTransactionManager = new JtaTransactionManager(); jtaTransactionManager.setTransactionManager(atomikosTransactionManager()); jtaTransactionManager.setUserTransaction(userTransaction()); return jtaTransactionManager; }

UserTransactionImp里的超时时间很关键,它决定了全局事务从开启到提交的最大等待时间。如果某个数据库执行慢,超过这个时间,事务管理器就会强制回滚。我这里业务 SQL 基本都在秒级,设置 60 秒已经足够;如果你的链路里还有外部接口调用,最好把超时时间调大,但也不要大到让连接池撑爆。

注册完成之后,Spring 会自动把这个JtaTransactionManager当作默认事务管理器,业务类上的@Transactional会默认使用它。

3.2 数据源代理与连接池参数

数据源配置是整个方案里最需要细心的地方。一个全局事务至少要涉及两个数据源,每个数据源都要用 Atomikos 包装。下面是我项目里的典型写法:

@Bean @Primary public DataSource orderDataSource() { return buildAtomikosDataSource("order-ds", ORDERS_URL, ORDERS_USER, ORDERS_PASSWORD); } @Bean public DataSource stockDataSource() { return buildAtomikosDataSource("stock-ds", STOCK_URL, STOCK_USER, STOCK_PASSWORD); } private DataSource buildAtomikosDataSource(String uniqueName, String url, String user, String password) { MysqlXADataSource xaDataSource = new MysqlXADataSource(); xaDataSource.setUrl(url); xaDataSource.setUser(user); xaDataSource.setPassword(password); AtomikosDataSourceBean atomikosDS = new AtomikosDataSourceBean(); atomikosDS.setUniqueResourceName(uniqueName); atomikosDS.setXaDataSource(xaDataSource); atomikosDS.setMinPoolSize(2); atomikosDS.setMaxPoolSize(10); atomikosDS.setDefaultAutoCommit(false); return atomikosDS; }

uniqueResourceName特别重要。它是这个数据源在 XA 资源管理器里的唯一标识,事务恢复日志会用到它。名字一旦确定,就不要随便改动,否则 Atomikos 重启时可能认不出历史事务分支,导致恢复失败。

连接池参数我一开始用的是默认值,结果有一次出现高并发时连接不够。后来把minPoolSize设置成 2,maxPoolSize设置成 10,应对日均几十万笔的跨库写入基本没问题。这里要记得,全局事务提交之前,每个参与的数据源都会占用一个连接,所以连接池上限不要设太保守。

3.3 事务日志目录与恢复机制

Atomikos 和普通连接池最大的区别是,它会把未完成事务记录到本地磁盘,这是 XA 恢复的前提。想象一下:事务管理器已经给数据库 A 发了准备提交,数据库 B 也准备好了,机器突然断电。如果没有任何日志,重启后事务管理器根本不知道这个全局事务处于什么状态,数据库 A 和 B 可能一个已提交、一个没提交,数据就分叉了。

Atomikos 的日志文件目录默认在工作目录下的 tmlog 里。为了可控,我建议在启动配置里显式指定日志目录:

System.setProperty("com.atomikos.icatch.log_base_dir", "/data/atomikos-logs"); System.setProperty("com.atomikos.icatch.force_rolling_back", "false"); System.setProperty("com.atomikos.icatch.force_shutdown_on_recovery_failure", "false");

log_base_dir是日志目录,一定要落在持久化磁盘上,不要放/tmp,系统清理临时文件是灾难。force_shutdown_on_recovery_failure设置成false可以让应用在恢复遇到问题时先启动起来,而不是直接卡死。这些属性需要在 Atomikos 加载之前设置,所以最好放在main方法开头,或者通过@PostConstruct提前执行。

4. 实操代码:两个库同时写入并回滚验证

4.1 Service 层事务边界

验证方案很直接:往订单库插入一条订单记录,再往库存库扣减库存,第二步故意抛出异常,看第一步会不会跟着回滚。

先定义 JdbcTemplate,分别绑定到两个数据源:

@Bean @Primary public JdbcTemplate orderJdbcTemplate(@Qualifier("orderDataSource") DataSource ds) { return new JdbcTemplate(ds); } @Bean public JdbcTemplate stockJdbcTemplate(@Qualifier("stockDataSource") DataSource ds) { return new JdbcTemplate(ds); }

Service 层的核心方法:

@Service public class TransferService { private final JdbcTemplate orderJdbcTemplate; private final JdbcTemplate stockJdbcTemplate; public TransferService(@Qualifier("orderJdbcTemplate") JdbcTemplate orderJdbcTemplate, @Qualifier("stockJdbcTemplate") JdbcTemplate stockJdbcTemplate) { this.orderJdbcTemplate = orderJdbcTemplate; this.stockJdbcTemplate = stockJdbcTemplate; } @Transactional(rollbackFor = Exception.class) public void createOrderAndReduceStock(String orderNo, int productId, int quantity) { orderJdbcTemplate.update( "insert into t_order(order_no, product_id, quantity, status) values(?,?,?,?)", orderNo, productId, quantity, 1); // 模拟库存扣减后业务校验失败 stockJdbcTemplate.update( "update t_stock set quantity = quantity - ? where product_id = ?", quantity, productId); throw new RuntimeException("库存扣减后发现问题,需要整体回滚"); } }

注意rollbackFor = Exception.class不是可有可无。Spring 默认情况下只对运行时异常和 Error 回滚,如果你在业务里抛的是检查异常,比如throws Exception,不强制指定的话事务会很“平静”地提交,数据就分叉了。这个细节我至少踩过两次。

4.2 测试验证与预期结果

写一个简单单元测试或者直接用 Controller 调试。调用createOrderAndReduceStock之后,分别查两个库:

select count(*) from t_order; select quantity from t_stock where product_id = 1001;

如果是正常关注点,第一次插入后 count 会变成 1,第二次更新后抛异常。但最终结果应该是 count 还是 0,库存字段也没有变化。我在日志里看到过这样一段关键输出,可以直观感受一下 Atomikos 的协调过程:

Atomikos: 开始 prepare 事务分支 order-ds Atomikos: 开始 prepare 事务分支 stock-ds 事务被标记为 rollback-only,开始 rollback 所有分支

看到 rollback 分支包括order-ds和stock-ds,就说明两个数据源确实都被纳入了同一个全局事务。如果日志里只有一个数据源出现,或者完全没有Atomikos字样,很可能是事务管理器没生效。

4.3 生产项目里怎么接 MyBatis

如果你在真实项目里用的是 MyBatis,也可以复用到上面这一套数据源。区别只是 JdbcTemplate 换成SqlSessionFactory。每个数据源对应一个SqlSessionFactory,Mapper 扫描各自指向对应的包和工厂,事务边界仍然是@Transactional,底层由 JTA 管理器协调。

这里要注意,不要用默认的 MyBatis 自动配置,因为它只会读取一个 DataSource。建议手动配置两个DataSourceTransactionFactory,分别通过MapperScannerConfigurer指定不同的 Mapper 包路径。配置逻辑不复杂,但代码量比 JdbcTemplate 示例多不少。核心事务配置和验证方式完全一样,所以日常验证分布式事务时,用 JdbcTemplate 最轻量。

5. 常见故障与排查记录

5.1 事务完全没有生效

最典型的现象是第二步抛异常,第一步却提交成功了。我排查思路是这样的:

第一,确认@Transactional所在的 Bean 是不是被 Spring 代理。如果是同类内部方法调用,比如 Service 内部在一个不带事务注解的方法里调用带@Transactional的私有方法,代理不会生效。

第二,确认事务管理器是不是JtaTransactionManager,如果项目里还有别的DataSourceTransactionManager,Spring 可能选择了错误的管理器。

第三,确认数据源是不是AtomikosDataSourceBean。你可以把两个数据源 Bean 的类型打印出来看看,如果写成了 HikariCP 的实例,那就说明自动配置没有被排除,或者 Bean 定义冲突了。

第四,确认业务异常没有被 try-catch 吞掉。事务回滚依赖异常传播到事务拦截器,如果你在 Service 内部自己 catch 住异常并正常返回,Spring 会认为事务成功。

5.2 启动日志出现 XAER_NOTA

XAER_NOTA的意思是事务管理器试图对一个已经不存在的 XA 分支执行操作。常见原因有三个:

一是uniqueResourceName被更改了。Atomikos 通过资源名关联恢复日志,改名后找不到原来的分支。二是日志目录里的旧事务记录和当前数据库状态不一致,可能是数据库表被清空或连接指向了别的实例。三是上一次应用非正常退出后,Atomikos 恢复逻辑没有成功完成。

遇到这个错误,先不要急。如果确认业务数据没有正在进行的交易,可以保留日志后重启,或者按照官方文档清理对应事务记录。但我建议没事不要随便清日志,而是先备份,再排查是不是资源名变化导致的。

5.3 连接池被打满,数据库连接数暴涨

我在测试阶段遇到过一个问题:两个数据源都设置了maxPoolSize为 5,结果压测时应用直接无响应,数据库端看到大量连接处于 Sleep 状态。原因是全局事务在提交前一直占着连接,如果事务里还调用了外部 HTTP 接口,接口响应很慢,连接会被越积越多。

排查命令很简单,在 MySQL 里执行:

select * from information_schema.processlist where state like '%XA%';

如果看到很多事务分支处于 prepare 或 idle 状态,基本就是事务长时间不释放。解决方法无非三条:缩短事务内耗时、调低事务超时时间、适当增加连接池上限。最好的思路是不要把外部网络调用放到全局事务里,先用本地事务记录中间状态,再通过补偿机制处理后续逻辑。

5.4 全局事务里夹杂了 Redis 操作

有些人会在同一个@Transactional方法里既写两个数据库,又写 Redis。这时要清楚:Atomikos 只能管 MySQL 的 XA 分支,Redis 那一步无论如何都不会跟随数据库回滚。比如先写 Redis、再写数据库,数据库失败后 Redis 里的数据就残留下来了。

我的处理方式是隔离:如果 Redis 是可以容忍短时间不一致的,把 Redis 操作放到事务提交之后执行,用TransactionSynchronizationManager.registerSynchronization注册 afterCommit 回调;如果 Redis 也必须精确回滚,就得引入另外的分布式事务框架,或者干脆不用 JTA,改用 Saga 模式。总之不要指望 Atomikos 能帮你回滚 Redis。

6. 实操心得与调优参考

6.1 事务超时与连接池的配合

事务超时时间不宜拍脑袋定。我一般先看事务内最慢 SQL 的执行时间,再留出 3 到 5 倍余量。比如最慢 SQL 是 200 毫秒,整体业务链路最多 2 秒,那setTransactionTimeout(30)已经非常宽裕。超时时间设太长,会让全局事务中的连接长时间占用,影响其他请求。

连接池方面,每个数据源minPoolSize维持 2 就够了,maxPoolSize按峰值并发估算。假设每秒最多 50 个请求同时进入跨库事务,每个事务持有两个连接,那么总连接数约 100。如果两个数据源都设 50,可能刚好满足。很多时候压测出问题,不是参数不够,而是事务没提交或者锁等待导致连接没有释放。

6.2 什么时候真不该用 JTA

我用 JTA + Atomikos 解决过跨库问题,但也有两个场景我后来放弃了。

第一个场景是事务里包含慢 SQL 和外部调用。一次事务超过几十秒,XA 准备阶段长时间锁表,业务上完全不可接受。这时候更适合的做法是重构业务,把“必须强一致”的范围缩小,或者改用消息驱动加本地事务表做最终一致。

第二个场景是跨库且跨异构资源。比如订单库是 MySQL,库存数据又有一部分要从 Redis 读取后更新,Redis 不支持 XA,强行包进 JTA 只会造成假一致。有些团队绕路用“先写 Redis 再写 MySQL,失败后反向删除 Redis”的方式,但这只是补偿,不是事务,现场一旦崩溃仍然可能不一致。

6.3 一点关于版本命名的题外话

这个项目标题里写的是“SpringBoot 11”,其实 Spring Boot 官方的版本号里没有 11,估计是从某个内部版本或分支代号复制来的。但不管你是 Spring Boot 2.3 还是 3.2,JTA + Atomikos 的集成思路完全一致,变的只是依赖坐标和javax到jakarta的包名迁移。所以如果你在旧项目里看到类似的标题,也可以大胆按照这套手动配置来迁移,不需要被数字版本吓住。

最后再分享一个小技巧:给 Atomikos 日志目录单独挂到持久化磁盘,并加一个定时备份脚本。数据可以靠数据库冗余恢复,但 XA 日志一旦丢了,悬挂事务的恢复就会变得很麻烦。我后来所有使用 JTA 的项目,都会把日志目录和备份策略写进部署文档,这个习惯帮我躲过多次半夜的数据库不一致问题。分布式事务不是银弹,但在合适的场景下,JTA + Atomikos 足够可靠,也足够轻量。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进