☰
Druid多数据源动态切换实战:Spring Boot+MyBatis从原理到踩坑
2026/10/17 4:01:04 网站建设 项目流程

多数据源这种需求,在后台管理系统里几乎绕不开:一套服务要同时连订单库、用户库、日志库,或者做读写分离时读从库、写主库。标题里的Druid指的就是那个数据库连接池,它和Spring Boot、MyBatis放在一起,能凑出一套非常经典的多数据源动态切换方案。这套思路适合刚接触多数据源的后端开发者,也适合想把手动切库、写死配置的方式改造成统一路由的老手参考。本文会完整讲清楚核心原理、代码结构、AOP切面顺序、事务边界这些最容易出问题的细节,并记录几个我实际踩过的坑,尽量让读者能照着复现而不是只看了个热闹。

1. 多数据源动态切换的痛点与方案选型思考

1.1 什么场景需要“动态切换”,而不是配置两套写死的数据源

很多项目一开始只需要连一个库,后来业务拆分成订单库、用户库、日志库,或者引入了读写分离,就要面对“一次请求里到底该连哪个库”的问题。如果只是每个Service里各自注入一个固定的DataSource,这叫多数据源,但不算动态切换。动态切换的含义是:同一个业务线程在运行时根据上下文选择数据源,可能两次SQL操作走的是两个不同的物理库。

典型场景包括:按租户分库、按业务类型分库、读写分离、灰度环境里临时切库验证数据等。这些场景的共同点是“选择权在运行时”,不能把数据源关系写死在代码调用链里,否则每加一种切库规则就得改一遍业务代码,维护成本会快速膨胀。

另外要注意,动态切换不等于分库分表引擎,后者的路由粒度是SQL级别,而本文这套方案的路由粒度是“请求或方法级别”。两者解决的问题不同,但很多项目会先用动态切换做轻量级分库,等到SQL维度也需要路由时再去引入更重的中间件,这个演进路径是比较顺的。

1.2 为什么是Druid连接池加AbstractRoutingDataSource

选型时我见过几种做法:自己写一个DataSource包装类,内部用switch分支返回不同的连接;或者引入第三方多数据源框架;再或者直接用Spring自带的AbstractRoutingDataSource。前两者要么维护成本高,要么封装得太重。我最后选择的是Druid连接池加AbstractRoutingDataSource的组合,理由有三点。

AbstractRoutingDataSource是Spring提供的抽象路由数据源,它的核心思想是维护一个目标数据源Map,然后在每次获取连接时通过determineCurrentLookupKey()决定当前线程该选哪个key。这意味着“路由逻辑”和“连接管理”解耦,业务代码只需要设置一个上下文Key,后续的连接获取、池化、状态检查都由框架接管。

Druid作为连接池,自带监控、慢SQL统计、SQL防火墙、连接回收检测等能力。在实际项目里,“能连上库”只是及格线,生产环境更需要知道SQL执行了多少、哪个慢查询需要优化、连接池是否出现泄漏。Druid在这些方面比很多轻量级连接池要完整,而且它和Spring Boot整合得不错,配置项都能通过yaml绑定。

还有一个容易被忽略的点:Druid的StatViewServlet可以把多个数据源的连接池状态汇总到一个监控页里。动态切换场景下可能会出现多个库、多个连接池,统一监控的价值非常大。如果只用默认连接池,想做到这个程度还得额外搭一套指标采集和展示系统。

2. 工程搭建与配置:依赖、连接池参数和监控页

2.1 依赖怎么引,版本搭配别乱来

先说依赖。我这里用的是Spring Boot 2.7.x配合MyBatis官方starter和Druid官方starter,Maven坐标大致如下:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.20</version> </dependency>

版本搭配是这类项目里最容易翻车的地方。MyBatis starter 2.3.x对Spring Boot 2.7兼容性很好,Druid starter 1.2.x也比较稳定。如果你用的是Spring Boot 3.x,那么MyBatis starter需要换3.x系列,很多旧博客里的坐标直接复制过去会报驱动或事务相关的错误。

另一个常见问题是Druid starter和Spring Boot自带的数据源自动配置打架。如果引入了druid-spring-boot-starter,并且配置了spring.datasource.druid,它通常会接管数据源创建;但如果你同时也保留了HikariCP等依赖,Spring Boot自动配置可能会混乱。我的习惯是:既然决定用Druid,就把spring-boot-starter-jdbc自带的连接池依赖排除掉,或者至少不要让它干扰我们手动配置的DruidDataSource。

