ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot多数据源切换失败:事务、AOP与线程池全解析

SpringBoot多数据源切换失败:事务、AOP与线程池全解析 1. 先给问题画个像你遇到的到底是哪种“切换失败”做Java后端的朋友尤其是维护过中大型项目的大概率都碰过这个场景项目里因为读写分离、分库分表、多租户或者单纯是把老系统的库并进来需要在SpringBoot里配置多个数据源。启动不报错配置看着也没问题结果一跑业务数据源就是切不过去或者时灵时不灵。有人戏称它为“薛定谔的数据源切换”——你以为切到了从库实际上查的还是主库。这个问题的覆盖面其实比想象中大得多。网上随便搜一下从CSDN到掘金再到各种技术交流群几乎天天都有人问。SpringBoot多数据源切换失败表面上看是一个简单的配置问题但实际排查起来往往涉及事务、AOP、连接池、线程模型、MyBatis插件等多个环节。任何一个环节出问题最终的表象都是“切换失败”。这篇文章我打算把这类问题掰开揉碎讲清楚不光告诉你“怎么修”更重要的是帮你建立一套排查思路以后再遇到类似问题可以快速定位。先说结论多数据源切换失败的根因九成以上出在三个地方——事务把数据源“焊死”了、AOP切面压根没生效、线程上下文丢了。剩下的属于连接池复用和配置覆盖这类边角问题。先说几个典型的“失败”现象你可以对照一下自己遇到的是哪一种现象A定义了动态数据源和切换注解但所有SQL都走了主库从库完全没流量。现象B切换偶发失效比如第一次调用走了从库第二次同样参数却走了主库或者反过来。现象C在某个Service方法里先切换数据源A再切换数据源B第二次切换无效。现象D方法内部开了一个异步线程异步线程里查出来的是主库数据但明明在异步逻辑里指定了从库。现象E项目里同时配了多个数据源和多个事务管理器启动报错或者运行时报“No qualifying bean of type PlatformTransactionManager”。这几种现象背后对应的原因完全不同。如果不先定位清楚是哪一类上来就改配置大概率是白费功夫。后面我会一个个拆解并给出对应的排查路径和解决方案。2. 追根溯源为什么好好的数据源会“切不动”要理解多数据源切换为什么会失败得先理解SpringBoot里多数据源切换到底是怎么实现的。市面上的方案看起来五花八门但底层基本都是同一个核心机制继承Spring提供的AbstractRoutingDataSource重写determineCurrentLookupKey方法在运行时动态决定当前线程使用哪个数据源。2.1 AbstractRoutingDataSource的“路由”机制AbstractRoutingDataSource是Spring对javax.sql.DataSource的一层包装它内部维护了一个targetDataSources也就是一个Mapkey是数据源标识value是真实的数据源对象。它自身实现了DataSource接口但getConnection()方法并不直接返回某个固定数据源的连接而是通过determineCurrentLookupKey()拿到一个key再去targetDataSources里找对应的数据源。这个机制理解起来可以类比快递分拣所有包裹先送到一个总站AbstractRoutingDataSource分拣员determineCurrentLookupKey看一眼包裹上的地址当前线程的数据源key然后决定把它放到哪条运输线上具体的数据源。关键点来了determineCurrentLookupKey()拿到的key绝大多数实现都是从一个ThreadLocal里读取的。比如我们常写的DynamicDataSourceContextHolder内部就是一个ThreadLocal 往里set一个值路由就切过去了用完再remove掉。这看起来很简单但问题恰恰就出在这个ThreadLocal上。ThreadLocal的特性是“线程隔离”也就是说每个线程只能看到自己线程里set的值别的线程看不到。这就引出了后面要讲的线程池场景。另外一个隐藏得更深的问题Spring的事务管理器在开启事务时会通过DataSourceUtils.getConnection()获取连接而这个获取动作一旦发生数据源路由就“定”下来了——同一个事务里后续所有操作都复用这同一个物理连接不会再重新走路由逻辑。2.2 为什么“先开事务再切数据源”是经典的翻车姿势很多人第一次遇到切换失败都是这么写的Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Transactional DS(slave) public ListUser listFromSlave() { return userMapper.selectAll(); } }注释也加上了注解也写了结果查询死活还是走主库。问题在哪在于Transactional和DS注解的执行顺序。Spring的声明式事务是基于AOP实现的事务拦截器会在目标方法执行之前开启事务。如果事务管理器是DataSourceTransactionManager它在开启事务时会调用DataSourceUtils.getConnection()获取连接。这个getConnection()会触发AbstractRoutingDataSource的getConnection()于是determineCurrentLookupKey()在“切数据源”之前就先执行了一次。再深入一点DS注解如果是你自己写的AOP切面的执行顺序默认和Spring事务切面谁先谁后取决于Order。通常事务切面的Order值比较靠前也就是先开启事务再执行你的切面——这时候切换数据源的操作已经晚了因为事务已经把第一个连接绑到了当前事务上后续所有SQL都用这个连接。有些人可能试过把DS和Transactional放在不同类、不同方法上发现有时能切过去有时不行这就是因为AOP代理的组合顺序不同。总而言之一句话事务和数据源切换的先后关系是多数据源切换失败的头号元凶。2.3 连接池复用带来的“串库”隐患还有一个很隐蔽的问题是连接池复用。HikariCP、Druid这类连接池为了提升性能会在一个线程使用完连接后把连接放回池子里下次任意线程申请连接时很可能拿到同一个物理连接。如果你的数据源切换只是在应用层通过ThreadLocal记录了一个key但底层连接池并没有区分物理连接属于哪个数据源就可能出现一种诡异情况第一次请求切到从库拿到一个连接用完放回池子下一次请求切回主库结果拿到的是刚才从库的物理连接——如果你没有正确配置多数据源的连接池隔离或者你其实是用同一个DataSource实例去建的连接池那就会出现“串库”。当然绝大多数情况下我们用的是两个独立的DataSource实例所以这个问题的表现形式不是真的串库而是“切换看起来没生效”其实是连接被事务提前绑定、或者连接池把目标数据源搞混了。排查时需要留意。3. 实战排查从日志到代码一步步定位遇到多数据源切换失败别急着改配置先按下面的顺序做一轮排查基本能在半小时内把问题圈定在一个小的范围内。3.1 第一步确认路由有没有真正执行先在determineCurrentLookupKey方法里加一行日志或者在自定义切面里打印当前要切换的数据源key。这是最快的定位手段。Slf4j public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { String datasource DynamicDataSourceContextHolder.getDataSource(); log.info(当前数据源切换为: {}, datasource); return datasource; } }如果日志里压根没打印说明AbstractRoutingDataSource的getConnection()没有被触发或者你的动态数据源根本没有被注册为默认数据源。如果日志打印了切换的值但实际SQL还是走主库那基本可以推断是事务或者连接复用的锅。这一步很基础但很多人会忽略。我见过不少项目排了半天最后发现是SpringBootApplication的扫描路径没覆盖到动态数据源配置类导致Spring容器里存在两个DataSource其中一个直接替代了动态数据源。3.2 第二步检查AOP切面和注解是否真的生效如果你是自己写的切面来切换数据源要重点检查这几个点切面类有没有被Spring扫描到是不是被Component标注了。切点表达式写对了没有是不是切到了一个接口方法上但实际上调用的是实现类内部方法。Aspect类有没有加Order注解如果没有切面的执行顺序可能和预期的相反。内部方法调用是一个很容易踩的坑。比如Service public class OrderService { public void process() { // 直接调用本类的另一个方法 queryFromSlave(); } DS(slave) public void queryFromSlave() { // ... } }这种写法DS注解不会生效因为Spring AOP基于动态代理只有通过代理对象调用方法时才会触发切面逻辑。this.queryFromSlave()调用的是原始对象的方法切面根本不会执行。解决办法是注入自己的代理对象或者把切换数据源的方法拆到另一个Service类中。另一个检查点切面里set完数据源之后有没有在finally块里做清理。很多人会忘记清理ThreadLocal导致当前线程后续操作一直沿用上一个数据源。特别是用了线程池的场景线程复用之后上次遗留的数据源key会污染下一次任务造成偶发性串库。Aspect Component Order(0) public class DataSourceAspect { Around(annotation(ds)) public Object switchDataSource(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { DynamicDataSourceContextHolder.setDataSource(ds.value()); try { return joinPoint.proceed(); } finally { DynamicDataSourceContextHolder.clearDataSource(); } } }3.3 第三步审视事务边界把事务和数据源切换的顺序理清楚这一步是整个排查过程的核心。如果确定路由日志没问题切面也执行了但就是切不过去那基本可以认定是事务管理器先获取了连接。排查方法在方法入口处加断点看DataSourceUtils.getConnection()的调用栈。如果看到事务拦截器先于你的数据源切面执行说明order没配对。解决办法有几种方案一把事务切面Order调大让数据源切换先执行。最简单的方式是给事务管理器设置order属性Bean public DataSourceTransactionManager transactionManager(DataSource dataSource) { DataSourceTransactionManager transactionManager new DataSourceTransactionManager(dataSource); transactionManager.setOrder(1); return transactionManager; }同时确保你的数据源切面Order比它小比如Order(0)。方案二使用不预先获取连接的事务管理器或者让事务延迟到数据源确定之后再创建。方案三如果条件允许尽量把需要切换数据源的方法和需要事务的方法拆开避免在同一个方法上同时使用Transactional和DS。方案四直接用成熟的开源方案这个我后面会专门讲。实际经验是方案一简单有效但只能解决部分场景。如果你的业务比较复杂比如在一个事务里需要切换多个数据源那就不能用DataSourceTransactionManager这种“单数据源事务管理器”了得考虑分布式事务方案或者干脆把大事务拆成多个小事务。3.4 第四步排查线程池和异步场景如果切换失败只发生在异步线程或者线程池任务里那问题基本出在ThreadLocal跨线程传递上。我们前面说了ThreadLocal是线程隔离的父线程set的数据源key子线程是拿不到的。Async注解开启的异步线程默认情况下不会继承父线程的ThreadLocal值。解决方案有在提交任务之前把数据源key作为参数传给子线程在子线程内部重新set。使用阿里开源的transmittable-thread-local它能自动在父子线程间传递ThreadLocal值。使用ThreadPoolTaskExecutor时自定义TaskDecorator在任务提交时捕获父线程的上下文在任务执行前恢复。我自己比较推荐第三种方案侵入性小不用改业务代码。大致写法如下Bean(asyncExecutor) public ThreadPoolTaskExecutor asyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setTaskDecorator(new ContextCopyingDecorator()); executor.initialize(); return executor; } public class ContextCopyingDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { MapString, String context DynamicDataSourceContextHolder.getContext(); return () - { DynamicDataSourceContextHolder.setContext(context); try { runnable.run(); } finally { DynamicDataSourceContextHolder.clear(); } }; } }核心思路就是提交任务时把父线程的数据源上下文快照一份任务执行时再恢复执行完清理避免污染线程池里的下一个任务。4. 一个能直接落地的完整配置参考理论讲了不少我贴一份我实际项目里验证过可用的一套多数据源配置包含动态数据源、切换切面、事务管理器和MyBatis配置你可以直接对照着改。4.1 数据源配置类Configuration public class DataSourceConfig { Bean ConfigurationProperties(spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } Bean Primary public DynamicDataSource dynamicDataSource() { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(master, masterDataSource()); targetDataSources.put(slave, slaveDataSource()); DynamicDataSource dynamicDataSource new DynamicDataSource(); dynamicDataSource.setDefaultTargetDataSource(masterDataSource()); dynamicDataSource.setTargetDataSources(targetDataSources); return dynamicDataSource; } }注意DynamicDataSource必须标注Primary否则Spring容器里有两个DataSource对象MyBatis或者JPA自动配置的时候找不到唯一候选者会启动失败。4.2 自定义注解和切面Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { String value() default master; }切面代码上面已经给过了需要特别强调的是Order(0)这个值。Spring事务切面的默认order是最低优先级也就是Integer.MAX_VALUE所以理论上你的切面只要Order是负数或者较小值就会比事务先执行。但为了保险起见建议同时给事务管理器显式设置一个更大的order比如1毕竟不同版本Spring源码对顺序的处理可能有微调。4.3 MyBatis和事务管理器配置Configuration MapperScan(com.example.mapper) public class MyBatisConfig { Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean sessionFactory new SqlSessionFactoryBean(); sessionFactory.setDataSource(dataSource); sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/*.xml)); return sessionFactory.getObject(); } Bean public DataSourceTransactionManager transactionManager(DataSource dataSource) { DataSourceTransactionManager transactionManager new DataSourceTransactionManager(dataSource); transactionManager.setOrder(1); return transactionManager; } }这里有一个容易忽略的点SqlSessionFactory和TransactionManager注入的DataSource必须是dynamicDataSource。如果你直接注入masterDataSource那你后面再怎么切换都没用因为MyBatis的SqlSession已经绑定到了固定数据源上。4.4 开源方案dynamic-datasource-spring-boot-starter如果不想维护上面这一堆代码直接用第三方封装好的方案会更省心。我自己用过baomidou的dynamic-datasource-spring-boot-starter稳定性和文档都还可以。用法很简单引入依赖后在配置文件里声明多数据源spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://localhost:3306/master username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://localhost:3306/slave username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver然后在方法上直接用DS(slave)注解使用体验和自定义方案基本一致但它内部已经处理了事务顺序、切面顺序、清理等一堆细节。尤其推荐不想深入研究底层原理、只想赶紧把功能跑通的同学。不过我也要说明一下它解决的是“应用层切换”的问题如果你的业务涉及跨数据源事务比如一个事务里先写主库再写从库它同样需要引入分布式事务中间件并没有银弹。5. 高频根因与排查速查表把这些年见过的多数据源切换失败案例整理成一张表方便你对照排查失败现象常见根因排查方向所有SQL都走主库切换注解完全无效切面未生效、注解扫描不到、内部方法自调用检查切面日志、代理对象调用、Aspect扫描路径启动报错多个DataSource无唯一候选动态数据源未加Primary确认Primary标注事务方法内切换无效事务管理器先获取连接路由已确定调整Order顺序拆分事务方法使用成熟框架偶发串库时好时坏ThreadLocal未清理线程池线程复用检查finally块清理逻辑增加日志确认上下文异步线程内切换无效父线程ThreadLocal未传递到子线程使用TaskDecorator或TTL传递上下文配置了mybatis-plus但多数据源不生效mybatis-plus拦截器链和动态数据源冲突检查MyBatisPlusConfig中的插件顺序读写分离偶尔读到脏数据主从延迟或连接复用导致读到旧库先确认是切换到旧库还是真正主从延迟再用强制走主库方案多租户场景切换失败租户上下文和数据源上下文混在一起清理时机冲突检查拦截器顺序确认租户上下文是否被提前清除表格里整理的都是高频问题。实际项目中可能几个因素叠加在一起比如既有事务先获取连接的问题又有线程池没传上下文的问题排查时要一层层剥离。6. 几条独家踩坑心得最后分享几条我在实际项目中攒下来的经验这些在官方文档里通常不会写但真能帮你少走弯路。第一多数据源切换的日志一定要留好。上线前就把determineCurrentLookupKey的日志打开哪怕生产环境用debug级别也要预留一个开关。这个日志是排查所有数据源问题的第一手依据没有它你只能靠猜。我见过的很多项目排查问题耗时最长的时间不是在定位代码逻辑而是在加日志重新发布。第二切面清理ThreadLocal一定要放在finally里而且清理的时机要等整个业务方法执行完。有些人喜欢在切换完之后马上切换回主库比如在DS(slave)的方法内部手动把上下文set回master这种做法在大多数场景下没必要还会带来上下文混乱的风险。切换后无论中途有没有异常finally里统一清理交给下一次切换时重新set这是最稳妥的。第三尽量别在一个事务里来回切换多次数据源。如果业务确实需要优先考虑换一个更合理的设计把不同数据源的操作拆到不同事务里或者引入分布式事务框架。频繁切换数据源除了增加排查难度还会带来连接管理上的开销性能上也不划算。第四如果项目是多人协作建议把多数据源配置独立成一个子模块或者一个单独的配置类并且在类上面写清楚使用规范和注意事项。因为这种问题一旦出现接手的人如果对机制理解不深很容易在原有代码上打补丁越补越乱。我见过一个项目里出现了三套不同的数据源切换实现互相嵌套最后谁都不敢动。第五升级SpringBoot版本之后一定要回归测试多数据源切换。Spring Boot 2.x到3.xSpring Framework 5.x到6.xAOP和事务的默认行为都有一些调整有些配置在新版本里可能不再生效。不要想当然地认为代码没动就没事。多数据源切换这个问题本身并不难难的是它横跨了Spring容器管理、AOP代理、事务抽象、线程模型好几个知识点任何一处理解不到位排查起来都会感觉像无头苍蝇。把这篇文章里提到的排查链路走一遍再配合日志定位基本能把九成以上的问题解决掉。剩下的那不到一成多半是业务逻辑或者框架版本的特殊情况那就需要具体问题具体分析了。
返回列表