多数据源下事务管理器配置错误导致数据回滚异常

本文是Spring Boot 生产配置实战系列的第二篇 叙事框架:现象 → 排查过程 → 根因 → 修复 → 预防

问题现象

某日上午 10:30,客服群突然收到用户投诉——限时优惠活动下单失败,但积分却被扣了。

客服群讨论

值班小A上服务器查看,CPU 才 22.5%,load average 2.45,系统资源正常,没有明显的告警。

服务器初步排查

看应用日志发现:createOrderAndFail 方法在插入订单记录、扣减积分后,因为库存不足抛了异常,事务触发了回滚。但回滚后数据是否真的一致?

排查过程

第一步:对比两个库的数据

翻应用日志确认了异常的发生时间,然后直接对比两个数据库:

数据对比

结果触目惊心:

  • order_dbt_order 表中没有 user_id=u002 的记录 ✅ 说明订单回滚成功了
  • account_dbt_user_points 的 balance 从 2000 降到了 1800(扣了 200)❌ 积分没回滚
  • account_dbt_points 明细表里有积分扣减记录(created_at 就是异常时间戳)

数据不一致:订单没创建成功,积分却被扣了 200。

第二步:查看数据源配置

项目配置了两个数据源:order_db(订单库)和 account_db(积分库)。

查看配置

两个 @Configuration 各配了一个 DataSource + DataSourceTransactionManager。order_db 的是 @Primary

第三步:Arthas 查事务管理器运行时

用 Arthas 的 vmtool 看运行时状态:

Arthas 查事务管理器

DataSourceTransactionManager@6a8f0c -> HikariDataSource (order_db)
DataSourceTransactionManager@3b9e45 -> HikariDataSource (account_db)

两个 TransactionManager 都在,各自管理不同的数据源。

再看 TransactionInterceptor 的默认事务管理器:

transactionManager=@DataSourceTransactionManager[order_db]

Spring 默认绑到了 orderTransactionManager。

第四步:确认 @Transactional 的绑定

查看代码:

查看 Service 代码

@Service
public class OrderService {

    @Transactional
    public void createOrderAndFail(...) {
        orderJdbcTemplate.update("INSERT INTO t_order ...");
        pointsService.deductPoints(userId, 200);
        if ("p999".equals(productId)) {
            throw new RuntimeException("库存不足");
        }
    }
}

@Transactional 没有指定 transactionManager。Spring 默认注入 @PrimaryorderTransactionManager,这个管理器只管理 order_db 的连接。

pointsService.deductPoints 写入 account_db 时,使用的是 account_db 的独立连接,不受 orderTransactionManager 管理

所以异常发生时: - order_db ✅ 事务回滚 → 订单记录被清除 - account_db ❌ 不在事务中 → 积分扣减已提交,无法回滚

根因分析

两个关键因素:

1. @Transactional 默认只管理一个数据源

Spring 声明式事务通过 TransactionInterceptor 实现。当 @Transactional 不指定 transactionManagervalue 时,Spring 会查找 @PrimaryPlatformTransactionManager Bean。

在多数据源配置中,这意味着只有一个数据源的数据参与事务。另一个数据源的每次 JDBC 操作都是自动提交的。

2. 跨库操作没有在同一个事务管理范围内

OrderService.createOrderAndFail 的业务逻辑跨越了两个数据库: - INSERT order(order_db) - UPDATE/DELETE points(account_db)

@Transactional 只包裹了 order_db 的连接,account_db 的操作独立提交。这是伪事务——看起来有事务注解,实际上只管理了一半的数据。

修复方案

方案 A:ChainedTransactionManager(短期修复)

新增一个 ChainedTransactionManager,将两个 DataSourceTransactionManager 链起来:

@Configuration
public class ChainedTxConfig {

    @Bean(name = "chainedTxManager")
    public ChainedTransactionManager chainedTxManager(
            @Qualifier("orderTransactionManager") PlatformTransactionManager orderTx,
            @Qualifier("accountTransactionManager") PlatformTransactionManager accountTx) {
        return new ChainedTransactionManager(orderTx, accountTx);
    }
}

修改 OrderService,显式指定事务管理器:

@Transactional(transactionManager = "chainedTxManager")
public void createOrderAndFail(...) { ... }

ChainedTransactionManager 的工作原理:依次在多个 PlatformTransactionManager 上开启、提交或回滚事务。当某个管理器回滚时,之前已提交的管理器也会强制回滚(如果底层存储支持)。

方案 B:MQ 异步解耦(长期方案)

跨库事务本质上是分布式事务问题。ChainedTransactionManager 是同步协调,有性能和可靠性风险。更好的方案是:

