动态数据源dynamic-datasource原理与实战:从注解切换到多库批处理避坑
2026/9/9 0:28:28 网站建设 项目流程

如果你们项目里出现了“一个服务要连好几个库”的需求,而你又不想引入重量级的分布式事务中间件,那么dynamic-datasource-spring-boot-starter大概率是你绕不开的一个库。这个组件在 Spring Boot 多数据源圈子里几乎成了标配方案,核心原因就一个:它把“多数据源”这件事的复杂度收敛到了注解和配置层面,业务代码基本无感知。

这篇文章我打算分上下两部分来写。上篇解决“是什么”和“怎么用起来”的问题,重点讲清楚它的核心原理、基础配置、以及很多人踩过坑的 MyBatis 批处理在多数据源下的诡异行为。下篇再深入源码层面,聊聊它底层基于 Spring 抽象做路由的完整链路,以及如何二次扩展。如果你正被多数据源切换、读写分离、或者多库事务问题折磨,这篇内容值得你花几分钟读完。

1. 多数据源的痛点与 dynamic-datasource 的解题思路

1.1 为什么“多个数据源”会成为一个问题

在单体应用阶段,一个 Spring Boot 工程直连一个数据库,DataSource只需要配置一个,一切都很自然。但业务一旦发展起来,多数据源的诉求几乎是必然出现的:

  • 读写分离:主库负责写,从库负责读,分摊压力;
  • 按业务拆库:订单库、用户库、商品库各自独立,互不干扰;
  • 历史数据归档:热数据在 MySQL,冷数据在另一个库甚至另一个数据库引擎;
  • 集成第三方系统:直接连对方的库表做数据同步,而不是走 API。

问题在于,Spring Boot 的DataSourceAutoConfiguration默认只认一个DataSourceBean。如果你在配置文件里写了两个spring.datasource开头的连接信息,不好意思,后一个会把前一个覆盖掉,或者干脆启动报错。即便你手动声名了多个DataSourceSqlSessionFactoryTransactionManager这些基础设施也只会绑定其中一个,MyBatis 的 Mapper 根本不知道该用哪个库执行 SQL。

说白了,多数据源的难点不在“能不能配多个连接”,而在“如何让上层组件在运行时动态选择正确的那个连接”。

1.2 市面方案的对比:为什么选这个 starter

我接触过的多数据源方案大致有三类:

手动维护多个DataSource+ 多个SqlSessionFactory,通过 Mapper 接口所在的包路径来区分。这个方案的缺点很明显——每个数据源都要配置一整套 MyBatis 相关 Bean,配置代码冗余,而且数据源之间无法动态切换,想做一个“同一接口按条件走不同库”的操作基本没戏。

基于 AOP 切面在方法执行前切换DataSource,这是dynamic-datasource-spring-boot-starter这类库的核心思路。它在方法调用前把目标数据源标识放到一个上下文中,真正获取连接时由路由数据源决定返回哪个库的连接。

引入 ShardingSphere-JDBC 这类重量级中间件,功能强大但学习成本和启动成本都很高。如果你的需求仅仅是“多库切换”、“读写分离”,没有必要为了杀鸡而动牛刀。

dynamic-datasource-spring-boot-starter的定位很精准:轻量、注解驱动、侵入性低。你只需要在 Mapper 方法或 Service 方法上加一个@DS("order")注解,就能让这个方法里的所有 SQL 走order这个库。不需要改任何 SQL 语句,也不需要给每个 Mapper 单独绑定 SqlSessionFactory,这个“约定优于配置”的体验,是真香。

1.3 核心功能一览

这个库的官方文档把功能点列得比较全,我把实际用下来比较关键的能力挑出来:

  • 支持多数据源自由切换,注解@DS可加在类或方法上,方法上的注解优先级更高;
  • 支持读写分离,配置slave数据源后可以用@DS("slave")或者内置的策略进行读负载均衡;
  • 支持嵌套切换,一个方法内部可以多次切换数据源而不会串线;
  • 支持数据源分组,比如把多个从库配到一个组里,内部做随机或轮询;
  • 对 MyBatis、MyBatis-Plus、JPA、JDBC 都有良好的适配;
  • 还提供了spring-boot-actuator的端点支持,可以动态查看数据源状态。

不过要特别说一句:任何数据源切换方案都不能解决跨库的分布式事务问题。@DS只负责切换,多个库之间的事务一致性它不管,也管不了。如果业务上真的需要跨库强一致性,你得考虑 Seata 或者本地消息表这类方案了。

