刚把项目里一个“一主多从”的定时任务模块改成多数据源读写分离,顺手又接了一个独立的报表库,整套下来踩了不少坑,也把 MyBatis-Plus 多数据源的玩法彻底摸透了。这篇就按我实际落地的顺序来写,不搞花活,直接上配置、上代码、上问题清单。
先说清楚这东西解决什么问题。很多项目一开始只有一个数据库,后来慢慢拆出报表库、日志库、或者主从库,一个应用需要连多个数据源。Spring Boot 原生支持多数据源,但配置繁琐,事务和切换很容易出幺蛾子。MyBatis-Plus 生态里的dynamic-datasource组件就是专门干这个的,基于AbstractRoutingDataSource做了封装,通过一个@DS注解就能在方法或类级别切换数据源,配置也大幅简化。这篇适合正在做多库接入、读写分离,或者被多数据源坑得头皮发麻的同学参考,我会把从依赖引入到生产排错的全过程都展开讲清楚。
1. 为什么需要多数据源,以及方案选型
1.1 最常见的三种多数据源场景
先说场景。我接触过的项目里,多数据源的需求基本逃不出下面这三类:
第一类是读写分离。主库负责写,从库负责读,通过主从复制同步数据。这种场景下,业务代码里查询走从库,增删改走主库,能有效分摊主库压力。尤其是报表类、统计分析类的查询,往往特别耗时,全压在主库上会导致主库 CPU 和 IO 被打满。
第二类是业务库隔离。同一个应用需要操作多个独立的业务数据库,比如订单库、用户库、商品库各自独立,但服务是同一个。这种比起拆微服务,成本更低,项目早期很常见。
第三类是异构数据源接入。比如应用主库是 MySQL,但要定时从 Oracle 或 SQL Server 里同步数据,或者把统计数据写入 ClickHouse。这种异构场景下,除了数据源配置,还要注意方言差异,MyBatis-Plus 的分页插件这时就很重要了。
1.2 实现多数据源的三种主流方案
多数据源的实现方案,网上能查到的基本就三种:
方案一:直接配多个 SqlSessionFactory。Spring Boot 官方文档里的做法,一个数据源对应一个 SqlSessionFactory,不同包下的 Mapper 用不同的 SqlSessionFactory。问题很明显:配置量大,每个数据源都要写一遍 SqlSessionFactory 和 MapperScan,而且事务跨数据源基本没法玩。
方案二:手动实现 AbstractRoutingDataSource。Spring 提供了一个路由数据源,它本身不干活,而是通过determineCurrentLookupKey()方法决定把请求路由到哪个目标数据源。实现思路是把这个 key 存在 ThreadLocal 里,切换数据源就是换 ThreadLocal 的值。这套方案能解决动态切换的问题,但要说好用,还差得远——事务、嵌套切换、连接池管理都要自己处理。
方案三:用 MyBatis-Plus 的 dynamic-datasource。它本质上还是 AbstractRoutingDataSource 的实现,但把 ThreadLocal 维护、注解切面、连接池切换、代码生成这些都封装好了。配置一份 yml,就能声明多个数据源;一个@DS注解,就能在运行时切换。这是目前我见过的最省心的方案,也是这篇要讲的方案。
1.3 为什么选 dynamic-datasource 而不是手写
我不推荐手写,真不是懒,是这套东西细节太多。
手写 AbstractRoutingDataSource,你要自己处理的坑包括但不限于:事务内数据源切换不生效、嵌套调用导致 AOP 代理失效、连接池监控配置重复、不同数据源类型的事务管理器冲突、MapperScan 的 basePackages 写错导致 Mapper 找不到等等。每一条都能让你排查一整天。
而 dynamic-datasource 已经帮你把这些问题大部分处理掉了:
- 内置了
@DS注解的 AOP 切面,方法执行前切换数据源,执行后清理 ThreadLocal; - 支持在 Service 方法、Mapper 接口、甚至类级别加注解,优先级是方法 > 类;
- 默认使用 Druid 或 HikariCP 连接池,可以针对不同数据源单独配置连接池参数;
- 内置数据源健康检查和
strict模式,能避免因为数据源 key 拼错导致静默失败。
所以这个选型,说白了就是:在 Spring Boot 的多数据源场景里,用社区里最成熟、最贴近业务习惯的现成轮子,把精力花在业务代码上,而不是底层切换逻辑上。
2. 环境准备与依赖引入
2.1 版本选择与兼容性
先说版本。我这次用的是 Spring Boot 2.7.18 + MyBatis-Plus 3.5.3.2 + dynamic-datasource-spring-boot-starter 3.5.2。
这里要特别提醒:dynamic-datasource 的版本和 Spring Boot 版本、MyBatis-Plus 版本之间是有兼容性要求的。Spring Boot 3.x 对应 dynamic-datasource 4.x,Spring Boot 2.x 对应 3.x。如果你用的是 Spring Boot 3.2,硬上 3.5.2 的 dynamic-datasource,启动的时候大概率会报ClassNotFoundException: javax.sql.DataSource之类的错误,因为 Spring Boot 3 里 Jakarta EE 的包名变了。
MyBatis-Plus 也类似,3.5.x 是目前的主流,3.4.x 的老项目也能跑,但不推荐在新项目里用。建议你动手前先去 Maven 中央仓库看一眼版本对应关系,别闭着眼睛复制高版本的依赖。
2.2 Maven 依赖配置
依赖就三样:
<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.2</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>3.5.2</version> </dependency>注意,用了 dynamic-datasource 之后,不要再单独引入mybatis-plus-boot-starter里的数据源自动配置类,也尽量不要自己再定义一个DataSource的@Bean。一旦你自己定义了一个DataSourceBean,它会把 dynamic-datasource 的自动配置顶掉,后果是@DS注解完全失效。
我遇到过有人把这个 starter 和druid-spring-boot-starter一起用,启动报错说找不到DruidDataSourceAutoConfigure。这种要么在启动类上 exclude 掉 Druid 的自动配置类,要么干脆直接用 dynamic-datasource 自带的连接池配置。我用的是 HikariCP,在application.yml里通过参数控制就可以了。
2.3 数据库准备:先建两个库
实战之前,先把数据库准备好。我这里以两个 MySQL 库为例,一个是master_db,一个是slave_db。里面的表结构可以完全一样,也可以完全不一样,这取决于你的业务场景。我们用一个最简单的user表来演示:
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(50) DEFAULT NULL, `age` int(11) DEFAULT NULL, `deleted` tinyint(1) DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;deleted字段是 MyBatis-Plus 逻辑删除用的,后面我会专门讲多数据源下这个字段的影响,先埋个伏笔。
往两个库都插入几条测试数据,但要保证数据不一样,这样切换数据源后你能明显看到结果差异。比如master_db里插一条name='master-张三',slave_db里插一条name='slave-李四'。
2.4 核心配置:application.yml 详解
依赖引入之后,多数据源的灵魂就在配置里。直接看我这份完整的application.yml:
spring: datasource: dynamic: primary: master strict: true datasource: master: url: jdbc:mysql://localhost:3306/master_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://localhost:3306/slave_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: map-underscore-to-camel-case: true几个关键参数拆开讲:
primary指定默认数据源,不写@DS注解的时候,所有操作都走这个库。我一般习惯把主库设为 primary,这样即使有人忘了加@DS,也不会把写操作误跑到从库去。
strict这个参数容易被忽略。把它设为true后,如果你在代码里用了@DS("xxx"),但 yml 里没有配置名为xxx的数据源,启动或调用时会直接抛异常,而不是静默地走主库。这个特性在测试环境特别有用,能帮你第一时间发现拼写错误。生产环境我也建议开着,宁可报错也别把流量打到错误的库上。
datasource下面的 master 和 slave,就是任意命名的数据源 key。除了这两个,你还可以继续加 report、log 等等。每个数据源内部的配置项,和 Spring Boot 单数据源配置基本一致。
有一点注意:连接池参数如果你是自定义的 HikariCP 参数,比如max-pool-size、min-idle,需要写在数据源下的hikari节点里,而不是直接平铺在某个数据源下面。我见过有人把max-pool-size直接写到slave的下一级,结果参数没生效。正确的写法是:
slave: url: jdbc:mysql://localhost:3306/slave_db username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 23. 核心实现:@DS 注解与代码实战
3.1 @DS 注解的使用规则
配置搞定,代码才是重点。dynamic-datasource 的切换核心是@DS注解,它的使用规则其实很简单,就这几点:
- 可以加在 Service 实现类、方法、Mapper 接口或方法上;
- 优先级从高到低:方法上的注解 > 类上的注解 > 全局默认数据源(primary);
- 注解的值就是 yml 里配置的数据源 key,比如
@DS("slave")。
也就是说,如果你在类上写了@DS("slave"),类里的某个方法又写了@DS("master"),那么这个方法执行时会切换到 master,而其他没标注解的方法走 slave。
所以最常用的姿势是:在 Service 实现类的方法上精确标注解,而不是在类上写死。类上写死意味着该类所有方法强制走同一个数据源,灵活性大打折扣。
3.2 最简单的读写分离示例
先看一段最基础的代码,感受一下 @DS 的用法。UserMapper 继承 BaseMapper:
public interface UserMapper extends BaseMapper<User> { }UserService 里加两个方法:
@Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { @DS("master") public void addUser(User user) { this.save(user); } @DS("slave") public List<User> listFromSlave() { return this.list(); } public List<User> listFromMaster() { return this.list(); } }这段代码表达得非常直白:
addUser明确走 master 库;listFromSlave明确走 slave 库;listFromMaster什么注解都没加,走默认的 primary 也就是 master 库。
这里有个容易踩的坑:this.save()这种自带方法,是在 ServiceImpl 内部调用,所以 @DS 注解加在当前类的公开方法上才有效。如果你把 @DS 加在一个私有方法上,或者通过同类内部this.xxx()去调用一个被 @DS 标注的方法,注解不会生效。AOP 切面只拦截通过代理对象调用的方法,内部this调用绕过了代理。
3.3 多数据源 + 多 Mapper 的组合场景
上面的例子是同一个 Mapper、不同数据源的表结构一样。但实际项目里更常见的是:不同的 Mapper 访问不同的库。比如订单表在订单库,用户表在用户库,报表查询在报表库。
这时有两种做法:
做法一:一个 Mapper 对应对个数据源。在 Service 方法上切换数据源,每个方法都标明@DS。这种做法比较推荐,因为结构清晰,事务边界和数据源绑定在业务方法上。
做法二:分包管理,每个数据源对应独立的一组 Mapper。如果你的两个数据源里表结构完全不同,想让 Mapper 天然绑定某个数据源,@MapperScan的basePackages按包路径拆开就够了:
@Configuration @MapperScan(basePackages = "com.example.mapper.master") public class MasterDataSourceConfig { } @Configuration @MapperScan(basePackages = "com.example.mapper.slave") public class SlaveDataSourceConfig { }但注意,如果你用的是 dynamic-datasource starter 并且在一个 SqlSessionFactory 下运行,上面的分包配置是不需要的,甚至可以不要。@DS注解在 Mapper 接口上加,同样能起到切换数据源的作用:
@DS("slave") public interface ReportMapper extends BaseMapper<Report> { }这种情况下,所有操作这个 Mapper 的方法都会被路由到 slave。
我的经验是:能用 Service 方法级 @DS 解决的,就别在 Mapper 上写死。为什么?因为如果哪天报表库要重建、需要临时切到别的库跑数据修复,你在 Service 层改一行注解就行,不用去动 Mapper。
3.4 分页与逻辑删除在多数据源下的表现
多数据源配置完成后,最容易被忽视的是 MyBatis-Plus 的分页插件和逻辑删除功能是否还生效。这里可以放心,它们都是生效的。
原因是:dynamic-datasource 最终提供的还是一个DataSource,MyBatis-Plus 的自动配置只初始化一次SqlSessionFactory,MybatisPlusInterceptor 也是在这个 SqlSessionFactory 上配置的。当你切换数据源时,底层路由的是 DataSource 获取的连接,而 SqlSessionFactory、Interceptor、逻辑删除处理这些机制完全不感知数据源的变化。
所以前面我特意在 user 表里加了deleted字段,就是为了验证:在从库 slave 上执行 list 查询时,MyBatis-Plus 依然会自动追加WHERE deleted=0。实测没问题。
但这里有一个提醒:不同数据源如果表结构不完全一致,比如一个表有deleted字段,另一个表没有,那么你的全局逻辑删除配置可能会在查那个没deleted字段的表时拼接出 SQL 报错。所以逻辑删除字段最好在所有库的所有表里保持统一,如果做不到,用@TableLogic在实体类的字段级别精确控制,比全局配置更安全。
分页插件的话,记得在配置 Interceptor 时指定方言类型:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); interceptor.addInnerInterceptor(paginationInnerInterceptor); return interceptor; } }多数据源都是同一个数据库类型时,这个配置一次性生效。如果你混用了 MySQL 和 PostgreSQL,那就要注意分页方言的差异,最好把不同方言的库拆到不同的数据源 key 下,由 MyBatis-Plus 自动识别。
3.5 多数据源切换的核心原理简述
用 dynamic-datasource 用得久了,忍不住想搞清楚它到底怎么实现的。一句话概括:它注册了一个 DynamicRoutingDataSource,这个类继承了 AbstractRoutingDataSource,重写了 determineCurrentLookupKey 方法,从 ThreadLocal 里取当前数据源的 key,然后返回对应的真实 DataSource。
@DS注解的切面在方法执行前,把注解里的值存进 ThreadLocal,方法结束后再把 ThreadLocal 里的值清掉。这样每次拿数据库连接时,路由数据源都能根据当前线程存放的 key,决定从哪个真实数据源取连接。
所以这里能推导出一个关键的注意事项:切换数据源是基于线程的。如果某个方法里开启了异步线程,异步线程里的操作不会继承主线程的数据源。你要自己在异步任务里再指定数据源,或者手动往子线程传递变量。我项目里就出现过异步导出报表时,数据源切到了主库的情况,后来在异步方法上显式加了@DS("slave")才修正。
4. 多数据源下的事务处理与取舍
4.1 事务与数据源切换的冲突
多数据源最恼人的问题,不是配置,而是事务。先记住一条铁律:事务一旦开启,数据源就定死了,之后你再怎么切 @DS 都没用。
为什么?因为 Spring 事务管理器在开启事务时,会从当前数据源获取连接并把它绑定到当前线程上。后续操作都复用这个连接,而不再通过路由数据源去获取新的连接。所以如果你在一个@Transactional方法里调用@DS("slave")的另一个方法,这个方法实际上还是走的原来那个数据源。
我举个例子,命中无数人的坑:
@Transactional public void doSomething() { userMapper.insert(user); // 主库写入 List<User> list = slaveService.list(); // 想法:走从库读 }SlaveService 里的list()虽然标了@DS("slave"),但因为这个方法是被外层事务包裹的,连接已经被事务绑定在主库上了,所以查询还是走了主库。这不是 bug,是 Spring 事务的合理行为。
所以,@DS 和 @Transactional 不要混用在一个事务链路上。如果确实要在一个业务场景里操作两个库,要么把其中一个数据源的操作独立成不带事务的方法,要么接受数据一致性的风险,用最终一致性方案。
4.2 本地事务只管理一个数据源
最常见的做法是:让事务只包裹主库的写操作,从库查询不开启事务。读写分离本身就要求读操作不需要事务,因为读操作不存在提交和回滚的问题。
写操作的场景,比如订单主数据在主库,流水日志在日志库,两边都要写,就麻烦了。dynamic-datasource 官方文档里给出的方案是,如果你要在一个事务里操作多个数据源,需要引入 Seata 之类的分布式事务框架,或者使用本地消息表这类柔性事务方案。如果你们项目暂时没有引入分布式事务的打算,我的建议很简单:
把多数据源写入拆成多个独立事务方法,接受非强一致的现状,通过补偿任务保证最终一致。
具体操作上,可以在同一个 Service 里写两个不带事务的方法,每个方法内部各自开事务:
public void createOrder(Order order) { // 主库写订单 this.insertOrderWithTx(order); // 日志库写流水 this.insertLogWithTx(order); }两个方法各自@Transactional,就不会出现一个方法里同时锁住两个库连接导致的长事务。这种设计对数据库的压力也小很多,代价是这两个库的写入不是原子的,中间失败了需要手动补偿。
4.3 事务边界设计的经验总结
经过多个项目的折磨,我总结出多数据源事务设计的几条经验:
第一,尽量把一个数据源的操作内聚在一个 Service 内部,通过类内部方法划分事务边界。不要在一个方法里频繁调用其他数据源的 Service,每次调用就是一次连接切换,连接数消耗不容忽视。
第二,事务方法里不要再调用带 @DS 切换的方法。如果你非要在事务方法里读另一个库,把读取操作放在事务开始之前或事务提交之后,保证事务操作的数据源一致。
第三,开启 strict 模式后,可以尽早暴露数据源配置错误。这不算事务知识,但和数据源路由正确性相关,我每次排查问题第一步就是看 strict 模式有没有开。
5. 常见问题与排查技巧实录
5.1 我踩过的 6 个高频问题
把实际项目里踩过的坑整理成一张表,按概率从高到低排:
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
| @DS 注解不生效,一直走主库 | @DS 加在了私有方法或同类内部调用 | 把 @DS 加在通过代理调用的 public 方法上;内部调用改用注入的 Service 或使用 AopContext.currentProxy() |
| 事务方法里数据源切换失效 | @Transactional 已经绑定了连接 | 调整事务边界,从库查询不放在事务方法里,或拆成独立事务 |
| 启动时报错 DataSource 相关 | 自己定义了 DataSource Bean,覆盖了 dynamic 配置 | 删除自定义 DataSource 配置,由 dynamic-datasource 统一管理 |
| yml 里写错数据源 key 且 strict=false | 路由失败后静默走主库,难排查 | 开启 strict=true,让错误立即暴露 |
| 从库查询出主库的数据 | 连接池复用了旧连接 / 事务未提交导致从库看不到主库变更 | 主从同步存在延迟,读操作适当做延迟路由;用 @DS("master") 强制一致性 |
| 分页插件不生效或 SQL 方言不对 | MybatisPlusInterceptor 未配置 DbType | 在配置类里显式设置 PaginationInnerInterceptor(DbType.MYSQL) |
5.2 从库数据延迟的权衡
读写分离架构里还有个绕不开的话题:主从延迟。即使你的多数据源配置完全正确,从库也可能因为同步延迟,读不到刚写入主库的数据。
我在项目里遇到过一个具体问题:用户在订单中心下单,主库订单表写入成功后,前端立刻查询订单列表,结果列表里看不到刚才的订单。原因就是从库同步还没完成,查询走了从库,自然查不到。
解决思路有这么几种:
- 对一致性要求高的操作强制走主库。比如刚下单后的立即查询,用
@DS("master")强制走主库。这是最简单直接的方式,适合读多写少且一致性要求高的场景。 - 接受短暂延迟。对实时性要求不高的列表、统计,继续走从库,用缓存或前端提示来兜底。
- 使用自定义路由策略。比如在 ThreadLocal 里标记某个线程需要强制走主库,这需要配合 AOP 做,咱们这里不细说。
我的建议是:别为了追求“所有查询都走从库”而牺牲业务正确性。用@DS最大的优势就是你能精确控制哪些查询走从库,哪些必须走主库,从库只放那些允许延迟且重IO的查询。
5.3 无侵入排查数据源路由的实用技巧
排查数据源问题时,最有效的办法不是靠猜,而是看实际执行的 SQL 和连接信息。MyBatis-Plus 开启 SQL 日志后,每条 SQL 执行前的数据源可以从日志里看出来吗?说实话,默认日志不会打数据源名,但你可以加一个拦截器,在执行前打印当前数据源 key:
@Component @Slf4j public class DataSourcePrintInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { String dsKey = DynamicContextHolder.peek(); log.info("当前数据源: {}", dsKey); return invocation.proceed(); } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } }这里的DynamicContextHolder.peek()是 dynamic-datasource 内部的一个 ThreadLocal 工具类,获取当前线程的数据源 key。这种方式在你怀疑数据源路由问题时非常有效,你只需要看日志,就知道这次查询到底走了哪个库。
不过要小心,你自定义的拦截器如果实现了 MyBatis 的 Interceptor,它会在所有 SQL 执行前触发,会不会影响性能?肯定有一点,但排查完问题后把它去掉就行,生产环境别留这种纯调试的日志。
5.4 项目落地时的其他建议
最后说几点项目落地时的建议,属于那种“不踩坑不知道,踩了坑才后悔”的内容。
建议一:数据源名称规范统一。master、slave、report、log,命名要能看出用途,别用 ds1、ds2。我见过一个项目里有四个数据源,分别叫 a、b、c、d,没人知道谁是谁,每次排查都要对着配置翻半天。
建议二:连接池参数独立配置。报表库和主库的访问量完全不是一个量级,连接池大小、超时时间应该各自设置。报表查询慢,连接占用时间长,连接池就要调大;主库写频繁,连接池就不宜过大,避免拖垮数据库。
建议三:从库的读方法命名上做区分。比如listUserFromSlave、getReportFromSlave,代码里一眼能看出数据源倾向。这个看起来是命名小事,但对后续维护帮助很大。
建议四:不要把多数据源写死在代码里到处滥用。每次加一个数据源,都要评估是不是应该拆服务了。项目到一定规模后,多数据源只会增加运维和排错成本,这不是危言耸听。
我在实际项目里的体会是,多数据源配置本身不难,难的是搞清楚每个业务场景到底该走哪个库、事务怎么圈、延迟能不能接受。dynamic-datasource 把这套事的复杂度降到了最低,但最终的数据源设计决策,还是要靠对业务的理解。希望这篇实战记录能让你在接入多数据源时少走几个弯路。
最后再分享一个小技巧:如果你在公司内部有多个环境(开发、测试、预发、生产),每个环境的数据库地址肯定不一样,多数据源的 yml 配置一定要拆分到 profile 里,千万别把生产库地址写在默认的 application.yml 里提交到代码仓库。我当年就因为配置文件搞混,把预发环境的订单数据接错到生产库,差点出大事。配置这东西,怎么谨慎都不为过。