ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot多数据源切换失败排查:从路由原理到工程实践

2026/9/24 23:21:47 拓冰建站 浏览量
SpringBoot多数据源切换失败排查:从路由原理到工程实践 说实话看到这个标题我就觉得亲切。多数据源切换失败这个问题在SpringBoot项目里太经典了后台白名单里面相关提问的频率也高连标题都带着“转载”两个字说明大家遇到这个问题之后第一反应就是搜帖子找答案而不是去看源码结果往往还是搞不定。我当年第一次在真实项目里配多数据源时也被折磨过。当时是读写分离主库负责写从库负责读本来以为就是多加一个DataSource的事情结果业务跑起来之后发现切来切去一直是同一个库偶尔在压测环境下还会出现连接错乱的问题。后来把AbstractRoutingDataSource和Spring的Bean注入机制吃透了才彻底搞定。这篇文章我就从头把这套东西讲明白。包括多数据源切换到底是怎么工作的、为什么你的代码看起来没问题却切不过去、事务和AOP是怎么把切换机制坑掉的以及我实际排查过程中那些最典型、最容易迷惑人的故障场景。1. 多数据源切换失败背后的机制先搞清楚它到底在切什么1.1 动态数据源的核心套路路由不是切换连接而是切换“路由键”很多初学者对“多数据源切换”有个误解以为是程序运行时动态地断开旧连接、重新建立新连接。实际上不是这样。主流方案都依赖Spring的AbstractRoutingDataSource它的工作原理用一句话概括就是维护一组目标数据源DataSource拿到之后根据一个“路由键”决定当前线程该拿哪个真实数据源的连接。具体看代码。核心类长这样public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSource(); } }这个方法每次getConnection的时候都会触发。Spring在拿到Connection之前会先调用determineCurrentLookupKey去拿一个路由键然后从内部的targetDataSources这个Map里找到对应的DataSource最后真正建立连接。也就是说整个切换机制本质上就是“换key取数据源”而不是物理地去断开连接。这个机制本身不难但真正容易出问题的地方在于路由键是通过ThreadLocal来传递的。1.2 ThreadLocal承载路由键跨线程就是第一个隐形炸弹DynamicDataSourceContextHolder的标准实现长这样public class DynamicDataSourceContextHolder { private static final ThreadLocalString CONTEXT_HOLDER new ThreadLocal(); public static void setDataSource(String dataSource) { CONTEXT_HOLDER.set(dataSource); } public static String getDataSource() { return CONTEXT_HOLDER.get(); } public static void clearDataSource() { CONTEXT_HOLDER.remove(); } }路由键跟着线程走所以只要代码执行的线程没变key就能一直带在身上。但一旦出现线程切换比如用了Async异步方法、线程池异步任务、或者内部的并行流拆分了线程子线程或者新线程根本拿不到父线程ThreadLocal里的数据源key。这时候会发生什么determineCurrentLookupKey返回nullAbstractRoutingDataSource是有默认兜底逻辑的key为null就走默认数据源。默认数据源一般是你指定的主库或者第一个数据源表现出来就是“切了但没完全切”从库读写全部落到主库上或者反过来。我见过最离谱的一个线上问题就是某公司报表服务里用了一个线程池去并行查多个业务库的数据每个任务都调用了setDataSource结果全部失效所有查询都打到主库里。排查了大半天才意识到是线程池导致的路由键丢失而不是配置错误。1.3 连接池的复用和threadlocal的错位会产生什么现象还有一个很隐蔽的坑就是连接池复用和ThreadLocal错位叠加产生的“连接串库”。HikariCP之类的连接池为了提高性能会复用物理连接。如果一个线程先被分配去操作数据源A执行完了线程回到池子里然后这个连接被交给另一个线程去操作数据源B表面上看起来没什么问题因为每次都是从DataSource去拿逻辑连接。但如果你在代码里手动开启了事务或者用了自己管理的Connection连接一旦在事务中绑定到某个数据源事务结束前你再去切换路由键是没有用的因为事务已经拿着旧连接了。这类问题最迷惑的地方在于——它不稳定时好时坏跟连接池的分配情况有关。单测环境数据量小、连接池空闲多的时候怎么跑都没问题一上生产并发一起来就偶发报错一会儿连错库一会儿又是Deadlock。所以排查这个问题第一件事不是去改配置而是要先搞清楚你的代码在哪个环节使用了连接连接生命周期有多长。2. 切换失败的第一大类原因配置和注入本身就错了2.1 多个DataSource Bean的加载顺序与Primary的边界条件SpringBoot里配置多数据源时最常见的问题不是路由逻辑写错了而是DataSource的Bean本身就没按你预期被管理。Spring容器里不能有多个相同类型的Bean否则注入的时候Autowired就会懵。你以为SpringBoot会根据名字注入实际上默认按类型注入同类型有多个Bean时直接就NoUniqueBeanDefinitionException了。这就是为什么大家都会在某个DataSource上加Primary注解。Primary的意思是说当多个同类型Bean都可以注入时优先选它。但Primary不是万能的。它只在注入点不指定名称的时候生效。如果你有两个DataSource一个标了Primary另一个不标那Autowired DataSource这种注入方式确实没任何问题。但如果某个地方用了Qualifier(slaveDataSource)或者Resource(name slaveDataSource)那就跟你有没有Primary一点关系都没有。在实际项目里Primary加在哪个数据源上是个战略性决策。我一般建议加在主库上。因为很多框架组件比如JPA的EntityManagerFactory、事务管理器、SpringBatch、Quartz这些它们自动配置的时候会去找容器里的DataSource做兜底如果默认拿到的不是你期望的主库后续所有自动配置出来的东西都会跑偏。2.2 配置绑定出错导致targetDataSources根本没有你定义的库还有一种情况更隐蔽——你的DynamicDataSource里targetDataSources根本没把所有的数据源实例装进去。来看一段经常翻车的配置Bean ConfigurationProperties(prefix spring.datasource.primary) public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix spring.datasource.secondary) public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } Bean public DataSource dynamicDataSource(Qualifier(primaryDataSource) DataSource primary, Qualifier(secondaryDataSource) DataSource secondary) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(primary, primary); targetDataSources.put(secondary, secondary); DynamicDataSource dynamicDataSource new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(primary); return dynamicDataSource; }这段代码看着很正常但有两个隐患点。第一ConfigurationProperties绑定的前缀必须跟application.yml里写的一致。如果你yml里写的是spring: datasource: primary: jdbc-url: jdbc:mysql://localhost:3306/db1 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver而Java这边绑定的前缀是spring.datasource.first那DataSourceBuilder创建出来的就是一个没有任何连接配置的空壳启动时候不报错第一次执行SQL才报错而且报的错还特别像“连接URL未设置”之类非常容易误导排查方向。第二DataSource的配置项到底应该写url还是jdbc-url这事在不同配置方式下表现不同。用DataSourceBuilder的时候url和driver-class-name是能被识别的。但如果你用ConfigurationProperties绑定一个自定义的DataSourceProperties对象然后手动复制属性就得区分好url和jdbc-urlSpringBoot官方默认属性类里用的是jdbc-url。我遇到过开发人员yml里写url代码里用的却是DataSourceProperties的jdbc-url结果每个库的连接URL都是null运行起来全崩。2.3 Druid、HikariCP这类连接池Bean被重复配置的问题再加一个很常见的坑。很多人项目里既有Druid的依赖又引入了SpringBoot默认的HikariCP然后手动定义了DataSource后又保留了SpringBoot的数据源自动配置。这种情况下容器里可能出现多个DataSource类型的Bean甚至你自己定义的DataSource真正生效的都不是你写的那份配置。尤其在SpringBoot 2.x中DataSourceAutoConfiguration会做兜底自动配置。只要你容器里已经有一个DataSource类型的Bean了自动配置就会通过ConditionalOnMissingBean失效掉不会再去创建。但如果你的DynamicDataSourceBean定义所在的配置类没有被Spring扫描到那么好容器里一个DataSource都没有自动配置就用application.yml里默认的datasource配置创建了一个HikariDataSource你的dynamicDataSource压根没进容器。你代码里Autowired注入DataSource时或许还能拿到一个数据源但是这个DataSource不会走路由逻辑切库自然完全无效。这个问题的排查方式就是启动的时候看一眼Bean定义里DataSource的具体类型。如果是HikariDataSource且跟你动态数据源类不搭边那基本可以确定是配置类扫描或者Bean定义顺序的问题。3. 切换失败的第二大类原因AOP切面根本没生效3.1 切面不生效的几种典型现场实际的动态数据源切换方案里最常见的做法是定义一个注解比如DataSource然后写一个AOP切面对注解做拦截在目标方法执行前调用DynamicDataSourceContextHolder.setDataSource执行完毕后调用clearDataSource清理。大部分“切换失败”的现场都出在这里。切面没执行路由键自然没被设置整个链路就直接走了默认数据源。AOP切面不生效的原因无外乎以下几种。第一切面类没有被Spring管理。你定义了一个普通的类上面用Component或者Configuration标记过了但所在包没被SpringBoot启动类的扫描路径覆盖到导致整个Bean都不存在。第二切点表达式写错了。比如你定义的注解类是com.example.datasource.annotation.DataSource切点写成了execution(public * com.example.service..*(..)) annotation(com.example.datasource.annotation.DB)之类的前后不一致表达式根本匹配不上目标方法逻辑上等同于切面没写。第三方法内部this调用绕过了代理。这是最折磨人的。Spring AOP基于动态代理实现代理只拦截外部调用。同一个类里的方法A调用了方法BB上面标了DataSource注解但B是通过this调用的根本没经过代理对象注解逻辑完全不会执行。3.2 为什么Spring AOP拦截不到类内部调用这个必须展开细说。Spring AOP的底层是代理模式。通过代理对象调用方法的时候代理会先执行切面逻辑然后反射调用真实目标方法。而真实目标对象内部如果方法B被方法A调用那这个调用是通过this指针直接触发的完全不经过代理切面自然拦不到。举个例子Service public class OrderServiceImpl implements OrderService { Override public void createOrder(OrderDto dto) { // 内部调用DataSource切面不会执行 this.queryFromSecondary(dto.getOrderId()); } DataSource(DataSourceType.SECONDARY) public String queryFromSecondary(Long orderId) { return orderMapper.findById(orderId); } }queryFromSecondary上的注解切面不会被触发因为它在对象内部通过this调用。解决办法有几种注入自己有点别扭、拆成两个Bean推荐、或者用AopContext.currentProxy()去拿代理对象需要exposeProxytrue。大部分开源项目里推荐的做法是把切库操作放到独立的Service或者Mapper层面让切面作用在别的Bean上。3.3 切面的Order顺序可能导致路由还没设置事务就已经开始了这是另一个高频坑AOP切面执行顺序和事务切面执行顺序搞反了。如果DataSource注解的切面没有设置OrderSpring默认按优先级最低处理。而Transactional注解的事务切面默认优先级也是最低两者之间顺序不确定但很多情况下事务管理器会先于数据源切面执行。这会造成什么后果事务一旦开启就会从DataSource里拿一个连接并绑定到当前线程的事务资源上。之后你再切路由键都已经晚了因为事务管理器手里的连接已经拿到手了determineCurrentLookupKey再切换到另一个数据源根本不会影响事务内正在使用的连接。所以现象就是开启事务的方法里内部所有查询操作在事务周期内都固定走同一个数据源跟你怎么切都无关。解决方案也很明确让数据源切面的优先级高于事务切面Aspect Component Order(0) public class DataSourceAspect { // ... }事务切面的默认顺序是Ordered.LOWEST_PRECEDENCE也就是Integer.MAX_VALUE级别的优先级。DataSourceAspect把Order设为0能保证在任何事务逻辑之前先把路由键设置好。3.4 注解在接口上和在实现类上的差异到底有多大Java注解的继承机制在Spring AOP里有特殊处理。Spring的AnnotationUtils是支持在接口方法、实现类方法、类级别三个层面查找注解的但查找规则有优先级之分。一个典型场景接口方法上标了DataSource注解实现类方法上没标。Spring AOP在默认配置下如果实现类本身没有其他自定义注解代理机制在判断切点匹配时能不能看到接口上的注解取决于代理方式。JDK动态代理保留了接口方法而CGLIB代理基于子类重写方法注解关联关系不一样。遇到这种方式引发的切换失败典型表现是一个模块里所有数据源切换都正常但某个类单独抽出来之后怎么切都不生效。原因就是那个service里实现了接口而接口方法上的注解信息在代理中解析不到。经验之谈注解统一放在具体实现类方法上别放接口。这个不只是为了动态数据源对事务注解同样适用。4. 实战排错从表象到根因的完整排查路径4.1 复现现场和打印日志的技巧先说一个根本性的问题很多人遇到切换失败之后第一件事是去百度“SpringBoot多数据源切换失败”然后对着帖子逐个试浪费大半天时间。根本原因是没搞清楚自己的失败到底是什么现象。多数据源切换失败的现象至少可以分四类现象可能原因启动时直接报循环依赖或NoUniqueBeanDefinitionExceptionBean注入错误多个DataSource没有明确主次启动正常但切到从库时报SQL执行失败targetDataSources里没有注册对应key或者从库配置错误启动正常全部请求都走主库AOP没生效路由键未设置或线程切换导致ThreadLocal丢失偶发性连接串库报SQL与库不匹配连接复用时key错乱或事务生命周期跨线程所以排查的第一步就是打开Debug级别的日志看一眼每次执行SQL前determineCurrentLookupKey返回的到底是什么。在DynamicDataSource重写这个方法时打日志Override protected Object determineCurrentLookupKey() { String dataSource DynamicDataSourceContextHolder.getDataSource(); log.debug(当前数据源路由key: {}, dataSource); return dataSource; }还有路由聪明的方式是继续在DataSourceAspect里打日志这样可以看到切面有没有执行、执行顺序对不对。4.2 判断切面是否生效的三个验证手段如果怀疑AOP没生效最快验证方法是写一个测试接口直接调用标了DataSource的方法然后打个断点看切面类的方法有没有进去。另外可以利用Spring容器的能力启动的时候打印所有被代理的BeanBean public CommandLineRunner printDataSourceProxies(ApplicationContext context) { return args - { String[] beanNames context.getBeanNamesForType(DataSource.class); for (String name : beanNames) { log.info(DataSource Bean: {}, name); log.info(实际类型: {}, context.getBean(name).getClass().getName()); } }; }如果打印出来的类型是com.sun.proxy.$Proxy开头说明走的是JDK动态代理。如果是CGLIB的类名说明是CGLIB代理。如果你发现dynamicDataSource根本没有被代理那切面这块就有大问题。还有个更清晰的手段直接在切面里临时加一个全局入参拦截把目标方法的全限定名和当前线程数据源key都打出来。4.3 用HikariCP自带指标确认最终拿到的连接来自哪个库判断最终执行SQL的库是哪一个光靠日志有时候还不够因为日志只是打印了路由key不代表实际连接就对了。连接池会复用物理连接路由key跟连接之间的关系并不是严格的一一对应。这时候可以借助HikariCP自带的一些指标在配置里把数据源名称设置到连接池里spring: datasource: hikari: pool-name: primary-hikari-pool然后在连接上执行SELECT DATABASE()之类的查询看当前所在库SELECT DATABASE();或者用更通用的方式看connection元数据Connection conn dataSource.getConnection(); System.out.println(conn.getMetaData().getURL());这样拿到的是这个连接实际指向的库地址一目了然。这个方法看起来很笨但往往是最有效的终极验证。4.4 一个经典案例的完整排错记录我做过一个收费系统里面分库分表订单库、用户库、日志库三个数据源。某次发布之后突然发现登录后的日志查询全查不到数据页面无报错但数据是空的。排查过程如下先看日志AOP切面正常打印了“当前数据源: logdb”说明路由键设置成功。再验证实际连接库手动执行SELECT DATABASE()结果返回的是主库不是log库。确认是连接复用的坑切面设置了key为logdb但DataSourrce连接池里根本没有logdb这个目标数据源因为targetDataSources里漏配了key匹配不到框架就回退到了默认数据源。根因特别简单就是targetDataSources里的key跟注解里定义的数据源名不一致。一个地方写的是logdb另一个写的是logDataSource代码和配置分属两个人维护谁都没发现。后来我在代码里加了一个启动时的自检逻辑把所有数据源key注册到一个Set里启动时扫描所有DataSource注解的value值跟Set做比对不一致就启动告警。5. 根治方案与工程化改造建议5.1 一套比较稳妥的动态数据源配置模板如果你的项目是从零开始接入多数据源推荐直接用下面这套配置能规避掉大部分初级问题。先看application.ymlspring: datasource: primary: jdbc-url: jdbc:mysql://localhost:3306/primary_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver secondary: jdbc-url: jdbc:mysql://localhost:3306/secondary_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver注意这里统一用jdbc-url这是SpringBoot官方DataSourceProperties的标准属性避免url和jdbc-url混用。Java端的配置类Configuration public class DataSourceConfig { Bean Primary ConfigurationProperties(prefix spring.datasource.primary) public DataSource primaryDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix spring.datasource.secondary) public DataSource secondaryDataSource() { return DataSourceBuilder.create().build(); } Bean public DynamicDataSource dynamicDataSource( Qualifier(primaryDataSource) DataSource primaryDataSource, Qualifier(secondaryDataSource) DataSource secondaryDataSource) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(DataSourceType.PRIMARY, primaryDataSource); targetDataSources.put(DataSourceType.SECONDARY, secondaryDataSource); DynamicDataSource dynamicDataSource new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(primaryDataSource); return dynamicDataSource; } }数据源类型枚举public enum DataSourceType { PRIMARY, SECONDARY }这个方案里我特意把主数据源标了Primary这样其他框架组件自动注入时能拿到主数据源。5.2 自动切换的注解与切面写法定义注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) Documented public interface DataSource { DataSourceType value() default DataSourceType.PRIMARY; }切面Slf4j Aspect Component Order(0) public class DataSourceAspect { Before(annotation(dataSource)) public void switchDataSource(JoinPoint joinPoint, DataSource dataSource) { DynamicDataSourceContextHolder.setDataSource(dataSource.value().name()); log.debug(切换数据源: {}, dataSource.value().name()); } After(annotation(dataSource)) public void restoreDataSource(JoinPoint joinPoint, DataSource dataSource) { DynamicDataSourceContextHolder.clearDataSource(); log.debug(清除数据源路由key); } }注意两点。一个是Order(0)一定要加保证切库在事务切面之前。另一个是切入点的写法我用的annotation(dataSource)这种注解绑定方式比execution表达式要直观得多也不容易出现表达式匹配错误。5.3 事务与多数据源的兼容策略事务问题是多数据源方案里绕不开的坎。如果你的业务方法既开了事务又要切库强烈建议拆开外层方法开事务管理主库写入内层通过独立的Service单独Bean调用从库查询并且从库查询的方法不要参与主库事务。为什么因为Spring的Transactional默认情况下只绑定一个数据源的事务。你外层方法一旦开启事务事务管理器在DataSource里拿到的连接就固定了。内层切库时即使路由key变了事务管理器依然会从当前线程的事务资源里拿绑定的旧连接切库对事务内操作无效。所以原理上事务与多数据源天然是冲突的。要么用分布式事务方案要么把跨库操作拆到独立事务里。绝大多数业务场景其实都不需要强一致拆开就好了。5.4 从SpringBoot 2.x到3.x有什么不同注意点SpringBoot 3.x下因为基础框架换成了Spring Framework 6.x默认的代理方式从JDK动态代理切到了CGLIB。很多在2.x上能正常工作的接口注解方案在3.x下面可能出现注解解析不到的情况因为CGLIB代理子类不会自动继承接口方法上的注解。另外一个差异是自动配置的类包路径变了从org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration变成了org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration但内部的ConditionalOnMissingBean机制还在。不过SpringBoot 3.x要求JDK 17以上有些老项目还在用JDK8配套JavaEE的依赖升级之后切面、动态数据源这块问题会集中暴露。如果你是从2.7升上来的强烈建议把切面、事务、数据源这三块单独做一轮回归测试。5.5 一个解决线上偶发串库的连接池关键参数如果项目里因为历史原因没法彻底改造代码只想降低偶发串库的概率HikariCP有一个参数值得关注maximumPoolSize和minimumIdle保持一致减少连接动态创建和销毁的频率同时把connectionTimeout设短一点让获取连接超时的请求快速失败而不是长时间等待后拿到异常连接。还可以开启HikariCP的心跳检测spring: datasource: hikari: connection-test-query: SELECT 1 validation-timeout: 3000 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000但说实话这些参数只是降低了症状没有根治问题。根治还是要回到数据源生命周期管理上确保每个线程内连接的使用边界清晰事务与线程严格绑定。6. 踩坑经验这些问题最容易伪装成“切换失败”6.1 参数绑定错位引发的“启动正常、运行全崩”有一个高级坑就是YAML配置格式问题。下面这个写法看起来没问题但特别容易出错spring: datasource: primary: jdbc-url: jdbc:mysql://localhost:3306/db1如果项目中同时引用了多个DataSource相关依赖有的版本会识别不了jdbc-url属性导致DataSourceBuilder创建出来的DataSource连接参数全部为null。启动时不报错第一次访问数据库时告诉你jdbcUrl is required。这种问题推荐直接用ConfigurationProperties绑定自定义配置类把参数手动设置到DataSource上虽然代码多几行但可控性高得多。6.2 全局拦截器或过滤器里切换数据源的后果有人会把数据源切换逻辑写在SpringMVC的Interceptor里根据请求参数或者Header去切换不同的库。这个做法在单线程同步请求下是可行的但一旦接口内部出现了异步调用、并行流或者线程池执行ThreadLocal就断层了。更麻烦的是Interceptor的执行顺序和AOP切面顺序不一致如果你在Interceptor里设置了路由key又在Service里通过DataSource注解去覆盖它最后谁生效完全取决于执行链路别指望可控。我的建议是数据源切换尽量收敛在Service层做别放在Web拦截器层面。除非你能保证整个请求周期内都是同一条线程从头走到尾。6.3 全局懒加载模式下切面不执行的情况SpringBoot默认是启动时实例化单例Bean的但如果你开了全局懒加载有些Bean到了第一次使用时才创建这时候如果配置类的创建顺序不对DataSourceAspect和目标Service的实例化时机可能错位导致切面没织入。排查方法很简单把全局懒加载关掉spring: main: lazy-initialization: false或者只对特定的非关键Bean开启懒加载。这种问题在常规项目里不多见但如果你的项目是一个多模块的SpringBoot应用模块互相引用很容易触发。6.4 多数据源和分布式缓存组件一起使用时要注意什么最后提一个很多人忽略的场景。如果项目里同时用了Redis、ES或者其他中间件而这些组件的自动配置也依赖DataSource那么多数据源切换相关的问题表面上看是“数据库串了”实际可能是中间件自动配置把DataSource当作数据库数据源去初始化了。具体表现就是某个查询方法在主库执行了结果缓存里写入的数据绑定了错误的库标识或者ES的索引刷新任务在初始化时拿默认数据源建连接导致后续所有流程都连错库。这类问题排查起来非常费劲因为它不是每一次都复现。建议在数据源初始化之后启动时打印所有DataSource Bean的边界和能力范围跟中间件配置做交叉核对。写在最后的个人体会多数据源切换失败这个问题表现形式千奇百怪但根因翻来覆去就是那几个地方路由键丢失、切面没生效、事务粘连连接、Bean注入错乱。我踩过的最值钱的一个坑就是所有代码看着都对注解也有了切面也打了日志事务也拆了最后还是偶发切库失败。后来发现是项目里有人引入了MyBatis-Plus的分页插件它内部包裹了Executor而分页插件在获取连接的时候提前触发了连接创建导致路由key设置之前连接就已经被拿走了。这种问题只有真正跟过一遍连接生命周期的人才会警觉。所以我在排查这类问题时习惯性先看两个东西第一个是ThreadLocal里的路由key变化轨迹第二个是Connection从哪个DataSource实例出来的。只要这两个问题明确了切换失败的原因基本就浮出水面了。如果你也在排查这个报错建议按这个顺序来先确认DataSourrce的Bean类型再确认切面执行顺序再确认事务边界最后再怀疑配置。别看网上帖子一堆90%的问题都集中在这四处。