Druid多数据源动态切换:连接池与路由机制实战解析

发布时间:2026/10/21 12:44:26
Druid多数据源动态切换:连接池与路由机制实战解析
做后端开发这些年连接池切换这套东西我踩过不少坑。最近在改一个报表项目同一个接口要根据传入的租户标识落到不同的业务库刚开始图省事在代码里写死两个数据源需要哪个库就把DataSource对象传来传去。没过几轮链路一长就乱了有的Service忘了传有的方法内部开起事务之后又切库最后查出来的数据对不上排查起来特别费劲。后来我把整套逻辑收敛成了一套Druid多数据源配置加动态切换的方案。核心思路不复杂用Spring自带的AbstractRoutingDataSource做路由用ThreadLocal保存当前线程需要使用的数据源Key上层再用AOP和注解把切换动作做成透明操作。这篇文章就把这套方案从原理到落地完整拆开讲内容包括为什么要做动态切换而不是单纯配多数据源、AbstractRoutingDataSource和ThreadLocal是怎么配合的、Druid连接池在中间扮演什么角色以及我实际踩过的几个坑。如果你正在Spring Boot加MyBatis的项目里做读写分离、租户分库或者只是不想为每个数据源复制一堆重复代码这篇应该能帮上忙。1. 为什么需要“动态”切换而不是只多配几个DataSource1.1 静态配置解决的是连接池共存解决不了调用时选择Spring Boot里想连接两个库最朴素的写法就是定义两个DataSource Bean一个叫masterDataSource一个叫reportDataSource业务代码里按需注入。这种写法能跑但它把“当前应该连哪个库”的判断责任全部压给了业务方法。你得自己封装DAO工具类或者每次手工指定数据源。一旦调用链多几层比如ServiceA调用ServiceBServiceB再点MapperC很容易出现某层忘记指定最终连错库的情况。动态切换要解决的就是这个问题。它不是把两个数据源摆在那里而是在每次获取数据库连接的那一刻根据当前线程的上下文自动判断应该用哪个连接池。这样业务代码只需要声明一个意图比如“这个方法走报表库”底层自然会在getConnection()时拿到对应的连接。1.2 读写分离、租户分库、模块隔离会用到动态切换的场景我见过会真正需要这套能力的项目大概分三类。第一类是读写分离主库负责增删改从库负责查询表结构一样但是接口级别要区分读写第二类是租户分库SaaS系统里每个租户的数据落在不同的库调用方只需要传一个租户ID底层要把请求路由到对应的库第三类是模块隔离订单库、商品库、日志库已经物理拆开了但Service层希望用统一入口访问多个库。这三类场景的共同点是数据源的选择不固定跟请求参数、操作类型、当前调用上下文强相关。动态切换就是把这种“相关性”变成一套统一机制而不是散落在各个业务方法里的if-else。1.3 为什么这个方案里选Druid而不是默认连接池Druid本身解决的是连接池管理问题。连接怎么创建、空闲连接怎么检测、连接不够了怎么扩容、慢SQL怎么记录、SQL注入怎么拦截这些能力Druid都打包好了。相比Spring Boot默认池Druid的监控体系更完整一个StatFilter开起来就能看到每个连接池的活跃连接数、执行SQL次数、慢SQL条数。动态切换需要一个稳定的底库。连接池管理本身不能出幺蛾子否则路由做得再好底层连接一旦泄漏或者断连上层全崩。所以我的定位很明确Druid负责物理连接的生命周期管理AbstractRoutingDataSource负责逻辑数据源的路由两边各司其职不混在一起。2. 核心机制AbstractRoutingDataSource、ThreadLocal和Druid的分工2.1 AbstractRoutingDataSource路由发生在getConnection()那一层很多人把AbstractRoutingDataSource想得很神秘其实它本质上是一个“带路由能力的DataSource代理”。它内部维护两个关键字段targetDataSources存放的是多个真实数据源defaultTargetDataSource是默认数据源。当你调用它的getConnection()时它会先调用子类重写的determineCurrentLookupKey()拿到当前要用哪个Key然后从targetDataSources里取出对应的真实数据源再调用这个真实数据源的getConnection()去拿物理连接。关键点就在这里路由判断发生在“获取连接”这一步而不是“执行SQL”那一步。同一时刻一个连接只能属于一个物理库。如果某个Connection已经被拿到手里并且被缓存了后面你再怎么改ThreadLocal里的Key这个连接也不会切换过去。这一点对理解后面的坑非常重要。2.2 ThreadLocal用一个静态上下文隔离不同调用链在Web请求场景里一个请求通常由一条线程从头处理到尾。ThreadLocal天生适合保存“只对当前线程有效”的临时状态这里这个临时状态就是数据源Key。我把这个Key的读写封装在一个独立类里public class DynamicDataSourceContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void setDataSource(String dataSource) { CONTEXT.set(dataSource); } public static String getDataSource() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }它的作用很简单在方法入口set一个Key让determineCurrentLookupKey()能从ThreadLocal里读出来方法结束之后必须在finally里remove。如果不清理一旦连接池的线程被复用来处理下一个请求下一个请求就可能读到上一个请求遗留的Key出现订单查到了报表库这种极其隐蔽的问题。2.3 Druid在这个架构里到底扮演什么Druid在整套架构里不负责路由它只负责连接池本身。每一个物理库对应一个独立的DruidDataSource实例这个实例里有自己独立的连接池、独立的监控数据、独立的WallFilter规则。从Spring容器角度看业务只感知到一个路由数据源但路由数据源往下铺开实际上是好几个DruidDataSource。理解这一点对排查监控问题非常关键。比如后面会讲到Druid监控页面上会列出多个数据源实例但有时候某个实例的统计一直是零不是监控坏了而是那个连接池压根没有被真实调用过或者切换动作根本没生效。3. 配置落地从依赖到三个核心Bean的完整步骤3.1 引入依赖与YAML外置参数先引入Druid的Spring Boot Starter和MyBatis Starter。Druid的Starter会自动装配一部分默认行为但多数据源场景下我更倾向自己定义核心Bean这样每个连接池的参数都可控。dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.20/version /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependencyYAML里的配置我习惯把每个库的连接池参数单独拆开不要图省事共用一套参数。报表库查询量大max-active可以放开到20订单库写入频繁max-wait就不要设太长否则数据库抖动时请求会全部卡住。spring: datasource: druid: master: url: jdbc:mysql://localhost:3306/order_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 validation-query: SELECT 1 test-while-idle: true report: url: jdbc:mysql://localhost:3306/report_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: report_user password: report_pwd driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 3 min-idle: 3 max-active: 10 max-wait: 30000 validation-query: SELECT 1 test-while-idle: true这里有个容易忽略的点Druid的参数绑定前缀是spring.datasource.druid.master而Spring Boot原生数据源前缀是spring.datasource。如果写错了前缀连接池会启动失败或者所有参数都用默认值Xmx看到的现象就是连接池好像只建了几个连接跟配置完全对不上。3.2 多数据源Bean与路由源Definition接下来写一个DataSourceConfig把master数据源和report数据源分别初始化成独立的DruidDataSource然后再建一个routingDataSource作为路由入口。Configuration public class DataSourceConfig { Bean(masterDataSource) ConfigurationProperties(prefix spring.datasource.druid.master) public DataSource masterDataSource() { return DruidDataSourceBuilder.create().build(); } Bean(reportDataSource) ConfigurationProperties(prefix spring.datasource.druid.report) public DataSource reportDataSource() { return DruidDataSourceBuilder.create().build(); } Bean(routingDataSource) Primary public DataSource routingDataSource( Qualifier(masterDataSource) DataSource master, Qualifier(reportDataSource) DataSource report) { MapObject, Object targetDataSources new HashMap(4); targetDataSources.put(DsKeyEnum.MASTER.name(), master); targetDataSources.put(DsKeyEnum.REPORT.name(), report); DynamicRoutingDataSource routingDataSource new DynamicRoutingDataSource(); routingDataSource.setDefaultTargetDataSource(master); routingDataSource.setTargetDataSources(targetDataSources); return routingDataSource; } }对应的路由数据源实现类长这样public class DynamicRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSource(); } }数据源Key我建议用枚举统一收口不要散落在代码里写字符串否则后期库一多大小写不一致问题就会冒出来public enum DsKeyEnum { MASTER, REPORT }Primary一定不要漏。Spring容器里现在有多个DataSource类型的Bean如果不标记哪个是主要的MyBatis自动配置、事务管理器自动配置在注入DataSource时就会报“expected single matching bean but found 2”或者说选了错误的那一个导致路由直接绕过。3.3 把路由源接入MyBatis和事务管理器数据源定义完之后还要让MyBatis的SqlSessionFactory和Spring事务管理器都拿到routingDataSource。这里是最容易出错的一步如果你在SqlSessionFactory上绑定了具体的masterDataSource那不管路由数据源怎么切换MyBatis最终还是会去master连接池拿连接。Configuration MapperScan(basePackages com.example.project.mapper) public class MyBatisConfig { Bean Primary public SqlSessionFactory sqlSessionFactory( Qualifier(routingDataSource) DataSource routingDataSource) throws Exception { SqlSessionFactoryBean sessionFactoryBean new SqlSessionFactoryBean(); sessionFactoryBean.setDataSource(routingDataSource); sessionFactoryBean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources(classpath:mapper/*.xml)); org.apache.ibatis.session.Configuration configuration new org.apache.ibatis.session.Configuration(); configuration.setMapUnderscoreToCamelCase(true); sessionFactoryBean.setConfiguration(configuration); return sessionFactoryBean.getObject(); } Bean Primary public DataSourceTransactionManager transactionManager( Qualifier(routingDataSource) DataSource routingDataSource) { return new DataSourceTransactionManager(routingDataSource); } }需要说清楚一个限制我这里只给Spring配置了一个DataSourceTransactionManager它管理的逻辑数据源是routingDataSource。它支持“路由之后的同库事务”但不支持一个事务横跨两个物理库。如果你在一个事务里既写了订单库又写了报表库那必须引入分布式事务方案比如XA或最终一致性组件这不是单靠动态切换能解决的。4. 注解AOP实现透明切换DS注解、ContextHolder与切面顺序4.1 定义DS注解和数据源Key配置都就位之后接下来的目标就是让业务代码用一行注解完成切换。定义一个DS注解既能标在方法上也能标在类上标记在类上时类里所有方法默认走这个数据源方法上的注解可以覆盖类上的。Documented Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { String value() default MASTER; }注意value的类型用字符串默认值给MASTER。这样做的原因是注解的value在编译期就必须是一个常量枚举的name()拿不到用字符串最直接。但业务侧调用时我会用DsKeyEnum.REPORT.name()传入保证两侧Key一致。4.2 切面实现前置设置Keyfinally清理Key切换的核心是一个AOP切面。用Around同时拦截方法注解和类注解在目标方法执行前把Key设置到DynamicDataSourceContextHolder执行完之后在finally里清理。Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceAspect { Around(annotation(dataSource) || within(dataSource)) public Object switchDataSource(ProceedingJoinPoint joinPoint, DS dataSource) throws Throwable { DynamicDataSourceContextHolder.setDataSource(dataSource.value()); try { return joinPoint.proceed(); } finally { DynamicDataSourceContextHolder.clear(); } } }注意切面要放在Order(Ordered.HIGHEST_PRECEDENCE)也就是优先级最高、最先执行。这里非常关键后面会详细讲原因。4.3 为什么切面必须最高优先级Spring里处理事务是通过TransactionInterceptor完成的它也是一个AOP增强。当事务拦截器和数据源切面同时存在时谁先执行谁就决定了在打开数据库连接的那一刻ThreadLocal里的Key是什么。如果数据源切面的优先级比事务拦截器低事务拦截器会先运行它会先从routingDataSource拿一个连接并绑定到当前事务资源里。等数据源切面再设置新的Key已经晚了事务上下文拿到的还是旧连接。所以数据源切面必须是优先级最高的那一个确保在事务还没开始之前ThreadLocal里就已经放好了目标数据源的Key。还有一个隐蔽问题是“自调用绕过代理”。如果同一个Service类里的方法A调用方法B而B上有DS注解这种this.method()调用不会经过Spring AOP代理切面不会执行。解决方法要么把B方法放到另一个Spring Bean里要么在类上统一标注DS。4.4 业务侧的实际用法配置完成之后业务代码写起来就非常简单了Service public class OrderQueryService { DS(DsKeyEnum.MASTER) public OrderDetail getOrder(Long orderId) { // 查询订单主库 } DS(DsKeyEnum.REPORT) public ListReportRecord listReport() { // 查询报表库 } }如果你有几十个方法都要走同一个报表库可以直接在类上标DS(DsKeyEnum.REPORT)类里的方法就不用每个都写了。需要某个方法例外时再在方法上用注解覆盖。5. 踩坑实录切了没生效、监控不显示、初始化冲突的排查链路5.1 加上Transactional之后切换失效连接在切面之前被绑定这是我遇到最多的一类问题。表现是方法上明明标了DS(REPORT)日志里ThreadLocal也被设置成了REPORT但SQL实际执行的库还是默认库。排查第一步先看是否有外层事务。假设Controller调ServiceAServiceA没有DS也没有Transactional但是ServiceA内部调用了ServiceBServiceB上同时有Transactional和DS(REPORT)。这里的隐患不在ServiceB而在更外层如果调用链上有任何一个方法率先开启了事务Spring的事务管理器会把一个连接绑定到当前线程的ConnectionHolder里MyBatis后续执行SQL时优先复用这个已经绑定的连接根本不会再去routingDataSource.getConnection()走路由逻辑。排查链路我一般按这个顺序走检查目标方法及所有调用方看有没有更早开启事务的地方在切面里加日志确认determineCurrentLookupKey()返回的Key确实已经变成REPORT看Druid监控或者连接池日志确认实际创建连接的池是哪个如果确认连接被事务提前绑定就把“开启事务”和“切换数据源”放在同一个方法层级让事务边界跟着数据源边界走。一句话总结数据源必须在事务开始之前定下来切库千万不能被包在已有事务里。5.2 注解和切面都正常但查到的还是主库SqlSessionFactory绑错了地方另一个常见问题是服务启动不报错注解也在生效日志显示ThreadLocal里是正确的Key但结果还是master的数据。这时候怀疑对象应该从切面转到SqlSessionFactory。Spring Boot自动配置会在类路径里发现DataSource之后自动创建SqlSessionFactory。如果你的自定义配置没有生效自动配置很可能把某个具体的DruidDataSource塞给了MyBatis而不是塞routingDataSource。一旦MyBatis拿着一个固定的物理数据源那它连接里的所有路由逻辑都不存在切面只是改了ThreadLocal根本不会被读取。排查方法很简单启动后在调试模式里看SqlSessionFactory对象的数据源实现类。如果它是DruidDataSource而不是DynamicRoutingDataSource那就说明MyBatis没有走路由源。这时候需要确认自定义SqlSessionFactory是否被Spring扫描到有没有和自动配置产生冲突routingDataSource上的Primary是否还在如果用了MyBatis-Plus要配置的是MybatisPlusSqlSessionFactoryBean不是普通SqlSessionFactoryBean。5.3 Druid监控里只看到一个池有数据另一个始终是零Druid的监控统计是按DruidDataSource实例区分的。你对master单独建了一个池对report单独建了一个池两个池在监控页面会以两个独立实例呈现各自的统计互不相干。如果你发现监控页面里report库的实例统计一直是0基本可以断定这个池在运行期根本没有被动用过也就是切换动作没有真正落到连接获取层面。这时候不要怀疑Druid监控坏了先回过头查上面两点是切面没执行还是MyBatis绑错了数据源。监控统计在这里是最好的“验尸报告”能直接反映真实连接分配去哪了。另外还要注意个别同学配置了StatViewServlet但没配置StatFilter或者YAML里spring.datasource.druid.filter.stat.enabled没有打开这种情况下监控页面上连SQL统计都没有。要确保每个物理数据源都挂上StatFilterspring: datasource: druid: filter: stat: enabled: true log-slow-sql: true slow-sql-millis: 10005.4 Bean初始化顺序和Primary引发的启动冲突多数据源搭建过程中启动直接报错的情况也不少见。比较典型的是“expected single matching bean but found 2: masterDataSource, reportDataSource”这类错误原因就是没有给routingDataSource加Primary或者加了Primary但被自动配置绕过了。还有一类更隐蔽的问题和ConfigurationProperties绑定顺序有关。DruidDataSourceBuilder.create().build()创建出来的DataSource一开始并没有URLSpring容器要等ConfigurationProperties把YAML里的配置绑定上去之后才有完整的连接信息。如果某个Bean在绑定期之前就被其他初始化逻辑拿去使用了比如在PostConstruct里执行查询就会拿到一个没配好URL的空连接池启动时直接连库失败。遇到这类问题我一般会启动参数里加--debug看Bean的创建顺序或者直接在routingDataSource的方法里打日志确认master和report两个Bean已经先一步创建完成。多数据源启动问题绝大多数都是Bean依赖顺序和Primary选择造成的不是路由逻辑本身的问题。6. 让动态切换跑得更稳的增强思路异步线程、分库路由与连接池调优6.1 Async异步线程里的ThreadLocal丢失和线程池复用污染动态切换的另一个天然边界是线程。ThreadLocal只对当前线程可见如果你在一个方法里设置了REPORT然后通过Async把任务丢到另一个线程执行新线程的ThreadLocal是空的路由会回落到defaultTargetDataSource也就是master库。更危险的是如果线程池里的线程复用任务A设置的Key可能被任务B读取到造成串库。处理办法有两种。一种是在异步任务执行体内部重新设置数据源Key另一种是给Runnable做一个包装把父线程的数据源Key带进去执行前设置执行完清理public class DataSourceContextRunnable implements Runnable { private final Runnable target; private final String dsKey; public DataSourceContextRunnable(Runnable target, String dsKey) { this.target target; this.dsKey dsKey; } Override public void run() { DynamicDataSourceContextHolder.setDataSource(dsKey); try { target.run(); } finally { DynamicDataSourceContextHolder.clear(); } } }提交任务时用new DataSourceContextRunnable(originalTask, DynamicDataSourceContextHolder.getDataSource())包装一层就能保证线程池环境下的路由上下文不丢失、不串用。6.2 按租户分库时路由Key不要用裸ID如果路由是靠租户ID计算出来的推荐把Key设计成“有业务含义、可枚举”的形式而不是直接拿租户ID当Key。比如路由规则是“租户ID对2取模”那数据源就设计成两个tenant_db_0和tenant_db_1业务代码里用tenant_db_ (tenantId % 2)拼出Key。这样设计的理由是AbstractRoutingDataSource的targetDataSources是预先注册好的MapKey必须提前可枚举。如果你的分库数量很大或者未来要扩容可以用一个独立的注册中心保存“租户ID到数据源实例”的映射让路由层动态往targetDataSources里注册新连接池。但这时要特别小心连接池数量膨胀的问题不能每来一个新租户就建一个新池否则内存和线程都会出问题。比较稳妥的做法还是预先分配固定数量的物理库用取模或哈希做分片库里再按表划分。如果需要靠请求参数计算路由Key比写死注解更灵活的做法是在切面里取方法参数自行计算。DS注解负责固定场景分库路由规则则应该集中在专门的路由服务里不要在业务方法里散落计算逻辑。6.3 Druid连接池参数建议与最后的提醒最后聊聊连接池调优。多数据源环境下每个库的连接池参数最好独立调整不要复制粘贴同一套。报表库查询量大max-active可以放宽到20甚至30订单库是高频写入max-wait不要超过10秒否则数据库抖动时请求全部堵在等待获取连接上。常规参数里initial-size和min-idle可以保持一致避免高峰期临时建连接validation-query配成SELECT 1配合test-while-idletrue做空闲检测但不要开test-on-borrowtrue那会在每次拿连接时增加一次额外探测高并发下损耗很明显。Druid的慢SQL监控也建议优先打开。切换数据源本身排查起来麻烦慢SQL日志至少能帮你快速判断某个SQL到底打到哪个库、执行了多久。我一般会把slow-sql-millis设成1000运行一段时候后去监控页看统计哪个库的池出现大量慢SQL哪个库的连接数偏紧一目了然。这套动态切换方案我用了很长时间最大的感受是动态切换本质上是一个约定。注解、切面、ThreadLocal、路由源四个部分必须形成闭环任何一环没接上现象都是“代码没写错但数据不对”。如果以后再碰到切换不生效的问题不要急着加日志先回答三个问题数据源Key有没有在正确的时间设置MyBatis和事务管理器有没有绑定路由源有没有外层事务提前绑定连接这三个问题排查完大部分疑难杂症都能定位到根因。