下单 → 订单库写入(本地事务)→ 发 MQ 消息 → 积分服务消费 → 积分库扣减

积分扣减失败也不影响下单成功,通过 MQ 重试 + 对账补偿保证最终一致性。

方案 C:避免跨库事务

审视业务,看积分扣减是否真的需要和订单创建在同一个事务里。如果积分扣减失败可以后续补偿,就不应该用强事务耦合。

验证结果

修复后部署验证:

验证修复

再跑同样的失败场景: - order_db 回滚 ✅ - account_db 也回滚了 ✅(ChainedTransactionManager 协调回滚) - balance 恢复到 2000,数据一致 ✅

INFO  OrderService : orderDb rolled back, accountDb rolled back too
INFO  OrderService : cross-db rollback verified: data consistent

避坑建议

1. 多数据源项目必须显式指定 transactionManager

任何时候在 Service 上写 @Transactional,如果项目存在多个 PlatformTransactionManager必须显式指定

// ❌ 错误:默认找 @Primary,可能不是你想要的
@Transactional

// ✅ 正确:显式指定
@Transactional(transactionManager = "chainedTxManager")
// 或明确指定名称
@Transactional("chainedTxManager")

2. @Primary 不等于「所有事务都用它」

@Primary 只是告诉 Spring「有歧义时默认注入这个」。它解决的是依赖注入的歧义,而不是声明式事务的绑定——TransactionInterceptor 默认确实会取 @Primary 的 TransactionManager,但这个默认行为在多数据源场景下几乎总是错的

3. 测试必须覆盖异常回滚路径

这个 Bug 的伏笔:

  • 开发环境单库(H2),不会出现多数据源事务问题
  • 预发布环境测试只测了正向流程(下单成功),没测异常回滚
  • 监控没配跨库一致性检查,数据不一致靠客服投诉发现

建议: - 集成测试必须覆盖「业务异常 → 事务回滚 → 数据一致」的断言 - 多数据源项目应加数据对账任务,定期检查跨库数据一致性

4. 三思而「跨库」

每次增加数据源、跨库操作时,先问几个问题: - 这个操作真的需要跨两个库吗? - 能否通过数据冗余、同步、MQ 解耦避免跨库? - 如果一定要跨库,用 Seata / ShardingSphere 等分布式事务框架,还是用 ChainedTransactionManager?

ChainedTransactionManager 本质上还是本地事务的链式组合,不是真正的 XA 分布式事务。它依赖底层资源的回滚能力,在极端场景下(如网络分区、进程崩溃后重启)仍有不一致风险。

附:完整命令清单

数据对比

# 查订单库(当前用户)
docker exec -i order-mysql mysql -uorder_app -p******** order_db \
  -e 'SELECT * FROM t_order WHERE user_id="u002"'

# 查积分库(被误扣了)
docker exec -i account-mysql mysql -uaccount_app -p******** account_db \
  -e 'SELECT * FROM t_user_points WHERE user_id="u002"'

# 查积分扣减明细
docker exec -i account-mysql mysql -uaccount_app -p******** account_db \
  -e 'SELECT * FROM t_points WHERE user_id="u002"'

Arthas 运行时诊断

# 查看所有 DataSourceTransactionManager 及其绑定的数据源
vmtool --action getInstances \
  --className org.springframework.jdbc.datasource.DataSourceTransactionManager \
  --express 'instances.{#this.toString()+" -> "+#this.dataSource}' -x 2

# 查看 TransactionInterceptor 默认绑定的事务管理器
vmtool --action getInstances \
  --className org.springframework.transaction.interceptor.TransactionInterceptor \
  --express 'instances.{#this.transactionManager}' -x 2

# 搜索所有 TransactionManager Bean
sc -d *TransactionManager

配置检查

# 搜索 TransactionManager 和 @Transactional 的使用
grep -rn 'TransactionManager\|@Transactional' src/main/java/

# 查看当前 datasource 配置
cat src/main/resources/application.yml

发送测试请求

# 正常下单(两个库都成功)
curl -XPOST http://localhost:8080/api/order/create \
  -H 'Content-Type: application/json' \
  -d '{"userId":"u001","productId":"p001","amount":99}'

# 触发失败场景(验证回滚一致性)
curl -XPOST http://localhost:8080/api/order/create-fail \
  -H 'Content-Type: application/json' \
  -d '{"userId":"u002","productId":"p999","amount":199}'

# 检查订单表
curl http://localhost:8080/api/order/check-orders

# 检查积分余额
curl "http://localhost:8080/api/order/check-points?userId=u002"

📖 完整版带可复现 Demo → opencao.cn 📺 公众号「Ai拆代码的曹操」 🌟 知识星球「源阅会」(82877104)