我先自己确认一下,这次要写的是一篇关于 SpringBoot 多数据源配置的实战经验分享,内容完全围绕技术本身展开,不涉及任何敏感话题,安全合规没问题。下面直接开始,标题编号、代码、实操细节都给到位,确保是能直接落地的干货。 SpringBoot 多数据源这个需求,接私活和做企业项目都特别常见。不管你是要读写分离,还是单纯把报表库、日志库从主业务库里拆出来,一开始的思路不捋清楚,后面配置完了跑起来,各种诡异报错能把人折磨到怀疑人生。这篇文章就把我之前在项目里整理的完整方案拿出来,从场景分析到核心代码到坑位排查,一条龙讲清楚。
如果你是刚接触 SpringBoot 不久,正好在准备 springboot 面试题,或者正在做的管理系统要连两个数据库,这篇内容应该能直接给你抄作业的底气。
1. 多数据源场景与方案选型
先说清楚什么时候真的需要多数据源,以及到底有哪几条路可以走。
1.1 常见需求场景拆解
很多初学者一听“多数据源”就觉得是高深玩意儿,其实它就是“一个应用要访问多个数据库”。我梳理了实际项目里最常见的几类场景,排名不分先后,但都是真实踩出来的:
表格里列得简洁,但实际选型就要思考更深一层。比如读写分离,你虽然可以把读库和写库拆成两个数据源,但如果只是想让查询走从库、写入走主库,用 AbstractRoutingDataSource 做动态切换是最轻量的方案。而如果是两个完全独立、表结构都不同的业务库,那就更适合用 MyBatis-Plus 多数据源插件,代码里加个注解就能隔离,维护起来也直观。
| 场景 | 典型需求 | 推荐方案 |
|---|---|---|
| 读写分离 | 主库写、从库读,降低主库压力 | AbstractRoutingDataSource 动态路由 |
| 独立业务库 | 不同模块物理隔离,比如订单库和用户库 | MyBatis-Plus 的 @DS 注解 |
| 报表/日志库 | 高频查询和历史归档数据不在主库 | 独立 SqlSessionFactory 硬隔离 |
| 第三方系统对接 | 需要直接读取第三方库的表数据 | 只读数据源 + 连接池隔离 |
1.2 三种主流实现方案对比
多数据源的实现思路,我总结下来无非是下面这三条路线。
方案A:用 AbstractRoutingDataSource 做动态数据源路由
Spring 内置提供了一个 AbstractRoutingDataSource 抽象类,它内部维护了一个目标数据源 Map,通过 determineCurrentLookupKey() 方法返回的 key 来决定当前线程用哪个数据源。你可以基于它封装一个动态路由组件,配合 ThreadLocal 存储当前线程的 key,再用 AOP 切面在 Service 方法执行前切换 key,方法执行完再清理。
这个方案最灵活,自由度最高。读写分离、多租户隔离都能做,但所有代码逻辑都要自己写,工程复杂度可控但细节多。
方案B:MyBatis-Plus 提供的 dynamic-datasource 插件
如果项目用了 MyBatis-Plus,那直接用 dynamic-datasource-spring-boot-starter 这个官方插件是最省事的。它封装了所有切换逻辑,只需要在 application.yml 里配置多个数据源,然后在 Mapper 或 Service 方法上加 @DS("dsName") 注解就能完成切换。
这个方案对业务代码入侵最小,代码上就是一个注解的事。但它强依赖 MyBatis-Plus,如果项目没用 MyBatis-Plus,引这个插件就有点大炮打蚊子了。
方案C:配置多个 SqlSessionFactory 实现物理隔离
这种方案等于给两个数据库各建一套独立的 MyBatis 基础设施,包括各自的 SqlSessionFactory、DataSource、SqlSessionTemplate、MapperScan。两套体系互不干扰,隔离性最强,适合那种两个库的表结构完全独立、业务上没有交叉的模块。
缺点也很明显:代码里不能自由切换数据源,事务跨库操作基本没法做。而且 Mapper 接口要按包名分开扫描,维护成本高,容易产生重复配置代码。
我在实际项目里最常用的还是方案A和方案B。如果是从零开始的新项目,且用了 MyBatis-Plus,我推荐 B,省心。如果是老项目改造,或者不想引入额外依赖,那就用 A,可控性最强。这篇文章主要展开讲 A 的完整落地过程,因为理解了它的路由原理,B 的很多细节你也能一眼看穿。
2. 环境准备与前置依赖
方案定了,接下来就是准备环境、配置依赖。
2.1 依赖导入与版本说明
我用的环境是 SpringBoot 2.7.x + MyBatis-Plus 3.5.x + MySQL 8.0,这些版本的组合测试了很久,稳定可靠。如果你的项目是 SpringBoot 3.x,需要注意 javax 命名空间变成了 jakarta,自定义数据源配置类里的 import 语句要相应调整。
pom.xml 里最小依赖集合是这样的:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>spring-boot-starter-aop 别漏了,动态数据源切换的核心逻辑要靠 AOP 切面来实现,少了这个依赖,你后面写的自定义注解就完全不会生效。
有一点要特别提醒:如果用了 MyBatis-Plus,就别再引入 mybatis-spring-boot-starter 了,不然两个 MyBatis 的自动配置会打架,报错信息还特别隐晦,极难排查。踩过一次这个坑之后,我现在写 pom.xml 都会格外注意依赖冲突问题。
2.2 数据库准备
为了方便演示,我准备了两个库:ds_master 作为主库,ds_slave 作为从库/业务库。两个库都建一张 user 表,但写入不同的初始数据,这样切换数据源查询时能直观看到效果。
-- 主库 CREATE DATABASE ds_master DEFAULT CHARACTER SET utf8mb4; USE ds_master; CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(50) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO `user` (`name`) VALUES ('master_zhangsan'), ('master_lisi'); -- 从库 CREATE DATABASE ds_slave DEFAULT CHARACTER SET utf8mb4; USE ds_slave; CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(50) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO `user` (`name`) VALUES ('slave_zhangsan'), ('slave_lisi');两个库里的数据区分得很明显,之后你一运行查询,返回的是 master_ 还是 slave_ 开头,就知道当前路由到了哪个数据源。
3. 核心实现:基于 AbstractRoutingDataSource 的动态路由
这一部分是整篇文章最核心的内容,理解了数据源切换原理,你就掌握了多数据源配置的底层逻辑。别慌,我一步步拆开揉碎给你讲。
3.1 原理先讲透:Spring 是怎么知道用哪个数据源的?
先从宏观上理解 Spring 的数据源加载流程。SpringBoot 启动时,如果没有特殊配置,会读取 application.yml 里 spring.datasource 下的配置,自动装配一个 HikariDataSource。但在多数据源场景下,我们要打破这个默认行为:不再让 SpringBoot 自动创建数据源,而是我们自己创建多个数据源,再交给一个“路由数据源”来管理。
这个“路由数据源”就是 AbstractRoutingDataSource。它内部维护了一个 Map<Object, Object> targetDataSources,key 是数据源标识,value 是实际的数据源对象。当代码里要获取连接时,AbstractRoutingDataSource 会调用它的抽象方法 determineCurrentLookupKey(),拿到当前线程对应的 key,然后从 Map 里取对应的真实数据源,再从这个真实数据源里 getConnection()。
这个过程有点像驿站送信:你手上有很多驿站(目标数据源),每个驿站对应一个地址编号(key)。每一次送信(获取数据库连接)前,得先查下表(determineCurrentLookupKey)看这封信该送到哪个驿站。动态数据源的所有设计,本质就是在维护“当前这封信该送到哪个驿站”这个信息。
那如何让每个线程都有自己的 key 呢?答案是 ThreadLocal。每个线程往 ThreadLocal 里放自己的数据源标识,AOP 切面在进入 Service 方法前放进去,方法执行完在 finally 里清理掉。这样多线程并发时,各线程的数据源互不干扰。
3.2 数据源上下文管理类:DataSourceContextHolder
先写一个简单的 ThreadLocal 封装,负责保存和获取当前线程的数据源 key:
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 getDataSource() { return CONTEXT_HOLDER.get(); } public static void clearDataSource() { CONTEXT_HOLDER.remove(); } }代码很简单,但作用很关键。这里有一个重要细节:清理数据源时一定要用 remove() 而不是 set(null)。因为线程池里的线程会被复用,如果你只 set(null) 而不 remove,之前设置的 key 可能残留在线程里,导致下一次请求路由到错误的数据源。这种 Bug 是间歇性出现的,排查起来非常抓狂。
我在生产环境就遇到过这种问题:正常跑几小时没问题,某几个接口突然就查询到了别的库的数据。后来靠线程池线程 ID 和请求日志一点一点排查,才发现是 ThreadLocal 没清干净。所以这里多啰嗦一句:清理用 remove(),不要用 set(null)。
还要解释一个疑问:为什么不用 synchronized 或者全局变量?因为高并发下,不同线程要访问不同的数据源。用全局变量会互相覆盖,用 synchronized 会阻塞其它线程,ThreadLocal 恰好是“线程隔离”的标准解决方案。
3.3 动态数据源路由类:DynamicDataSource
接下来创建一个类继承 AbstractRoutingDataSource:
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }就这么几行代码,但它是整个方案的心脏。AbstractRoutingDataSource 在初始化时会调用 afterPropertiesSet() 方法,将我们传入的 targetDataSources 解析并放好。每次 getConnection() 时都会先走 determineCurrentLookupKey(),拿到当前线程的数据源 key,再去 Map 里取真正的数据源。
这个方法返回 null 也没关系,AbstractRoutingDataSource 会使用默认数据源(defaultTargetDataSource)。所以我的习惯是:把主库设为默认数据源,这样即使某处忘记加切换逻辑,至少读写不会走到从库上去,保证核心业务安全。
3.4 自定义注解与 AOP 切面:数据源切换的入口
光有路由类还不够,得有个东西告诉它“什么时候切到哪个数据源”。我选择自定义注解 + AOP 切面的方式,这也是可读性和维护性最好的方式。
先定义注解:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DS { String value() default "master"; }然后定义切面:
@Aspect @Component public class DataSourceAspect { @Around("@annotation(ds)") public Object around(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { DataSourceContextHolder.setDataSource(ds.value()); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clearDataSource(); } } }这个切面拿到的 ds.value() 就是要切换的数据源标识。在方法执行前设置,执行后清理,确保线程不残留。
AOP 切面拦截的本质是 Spring AOP 动态代理。有一点需要注意:由于 Spring AOP 默认是基于接口代理的,所以这个切面只能拦截 Spring 管理的 Bean 的方法调用。如果你在同一个类里一个方法调另一个方法,切面是不会生效的,因为调用没有经过代理对象。这是 Spring AOP 的基本特性,也是很多人切换数据源失败的根本原因之一。
我自己就踩过这个坑:Service 里有一个公共方法调用了本类中加了 @DS 注解的另一个方法,结果数据源切换死活不生效,查了半天才发现是方法自调用问题。
3.5 数据源配置类:把多个数据源注册给路由源
这一步要把多个数据源创建出来,注册到 DynamicDataSource 里,并且接管 SpringBoot 的自动数据源配置。
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties(prefix = "spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties(prefix = "spring.datasource.slave") public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } @Bean @Primary public DynamicDataSource dynamicDataSource( @Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("slaveDataSource") DataSource slaveDataSource) { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("master", masterDataSource); targetDataSources.put("slave", slaveDataSource); DynamicDataSource dynamicDataSource = new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(masterDataSource); return dynamicDataSource; } }DataSourceBuilder.create().build() 会根据配置文件的属性自动推断连接池类型。只要能找到 HikariCP 依赖,默认就是 HikariDataSource。@Primary 注解是关键,它告诉 Spring 这是首选的 DataSource Bean,否则你会遇到“expected single matching bean but found 2”的错误。
有一个细节要额外注意:@ConfigurationProperties(prefix = "spring.datasource.master") 这种方式,要求配置项不能写在 SpringBoot 默认的 spring.datasource 节点下,而是写在自定义的 spring.datasource.master 和 spring.datasource.slave 子节点下。否则的话,SpringBoot 自动配置机制会和你的自定义配置发生冲突,容易导致数据源被重复创建或者配置项读取不到。
我用这个方案配置好了之后,还要把 MyBatis-Plus 的 SqlSessionFactory 创建交给动态数据源。其实大多数情况下,只要动态数据源是 @Primary 的 DataSource,MyBatis-Plus 会自动拿到这个路由数据源来创建 SqlSessionFactory,动态切换就能自动生效。不需要额外再写 SqlSessionFactory 的配置,这也是这个方案轻量化的一个点。
3.6 application.yml 配置
对应上面代码,配置文件这样写:
spring: datasource: master: jdbc-url: jdbc:mysql://localhost:3306/ds_master?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver slave: jdbc-url: jdbc:mysql://localhost:3306/ds_slave?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver注意,这里用的是 jdbc-url 而不是 url。如果使用 HikariCP 作为连接池,它会把 url 当作一个普通属性,而不会正确解析为 JDBC URL,导致连接失败。当然,如果不用 DataSourceBuilder,而是直接 new HikariDataSource() 然后 setJdbcUrl,那配置项的命名就无所谓了。但既然用了 DataSourceBuilder,还是老老实实写 jdbc-url 最稳妥。
这里需要提醒一个核心逻辑:key 的名称必须与 @DS 注解的 value 值保持一致。尤其在切面里,如果你写 @DS("master"),那 Map 里的 key 必须是 "master"。大小写、空格有一处不一致,切换到不存在的数据源 key 时,会抛出无法确定数据源的异常。所以通常我会定义一个常量类统一管理数据源名称,避免手写字符串导致的小错误。
4. 实际使用与事务边界问题
代码配置完了,怎么在业务里用起来,以及事务对数据源切换有什么影响,这一节说清楚。
4.1 Service 层使用示例
直接用注解切换,代码清爽:
@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override @DS("master") public List<User> listMasterUsers() { return userMapper.selectList(null); } @Override @DS("slave") public List<User> listSlaveUsers() { return userMapper.selectList(null); } }运行后,listMasterUsers() 返回的是 ds_master 库里的 user 数据,listSlaveUsers() 返回的是 ds_slave 库里的 user 数据。说明数据源切换成功。
刚入门的读者可以这样理解:加了 @DS 注解的方法,就相当于在方法执行前调用了 DataSourceContextHolder.setDataSource("slave"),方法结束后自动调用 clearDataSource()。这个切换对业务代码完全透明,你不需要在 Mapper 层写任何与多数据源相关的代码。
还可以给 Mapper 接口加注解。当 @DS 同时出现在类和方法的注解上时,方法上的注解优先级更高,覆盖类级别的配置。利用这个特性,你可以把默认库配在类上,特殊方法再单独指定库。
4.2 事务机制对数据源切换的影响
这是多数据源方案里最暗藏的坑,也是面试官最喜欢追问的点。
Spring 的 @Transactional 是基于 AOP 的,它会在方法开始前开启事务,然后整个方法的数据库操作都在这个事务里执行。问题来了:事务管理器和数据源是绑定的。Spring 开启事务时,会从当前数据源获取一个连接,并且这个连接在整个事务期间都被绑定到当前线程,即使你中途用 @DS 切换了数据源 key,事务内已经持有的连接也不会变。
通俗点说:事务像一个大袋子,先把数据源A的连接装进去了。你后续切到了数据源B,但是事务袋子里的连接还是数据源A的,查询仍然会落在数据源A上。
所以结论是:@DS 和 @Transactional 不能简单地同时用在同一个方法上,尤其当你需要在一个事务里操作两个不同数据源的时候。这两种机制没法直接实现跨库事务的一致性,强一致性需要引入分布式事务框架,比如 Seata。但如果只做单数据源上的事务,切分好边界即可。
我的一些经验总结:
- 写操作和读操作分别放在不同方法里,写方法上加 @DS("master") 和 @Transactional,读方法上加 @DS("slave"),不加事务或只加只读事务。
- 某个方法同时需要操作两个库的数据,比如先从主库查订单,再写到从库的日志表,不要在一个方法里同时切换。建议拆成多个方法,每个方法指定一个数据源,外层用编程式事务来控制整体逻辑。
- 尽量让方法的事务边界和数据库连接的使用边界保持一致,这是避免这类问题的最基本法则。
4.3 编程式事务在跨库场景中的应用
如果确实需要在一个业务里连续操作两个数据源,比如先从主库扣库存,再向从库写操作日志,并且要求“库存扣减成功才写日志”,这时候可以把事务控制改为编程式。让每个数据源的操作各自独立提交,通过业务逻辑来保证最终一致性:
@Autowired private TransactionTemplate transactionTemplate; public void businessOperation() { // 操作主库 transactionTemplate.execute(status -> { orderMapper.updateStock(); return null; }); // 操作从库,独立事务 transactionTemplate.execute(status -> { logMapper.insertLog(); return null; }); }注意,这只是牺牲了强一致性换取性能的折中方案。扣库存和写日志中间如果进程崩了,还是会出现两边数据不一致的情况。真正要跨库强一致,得上分布式事务,这块内容展开又是一篇长文,这里先点到为止。
如果你的面试官问“多数据源下事务怎么保证”,你能说出上面这段分析,基本能拿高分。
5. 连接池与性能调优细节
数据源切换只是基础,实际应用里连接池的配置直接决定系统的稳定性和并发表现。
5.1 连接池参数怎么调
我用了 HikariCP,它默认配置已经很优秀,但有几个参数值得根据场景自定义:
| 参数 | 默认值 | 我的推荐 | 说明 |
|---|---|---|---|
| maximum-pool-size | 10 | 30-50 | 连接池最大连接数,按接口 QPS 估算 |
| minimum-idle | 10 | 5-10 | 最小空闲连接数,太低会频繁创建连接 |
| connection-timeout | 30000 | 30000 | 获取连接超时时间,等太久直接失败 |
| idle-timeout | 600000 | 600000 | 空闲连接存活时间 |
| max-lifetime | 1800000 | 1800000 | 连接最大生命周期,避免数据库超时断开 |
需要留意的是,每个数据源都是独立的连接池,内存占用是叠加的。你配置了 3 个数据源,每个 30 个连接,那应用最多可能持有 90 个数据库连接。连接比较多的库(比如主库)可以给大一点,从库或日志库不需要那么大。这个比例要结合数据库本身的 max_connections 上限来规划,别光顾着应用性能,把数据库压垮了。
5.2 慢 SQL 与连接池监控
多数据源环境里,最怕的不是某个库慢,而是不知道是哪条慢 SQL 导致连接池被打满。建议给每个数据源配置独立的慢 SQL 日志和监控指标,比如开启 MyBatis-Plus 的慢 SQL 拦截器:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }又或者加一个 SQL 性能分析插件,把执行时间超过阈值的 SQL 打印出来。这个对定位慢查询非常有效。我上线新系统时,一般会在测试环境开 SQL 日志,观察每个接口实际路由到了哪个库、执行的 SQL 长什么样,确认没问题后再关掉,减少日志输出压力。
6. 常见问题与排查技巧实录
下面是实操中大概率会碰到的几个问题,我按“症状 -> 原因 -> 解决”来梳理。
6.1 启动时报 “Failed to configure a DataSource”
这个报错在排除了数据库连接配置错误之后,最常见的原因是 SpringBoot 自动配置的数据源和自定义数据源冲突了。解决办法是显式排除 DataSourceAutoConfiguration:
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }或者明确指定spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration。
这样做的作用,是告诉 SpringBoot:我自己管理数据源,不用你自动装配了。但如果用了我上面给的 @Primary + DynamicDataSource 方案,通常不需要这一步;一旦出现这种问题,你首先要检查的是 spring.datasource 配置节点和 @ConfigurationProperties 前缀是否匹配。
6.2 数据源切换不生效,一直走默认库
这个问题的排查优先级很高。我遇到过的情况有两种:
第一种,切面没生效。检查自定义切面有没有加上 @Aspect 和 @Component 注解,再检查 spring-boot-starter-aop 依赖是否引入。还有一种可能是同类自调用,也就是同类里一个方法调用另一个带 @DS 注解的方法,AOP 不生效。解决办法是把被调用方法单独拆到一个类里,或者使用 AopContext.currentProxy() 获得代理对象再调用。
第二种,事务先行了。如果外层方法加了 @Transactional,Spring 在进入你的代码之前就已经拿到主库连接,事务内部的数据源切换自然不会生效。这个需要你把 @Transactional 拆开,让每个数据源的操作分别进入独立事务,或者用编程式事务。
6.3 报 “Cannot determine target DataSource for lookup key”
这个错误信息非常直观:路由数据源找不到你要的 key。最常见的几个原因如下:
- @DS 注解里的值和 targetDataSources Map 里的 key 不一致。比如注解写了 "slave" 但 Map 里 put 的是 "Slave"。
- 动态数据源的异常拦截没配置好,比如使用 @DS("notExist") 这种明显不存在的 key。
- targetDataSources 是普通 HashMap,有并发修改的问题。不过通常在初始化阶段就 put 完了,不会触发。为了稳妥,我还是建议用 ConcurrentHashMap。
解决方案:建立一套常量类,把数据源的 key 统一定义,比如 DSType.MASTER 和 DSType.SLAVE,修改和引用都走常量,从根上避免手误。
6.4 多数据源下 MyBatis-Plus 分页失效
这个遇到的人也不少。MyBatis-Plus 分页需要配置 PaginationInnerInterceptor,但在多数据源场景下,这个拦截器要拿到当前数据源的数据库类型才能正确生成分页 SQL。如果你把所有数据源都配成了一种数据库,比如都是 MySQL,那直接写死 DbType.MYSQL 也没问题。
但如果你的多数据源涉及不同数据库类型(比如 MySQL + PostgreSQL),就不能用写死的方式。需要实现一个动态获取数据库类型的处理:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); DynamicDatabaseInterceptor dynamicDbInterceptor = new DynamicDatabaseInterceptor(); interceptor.addInnerInterceptor(dynamicDbInterceptor); return interceptor; }核心思路是拿到连接后,通过连接的元数据获取当前实际数据库类型,再动态决定分页方言。这个细节不处理好,你切换数据源后,分页 SQL 可能带上了错误方言,轻则分页不准,重则直接把 SQL 发到数据库上执行报错。
6.5 连接池泄漏与连接耗尽
当连接池出现连接耗尽时,系统通常会表现为:某个接口突然变慢,后续请求大量超时。虽然错误信息会提示连接超时,但很多人的第一反应是数据库慢,而不是连接池被占满。
排查方式也很直接:看连接池的 active 数量和数据库侧的 sleep 连接数。HikariCP 自带监控指标,可以通过 actuator 暴露出来。我在生产环境就把 jdbc 连接池相关的指标接入了监控系统,一旦 active 连接数接近 maximum-pool-size,就立刻告警。
常见原因往往是代码中的“未关闭连接”。使用 MyBatis-Plus 一般不会出现这个问题,因为框架处理得很完善。但如果你手动使用了 JdbcTemplate 或原生 JDBC 操作数据源,就必须小心每一个 Connection 的关闭时机。尤其是项目使用动态数据源的时候,如果自定义的代码中途抛异常且未进入 finally 清理,连接就直接泄漏掉了。关于这点,我建议所有手动获取连接的地方都严格按照 try-with-resources 来写。
7. 多数据源方案的进阶扩展
基础版跑通之后,你可以根据业务需求继续往深了扩展。
7.1 动态新增数据源(运行期注册)
有些系统要求不用重启应用就能切换新增的数据源,比如多租户场景下每个租户一个库。理论上可以在应用运行时调用 DynamicDataSource 暴露的新增方法,往 Map 里 put 新的数据源键值对。我做过的一个项目就是用这种方式,每接入一个新租户,就通过接口把数据源注册进路由表。但这里面有几个坑:连接池是重量级资源,频繁增删会带来系统开销;数据源 key 的管理要配套生命周期,租户下线要及时移除对应数据源和连接池。这种方式适合运维能力强的团队,建议谨慎使用。
7.2 读写分离下的负载均衡
如果从库有多个,你可以稍微改造一下路由逻辑。在 DataSourceContextHolder 里把同一个逻辑数据源映射到多个物理数据源,每次切换时从这几个库中选一个最空闲或随机的。实现原理不复杂,就是维护一张配置表,在确定路由 key 时做一次负载策略选择。
不过要提个醒:主从之间存在同步延迟,你的业务对数据一致性要求较高时,一定不能随便把强一致性的读请求放到从库。常见的做法是强制路由,比如刚写入的数据在会话内读主库,或者延迟较低的对账业务才允许走从库。
7.3 多数据源结合 MyBatis-Plus 多租户插件
MyBatis-Plus 的租户插件实现思路是给所有 SQL 自动拼上租户 ID 条件。多数据源加多租户后,要注意租户 ID 和租户数据源的映射关系。通常做法是在请求线程拦截器里,先根据请求头解析出租户 ID,再动态设置数据源 key 和租户上下文。这个时候,ThreadLocal 里除了要存数据源 key,还要存租户 ID,所以在 DataSourceContextHolder 之外再加一个 TenantContextHolder,各自管理各自的上下文。
这个设计能同时解决“用户要访问哪个库”和“在库里要过滤哪些数据”两个问题。但组合复杂度较高,建议在基础版跑通并稳定运行之后,再逐步演进。
7.4 其他实现方案:MyBatis-Plus dynamic-datasource 插件
最后再简单补充一下 MyBatis-Plus 的 dynamic-datasource 插件方案,毕竟这也是一个主流选择。如果你用的是纯 SpringBoot + MyBatis-Plus 技术栈,引入:
<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>3.6.1</version> </dependency>然后在 yml 里配置:
spring: datasource: dynamic: primary: master strict: true datasource: master: url: jdbc:mysql://localhost:3306/ds_master?... username: root password: root123 slave: url: jdbc:mysql://localhost:3306/ds_slave?... username: root password: root123使用起来更简单,直接:
@DS("slave") public List<User> listSlaveUsers() { return userMapper.selectList(null); }但我的建议是:如果你是为了面试或深入学习,一定要先从 AbstractRoutingDataSource 手工实现一遍,再去用插件。手工实现能让你彻底理解 AOP 切面、ThreadLocal 上下文、连接池绑定的底层原理,这些知识在排查线上问题时会直接变成你的反应速度。直接上手封装的插件,确实开发效率高,但一旦遇到不符合插件默认行为的问题,就会比较被动。
8. 写完这段代码,我在实际测试里体会到的几件事
代码写完之后,建议不要急着直接连生产库做验证。一定要在本地起两个 MySQL 实例或者两个 Schema,用清晰的测试数据先验证路由逻辑。我这个方案在本地验证的时候,发现一个非常容易忽略的事:SpringBoot 的懒加载数据源机制。HikariCP 默认是懒加载连接的,也就是说,即使你配置了错误的数据源连接信息,应用启动的时候可能并不会报错,要等第一次请求真正去获取数据库连接时才会暴露问题。
所以我每次改完数据源配置,都会写一个最简单的 Controller 或者用单元测试,把每个库的查询都跑一遍。验证顺序也很简单:先查主库数据,确认返回 master 开头;再查从库数据,确认返回 slave 开头;最后在没有任何 @DS 注解的方法里查一次,确认默认走了主库兜底。三步验证通过,这个数据源配置才算真正稳定。
比配置代码本身更重要的是团队约定。动态数据源方案给你了很强的灵活性,但是代价是代码的可读性下降。一个方法到底访问哪个库,需要看类上注解、方法注解、切面逻辑、事务边界才能判断。因此我建议使用这个方案时,在项目 README 里专门写一节“数据源路由约定”,把哪些业务模块必须走哪个库固化下来,省得后来者读代码时一头雾水。
有一次我接手一个同事的项目,他的多数据源配置里把主库和从库的连接池参数调成完全一样,其实没什么问题,但读库有大量统计报表,连接需求大,主库却是高并发写,连接需求反而没那么多。这种细微的配置差异,如果不根据实际业务流量调整,等到大促流量一来就会出问题。所以连接池参数的调整,一定要结合真实的流量模型来迭代,而不是一劳永逸。