2.2 多套数据源配置和Druid监控参数

既然要做动态切换,至少要定义两套及以上数据源配置。我这里用一个模拟项目X来做演示,它有order_db和user_db两个业务库。配置文件我习惯在每个业务数据源前缀下单独放一套,公共的Druid参数可以抽到spring.datasource.druid.common里。

spring: datasource: order: url: jdbc:mysql://192.168.1.10:3306/order_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: order_app password: order_pass driver-class-name: com.mysql.cj.jdbc.Driver druid: common: initial-size: 2 min-idle: 2 max-active: 20 max-wait: 3000 validation-query: SELECT 1 test-while-idle: true time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 300000 filters: stat,wall,slf4j stat-view-servlet: enabled: true url-pattern: /druid/* reset-enable: false login-username: monitor_user login-password: monitor_pass web-stat-filter: enabled: true url-pattern: /* exclusions: /static/*,*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/* filter: stat: slow-sql-millis: 1000 log-slow-sql: true user: url: jdbc:mysql://192.168.1.11:3306/user_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: user_app password: user_pass driver-class-name: com.mysql.cj.jdbc.Driver druid: common: initial-size: 2 min-idle: 2 max-active: 20 max-wait: 3000 validation-query: SELECT 1 test-while-idle: true time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 300000 filters: stat,wall,slf4j

配置项看着多,但核心就是两类。第一类是连接池自身参数,比如initial-size、min-idle、max-active、max-wait,这决定了每个业务库的连接池规模;第二类是监控和拦截,比如filters: stat,wall,slf4j,它开启了SQL统计、防注入插件和日志输出。如果只配置了stat-view-servlet而忘了filters,监控页里能看到数据源却看不到SQL执行详情,这个后面专门讲。

多套数据源各自独立维护连接池这件事,新手容易忽略。动态路由只是决定“这次请求用哪个连接池”,并不会把连接池合并,所以每个库的连接池参数要单独评估。比如订单库QPS高,max-active可以给大一点;日志库可能只是偶尔查询,max-active给10就够。

2.3 数据源容器类:把几个DruidDataSource交给路由管理器

配置好yaml之后,需要写一个配置类,把每个DruidDataSource创建出来,再放入DynamicDataSource。这里的关键点是使用DruidDataSourceBuilder,而不是new一个DruidDataSource然后手动setUrl,前者才能正确读取spring.datasource.order.druid.common里的公共参数。

@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties("spring.datasource.order.druid.common") public DataSource orderDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.user.druid.common") public DataSource userDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean @Primary public DynamicDataSource dynamicDataSource( DataSource orderDataSource, DataSource userDataSource) { DynamicDataSource dynamicDataSource = new DynamicDataSource(); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("order", orderDataSource); targetDataSources.put("user", userDataSource); dynamicDataSource.setTargetDataSources(targetDataSources); // 默认数据源,找不到上下文key时兜底 dynamicDataSource.setDefaultTargetDataSource(orderDataSource); return dynamicDataSource; } }

注意这里dynamicDataSource加了@Primary,因为项目里后续注入DataSource的地方很多,事务管理器、MyBatis的SqlSessionFactory都可能自动注入DataSource,如果不标记@Primary,容器里存在多个DataSource Bean时,Spring会不知道该选哪个。

从这一步开始,MyBatis的自动配置会发现DataSource类型是一个DynamicDataSource,后续所有Mapper操作拿到的都是动态路由后的连接。这个设计非常巧妙,业务代码完全不需要感知数据源细节。

3. 动态路由核心实现:上下文、切面和代理顺序

3.1 DataSourceContextHolder:线程级隔离是路由的基础

动态路由的第一步是设计一个线程级上下文,用来保存“当前线程希望使用哪个数据源Key”。既然是多线程环境,就必须保证线程之间互不干扰,所以通常用ThreadLocal实现。

public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>(); public static void setDataSource(String dataSourceKey) { CONTEXT_HOLDER.set(dataSourceKey); } public static String getDataSourceKey() { return CONTEXT_HOLDER.get(); } public static void clear() { CONTEXT_HOLDER.remove(); } }

为什么必须用ThreadLocal?因为数据库连接本身是和线程绑定的。Spring在同一个线程里执行一串SQL时,如果每次都去查ThreadLocal的值,就能做到“同一个请求内保持同一个数据源;不同请求之间互不干扰”。这是实现动态路由的地基。

