SpringBoot【十一】mybatis-plus实现多数据源配置,开箱即用!
多数据源这个需求,说大不大,说小不小。我接手过的项目里,十有八九遇到过这种局面:业务库拆了两个,读写分离要落地,或者报表系统得从独立的库里拉数据。SpringBoot + MyBatis-Plus 的组合自然是熟得不能再熟,可一说到多数据源,很多人第一反应就是自己写个 AOP 切面,搞个AbstractRoutingDataSource,再整个ThreadLocal存当前库的 key,一套方案撸下来确实能用,但维护成本一点不少,代码里那股"手动切库"的味道也挥之不去。
这篇文章要说的,是我自己在项目里实测下来最省事的方案:用 MyBatis-Plus 官方配套的dynamic-datasource-spring-boot-starter,配置两段 yml,写一个@DS注解,就完成多数据源接入。开箱即用这四个字,真不是宣传话术,我踩完坑回头再看,这玩意确实是目前 SpringBoot 生态里最顺手的多数据源轮子。这篇文章我会把完整的配置步骤、切换原理、事务坑位和几个项目经验里的排查技巧都交代清楚,适合正在玩 SpringBoot,想让项目一次性搞定多数据源切库的 Java 开发。
1. 为什么需要多数据源:先搞清楚出发点和方案选型
多数据源不是炫技,是业务被逼出来的。你得先想明白自己为什么需要多数据源,再动手搞,不然大概率会把代码越搞越乱。
1.1 多数据源的常见落地场景
我遇到过的场景大致分三类,你对照着自己项目看看属于哪种:
第一类是读写分离。主库扛写入,从库分担查询压力,这是最经典的多数据源诉求。好处是主库不被慢查询拖死,从库可以挂多个,分摊读流量,查询接口响应速度也能提上来。这种场景下,你通常希望select走从库,insert/update/delete走主库。
第二类是业务库隔离。微服务架构下,一个服务可能需要访问多个不同的业务库,比如订单服务既要查自己的订单库,又要调用户中心的用户信息库。两个库可能在不同的数据库实例上,甚至不同的机器上。这种场景下,你不能把两个库的数据表放到同一个数据源里统一访问,必须按库维度切分。
第三类是报表与分析库。业务数据和报表数据常常分开存,线上交易库要保持轻量,报表库专门跑聚合查询。那你跑报表任务时,就得切换到报表库去干活。
这三种场景,核心诉求都是一个:同一套代码里,按不同业务逻辑连接到不同数据库执行 SQL。
1.2 三种主流实现方案对比
既然确定了要搞多数据源,那方案选型就得认真做一下,因为这个决定直接影响后面几周甚至几个月的开发效率。
第一种,手动切库。自己在代码里写个DataSourceContextHolder,用一个ThreadLocal存当前线程要用的数据源 key,然后在 Mapper 调用前手动 set 一下,用完再 clear。代码侵入性强,每个要切换的地方都得写一遍切换逻辑,漏了 clear 还会导致线程池复用时的数据源串线问题。这个方案我早期写过一次,后来再也没用,太容易出线上事故。
第二种,AOP + AbstractRoutingDataSource。Spring 自带的AbstractRoutingDataSource允许你通过determineCurrentLookupKey()方法动态决定当前用哪个 DataSource。配合 AOP 切面,在方法执行前把目标数据源 key 放进去,执行后清掉。这个方案比手动切库优雅,但依然要自己维护切面逻辑、注解定义、异常处理,工程化成本不低。
第三种,MyBatis-Plus 官方的 dynamic-datasource-spring-boot-starter。这个组件本质上也是基于AbstractRoutingDataSource实现动态路由,但它的价值在于:把切面逻辑、注解体系、数据源初始化、连接池管理全部封装好了。你只需要配一个 yml,写一个@DS注解,剩下的一切框架自动处理。
这三种方案的对比,我整理了一个表格:
| 对比维度 | 手动切库 | AOP + AbstractRoutingDataSource | dynamic-datasource starter |
|---|---|---|---|
| 配置复杂度 | 高,全部手写 | 中,切面和注解自己写 | 低,两段 yml + 注解 |
| 代码侵入性 | 高,每个切换点手写逻辑 | 中,注解仍需自己定义 | 低,直接用 @DS |
| 事务支持 | 不完善 | 需自己处理 | 内置本地事务增强 |
| 数据源监控 | 无 | 无 | 支持 Druid 监控、日志打印 |
| 社区维护 | 无 | 无 | 活跃,随 MP 版本更新 |
| 坑位数量 | 极多 | 中等 | 较少,文档齐全 |
体验过这套 starter 后,我个人的结论很明确:除非你的多数据源逻辑极端复杂,必须完全自定义路由策略,否则优先用 MP 的 dynamic-datasource 就对了。不是为了省事才选它,而是它的封装质量确实比你自己动手要强得多。
2. 快速接入:三步完成多数据源配置
这一节直接进入实操。我用的环境是 Spring Boot 2.7.x + MyBatis-Plus 3.5.x,JDK 1.8。如果你用的是 Spring Boot 3.x,记得把 dynamic-datasource 的依赖换成dynamic-datasource-spring-boot3-starter,整体包名和用法基本一致。
2.1 引入依赖与版本说明
在pom.xml里,加上dynamic-datasource-spring-boot-starter这个依赖:
<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>4.3.1</version> </dependency>同时确保你已经有mybatis-plus-boot-starter依赖。版本上我建议mybatis-plus用 3.5.x 系列,比如 3.5.3.1 或更高。dynamic-datasource 4.x 对 MyBatis-Plus 3.5.x 匹配得很稳定,我实测下来没有遇到冲突。
一个小提示:这个 starter 并不会把 MyBatis-Plus 再拉一份进来,它是独立的数据源路由组件,和 MP 是配套关系。你原来的 MyBatis-Plus 配置、分页插件、SQL 日志插件都还能继续用,不需要改动。
2.2 多数据源核心配置:两段 yml 搞定
接下来是application.yml里的配置。先看一个最典型的双数据源配置:
spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://127.0.0.1:3306/business_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://127.0.0.1:3306/readonly_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver注意几个关键参数:
spring.datasource.dynamic.primary:指定默认数据源的 key,这里是master。当代码里没有用@DS明确指定数据源时,框架自动走这个 key 对应的数据源。spring.datasource.dynamic.strict:严格模式开关。false表示非严格模式:如果某个 Mapper 或方法指定了不存在的数据源 key,不会报错,而是回退到默认数据源。true则表示找不到目标数据源直接抛异常。我建议开发阶段开true,能快速暴露配置错误;线上可以关掉,防止某个数据源挂了影响主流程,但这么干自己要权衡。每组
datasource.xxx就是一个数据源,key 名随意,但不要重名,也不能用primary作为 key。primary是保留配置项。
加密的密码、连接池参数、Druid 监控这些都可以配,后面我会单独讲连接池调优的细节。但基本的双数据源配置,到这里就已经可以跑起来了。
2.3 启动类排除默认数据源:为什么要排除
这一步很多第一次用的人容易漏。在 Spring Boot 启动类上,加一个排除配置:
@SpringBootApplication @MapperScan("com.example.mapper") // 关键:排除 DataSourceAutoConfiguration @SpringBootApplication(exclude = { org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class }) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }为什么要排除?原因在于:Spring Boot 默认会根据spring.datasource.url自动装配一个数据源。而我们多数据源场景下配置的是spring.datasource.dynamic.datasource这个嵌套结构,没有顶层的spring.datasource.url,不排除默认自动装配的话,Spring Boot 会尝试按照单数据源的逻辑去加载,启动容易报Failed to configure a DataSource或者Cannot determine embedded database driver class for NONE之类的错误。
排除之后,数据源就完全交给 dynamic-datasource 来管理了。这一步做完,启动你的 SpringBoot 项目,能看到日志里打印出多个数据源的初始化信息,说明配置生效了。
注意:如果你的项目用了
spring-boot-starter-data-jdbc或 JPA,排除DataSourceAutoConfiguration后还需要额外确认 JPA 的相关配置,最好让项目里只保留 MyBatis-Plus 这一套数据访问层,避免两套框架争抢数据源。
3. 使用 @DS 注解实现数据源切换:原理与实操
配置完成只是第一步,真正每天都在写的是@DS注解。这个注解的使用粒度、放置位置,直接决定了多数据源好不好用。
3.1 @DS 注解的四种使用方式
@DS可以放在四个不同的层级上,我全部都实测过:
放在 Service 实现类上:整个类里的方法都走指定数据源。
@Service @DS("slave") public class UserQueryServiceImpl implements UserQueryService { @Autowired private UserMapper userMapper; @Override public List<User> listAll() { return userMapper.selectList(null); } }放在 Service 方法上:只对方法生效,类上没有@DS时,老方法走默认数据源,新方法走指定数据源。
@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Override @DS("slave") public List<Order> listOrders() { return orderMapper.selectList(null); } @Override @DS("master") public void createOrder(Order order) { orderMapper.insert(order); } }放在 Mapper 接口方法上:直接控制 Mapper 层面的数据源,适合 Mapper 之间明确分库的场景。
public interface UserMapper extends BaseMapper<User> { @DS("slave") List<User> selectAllFromSlave(); @DS("master") int insertToMaster(User user); }配合自定义注解使用:如果你不喜欢在业务代码里写字符串 key,可以自定义一个注解做封装。
@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @DS("slave") public @interface SlaveDataSource { }这个方法在团队规范比较严格的项目里很好用,比如只能允许@MasterSource和@SlaveSource出现在代码里,不允许业务开发直接写@DS("db2-222-xxx")这种魔法字符串,代码审查的时候清爽很多。
3.2 数据源切换原理:从 AOP 到 ThreadLocal
用了@DS之后,你可能会好奇它背后是怎么做到自动切换的。这里我用最直白的方式讲清楚整个链路,理解了原理,后面排坑会容易很多。
整个机制的核心是 Spring 的 AOP 与AbstractRoutingDataSource的组合。dynamic-datasource 在启动时注册了一个DynamicDataSourceAnnotationAdvisor切面,这个切面专门处理@DS注解。当你的业务方法带着@DS被调用时,切面拦截器会执行一个DynamicDataSourceContextHolder.push(dataSourceKey)操作,把注解里指定的数据源 key 放进去。
这个DynamicDataSourceContextHolder内部维护的是一个ThreadLocal<Deque<String>>。这里用 Deque(双端队列)而不是简单的一个变量,是为了支持数据源切换的嵌套场景:一个方法切到slave后,内部又调用了另一个切到report的方法,执行完内层方法后应该回到slave,而不是直接回到默认数据源。Deque 的 push / poll 机制完美解决了这个"栈式"切换需求。
数据源本身被包装在DynamicRoutingDataSource里。这个类继承了 Spring 的AbstractRoutingDataSource,重写了determineCurrentLookupKey()方法,方法体就是从当前线程的ThreadLocal里拿出数据源 key,然后路由到真正的目标 DataSource。如果拿不到 key,就用默认的primary数据源。
finally 块里还有一个DynamicDataSourceContextHolder.poll()操作,确保方法执行完毕后把当前线程的 key 清掉,防止线程池复用线程导致数据源串线。这一步和ThreadLocal的设计配合,是多数据源不出乱子的关键保障。
我画了一个非常抽象的执行时序,配合理解:
- 调用
orderService.listOrders(),该方法标注@DS("slave") - 切面拦截器拦截方法,执行
push("slave") - Mapper 执行查询,连接从
DynamicRoutingDataSource获取,拿到 key 为slave,返回对应数据源 - SQL 在从库执行
- 方法返回,切面执行
poll(),清理ThreadLocal
这个机制的精妙之处在于:业务代码完全不用关心数据源切换的细节,只需要把注意力集中在业务逻辑上。你不用像手动切库那样,在每次调用 Mapper 之前手动 set key,用完再手动 clear,因为切面把这些事都做了。
4. 多数据源与事务、连接池的那些坑
配置好、注解也加了,但真实项目跑起来之后,坑才开始浮出水面。这一节我把自己踩过的坑和一些排查技巧完整分享出来,这部分是网上教程很少提及的。
4.1 事务内外数据源失效与排查
这是我在项目里被坑得最惨的一次。当时在 Service 里给一个查询方法加了@Transactional和@DS("slave"),然后发现 SQL 并没有跑到从库上,数据始终是从主库查出来的。
排查之后,原因很清晰:Spring 声明式事务是基于连接绑定的。开启事务的那一刻,连接就已经从某个数据源获取并绑定到当前线程上了,后续所有 SQL 都复用同一个连接。如果@Transactional和@DS同时标注在同一个方法上,事务的创建和切面的数据源切换存在执行顺序问题,一旦连接先于数据源切换被获取,后续@DS怎么改都没用,因为连接已经绑定到主库了。
解决方案有两个方向:
第一个方向:不要混用 Spring 的@Transactional和@DS。多数据源场景下,如果必须用事务,优先使用 dynamic-datasource 提供的@DSTransactional注解替代@Transactional。这个注解是组件自己实现的本地事务,能保证连接和数据源切换的绑定顺序正确。
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @DSTransactional @DS("master") public void createOrderWithLog() { orderMapper.insert(order); // 同一个事务内写日志表 logMapper.insert(log); } }第二个方向:把事务和数据源切换分层。把带事务的方法拆出去,让@DS标注在 Service 外层方法上,事务逻辑放到另一个类中处理。这本质上是用 Spring 的 AOP 执行顺序来解决切换时机问题,但代码结构上更绕,不如直接用@DSTransactional来得干净。
不管用哪个方案,都要记住一条铁律:多数据源下想强一致事务,基本只能靠分布式事务方案(如 Seata),单一的本地事务无法跨库保证一致性。如果是不同数据库实例之间的写入,别指望一个@DSTransactional搞定一切。
4.2 连接池参数调优与多数据源监控
多数据源配好之后,连接池参数默认都是 HikariCP 的默认值。说实话,很多项目跑起来默认值也能凑合,但稍微上点量就有问题:从库查询慢,连接池等待时间拉长,接口响应抖动明显。
我把实际调优过的参数整理在下面,你可以直接参考:
spring: datasource: dynamic: hikari: # 最小空闲连接数 minimum-idle: 5 # 最大连接数 maximum-pool-size: 20 # 空闲连接超时时间,默认10分钟 idle-timeout: 30000 # 连接最大存活时间 max-lifetime: 1800000 # 获取连接超时时间 connection-timeout: 30000 # 连接测试语句 connection-test-query: SELECT 1几个调优心得:
maximum-pool-size不是越大越好。连接数太多反而会加大数据库的压力,我一般先按CPU核数 * 2起步,然后根据压测结果增减。如果接口 QPS 高、单次 SQL 很快,小池子就够用;如果 SQL 慢、耗时长,才需要适当调大。max-lifetime最好比数据库的wait_timeout短一点,避免连接被数据库服务端提前断开,客户端还拿着一个坏连接去执行 SQL。- 多数据源场景下,最好能针对不同数据源分别监控。如果使用 Druid 作为连接池,可以打开 Druid 的监控页面;HikariCP 没有现成的监控界面,但可以通过引入
micrometer-register-prometheus配合 Grafana 把hikaricp.connections.pending、hikaricp.connections.active、hikaricp.connections.idle这些指标抓出来看。通不通就看数据库负载和连接池指标。
4.3 常见问题速查表:一个表帮你省半天排查时间
我把平时遇到过的典型问题整理成表,排查时照着顺序过一遍,大概率能快速定位:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
启动报Failed to configure a DataSource | 未排除DataSourceAutoConfiguration | 启动类加exclude |
@DS("xxx")指定了不存在的数据源 | 数据源 key 拼写错误或未配置 | 检查 yml,建议开发期strict: true |
@DS不生效,SQL 全部走默认库 | 方法被 Spring 事务代理拦截,连接提前绑定 | 改用@DSTransactional,或将事务与切换分层 |
| 线程池执行异步任务时数据源串线 | ThreadLocal未能跨线程传递 | 不推荐跨线程切库,必要时手动在任务内部指定 |
动态接口报connection is not available | 连接池耗尽 | 查看连接池指标,调大池容量或优化慢 SQL |
基于自定义注解的@DS失效 | 自定义注解未标记@DS元注解 | 确认@DS是否加在自定义注解的@Target上 |
Mapper 方法上@DS不生效 | Mapper 接口被多次代理 | 确保 Mapper 扫描路径正确,避免数据源在事务上下文中提前绑定 |
5. 进阶玩法:动态管理数据源、读写分离扩展与批量操作配合
多数据源入门之后,有些更进阶的玩法值得了解一下。我最近一个项目里就用到了这些能力,实测下来收益挺大。
5.1 运行时动态新增/移除数据源
有些场景下,数据源不是固定的,可能要在运行过程中动态增加一个新的库。比如租户系统,每个新租户可能对应独立的数据库,你得能直接在运行时把租户库注册进来,而不是每次加库都要重启应用。
dynamic-datasource提供了DynamicRoutingDataSource,可以通过它手动注册新的数据源。我封装过一个工具类,代码大致是这样的:
@Component public class DynamicDataSourceRegistrar { @Autowired private DataSource dataSource; public void addDataSource(String key, String url, String username, String password) { DynamicRoutingDataSource ds = (DynamicRoutingDataSource) dataSource; HikariDataSource newDs = new HikariDataSource(); newDs.setJdbcUrl(url); newDs.setUsername(username); newDs.setPassword(password); newDs.setDriverClassName("com.mysql.cj.jdbc.Driver"); ds.addDataSource(key, newDs); } public void removeDataSource(String key) { DynamicRoutingDataSource ds = (DynamicRoutingDataSource) dataSource; ds.removeDataSource(key); } }注意一点:DataSource注入的时候要小心,Spring 容器里可能有两个DataSourceBean,一个是DynamicRoutingDataSource,一个是默认的。我建议直接用@Qualifier("dataSource")来注入,确保拿到的是 dynamic 组件注册的路由数据源。运行时新增数据源后,代码里就能直接用@DS("新key")来切换了。
这个能力在配置中心场景下很实用,比如在 Apollo 或 Nacos 里把数据库配置发布出来,监听配置变更后自动注册新数据源,应用全程不用重启。
5.2 多数据源场景下配合批量 CRUD 提速
很多项目用 MyBatis-Plus 的BaseMapper.insert()一条条插数据,性能瓶颈肉眼可见。MyBatis-Plus 3.5.x 提供了insertBatchSomeColumn自定义方法或者IService.saveBatch()批量插入能力,配合多数据源的时候需要注意连接一致性。
我做批量写入的大致思路是:
- 如果批量写入量大,先把整个批量操作放在一个方法里,方法上标注
@DS,确保整批数据都压到目标数据源。 - 批量操作建议开启
rewriteBatchedStatements=true(MySQL JDBC 驱动参数),否则批量操作只是"看起来批量"。 - 不要在一个循环里频繁切换数据源,每次切换都有 ThreadLocal push/poll 开销,量大了性能下降明显。
具体到 MyBatis-Plus 的ServiceImpl,它还提供了saveBatch()方法,底层实现也是先获取连接,然后批量执行。只要能在 Service 层控制好数据源,批量效率还是很可观的。
5.3 实操过程中的几点建议
多数据源配置看起来简单,但要想稳定跑上线,有几个建议供参考:
- 数据源的 key 命名要有规范。不要叫
db1、db2,而是用业务含义命名,比如master、slave、report、user-center,一眼能看出这个库是干嘛的。团队里最好在统一文档里维护数据源 key 清单。 - 读写分离不一定非要引入中间件。如果你的项目已经用了这个 dynamic-datasource,那读写分离完全可以靠
@DS("master")和@DS("slave")完成。配合spring.datasource.dynamic.primary: master作为兜底,即便遗漏了@DS注解,写入也算安全。 - 多数据源里的连接池参数一定分开配置。不同库的负载特征不一样,主库写入频繁、从库查询频繁,各自连接池大小可以差异化。dynamic 支持这样配置:
spring: datasource: dynamic: datasource: master: hikari: maximum-pool-size: 20 slave: hikari: maximum-pool-size: 50这个配置方式我实测是生效的,主从库各走各的连接池配置,互不影响。
最后再分享一个小技巧。我在实际项目里发现,多数据源最难的还真不是配置,而是团队里的人记不清哪个方法走了哪个库、有没有走错库。建议你把动态数据源开启 SQL 日志打印,或者用dynamic-datasource自带的日志输出,把每个 SQL 执行时用的数据源 key 打印到日志里。我在项目里就设置过一个 filter,请求进来时在日志开头打印当前的DataSourceKey,上线排障时一眼就能定位 SQL 落在哪个库里。这个习惯帮我省了至少三次通宵排查的时间,强烈建议你也加上。