做后端开发的兄弟,大概率都遇到过这样的场景:项目刚上线时一套MySQL库跑得挺欢,后来订单量大了,老板说要把报表拆出来,于是又多了一个只读库;再往后,用户服务单独拆了一套库,数据分析组还要连ClickHouse或者PostgreSQL。这时候如果代码里还是自己手动切换Connection,到处都是DataSourceUtils.getConnection(某个dataSource)这种写法,数据源一多基本就没法维护了。
我这个项目正好也是典型的SpringBoot + MyBatis-Plus组合,需求很明确:一个应用同时访问多个数据库,配置要少,切换要稳,最好开箱即用。折腾一圈之后,最终选了dynamic-datasource-spring-boot-starter,整个落地过程还算顺利,但中间也踩了不少坑——尤其是事务、连接池、国产数据库兼容这几个环节,官方文档写得并不算细。这篇文章把整个选型思路、配置方法、运行原理和排障记录全整理出来,适合正在给SpringBoot项目接入多数据源的开发者参考,也适合那些已经接上了、但偶尔发现@DS切换不生效的兄弟对照自查。
1. 为什么需要多数据源:三类典型场景与选型思路
1.1 多数据源不是架构师在炫技,是业务跑起来后的必然结果
很多项目一开始是单库,但业务增长之后,单库的瓶颈很快就会出现。最常见的有三类情况:第一类是读写分离,主库承担写操作,从库扛读流量;第二类是业务分库,订单、用户、商品各自落库,一个服务要同时访问若干个库;第三类是异构数据源并存,业务数据在MySQL,统计报表在PostgreSQL或者ClickHouse,甚至某些报表模块需要直连数据仓库。我见过很多项目甚至还要在同一个应用里同时连MySQL、Oracle和国产数据库。
这几种场景本质上都是同一个问题:应用代码里不能把数据源写死,需要在运行时根据上下文决定访问哪个库。如果用一个Map<String, DataSource>手动管理,每次都要写一堆模板代码,还容易漏掉连接释放,出问题的时候排查成本极高。所以,多数据源方案的最终目标,不是单纯能把多个库连上,而是:配置简单、切换可靠、事务可控、碰到异常能快速定位。
1.2 三种方案各有利弊:手写AOP、注解封装、第三方starter
多数据源实现的方案大体有三种。第一种是原始方案,自己维护数据源的Map,写一个RoutingDataSource继承AbstractRoutingDataSource,再通过一个ThreadLocal变量动态切换。这个方案代码量不大,二三十行就能搞定最简版本,但真正做上生产之后,你会发现自己要解决的问题还有一堆:连接池管理、事务边界、多库回滚、配置刷新、监控打点。每一个问题都需要花时间填坑。
第二种方案是自己写AOP切面,用自定义注解标注目标数据源。这个方案比纯手动方式好一些,能把切换逻辑跟业务代码解耦,但本质上只是把第一种方案包装得更优雅。切面的顺序、异常处理、嵌套切换、事务传播行为,都需要你自己踩一遍才知道坑在哪。第三种方案就是直接用社区成熟的starter,MyBatis-Plus官方周边生态里,dynamic-datasource-spring-boot-starter是使用率最高的一套。它把数据源注册、路由、注解解析、负载均衡、多源事务都做了封装,生产验证的案例也足够多。
1.3 为什么是dynamic-datasource:开箱即用这个词,不能随便用
我最终选dynamic-datasource,核心原因是它确实做到了“开箱即用”。首先,它支持@DS注解做类级和方法级切换,方法级优先,这个语义非常符合日常开发习惯。其次,它在AbstractRoutingDataSource的基础上做了增强,支持多组数据源、主从分组、负载均衡、嵌套切换,甚至兼容Druid、HikariCP、BeeCp等主流连接池。第三,它的@DSTransactional提供了多数据源的本地事务支持,虽然不保证强一致,但解决了很多轻量级场景下的多库写入问题。
另外一点,这个库本身就是com.baomidou全家桶里的成员,跟MyBatis-Plus配合得最自然。你用mybatis-plus-boot-starter做ORM,再用同一组织维护的dynamic-datasource做数据源路由,踩坑时能查到的资料也最多。如果你项目用的是SpringBoot 2.x,直接用3.6.x版本即可;如果项目已经升级到SpringBoot 3.x或JDK 17以上,记得用4.x版本,因为SpringBoot 3的自动配置加载机制改了,旧版本很多会直接失效。
2. 快速集成dynamic-datasource:五分钟搭出可运行的demo
2.1 引入依赖:Maven坐标与版本选择
第一步是加依赖。Maven坐标如下:
<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>4.2.0</version> </dependency>如果你的项目还是SpringBoot 2.x,建议用3.6.x的最后一个稳定版本。这里有一个很容易踩的坑:有些兄弟在SpringBoot 3的环境里引入了3.x版本,运行时报NoSuchBeanDefinitionException或者干脆数据源都没有被自动装配。原因就是SpringBoot 3把自动装配的注册文件从spring.factories改成了AutoConfiguration.imports,旧版本的starter没有适配这个机制。说白了,不是配置写错了,是starter版本跟Boot版本不匹配。
还有一个细节,如果你的项目原本单独引了druid-spring-boot-starter,引入dynamic之后要小心Bean冲突。dynamic在开启Druid支持的情况下,会自动创建Druid相关数据源,如果两边同时存在,容易出现数据源被覆盖或者属性不生效的情况。我的做法是:只用dynamic一个starter,连接池参数统一在spring.datasource.dynamic下配置,不额外引Druid的独立starter。
2.2 配置文件模板:一个主库两个从库的完整示例
以三个数据源为例:master是主库,slave_1和slave_2是只读从库,配置如下:
spring: datasource: dynamic: primary: master strict: true lazy: true datasource: master: url: jdbc:mysql://192.168.10.10:3306/order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave_1: url: jdbc:mysql://192.168.10.11:3306/order_ro?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave_2: url: jdbc:mysql://192.168.10.12:3306/order_ro?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver三个参数值得特别说明。primary指定的库是默认数据源,你写@DS("xxx")没有匹配到任何数据源时,或者方法上根本没加注解时,都会走这个主库。strict建议显式配置成true,否则数据源key写错了,很多版本会静默回退到主库,不会报错,这种隐性Bug排查起来非常难受,你还会以为是SQL写错了,实际上是数据源根本没切过去。lazy建议配置成true,启动时不会立刻把所有的数据源都初始化连接,而是等第一次用到某个库时再懒加载。这样如果某个备用库临时连不上,应用依然可以正常启动,只有真正访问到那个库时才会报错,这在多环境部署时很实用。
如果你还用了Druid连接池,参数是这样加的:
spring: datasource: dynamic: druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 test-while-idle: true test-on-borrow: false validation-query: SELECT 1 time-between-eviction-runs-millis: 600002.3 验收标准:跑通一个多源CRUD就算入门了
配置好之后,写个简单的Service验证一下。比如我们有个OrderMapper,是标准的MyBatis-PlusBaseMapper:
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @DS("slave_1") public List<Order> listFromSlave1() { return orderMapper.selectList(null); } @DS("slave_2") public List<Order> listFromSlave2() { return orderMapper.selectList(null); } public List<Order> listFromMaster() { return orderMapper.selectList(null); } }写个接口分别调用这三个方法,看日志或者看数据库连接就能确认是否切库成功。如果三个方法返回的数据来自不同的库,基础功能就通了。这里还要提醒一句:MyBatis-Plus自带的BaseMapper自带通用CRUD,在多数据源环境下照样能用,这一点比手写JDBC方便太多。你不需要自己维护数据源上下文,只需要关注注解该标在哪个类、哪个方法上。
3. 深入理解@DS切换机制:注解是怎么让数据源“动”起来的
3.1 AbstractRoutingDataSource是基石,但还差两步
Spring本身提供了一个AbstractRoutingDataSource,它的核心思想很简单:维护一个目标数据源集合,执行数据库操作前通过determineCurrentLookupKey()决定当前线程应该用哪个key。这个key通常从ThreadLocal里取。basic版的实现就是把这个抽象类用起来,但有一个很头疼的问题:Spring容器在启动时会初始化这个RoutingDataSource,而你的目标数据源如果不是全部配好并且可用,启动阶段就可能报错,这跟很多项目的多环境部署需求是冲突的。
dynamic-datasource在AbstractRoutingDataSource基础上做了一套改进:它的路由规则可以动态增减,数据源可以懒加载,同时维护了一个基于Deque的栈,用来支持嵌套切换。Deque的意思是,你可以在一个方法里切到库A,然后在A的内部再切到库B,方法结束之后会按栈的顺序自动弹回。这个栈式设计让@DS注解的组合使用变得灵活,但理解不到位也容易踩到“切了没生效”的坑,后面会细说。
3.2 @DS注解的解析时机:方法级优先与SpEL表达式
@DS注解可以放在类上,也可以放在方法上。放在类上表示这个类里面的所有方法默认走某个数据源;放在方法上会覆盖类上的配置。如果你在Controller里或者Service方法间调用,切面会拦截加了注解的方法,在方法执行前解析注解的value,把对应的数据源key压入栈,方法结束后再从栈里弹出,恢复调用前的数据源上下文。
这里有一个很多人误解的点:@DS的解析是AOP切面在方法执行前进行的,而不是在SQL执行时才去解析。所以如果你在方法体内修改了某个参数,而这个参数又是SpEL表达式依赖的入参,修改后的值不会影响到已经确定的数据源。
@DS还支持SpEL表达式,这个功能在动态切换场景里非常有用。比如你的系统是多租户的,每个租户对应一个独立数据库,就可以这样写:
@DS("#tenantId") public List<Order> listByTenant(Long tenantId) { return orderMapper.selectList(null); }如果你的方法参数不止一个,Spring的SpEL可以通过参数名解析,但有个前提:编译时需要开启-parameters参数,否则只能退而用#p0、#p1这样的位置参数。举个实际例子:
@DS("#p0.tenantId") public List<Order> listByTenant(OrderQuery query) { return orderMapper.selectList(query); }我建议在大项目里统一开启-parameters编译参数,这样代码可读性更好;小项目图省事的话,直接使用#p0反而最稳。
3.3 切换为什么“看起来失效了”:线程栈与事务连接
最常见的“@DS不生效”问题,十有八九是发生在嵌套调用或者事务方法里。先看一段典型的错误写法:
@Transactional public void saveOrder(Order order) { orderMapper.insert(order); // 当前事务数据源是 master this.saveLog(order); // 自调用,注解不生效 } @DS("log_db") public void saveLog(Order order) { logMapper.insert(order); }这段代码有两个坑。第一,this.saveLog()是同类内部调用,不经过Spring的代理对象,所以AOP切面根本不会执行,@DS注解直接失效。第二,就算你把saveLog拆到另一个Service类里,外层方法已经加了@Transactional,Spring事务管理器在执行第一条SQL时就已经从master获取了数据库连接并绑定到当前线程。后续的@DS("log_db")虽然把数据源上下文切到了log_db,但MyBatis在执行时会优先使用当前线程事务绑定的连接——也就是master的连接,log_db根本不会被访问到。
要解决这个问题,思路是这样的:如果多个库的操作之间确实需要同时成功或失败,就用后面会讲到的@DSTransactional;如果不需要强一致,就把事务拆开控制。最忌讳的是在@Transactional方法里混着用@DS,因为它的表现是不报错、不切换,极其隐蔽。
3.4 事务与多数据源的相爱相杀:事务内切换为什么经常失效
这里把事务和数据源切换的关系再掰开揉碎一点。Spring事务的本质是“同一个线程内复用同一个数据库连接”,而多数据源切换的本质是“切换数据源key以获取不同的连接”,当这两者碰在一起时,先获取连接的一方决定了整个事务期间使用的数据源。不管后面的注解怎么写,只要事务没提交,连接就不会换。
所以,真正正确的姿势是:把涉及不同数据源的方法拆成独立的事务边界。比如A方法不加事务,分别调用B方法(操作主库,自身加事务)和C方法(操作日志库,自身加事务),这样每个库的事务各自提交。缺点是如果第二步失败,第一步已经提交了,数据会不一致。优点是简单、可靠、不会出现诡异的“切不动”问题。业务上如果容忍这种短时间不一致,就选这种方式;如果不容忍,再考虑更强的事务方案。做技术选型不是追求炫技,而是搞清楚自己的系统能接受什么程度的不一致。
4. 高级玩法:读写分离、多主多从与事务控制
4.1 读写分离配置:主从分组与负载均衡
dynamic-datasource里有一个很舒服的配置:主从分组。分组之后,你可以用@DS("slave")直接指向一组从库,框架会在这一组内做负载均衡,而不需要关心具体的库是slave_1还是slave_2。
spring: datasource: dynamic: datasource: master: url: jdbc:mysql://192.168.10.10:3306/order username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave_1: url: jdbc:mysql://192.168.10.11:3306/order_ro username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave_2: url: jdbc:mysql://192.168.10.12:3306/order_ro username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver group: slave: - slave_1 - slave_2这个分组的负载均衡策略默认是随机,你也可以自己替换实现。逻辑上,凡是只读的列表查询接口,统一标@DS("slave");写操作不标注,走默认的master。这样配置上非常清爽,后续扩从库时,只需要在group里加一个数据源key,业务代码完全不用动。
4.2 多源事务的三条路线,该怎么选
多数据源的事务问题,是绕不开的话题。我的建议是分三层来考虑。第一层,如果只是要在多个库上分别执行几条操作,但可以接受“最终一致”,最简单的方式就是拆方法、各自事务。第二层,如果你需要多个库的本地事务一起提交回滚,可以用@DSTransactional。这个注解的实现机制是在多个数据源上各自开启事务,提交时依次提交——注意,它不是分布式事务,不保证原子性,如果第二个库提交失败了,第一个库已经提交成功,它不会回滚。所以它比较适合“大多数时候不会失败”的场景,比如业务日志、操作审计这类辅助性写入。
第三层,如果业务真的需要强一致,那就要上真正的分布式事务方案,比如Seata。Seata的AT模式配合dynamic-datasource是可以用的,但会带来额外的部署成本和性能开销。我见过不少项目上来就给多数据源标配Seata,最后发现99%的表都难出现跨库同时操作的情况,完全是在给自己加运维负担。先想清楚业务是否真的需要强一致,再决定要不要上分布式事务,这才是正确的选型顺序。
4.3 同一个事务写多个库时,你需要的不是切换,而是路由
有一种业务场景很有意思:同一个表结构,因为数据量大被水平拆到了多个库,事务里需要按某个字段路由到特定库去写入。这时候@DS加SpEL表达式就非常合适。比如按用户ID尾号分库:
@DS("#userId % 10 == 0 ? 'db_user_0' : 'db_user_1'") public void saveUser(User user, Long userId) { userMapper.insert(user); }当然,SpEL表达式不建议写得太复杂,否则读代码的人一眼看不懂,维护成本很高。我的习惯是在Service层先算好数据源key,再用方法直接指定到这个key。这种写法逻辑清晰,排查问题时也方便。
多数据源技术本身并不局限于数据库。实际项目里,你还会把MinIO对象存储、消息队列、定时任务等组件跟多数据源组合起来用。比如定时任务扫描某个数据源中的待处理数据,处理完之后把结果写到另一个库,同时把对应的文件传到MinIO。这种场景下,数据源切换本身只是其中一环,真正需要你关注的是任务执行过程中哪些步骤失败可以被重试、哪些需要手动补偿。不要试图用一个注解解决所有一致性问题,那是不现实的。
5. 我踩过的坑:常见问题与排查实录
5.1 必须记住的十个坑
我把团队里几个人踩过的坑汇总成一张速查表,按出现频率排了序:
| 表现 | 原因 | 解决 |
|---|---|---|
| 启动后Mapper扫描不到接口 | @MapperScan没有生效,或者多模块下扫描路径不对 | 指定正确的Mapper包路径,确认启动类上注解 |
| 数据源key写错但没报错 | strict未设置为true | 显式配置spring.datasource.dynamic.strict=true |
| 方法内自调用@DS不生效 | 同类内部调用不走代理 | 把切库逻辑抽到另一个Bean里 |
| 事务方法里@DS不生效 | 连接已被事务占用 | 拆分事务或用@DSTransactional |
| SpringBoot3下无法自动装配 | starter版本过低 | 升级到4.x |
| 分页插件查数据源错乱 | PaginationInnerInterceptor的DbType被固定 | 升级MyBatis-Plus版本或动态识别DbType |
| 启动失败,备用库连不上 | 数据源全部启动时初始化 | 配置lazy: true |
| Druid配置不生效 | 重复引用了druid starter | 只保留dynamic里Druid配置 |
| 数据库驱动类不存在 | 驱动jar包未引入 | 检查依赖,确认driver-class-name |
| 从库连接断开后不会自动恢复 | 缺少连接池保活配置 | 配置test-while-idle、validation-query等 |
5.2 启动阶段:驱动背锅、端口背锅、配置背锅
项目刚集成多数据源时,最常遇到的报错是Failed to configure a DataSource。很多新手兄弟一看这个错,第一反应是去查URL、用户名密码,但实际上这个错误还有一个常见原因:dynamic-datasource的自动装配覆盖了SpringBoot默认的数据源装配,而你配置了多个数据源却没有指定primary。primary一定不能省,它告诉框架在没有@DS注解时默认用哪个数据源。
另外一个启动阶段的高频问题,是MySQL 8的驱动类写法。老项目里很多人还写着com.mysql.jdbc.Driver,在MySQL 8以上版本会直接报ClassNotFoundException,正确的写法是com.mysql.cj.jdbc.Driver。这里建议直接把driver-class-name省略,让连接池根据URL自动识别驱动,反而更省事。
还有一点,如果你的备用库IP是个内网地址,在本地开发时连不通,记得开lazy。之前有个同事把一套配置从测试环境拷贝到本地,测试环境能跑,本地一启动就失败,就是因为备用库的地址在本地网络不可达,而旧版的lazy默认值是false,启动时把每一个数据源都初始化了一遍。这个问题本身不是大问题,但排查起来特别浪费时间。
5.3 运行阶段:切换失效、连接被回收、慢查询扎堆
运行阶段最大的坑是“数据源切换失败了,但业务没有报错”。我排查过最典型的一个案例:A服务通过Feign调用B服务,B服务的某个接口标了@DS("slave"),结果实际查询走的还是主库。排查了很久,最后发现是Feign接口所在的类被@Transactional给拦了,整个请求链路一旦开启事务,数据源就被锁定在主库上。这类问题通常发生在“框架封装太深、开发人员不知道底层有事务代理”的场景。
连接被回收的问题也值得单独说。如果应用里有慢SQL,特别是从库上的大查询,会把连接池里的连接拖死,最终触发连接泄漏、后续请求排队等待、接口RT飙升。排查时可以先看连接池监控,再开启MyBatis-Plus的SQL日志或者p6spy,定位慢SQL。多数据源环境下,生产环境建议把SQL日志打到独立的日志文件里,方便按数据源维度排查问题。
5.4 兼容国产数据库:金仓、达梦需要注意的细节
国内项目里,多数据源经常还会涉及国产数据库,比如金仓、达梦、人大金仓等。这些库跟MySQL、PostgreSQL的兼容性参差不齐,集成时要注意几点:驱动类名要写对,金仓的驱动是com.kingbase8.Driver,达梦的驱动是dm.jdbc.driver.DmDriver;URL前缀各不一样,金仓是jdbc:kingbase8://host:port/db,达梦是jdbc:dm://host:port/db。如果项目里同时手动配置了driver和Dialect,还有可能影响MyBatis-Plus分页插件的DbType识别,导致分页SQL生成错误。
我的建议是:先写一个独立的连接测试,在引入业务代码之前把“能连上、能查询、能分页”这三个基本能力跑通。很多时候大家一上来就改Mapper,结果发现是数据库驱动的问题,绕了一大圈。另外,国产库的驱动版本也要注意,有些版本和数据库服务端的协议不兼容,连上去就会出现随机断连的现象,这已经超出应用层能解决的范围了,通常需要和数据库厂商确认驱动版本。
6. 性能与运维:多数据源上线前必须检查的细节
6.1 连接池参数怎么给才不拖垮主库
多数据源环境下,连接池参数必须按库的实际压力单独给,不能一套参数走天下。主库承担所有写操作和部分读操作,通常并发压力最大,连接数上限可以给到20~50,视QPS而定;从库主要服务报表和大查询,可以给到10~20,但要注意从库上的慢查询会把连接占满,所以查询超时参数一定要配置。HikariCP示例:
spring: datasource: dynamic: datasource: master: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 slave_1: hikari: maximum-pool-size: 10 minimum-idle: 3 connection-timeout: 15000 idle-timeout: 600000 max-lifetime: 1800000这里有个很重要的经验:主库和从库的事务隔离级别、自动提交设置、连接超时时间都可以不一样。从库如果只读,连接池参数可以更激进一点,允许更短的连接超时,避免报表查询拖住太长时间。
6.2 监控和数据源体检清单
多数据源上线后,监控是必须的。如果你用的是Druid,可以开启内置的监测页面;如果是HikariCP,需要接Micrometer或Prometheus暴露指标。我建议至少盯三个指标:每个数据源的活跃连接数、等待获取连接的时间、SQL执行耗时。这三个指标能帮你快速判断是“连接池不够用”还是“SQL本身慢”。
上线前,我还习惯做一次“数据源体检”,流程很简单:确认每个数据源都能正常连通;用@DS注解访问每一个库的简单查询和分页查询;确认主从切换后事务不受影响;关闭一个备用数据源的网络,确认应用其余功能依然可用;模拟主库宕机,确认从库读取不受影响。这套体检流程每做一个项目都跑一遍,能提前暴露绝大多数配置问题。
6.3 当数据源多到十个以上,配置如何治理
数据源的数量达到一定规模之后,配置治理就成了大问题。十个数据源全部写在application.yml里,文件会变得非常长,而且很容易重复。我见过的项目里,有把数据源配置切分成多个Profile文件的,也有把配置放到Nacos、Apollo这类配置中心的。配置中心的优势很明显:改连接串不用重新发版,某个数据源有问题可以直接在配置中心摘除。
另一个实用手段是给数据源key建立命名规范,比如业务域_用途_序号:order_master、order_slave_1、report_clickhouse。命名规范虽然简单,但能避免很多低级的key写错问题。再说直接一点,多数据源最大的风险不是技术问题,而是你根本不知道一个请求最终走了哪个库。所以,规范的命名、完整的日志、及时的监控这三件事,比任何框架技术都重要。
最后说点个人感受。我做了好几个多数据源的SpringBoot项目,越来越觉得,这类问题真正的难点不在于“怎么把库连上”,而在于“连上之后如何保证业务在复杂场景下依然正确、可排查”。dynamic-datasource确实做到了开箱即用,但开箱之后的维护工作,仍然需要你对事务边界、连接池参数、数据源命名有足够清晰的认知。遇到问题不要慌,多看看实际的SQL日志和连接池监控,大部分坑都能定位到具体环节。希望这篇文章能让你少走一些弯路。