有一个小细节:使用完一定要clear,最安全的做法是在环绕通知的finally块里清除。如果只set不remove,在Tomcat这类线程池复用的容器里,下个请求可能读到上个请求留下的数据源Key,造成串库。这属于隐藏比较深的故障,排查起来很耗时间。

3.2 DynamicDataSource:Spring怎么知道该用哪个库

接着定义一个DynamicDataSource继承AbstractRoutingDataSource,实现determineCurrentLookupKey方法。这个方法的返回值就是targetDataSources里的Key。

public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceKey(); } }

AbstractRoutingDataSource的工作原理可以拆成三步理解。第一步,Spring容器初始化时,把targetDataSources里的所有DataSource按Key存到一个Map里;第二步,每次调用getConnection()获取连接时,先执行determineCurrentLookupKey()拿到当前线程想要的Key;第三步,用这个Key从Map里挑出真正的DataSource,再从该连接池获取连接。

所以在排查路由不生效的时候,有一个很重要的判断点:如果determineCurrentLookupKey()返回null,AbstractRoutingDataSource会直接走defaultTargetDataSource。有些场景下“默认库”是一种保护机制,但在测试时容易被误导,让人以为AOP没有执行。我的习惯是开发阶段在determineCurrentLookupKey里加一行日志,确认每次获取连接时上下文到底是什么值,排查问题会快很多。

3.3 注解加AOP切面,以及一个几乎必踩的代理自调用问题

有了上下文和路由数据源,剩下的问题就是:怎么自动把数据源Key设置到ThreadLocal里。直接用编程式调用也可以,但代码会非常啰嗦。更优雅的方式是自定义一个@DS注解,再用Spring AOP切面统一处理。

先定义注解:

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DS { String value(); }

再写切面:

@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceAspect { @Around("@annotation(ds) || @within(ds)") public Object switchDataSource(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { DataSourceContextHolder.setDataSource(ds.value()); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); } } }

这里有两处非常关键。第一,切面必须设置@Order(Ordered.HIGHEST_PRECEDENCE),保证它在事务拦截器之前执行,这个顺序问题放在第4章详细讲。第二,@annotation(ds)和@within(ds)都要写,前者拦截方法上的注解,后者拦截类上的注解,这样注解既可以放在Service方法上,也可以放在Controller类上做统一路由。

使用起来就是这样的效果:

@Service public class OrderService { private final CommonMapper commonMapper; public OrderService(CommonMapper commonMapper) { this.commonMapper = commonMapper; } @DS("order") public List<String> queryFromOrder() { return commonMapper.querySomething(); } @DS("user") public List<String> queryFromUser() { return commonMapper.querySomething(); } }

在实际项目里最容易踩的坑是代理自调用。Spring AOP的代理机制只对“外部通过代理对象调用方法”才有效,同一个类内部方法之间互相调用时,走的是this指向的原始对象,切面根本不会触发。比如在一个方法里直接调用同一个类的queryFromOrder(),@DS注解会静默失效,然后所有SQL都跑到默认库去。排查思路是第一优先级检查调用方是不是自己调自己,如果是,就把方法拆到别的Service类里,或者注入自身代理对象再调用。

4. 实战踩坑记录与完整排查链路

4.1 事务开启后路由失效:切面执行顺序和事务边界

这是我在动态切换方案里遇到的最经典问题。现象很简单:某个Service方法同时加了@Transactional和@DS("order"),然后发现SQL还是跑到了默认库。把@Transactional去掉之后,路由又正常了。当时我花了不少时间才把根因理清楚。

排查过程是这样走了一遍。第一步,在DataSourceAspect的switchDataSource方法里加日志,确认切面确实执行了,上下文也设置成了order。第二步,在determineCurrentLookupKey里打日志,发现它返回了order,但实际获取的连接还是指向默认库。第三步,检查Spring事务同步机制,发现当前线程早已经有了一条绑定好的数据库连接。

问题根因在于事务拦截器的执行顺序。当方法上有@Transactional时,Spring事务管理器会在进入业务方法之前获取一次数据库连接,并把这条连接绑定到当前线程的事务同步器里。后续MyBatis执行SQL时,会把事务同步器里已有的连接拿出来直接用,而不会再去调用AbstractRoutingDataSource的determineCurrentLookupKey。而我的切面如果没有调整顺序,执行时机在事务拦截器之后,设置ThreadLocal再晚也晚了。