2. 核心原理解析:一个注解背后的路由逻辑

2.1 动态数据源的底层抽象:AbstractRoutingDataSource

要理解dynamic-datasource的工作原理,先得知道 Spring 提供了一个现成的抽象类——AbstractRoutingDataSource。它内部维护了一个Map<Object, Object> targetDataSources,也就是“目标数据源集合”,同时暴露了一个抽象方法determineCurrentLookupKey()。每次调用getConnection()的时候,AbstractRoutingDataSource会先调用这个抽象方法得到一个 key,然后用这个 key 从targetDataSources里取出真正的DataSource,再返回连接。

这个设计很像酒店前台:客人来办入住,先问要去哪个房间(key),然后根据房号找到对应的房间(DataSource),把房卡给你。你拿着房卡刷卡开门,整个过程对外部调用方是透明的。

dynamic-datasource-spring-boot-starter的核心,就是把determineCurrentLookupKey()的实现从“固定返回某一个 key”改成了“从当前线程的上下文中读取 key”。而这个线程上下文,就是它实现动态切换的关键。

2.2 线程上下文与 AOP 的配合

在每个数据源切换的请求链路里,这个库维护了一个ThreadLocal变量,里面存放当前线程应该使用的数据源 key。这个过程大致是:

  1. 拦截器拦截到带有@DS注解的方法;
  2. 解析注解值,把对应的 keypushThreadLocal
  3. 真正执行方法体里的业务代码时,所有getConnection()调用都会从ThreadLocal里拿 key;
  4. 方法执行完毕,在finally块里把ThreadLocal里的 key 清除。

这套“AOP 切面 + ThreadLocal + AbstractRoutingDataSource”的组合拳,就是整个库最核心的骨架。理解了这条链路,后面排查“为什么我的注解没生效”、“为什么切换了数据源但 SQL 还是走了默认库”这类问题就会非常有方向感。

2.3 嵌套切换与栈式存储

如果你在一个方法里先调用了@DS("db1")的 A 方法,A 方法里又调用了@DS("db2")的 B 方法,B 执行完回到 A 后,数据源应该是 db2 还是 db1?

如果你的第一反应是“应该回到 db1”,那么恭喜你,你对动态切换的语义理解是对的。但这个“回到上一个数据源”的实现,如果用简单的ThreadLocal.set()是做不出来的——你存进去一个新 key 就会把旧的覆盖掉,B 执行完以后ThreadLocal里只剩 db2 了,A 接下来的 SQL 就全跑偏了。

所以这个库的ThreadLocal里存的不是单个 key,而是一个Deque(双端队列)。进入一个数据源上下文时push,退出时pop。这样嵌套切换时,始终能回到上一层的 key。这也是为什么叫DynamicDataSourceContextHolder.push而不是set

这个小细节很容易被忽略,但一旦你的业务里出现“在循环中切换数据源”或者“Service 嵌套调用”的场景,它就成了保命设计。

3. 快速接入:从依赖到配置再到第一个 @DS 注解

3.1 引入依赖与版本选择

这个库的 Maven 坐标我放在下面,需要注意版本号和 Spring Boot 版本的匹配。以目前主流的 Spring Boot 2.x 线为例,3.4.x 以上的版本通常对 Spring Boot 2.5+ 支持得都不错。如果你用的是 Spring Boot 3.x,建议用 4.x 线的新版本,因为旧版本对 Jakarta 命名空间的支持不完整。

<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>3.6.1</version> </dependency>

如果你是 Spring Boot 3.x 项目,可以用:

<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>4.2.0</version> </dependency>

这里有个选型建议:不要盲目追求最新版,先上稳定版本。这个库的迭代速度不算快,但每个版本都有升级日志,建议升级前看一眼是否涉及破坏性变更。

3.2 多数据源配置的最佳实践

引入依赖后,spring.datasource的配置方式就完全变了。不再是原来的单数据源写法,而是统一收敛到了一个叫spring.datasource.dynamic的配置块下。

spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://localhost:3306/master_db username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver order: url: jdbc:mysql://localhost:3306/order_db username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver user: url: jdbc:mysql://localhost:3306/user_db username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver

这里有几个关键参数:

  • primary:默认数据源。当没有加@DS注解时,所有 SQL 都会走这个数据源。所以一般把读写最频繁的那一个库设为 primary。
  • strict:是否开启严格模式。默认 false。在非严格模式下,如果你写了@DS("not_exist")而实际上没有配置这个数据源,它会回退到 primary 数据源,而不是报错。这在开发阶段很容易掩盖配置错误,我建议测试环境开启 strict: true,让错误尽早暴露。
  • 每个子数据源的driver-class-name建议都手动指定,不要偷懒,避免某些数据库驱动自动识别异常。

3.3 第一个@DS注解的完整示例

配置完成之后,使用方式非常直接。假设我们有个订单查询服务,需要从order库读数据:

@Service public class OrderService { @DS("order") public List<Order> getOrderList(Long userId) { // 这里执行的 SQL 会走 order 数据源 return orderMapper.selectByUserId(userId); } }

如果你有一个方法需要同时操作两个库,可以在方法内部或者通过多个 Mapper 配合完成。这里有个小建议:@DS注解加在 Service 方法上还是 Mapper 接口上,取决于你的粒度需求。

如果你把@DS加在 Mapper 接口上,那么这个 Mapper 的所有方法都会固定走某一个库。如果加在 Service 方法上,则这个 Service 方法内部调用的所有 Mapper(不管这些 Mapper 上有没有@DS,如果有则遵循就近优先)都会切换到该数据源。

实际项目里我推荐“Service 方法级别”粒度。因为一个 Service 方法往往是一个完整的业务单元,它内部可能需要调用多个 Mapper,而这些 Mapper 统一走同一个数据源,体验最佳。

3.4 代码里手动切换数据源的补充方案

注解能满足 90% 的场景,但总有一些特殊情况——比如数据源的名字是运行期动态计算的,或者你需要在一个循环里连续切换不同的数据源,此时注解就有点力不从心了。

这时候可以用自带的 API 手动切换:

DynamicDataSourceContextHolder.push("order"); try { // 手动切换后的业务代码 } finally { DynamicDataSourceContextHolder.poll(); }

使用手动模式时,一定记得在finallypoll(),否则ThreadLocal里的 key 会一直残留。线程池复用的场景下,残留的 key 会导致后续任务错误地走到这个数据源,而且是那种间歇性、极难排查的 Bug。这一点我后面还会再强调。

4. 实战难点:MyBatis 批处理在多数据源下的诡异行为

4.1 热搜词背后的真实痛点

这次热搜词里出现了“MyBatis 的 saveOrUpdateBatch 多数据源的问题”,我一看就知道说的是什么场景,因为我自己也在这个坑里躺过。

先说现象:项目里有多个数据源,用的是 MyBatis-Plus 的IService.saveOrUpdateBatch()或者ServiceImpl.saveBatch(),期望它按照@DS注解切换到指定库去执行。但实测发现,数据有时候写进了默认库,有时候又正常;或者干脆直接报错说表不存在,但实际上目标库里明明有这个表。

为什么会这样?关键点在 MyBatis-Plus 的批量操作方法内部实现。它并不是在一行 SQL 里完成所有数据的插入,而是通过一个SqlSessionTemplate分批执行多条 SQL。这个分批执行的逻辑用到了 MyBatis 的ExecutorType.BATCH,而SqlSessionTemplate内部的DataSource绑定是在SqlSession创建时确定的,不是每次 SQL 执行时动态获取的。

换句话说,saveOrUpdateBatch的整个批处理过程是“一次性申请一个连接,然后在上面执行所有 SQL”。如果在这个批处理过程中数据源上下文发生了变化,或者上下文里的数据源和实际执行的数据源不一致,就会出现前面说的诡异现象。

4.2 批处理失效的一个典型场景

假设你有如下代码:

@DS("order") public void batchSaveOrders(List<Order> orders) { // 这里有一个拦截器或切面,内部通过 DynamicDataSourceContextHolder.push 切换了数据源 orderServiceInner.saveOrUpdateBatch(orders); }

如果在执行saveOrUpdateBatch之前,有某个切面把数据源切到了master,并且执行完之后没有正确恢复,那么批处理就会在错误的库上执行。更隐蔽的是,如果这个错误恰好没有报错——比如两个库的表结构恰好一样——那数据就会写进错误的库,而且没有任何提示。

还有一种常见场景是循环中批量保存:

for (String tenantId : tenantIds) { DynamicDataSourceContextHolder.push("tenant_" + tenantId); // 这里调用 saveBatch 或自定义批量插入 orderService.batchInsert(tempOrders); DynamicDataSourceContextHolder.poll(); }

如果你在批处理内部对数据源做了二次切换,或者批处理框架自身缓存了数据源连接,就可能出现“第一个租户的数据写对了,第二个租户的数据写到了第一个租户的库里”的错乱。

4.3 规避这个问题的方法

这个问题的根源在于:批处理场景下,连接何时获取、何时绑定,和普通单条 SQL 执行不完全一样。所以规避方案要围绕“让批处理在正确的数据源上下文里稳定执行”来设计。

一个最直接的做法,是把批处理单独抽取到一个独立的事务方法里,并且让这个事务方法上的@DS和实际数据源严格对应,不要依赖外层调用方的上下文:

@Service public class OrderBatchService { @DS("order") @Transactional(rollbackFor = Exception.class) public void batchSaveOrders(List<Order> orders) { // 这里尽量只做和 order 库相关的操作 orderMapper.saveOrUpdateBatch(orders); } }

注意这里@DS@Transactional的顺序没有强制要求,但两者相遇时会有一个“事务优先于数据源切换”的隐含行为。具体来说,Spring 的事务拦截器会先于@DS的切面执行,它会在事务开始时获取连接并绑定到事务上下文。如果你的DataSource本身有动态路由能力(比如通过@DS切换),那么事务连接是在切换后的数据源上获取的;但如果你的数据源没有路由能力,那么事务会直接绑定到 primary 数据源,这时候@DS就失效了。

这也是一个高频踩坑点:多数据源 + 事务的组合,一定要把@DS@Transactional放在同一个方法上,让注解先切换数据源,事务再在正确的数据源上开启。如果你把@DS放在外层调用方法上,而@Transactional在内层方法上,外层切面先切了库,内层事务启动时按当前上下文获取连接,理论上是可行的;但如果你把@Transactional放在外层,而@DS放在内层,那个事务连接就会按照外层方法执行时的上下文来决定,极大概率不是你想要的库。

这种顺序问题没有绝对的标准答案,因为不同版本的拦截器执行顺序会有差异。我给你的建议是:自己写一个小 Demo,把你实际用到的注解组合方式测一遍,确认行为符合预期再铺到业务里。

4.4 如何自查:一笔写错的定位思路

遇到“批处理写入错库”的诡异问题,我的排查路径一般是三步。

第一步,确认当前线程最终拿到的是哪个数据源。可以在可疑代码前后手动打印DynamicDataSourceContextHolder.peek(),看看进入批处理之前上下文里存的是什么 key。

第二步,确认批处理是否真的在切换后的连接上执行。如果你开启了 MyBatis 的 SQL 日志,可以在日志里找到批处理方法对应的连接 ID 或数据库关键字,结合 MySQL 的show processlist观察哪个库出现了写入。

第三步,检查是否有其他切面在@DS切面执行后又修改了上下文。比如某些幂等组件、审计组件、多租户插件会在拦截器里自行切换数据源,它们的执行顺序如果夹在@DS和业务方法之间,就很容易把上下文改乱。

5. 多数据源场景下的连接池与事务配置

5.1 连接池参数要不要单独配

很多人配置多个数据源时,只关注 URL、用户名、密码,连接池参数全部使用默认值。这在早期没问题,但当某个数据源的访问量明显偏高时,连接池过小会导致连接等待超时;而连接池过大的数据源又白白占用机器资源。

这个库支持为每个数据源单独配置连接池参数,以 Druid 为例:

spring: datasource: dynamic: datasource: master: url: jdbc:mysql://localhost:3306/master_db username: root password: 123456 druid: initial-size: 5 max-active: 20 min-idle: 5 order: url: jdbc:mysql://localhost:3306/order_db username: root password: 123456 druid: initial-size: 3 max-active: 10 min-idle: 3

用这种写法,你可以按业务的重要程度给不同库分配不同的连接池规格。但我不建议一开始就把大量精力花在调参上,先根据每个库平时的 QPS 和 RT 做初步估算,后续压测再慢慢调整。

5.2 事务边界与数据源切换的相互影响

多数据源和事务的相爱相杀,是实际开发中矛盾最集中的地方。除了前面说的批处理问题,还有一个很典型的场景:

@Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { userMapper.insertUser(order.getUser()); // 期望走 user 库 orderMapper.insertOrder(order); // 期望走 order 库 }

这段代码的诉求是“一个事务里操作两个库”。但 Spring 的声明式事务默认是基于单个DataSource的。你开一个事务,它获取一个连接,然后所有 SQL 都在这一个连接上执行。如果这个事务连接绑定了 user 库,那么 order 库的 SQL 就会在 user 库上执行,大概率直接报“表不存在”。

所以结论很明确:@Transactional加上多数据源切换,本质上不能做到跨库强一致。要么你接受“每个数据源各自一个事务”的现实,把事务粒度收缩到单个数据源操作内;要么引入分布式事务框架。

我在实际项目中推荐的做法是:

  • 如果需要操作的多个库之间没有强一致要求,直接用多数据源切换,不加全局事务;
  • 如果某个流程确实需要跨库强一致,优先考虑能否通过业务设计规避(比如先写本地表,再异步同步到其他库);
  • 如果规避不了,再尝试引入 Seata 的 AT 模式或者 TCC 模式。

顺序很重要——先考虑能不能不做,再考虑怎么做,这是架构设计的基本素养。

5.3 Actuator 端点:多数据源监控的正确姿势

热搜词里还有一条 “micrometer + spring boot actuator” 和 “spring boot actuator 未授权访问”,单独看是另一条技术线的内容,但和本话题有一个交汇点:动态数据源也可以通过 Actuator 暴露运行状态。

这个库原生支持 Actuator 端点,开启后你可以通过/actuator/dynamic-datasource查看当前所有数据源的信息,包括每个数据源的类型、连接池状态、活跃连接数等。配置文件开关如下:

management: endpoints: web: exposure: include: dynamic-datasource

这个端点在联调和排查“某个库连接被打满”、“某个库配置错误导致许多请求走 fallback”这类问题时非常有用。但要注意,Actuator 端点本身是敏感信息,生产环境需要配合安全认证,不要裸奔暴露到公网。Actuator 未授权访问导致的泄露事件我之前也遇到过,所以顺手补一句:生产环境的management.endpoints.web.exposure.include一定要按需开放,不要图省事用*

6. 目前看到的问题与后续下篇扩展

6.1 多数据源场景下的一个“隐性问题”

配置多个数据源后,如果你有一个 Mapper 方法没有加@DS,也没有默认走 primary,那么这个查询就会在 primary 上执行。这在很多情况下不是你想要的结果——你会以为它应该在最近一次切换的库上执行,但实际不是。

这个“回退到 primary”的设计,在strict: false模式下尤其危险。一个@DS("wrong_db")并不会像想象中那样直接报错,而是会静默地走 primary。所以我的建议是:所有的 Mapper 接口都检查一遍,确定每个方法的数据源归属;数据源比较多的项目,建议开启strict: true,宁可让配置错误立刻暴露,也不要让它默默跑偏。

6.2 数据源 Schema 变化后的热更新

还有一个很多人没注意到的点:这个库支持运行期动态新增数据源。你可以通过代码注册一个新的数据源到组里:

DynamicRoutingDataSource ds = (DynamicRoutingDataSource) SpringContextHolder.getBean(DataSource.class); DataSource newDataSource = buildDataSource(url, username, password); ds.addDataSource("new_db", newDataSource);

这个能力在做多租户系统时很实用,租户首次注册时动态创建库,再把它动态加进路由表。但要注意,新增数据源只是把 key 和 DataSource 的映射加入了路由表,不会自动处理历史的连接绑定,所以注册完成后,下一个请求才会走到新数据源。

这个话题如果展开讲,不少内容可以再写一篇长的,比如租户切换的最佳实践、动态数据源和 Sa-Token / Shiro 集成时的上下文传递、以及基于@DS做灰度切库的玩法。这些内容,我放到下篇去写,下篇会着重从源码层面剖析:拦截器的执行顺序、DeterminedDataSource的创建时机、以及自定义扩展一个“按 SpEL 表达式动态计算数据源”的能力。

6.3 个人实践总结

最后用我自己的感受收个尾。dynamic-datasource-spring-boot-starter这个库整体设计是很成熟的,官方文档也写得扎实,但真正的难点从来不在“怎么用”,而在“用的时候出了错怎么查”。

多看一点点源码,尤其是在你遇到“注解不生效”“切换偶发失效”“批处理写入错库”这类问题时,带着问题去翻DynamicDataSourceContextHolderDynamicRoutingDataSource,能把问题定位得快很多。所有的黑科技,拆开看底层,都是 Spring 代理和上下文切换那点事儿。

写这个系列的原因,也是因为我在多数据源这条路上踩了不少坑,希望能帮你把前路铺平一点。下篇见。

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

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

立即咨询