Spring Boot整合JTA+Atomikos实现分布式事务实战 第一次接触分布式事务是很多年前在一个双库项目上。当时业务不复杂就是订单数据落在 order 库库存数据落在 storage 库客户端下了单以后两边都要写。最初我用的是“先写一个库再写另一个库”的笨办法结果线上出了好几次脏数据订单显示已支付库存却没扣成功每次排查都要靠人工对账捞数据那种感觉非常痛苦。后来有人告诉我可以试试 JTA Atomikos 的组合用两阶段提交来保证跨库操作的原子性。Spring Boot 集成 JTA 的资料不算少但我第一次照着配置完发现事务根本不生效两个数据源之间还是各管各的。这篇文章把我从“能跑通Demo”到“敢用上生产”这段时间的所有实操经验写下来包括配置写法、版本匹配、事务管理器的绑定方式还有我踩过的三个大坑。1. 为什么“先写订单、再扣库存”这种写法早晚出事1.1 一个常见的双写场景假设有个电商服务的核心链路用户下单后系统要在订单库插入一条订单记录在库存库扣减一件商品的库存。如果这两个库都在同一个 MySQL 实例里那用普通Transactional就够了因为本地事务天然管着同实例下的所有表。但微服务化之后订单库和库存库往往被拆开数据库连接是两个独立的连接本地事务管理器没法同时感知两个连接上的提交状态。一开始我也天真地想过既然事务管不住两个库那我“先扣库存、再写订单”“写失败就补偿”总行了吧实际做下来才发现补偿逻辑远没有想象的简单。扣库存成功了订单写失败这时候要反向把库存补回来补库存如果也失败呢消息队列还没来得及发数据就已经不一致了。更麻烦的是如果两个操作之间存在半点时间差监控系统就可能在这段时间里把部分错误数据透传给下游。这种手工补偿方案不是不能用但代价是被迫为每个业务场景写一套对账和回滚代码。1.2 两阶段提交到底解决了什么JTA 里的事务协调者Transaction Manager会把多个资源纳入同一个全局事务用两阶段提交2PC让它们保持一致。我习惯用一个生活化类比来理解它你在两个窗口分别办了业务办事员都问“你确定要办吗”等两边确认后系统才真正落章。任何一个窗口说“办不了”两个窗口都不落章。这个“先确认、后提交”的过程就是 Prepare 阶段和 Commit 阶段。两阶段提交的核心价值就是让多个数据库的提交动作从“顺序操作”变成“原子操作”。要么都提交要么都回滚。Atomikos 在其中扮演的角色就是 JTA 协议的落地实现它管理 XA 事务、维护事务日志、协调多个 XA 数据源。Spring Boot 应用里只需要把事务管理器配好剩下的两阶段提交细节不用自己写。1.3 为什么我最终选了 JTA 而不是别的事务方案分布式事务的方案很多消息事务、本地消息表、TCC、Saga、Seata 单元等等。我最终选择 JTA Atomikos最关键的原因是它不需要改动业务表结构也不需要引入一套独立的事务协调集群。对于数据源数量不多两三个库、事务跨度又不太长的单体应用来说JTA 是这些方案里侵入性最小的一种代码里一个Transactional就能把多个数据源包进同一事务。当然JTA 也不是银弹。它最适合的单体应用内部多数据源场景一旦跨服务跨系统、调用链路较长就应当考虑 TCC 或 Saga 这类方案。所以如果你现在已经被分布式事务折磨得焦头烂额先想清楚一个问题你的“分布式事务”究竟是“多数据源事务”还是“多服务事务”。前者用 JTA后者建议另走他路。2. 把 Atomikos 放进 Spring Boot 工程前有几个版本问题必须先想清楚2.1 Spring Boot、JDK、Atomikos 之间的兼容关系我最早是在 Spring Boot 2.x JDK 8 的项目里接入 Atomikos 的JDK 8 用得很顺利。后来项目升级到 JDK 11、Spring Boot 2.3 也没问题。真正需要警惕的是 Spring Boot 3.x它把内置的spring-boot-starter-jta-atomikos移除了而且Jakarta EE 命名空间从javax.transaction变成了jakarta.transaction。如果在 Spring Boot 3.x 项目里还按 2.x 的依赖来加会直接报找不到类。我现在的参考基准是这样Spring Boot 版本JDK 版本Atomikos 引入方式2.2 - 2.7JDK 8/11/17用spring-boot-starter-jta-atomikos最省事自动装配完整3.xJDK 17删掉内置 starter手动引入 Atomikos 核心依赖和jakarta.transaction-api1.xJDK 7/8也能用 starter但配置方式偏旧不推荐新项目再踩我在一个 Spring Boot 3.2 的项目里用的是手动引入方案atomikos-transactions-jdbc、atomikos-util和jakarta.transaction-api然后自己写配置类把UserTransaction、UserTransactionManager、JtaTransactionManager三个 Bean 全部定义出来。这样反而可控性更强因为版本都由自己锁定不随 Spring Boot BOM 漂移。2.2 明确“不适合”用 Atomikos 的场景这个判断比版本问题更重要。如果你的业务事务里要操作的消息队列是 RocketMQ 或 Kafka它们并不天然支持 XA 资源那 JTA 也接管不了队列的半提交语义。又比如你要同时更新 MySQL、Redis 和 MongoDBRedis 的 XA 支持比较弱硬塞进 JTA 事务里意义不大。JTA 最舒服的边界是“数据库 数据库”或“数据库 支持 XA 的消息中间件”。如果数据源太多、事务执行时间太长两阶段提交的锁时间也会拉长并发能力会明显下降。这里不是贬低这种方案而是提醒你自己心里有杆秤分布式事务的强一致性从来不是免费的它牺牲了部分可用性和性能。3. 动手写配置两个 XA 数据源的正确创建方式3.1 依赖怎么加如果你是 Spring Boot 2.x最省事的做法是在 pom.xml 里加一个 starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jta-atomikos/artifactId /dependency这个 starter 会把 Atomikos 的事务管理器和自动配置都带进来省掉很多重复代码。我后来在 Spring Boot 3.x 里手动加的是这样一组dependency groupIdjakarta.transaction/groupId artifactIdjakarta.transaction-api/artifactId version2.0.1/version /dependency dependency groupIdcom.atomikos/groupId artifactIdatomikos-transactions-jdbc/artifactId version6.0.0/version /dependency dependency groupIdcom.atomikos/groupId artifactIdatomikos-util/artifactId version6.0.0/version /dependencyatomikos-transactions-jdbc提供的是AtomikosDataSourceBean这正是我后面用来封装 XA 数据源的核心类。注意 6.0.0 这个版本系列对 JDK 11/17 的支持比较稳定。3.2 YAML 里的 JTA 全局参数全局 JTA 参数放在 application.yml 里主要是事务日志目录和超时控制。我一般会这样配spring: application: name: multi-ds-atomikos-demo jta: enabled: true atomikos: properties: log-base-dir: ./tx-logs max-timeout: 300000 default-jta-timeout: 10000 transaction-manager-unique-name: atomikos-demo-tm这里的log-base-dir是 Atomikos 写事务日志的位置两阶段提交挂了之后要根据它来做恢复。max-timeout是全局最大事务超时单位毫秒default-jta-timeout是默认事务超时。如果你使用 Spring Boot 3.x 手动集成这个 yaml 片段里的spring.jta.enabled不一定生效我的做法是把这些参数直接在 Java 配置里用Properties传给 Atomikos避免两边不一致。3.3 Java 配置里创建两个 AtomikosDataSourceBean在 Spring Boot 2.x 时自动配置可以读 yml 里的spring.jta.atomikos.datasource配置来生成数据源。但我更推荐显式写两个Bean因为多数据源场景下每个库的连接池大小、账号、URL 本来就不一样写死在配置文件里反而不如 Bean 直观。Configuration public class AtomikosDataSourceConfig { Bean(name orderDataSource, initMethod init, destroyMethod close) public DataSource orderDataSource() { AtomikosDataSourceBean ds new AtomikosDataSourceBean(); ds.setUniqueResourceName(ORDER_XA_RESOURCE); ds.setXaDataSourceClassName(com.mysql.cj.jdbc.MysqlXADataSource); ds.setXaProperties(buildXaProperties( jdbc:mysql://localhost:3306/order_db?useSSLfalseserverTimezoneAsia/Shanghai, orderUser, orderPwd)); ds.setMinPoolSize(2); ds.setMaxPoolSize(20); ds.setMaxLifetime(30000); ds.setBorrowConnectionTimeout(30); return ds; } Bean(name storageDataSource, initMethod init, destroyMethod close) public DataSource storageDataSource() { AtomikosDataSourceBean ds new AtomikosDataSourceBean(); ds.setUniqueResourceName(STORAGE_XA_RESOURCE); ds.setXaDataSourceClassName(com.mysql.cj.jdbc.MysqlXADataSource); ds.setXaProperties(buildXaProperties( jdbc:mysql://localhost:3306/storage_db?useSSLfalseserverTimezoneAsia/Shanghai, storageUser, storagePwd)); ds.setMinPoolSize(2); ds.setMaxPoolSize(20); ds.setMaxLifetime(30000); ds.setBorrowConnectionTimeout(30); return ds; } private Properties buildXaProperties(String url, String username, String password) { Properties p new Properties(); p.put(URL, url); p.put(user, username); p.put(password, password); return p; } }这个写法有几个关键点setUniqueResourceName必须是全局唯一的资源名。我一开始图省事两个数据源都叫mydb_xa结果 Atomikos 跑起来后恢复日志一直交替报资源冲突根本没法正常启动事务。MysqlXADataSource是 MySQL 驱动提供的 XA 数据源实现它本身是连接 MySQL 的底层入口。Atomikos 只是把它包起来额外提供连接池和 XA 管理能力。数据源 Bean 上必须设initMethod init和destroyMethod close。这一步最容易漏。漏掉之后 Bean 创建时不初始化实际运行时连接池一直没有真正启动事务会因为拿不到连接而卡死。3.4 为什么不能直接用一个普通连接池包 XA 驱动我见过不少配置直接把MysqlXADataSource塞给 HikariCP再用HikariDataSource当数据源然后期待 JTA 能自动把它纳入分布式事务。结果项目里的事务管理器确实创建出来了两个数据源也确实都在更新但回滚时只回滚了一个库另一个库的数据纹丝不动。原因是事务协调者需要从一个资源里拿到 XA 分支XAResource才能参与两阶段提交。普通连接池只管理普通 JDBC 连接它并不知道什么是 XA 事务分支。AtomikosDataSourceBean的特殊之处在于它支持XAConnection让连接池里的每一条连接都具备 XA 能力这样事务管理器才能在 Prepare / Commit / Rollback 阶段精确指挥每个库。所以正确的姿势一定是XA驱动实现如MysqlXADataSource)交给AtomikosDataSourceBean管理而不是用普通连接池包一层。这一点会在你做回滚测试时体验得特别明显。4. 让两个数据源进入同一个事务管理器4.1 先理解“事务管理器只有一个”JTA 的模型是整个服务进程里应该只有一个事务协调点所有参与分布式事务的数据源都把资源注册到这个事务管理器上。Spring 也遵循这个约定所以不管你有多少个数据源、多少个 EntityManagerFactory最后的PlatformTransactionManager只能有一个而且必须是JtaTransactionManager。有些同学会按数据源数量创建多个事务管理器再通过Transactional(transactionManager xxx)区分。这在普通多数据源场景下没问题但在 JTA 场景下是错的做法。因为两个数据源如果各自绑定独立的事务管理器那两次提交就是两个独立全局事务原子性从根上就破坏了。4.2 手动组装事务管理器的 Bean在 Spring Boot 2.x 有 starter 时自动配置会创建好JtaTransactionManager。但我仍然建议搞明白它内部的两个重要组件UserTransaction和UserTransactionManager。UserTransaction是应用层开启、提交、回滚事务的入口UserTransactionManager是底层负责协调多个 XA 资源的管理器。Spring 的JtaTransactionManager会把这两者串联起来。手动配置时我的写法是Configuration public class TransactionManagerConfig { Bean(name atomikosUserTransaction, destroyMethod ) public UserTransaction atomikosUserTransaction() { UserTransactionImp userTransaction new UserTransactionImp(); return userTransaction; } Bean(name atomikosTransactionManager, destroyMethod ) public UserTransactionManager atomikosTransactionManager() { UserTransactionManager manager new UserTransactionManager(); manager.setForceShutdown(false); return manager; } Bean(name transactionManager) public JtaTransactionManager transactionManager( UserTransaction atomikosUserTransaction, UserTransactionManager atomikosTransactionManager) { JtaTransactionManager jtaTransactionManager new JtaTransactionManager(); jtaTransactionManager.setUserTransaction(atomikosUserTransaction); jtaTransactionManager.setUserTransactionManager(atomikosTransactionManager); return jtaTransactionManager; } }注意destroyMethod 这是为了避免 Spring 容器关闭时MyBatis 或 JPA 的反注册流程跟 Atomikos 的关闭流程互相打架。4.3 JPA 场景下怎么绑定持久化单元如果是 JPA 实现每个数据源都要有一个LocalContainerEntityManagerFactoryBean而且必须调用setJtaDataSource(dataSource)把事务数据源指向 Atomikos 管理的那个DataSource。同时EntityManagerFactory 本身不要传入自己的事务管理器否则会变成两个事务管理器同时管持久化单元。Bean(orderEntityManagerFactory) public LocalContainerEntityManagerFactoryBean orderEntityManagerFactory( Qualifier(orderDataSource) DataSource orderDataSource) { LocalContainerEntityManagerFactoryBean factory new LocalContainerEntityManagerFactoryBean(); factory.setDataSource(orderDataSource); factory.setJtaDataSource(orderDataSource); factory.setPackagesToScan(com.example.demo.order); factory.setPersistenceUnitName(orderPU); factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); factory.setJpaPropertyMap(Map.of( hibernate.dialect, org.hibernate.dialect.MySQL8Dialect, hibernate.transaction.jta.platform, org.hibernate.service.jta.platform.internal.AtomikosJtaPlatform )); return factory; }hibernate.transaction.jta.platform这个配置很关键它告诉 Hibernate 使用 Atomikos 提供的 JTA 平台而不是默认的本地事务平台。漏掉这个配置Hibernate 依然会把事务当成本地事务处理平时跑测试不容易察觉一旦真正遇到跨库回滚问题就暴露了。如果你用的是 MyBatis事情更简单只需要保证两个 SqlSessionFactory 的 DataSource 来自 AtomikosDataSourceBean事务管理器用同一个 JtaTransactionManager方法上加Transactional即可。MyBatis 自己没有独立事务管理器它天然吃 Spring 的事务管理反而少踩一层坑。5. 事务验证用“注定失败”的操作用来测试回滚能力5.1 先验证正常提交配置做完后第一件事不是验证分布式事务而是先验证两个库能正常写入。我写了一个很简单的服务Service public class OrderService { Transactional(rollbackFor Exception.class) public void createOrderWithStorage(String orderNo, String productNo) { orderDao.insert(orderNo); storageDao.deduct(productNo); } }orderDao操作的是 order 库storageDao操作的是 storage 库。两个 DAO 都使用 Spring 事务管理范围内打开的连接。如果两个库都能各自插入成功说明 AtomikosDataSourceBean 的连接池工作正常事务管理器也被正确加载了。这时候再去看数据库order 表和 storage 表都有新增记录一切正常。5.2 制造一个必然回滚的场景接下来是关键测试。我在事务方法里故意让第二个库的 SQL 抛出异常并且异常是在第二个库写入之前抛出的Transactional(rollbackFor Exception.class) public void createOrderAndFail(String orderNo, String productNo) { orderDao.insert(orderNo); throw new RuntimeException(模拟第二个库写入失败); }这个时候第一个库的 insert 已经执行了但它没有提交随后事务回滚。执行完后检查 order 表数据不应该存在。如果这个测试里 order 库的数据还留在表中那说明你的Transactional根本没有绑定 JtaTransactionManager大概率是前面说的“多个事务管理器”或“数据源不是 Atomikos 管理”的问题。我自己的经验是这个“必现回滚”的测试应该放在任何业务功能之前越早跑越能发现问题。很多项目接入分布式事务后只觉得“好像没问题”是因为日常接口都成功了回滚路径根本没被触发过。5.3 用事务状态确认当前是否为 JTA 全局事务如果不确定运行时实际上用的哪个事务管理器我习惯在测试代码里加一段检查逻辑boolean isJtaActive TransactionSynchronizationManager.isActualTransactionActive(); if (!isJtaActive) { throw new IllegalStateException(当前没有事务); }还可以把TransactionSynchronizationManager.getCurrentTransactionName()打印出来确认方法名被拦截到了。更严格的做法是检查DataSource的类型运行期打印数据源 class如果显示是AtomikosDataSourceBean就对了。排查时先把日志级别调到 DEBUGSpring 的JtaTransactionManager会打印事务提交和回滚的关键节点。它会明确输出Participating in existing transaction还是Creating new transaction with name [...]看到这一行基本能判断事务切面是否生效。6. 走上生产环境后真正麻烦的是这些边界问题6.1 XA 资源名不能重复更不能随便换Atomikos 的恢复机制依赖全局唯一的资源名来定位事务分支。如果你在同一个 JVM 里启动了两个orderDataSource它们用了同一个setUniqueResourceName恢复日志会混乱启动时可能直接报类似 “XAER_RMERR” 的错误。更隐蔽的一个坑是线上环境里如果你把资源名改掉旧的事务日志还残留着旧名字的事务分支Atomikos 恢复时找不到对应的资源分支事务会一直处于悬挂状态。所以我给资源名的建议是跟数据库库名绑定写完就不要再变。如果需要改库先清空旧的事务日志再部署新版本否则恢复过程会让你头疼。6.2 连接池被占满所有的线程都堵在“等我”JTA 事务一般比普通本地事务耗时更长因为两阶段提交期间协调者要跟多个资源通信每个资源上的数据库连接都要被事务长期持有。如果连接池配置过小很容易出现第一个事务待在 Prepare 阶段迟迟没结束时后续请求全都卡在borrowConnectionTimeout上。我遇到的典型情况是单库本地事务几毫秒就完事接入 Atomikos 后随手配了maxPoolSize10结果业务一上量接口耗时从 50ms 涨到 5 秒日志里全是连接获取超时。后来我把连接池提到 30同时把事务内的非必要操作挪到事务外现象才好很多。这里有一个比较实用的原则把borrowConnectionTimeout设得比全局事务超时短这样连接拿不到时能快速失败而不是无限等下去。同时适当调大连接池并控制事务内执行时间不要让事务内出现耗时的远程调用。6.3 超时参数之间的嵌套关系Atomikos 里的超时参数不是只有一个它们存在层级关系。默认值都不同搭配起来容易判断失误。我把几个关键参数整理成一张表参数作用默认值建议值max-timeout全局最大事务超时上限300000ms30000ms 以内default-jta-timeout未指定时事务默认超时10000ms按业务定建议不超过 30000msborrow-connection-timeout连接池获取连接的最大等待30s3000ms - 5000msmax-lifetime连接在池里最大存活时间30s按数据库 wait_timeout 调整容易犯的错是只配了default-jta-timeout但没有调borrow-connection-timeout。当连接池满时拿连接会卡住导致事务超时也无法结束回调的报错还不是 JTA 超时错误而是连接获取超时排查方向很容易跑偏。6.4 用 atomikos 事务日志反推故障现场Atomikos 会把每个全局事务的注册信息、准备状态、提交/回滚动作写到log-base-dir下文件名通常是tmlog前缀。当某个事务在 Prepare 之后进程崩溃重新启动时 Atomikos 会根据日志里的记录主动询问所有资源分支对未完成的全局事务进行恢复。因此log-base-dir目录必须放到持久化磁盘并且定期备份。如果只是放在容器临时目录容器重启文件丢失Atomikos 可能会认为事务悬挂部分资源会一直锁着不放。我还遇到过日志膨胀的问题事务量大时tmlog文件会快速增长需要按文档定期清理已完成的日志段或者给日志目录挂上磁盘配额监控。这些经历如果不在生产环境摸爬滚打光看官方文档真的很难提前注意到。最后分享一个小技巧在项目初期我会专门留一个“故障注入接口”通过开关让某个数据源在指定条件下报错以此定期验证分布式事务的回滚能力。线上系统经过几次版本迭代后配置可能被无意改坏比如把某个数据源悄悄换成了普通连接池这种故障注入接口能很快让问题暴露。分布式事务这东西平时看起来一切正常一旦出错就是脏数据所以提前把回滚链路练好比什么都重要。