解决方案有两个层次。第一层是保证@DS切面在事务拦截器之前运行,所以给切面加上@Order(Ordered.HIGHEST_PRECEDENCE),让路由上下文在事务管理器获取连接之前就设置好。第二层是理解事务边界本身,一个方法上同时加@Transactional和@DS,本质上要求事务从开始到提交都使用同一个数据源,如果事务中途再切换数据源,即使切面顺序正确,事务管理器也会因为连接绑定而无法真正切换。所以不要把动态切换理解为“同一个事务里来回横跳”,那需要X/分布式事务方案,不是这个轻量级路由能解决的。

我的最终做法是:把路由注解放在事务外层的Service方法入口处,事务方法内部如果需要跨库,就拆成多个事务方法,一个方法只对应一个数据源。这样既保持了路由能力,又不会破坏本地事务边界。

4.2 @DS放在同类自调用方法上:为什么注解没有生效

另一个高频问题发生在重构时。某开发者发现一个Controller方法调用了自己Service类里的queryFromUser(),但是查询结果不对,怎么调数据都对不上。检查代码发现queryFromUser标注了@DS("user"),理论上应该走user库,可实际走的却是order库。

我带着他排查了一遍。第一步,确认方法权限是public,Spring AOP对private方法不生效;第二步,确认类是否被Spring管理,问了一圈发现Service确实标注了@Service;第三步,在切面里打日志,发现切面根本没有进入queryFromUser方法。这就是典型的Spring AOP代理自调用。

原理是Spring容器里放的是代理对象,外部调用queryFromUser时,经过代理对象,切面生效;但是当同一个类里的另一个方法通过this.queryFromUser()调用时,this指向的是原始对象,不是代理对象,因此切面完全被绕过。解决手段很直接:把不同数据源的方法拆分到不同Service Bean里,或者通过AopContext.currentProxy()获取代理对象再调用。AopContext的方式需要额外开启exposeProxy=true,我一般直接用拆分Bean的方式,结构更清晰,也方便单元测试。

4.3 Druid监控页看不到SQL统计:filter配置与安全过滤链

Druid监控页正常打开后,数据源列表能看到order和user两个连接池,但“SQL监控”标签里一直为空。很多人会怀疑是埋点问题,其实大多时候只是stat filter没有生效。

