Spring 声明式事务在多数据源切换时的失效排查与 DynamicDataSource 实践
在读写分离、分库分表或多租户业务架构中,单应用连接多个数据库实例(如 Master 主库写、Slave 从库读)是非常常见的场景。
很多团队基于 Spring 提供的AbstractRoutingDataSource,配合自定义注解@TargetDataSource("slave")与 AOP 切面实现动态数据源切换。
然而,一旦在业务方法上同时使用了@TargetDataSource("slave")和@Transactional,往往会出现极其诡异的 Bug:
“明明注解显式指定了切换到从库(Slave)执行只读查询,但日志里打印出的 SQL 却依然在主库(Master)上被执行;或者在事务内切换数据源完全失效!”
这个经典问题的根源,在于Spring 声明式事务与动态数据源路由切面在 AOP 执行顺序以及数据库连接绑定时机上的脱节。
今天我们把这套底层调用链彻底理顺,并给出生产可用的多数据源高可用方案。
事故复现:为什么加了@Transactional数据源就切不动了?
来看典型的业务切面代码:
@Aspect @Component @Order(1) // 很多人以为把数据源切面 Order 调高就能解决问题 public class DynamicDataSourceAspect { @Before("@annotation(targetDataSource)") public void switchDataSource(JoinPoint point, TargetDataSource targetDataSource) { String dsKey = targetDataSource.value(); DynamicDataSourceHolder.setDataSourceKey(dsKey); // 将数据源 Key 存入 ThreadLocal } @After("@annotation(targetDataSource)") public void restoreDataSource(JoinPoint point, TargetDataSource targetDataSource) { DynamicDataSourceHolder.clear(); } }@Service public class ReportService { // 踩坑点:同时使用了事务注解与切换从库注解 @Transactional(readOnly = true) @TargetDataSource("slave") public ReportData generateReport(Long tenantId) { // 期望从从库读取海量报表数据,但实际依然连接到了主库! return reportMapper.queryLargeStats(tenantId); } }底层时序崩塌过程:
- Spring 声明式事务切面(
TransactionInterceptor)默认是由InfrastructureAdvisor注册的,其底层优先级非常高; - 当调用
generateReport时,事务切面率先介入,触发PlatformTransactionManager.getTransaction(); - 事务管理器立即调用当前配置的
DataSource.getConnection()从数据源获取数据库连接Connection; - 此时,由于
ReportService.generateReport()的方法体还没正式执行,数据源切面的@Before甚至还没被触发,ThreadLocal中记录的依然是默认的 Master 主库 Key; AbstractRoutingDataSource.determineCurrentLookupKey()读取到 Master,从主库连接池中获取了 Connection,并将该 Connection 绑定到了当前线程的TransactionSynchronizationManager;- 紧接着数据源切面虽然执行了
setDataSourceKey("slave"),但后续 MyBatis 或 Hibernate 执行 SQL 时,发现当前线程已经绑定了活跃的事务连接,直接复用了刚才的主库 Connection!
核心解法:引入LazyConnectionDataSourceProxy延迟获取连接
根本破局之道不是去死磕 AOP 的@Order排序,而是改变获取物理数据库连接的时机。
Spring 官方提供了LazyConnectionDataSourceProxy。它的核心思想是:
在事务开启或从数据源获取连接时,先返回一个动态代理连接(Proxy Connection),此时根本不去真正的底层物理连接池拿连接;
只有当业务代码真正执行到第一条 SQL(调用Statement.execute()或PreparedStatement.executeQuery())时,代理连接才会根据此时 ThreadLocal 中的最新数据源 Key,去对应的物理连接池索要真实连接!
@Configuration public class DataSourceConfig { @Bean public DataSource dynamicDataSource() { DynamicRoutingDataSource routingDataSource = new DynamicRoutingDataSource(); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("master", masterDataSource()); targetDataSources.put("slave", slaveDataSource()); routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(masterDataSource()); // 关键点:用 LazyConnectionDataSourceProxy 包装动态路由数据源 return new LazyConnectionDataSourceProxy(routingDataSource); } @Bean public PlatformTransactionManager transactionManager(DataSource dynamicDataSource) { return new DataSourceTransactionManager(dynamicDataSource); } }多数据源架构的生产避坑指南
- 单个事务内严禁跨数据源物理操作:
LazyConnectionDataSourceProxy解决了“方法入口确定数据源”的问题,但如果你试图在同一个@Transactional方法内先往 Master 写、后又动态切换到 Slave 读,依然是不可能的。因为一个 Spring 事务在单线程内只能绑定一个固定的物理 Connection。 - 跨数据源一致性必须升级分布式事务:
如果业务确实需要在一次请求中同时修改两个物理隔离的数据库实例,必须引入Seata(AT 模式)或基于消息队列的可靠消息最终一致性方案,单机的DataSourceTransactionManager无法保证跨库原子性。 - ThreadLocal 必须在
finally块中清理:
由于 Tomcat / 线程池中的工作线程是复用的,如果不在切面的finally或afterCompletion中显式调用remove(),该线程下一次处理请求时会产生极其严重的数据源污染隐患。