检查方法比较固定:先确认依赖里有没有排除掉Druid监控相关组件;再确认yaml里filters是否包含stat;最后确认web-stat-filter有没有把请求纳入统计范围。如果用了Spring Security或自定义拦截器,还要在安全过滤链里放行/druid/**路径,否则StatViewServlet自己看到了,但SQL请求被拦截在更前面,统计自然会缺失。

我这里最终配置是filters: stat,wall,slf4j,同时设置filter.stat.log-slow-sql=true和slow-sql-millis=1000。这样单个慢SQL超过1秒就会被记录到日志。生产环境还应该把监控页的登录账号密码换成强密码,并关闭reset-enable,避免运行期被误重置统计信息。

4.4 动态新增数据源时,连接池重建和旧连接处理

有些项目把数据源配置放在配置中心里,运行期可能新增一个库。如果直接用@RefreshScope修饰DruidDataSource,很容易出现“配置刷新了但连接池还是旧的”的情况。更稳重的做法是封装一个数据源注册中心,由它负责接收配置变更事件,然后按“新配置创建连接池、测试连通性、写入DynamicDataSource、移除旧映射、延迟关闭旧连接池”的顺序做平滑切换。

为什么旧连接池不能立刻关闭?因为可能还有少量正在执行的SQL持有旧连接。如果注册中心更新完targetDataSources就把旧连接池close掉,这些SQL轻则报错,重则导致事务回滚。我的实现里通常会留一个延迟队列,等一个缓冲区时间后再关闭旧连接池。

不过这里我也想多说一句:绝大多数业务系统并不需要运行期动态增加数据源。如果需要,建议先评估到底是不是多租户、分库等复杂场景,如果只是两三个相对固定的库,启动时加载配置、缺少调用方再补充配置,反而更简单可靠。过度设计运行期动态注册,会带来连接池泄漏、配置一致性问题,收益却不明显。

5. 路由正确性验证与生产建议

5.1 select database():最直接验证不同库路由是否生效

验证路由正确性的方法有很多,但最直接、最能说明问题的不是查一条带业务标识的数据,而是直接查询当前连接的数据库名。我在模拟项目X里写了一个通用Mapper:

public interface DatabaseMapper { @Select("SELECT DATABASE()") String currentDatabase(); }

然后在不同Service方法上标注不同数据源:

@Service public class DatabaseService { private final DatabaseMapper databaseMapper; public DatabaseService(DatabaseMapper databaseMapper) { this.databaseMapper = databaseMapper; } @DS("order") public String orderDb() { return databaseMapper.currentDatabase(); } @DS("user") public String userDb() { return databaseMapper.currentDatabase(); } }

在Controller里分别调用orderDb和userDb,返回结果应该分别是order_db和user_db。如果两个方法的返回结果相同,说明路由没有生效,按照第4章的顺序逐步排查:切面是否执行、上下文是否设置、是否存在自调用、事务是否提前绑定连接。

还需要确认MyBatis的SqlSessionFactory使用到了DynamicDataSource。有个易错点:如果项目里手动配置了多个SqlSessionFactory,每个Factory绑定不同的DataSource,就会绕过动态路由,此时@DS的上下文设置得再对,Mapper也可能固定走某个库。所以配置时尽量保持一个SqlSessionFactory对应一个DynamicDataSource,这是最不容易打架的方式。

5.2 并发场景验证:不会串库才是关键

单线程验证通过只说明基本逻辑对了,动态路由是否真正可靠,要在并发场景下看是否串库。可以写一段简单的压力测试代码,用线程池提交一批任务,每个任务随机指定走order库还是user库,最终收集结果统计是否出现错误库。

我实际测试时会加入一个CountDownLatch,让所有线程同时开始,制造一个高并发抢夺上下文的场景。由于每个线程的ThreadLocal是隔离的,理论上线程之间不会互相覆盖。真正容易出问题的是线程结束时有没有清理上下文,以及线程池复用场景下有没有残留。

这里有个很隐蔽的细节。如果AOP切面把set和remove都包住了,理论上无论任务成功还是失败都会清理。但如果项目中有人在业务代码里手动调用DataSourceContextHolder.setDataSource,却在finally里忘了clear,那么当这个线程被线程池复用后,下个任务就可能在错误数据源上执行。排查此类问题最好的手段就是时刻保持“哪里set了,哪里就必须兜底clear”的纪律。

5.3 生产环境连接池参数与监控经验

最后分享几个我在生产环境积累的参数经验和观察手段,不一定适合所有业务,但可以作为初始基线。

首先是连接池大小。单机QPS不高的情况下,Druid的max-active给20左右通常够了,min-idle保持2到5即可。连接池开得过大并不会提升性能,反而会占用数据库端资源。连接池里的每个物理连接对数据库来说都是一个客户端会话,几十个服务实例如果各自开50个链接,数据库压力会很大。

其次是连接有效性检测。Druid默认的test-while-idle可以保证空闲连接被回收,配合validation-query: SELECT 1和time-between-eviction-runs-millis: 60000,基本能避免连接被数据库主动断开后,应用还在使用时才发现的尴尬。一些博客推荐开启testOnBorrow,我一般不推荐,因为每次借出连接都做一次探测会有额外开销;有testWhileIdle兜底就够了。

再看慢SQL监控。slow-sql-millis先设置成1000毫秒观察一段时间,如果日志里全是慢SQL,再根据实际情况把阈值上调到1500或2000毫秒。不要一上来就定一个严格的阈值,否则日志会非常吵,反而掩盖真正需要关注的SQL。

Druid监控页里的“连接池”页签可以查看每个数据源的活跃连接数、等待连接数、闲置连接数。如果某个数据源的活跃连接数长期逼近max-active,优先去看是否并发过高、是否有连接泄漏,再去调大max-active。连接泄漏是动态数据源场景里的重灾区,因为代码里可能会忘记关闭ResultSet、Statement或Connection,Druid可以通过连接池面板里的“逻辑连接”和“物理连接”数量间接发现异常。

另外还要检查一下同一份定时任务是否会在多个实例上同时跑。多实例部署下,如果定时任务没有分配,每个节点都会切到同一个数据源执行,连接池水位会异常抬高。最简单的办法是用分布式锁或任务调度框架控制单实例执行,这比盲目扩大连接池更合理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询