☰
dynamic-datasource多数据源实战:原理、配置与排错
2026/10/21 13:19:42 网站建设 项目流程

做后端开发这几年,数据源配置从最开始的一行jdbc:mysql,慢慢变成了一份好几十行的 YAML。业务拆分、读写分离、报表查询、多租户隔离——每个诉求都在逼着同一个应用去连接多个数据库。尤其是在 Spring Boot 项目里,多数据源是个绕不开的话题,而dynamic-datasource是圈子里用得最多、也最顺手的一个开源方案。

这篇内容不打算写成官方文档的复读机,我想围绕自己实际使用这个框架的经验,把它的原理、配置、经典坑和排错思路完整梳理一遍。适合刚接触多数据源的开发者,也适合已经用了一段时间但总被事务和切换问题困扰的人。看完之后,你应该能判断这套方案适不适合你的项目,以及真的出了问题该从哪里下手。

1. 从读写分离到多业务库:dynamic-datasource要解决的痛点

1.1 哪些业务场景会把"多个数据源"变成刚需

先盘一下现实中最常见的多数据源需求来源。

第一类是读写分离。主库负责写,从库负责查,这是最经典的做法。报表、统计、列表查询这类读多写少的接口,如果全都打到主库,主库的 CPU 和 IO 很快会被拖垮。这时候应用必须在执行 SQL 前决定走主库还是从库。

第二类是业务库拆分。订单库、用户库、库存库、日志库各自独立,而且不一定用同一个数据库产品。订单在 MySQL 里,历史报表在 PostgreSQL 里,甚至某些国产库也在用。跨库 join 基本不可能,只能靠应用层去分别访问。

第三类是数据中台和 BI 场景。业务库和分析库分开,应用既要写完业务数据,又要定期把数据同步到分析库,或者直接连分析库做复杂查询。

第四类是多租户系统。每个租户一个独立库,租户 A 的请求绝对不能查到租户 B 的数据,路由条件往往来自登录态或请求头。

还有一类容易被忽略:异步任务和定时任务。一个数据同步任务可能要同时写多个库,比如每天晚上从核心业务库抽取数据,写入报表库和归档库。

这些场景单独看都不复杂,但同一个应用里同时出现两三类的时侯,数据源数量就会快速膨胀。我接过一个普通的管理后台项目,线上环境维护了七八个连接串,每次新人都要花半天去理解哪个服务连哪个库。

1.2 传统多数据源方案的维护成本和致命伤

在没有框架辅助的时候,传统做法是手动定义多个DataSourceBean,再用@Qualifier("orderDataSource")注入。看起来问题不大,实际做起来有两个让团队很头疼的死角。

第一个是代码入侵。每个业务方法里都要塞一段"选库"逻辑,从请求上下文里拿租户编号、拼接数据源 key、调用自己写的路由类。这套逻辑一旦散落在几十个 Service 里,后续调整路由规则几乎是一场灾难。改一个命名规范,全项目的 Service 都要动。

第二个是事务边界。同一段业务逻辑中,先写订单库再写用户库,中间只要有一个失败,两边数据就不一致了。单数据源下我们用@Transactional很顺手,但多数据源下 Spring 的默认事务管理器帮不上忙,因为它只认一个数据源连接。手动编程式事务又会让代码变得又臭又长。

还容易忽略的一点是连接池类型的统一。团队里有人想用 HikariCP,有人想用 Druid,手动配置多个连接池 Bean 不仅麻烦,监控也不好统一做。

dynamic-datasource的核心价值,并不是说它有什么黑科技,而是它把"多个数据源的使用体验"硬生生退化成了"一个数据源"。开发者只需要在方法上标注一下当前走哪个库,剩下的切换、清理、路由、默认值,全部交给框架。

2. @DS注解背后的路由原理:AOP拦截与ThreadLocal协作

2.1 AbstractRoutingDataSource与动态数据源的诞生

理解它的实现原理,得先认识 Spring 自带的一个抽象类:AbstractRoutingDataSource。

这玩意的思路很朴素。数据源本质上是拿着连接参数、负责创建连接的对象,核心方法是getConnection()。Spring 给出的方案是做一个"路由器"类型的 DataSource,它自己不真正创建连接,而是持有一组真实数据源(Map<String, DataSource>)。每次getConnection()时先决定一个 key,再根据 key 从 map 里找到真实数据源,把连接交给调用者。

问题就出在"决定 key"这一步:这个 key 应该从哪里来?

最自然的办法是放进线程上下文。Web 请求是基于线程的,一次请求处理过程中,业务线程可以一路携带一个"当前数据源标识"。用一个全局静态的ThreadLocal保存这个标识,就构成了动态路由的关键。

dynamic-datasource正是沿着这条路做的,而且更进一步:把ThreadLocal里的值从单纯的String升级成了Deque<String>(双端队列)。为什么要栈结构?因为方法可以嵌套。外层方法指定了 master,中途调用内层方法指定了 slave,内层方法执行完退出后,外层后续的逻辑还要回到 master。用栈来 push 和 poll,天然解决了嵌套恢复的问题。

这个细节单独拎出来说,是因为很多自己在项目中做过"简易动态切换"的团队,往往会忽略掉嵌套场景,最后只能要求所有人"不要嵌套使用",限制非常大。

2.2 @DS注解的拦截链路与上下文清理

ThreadLocal 里的值到底是怎么设置进去的?答案是 AOP。

框架启动时会注册一个切面,凡是标注了@DS注解的方法都会被拦截。拦截器在方法进入之前做三件事:解析注解里的 value(比如"slave")、把 value 压入 ThreadLocal 栈、放行方法执行。方法退出之后,不管正常返回还是抛异常,栈顶的值都会被弹出,恢复之前的数据源上下文。

这个清理动作极其重要。应用服务器通常用线程池处理请求,线程执行完任务并不会销毁,而是归还给线程池复用。如果弹出动作漏掉,下一个请求就可能会带着上一个请求设置的数据源标识继续跑,导致用户 A 的请求访问到用户 B 的数据库。这种串库事故在线上非常隐蔽,表现出来就是"偶尔拿到别人的数据"。

@DS注解本身支持放在类上和方法上,类级别的注解表示这个类所有方法默认走指定数据源,方法级别的注解优先级更高。嵌套调用时,内层方法临时切换,退出后自动恢复,这些都是拦截器在 Stack 上做 push/poll 带来的效果。我整理了一下,方便对照:

注解位置行为典型场景
类上该类所有方法默认走该数据源Service 层统一指定主库或从库
方法上仅当前方法走该数据源,优先级高于类注解个别查询需要走报表库
嵌套调用内层临时切换,退出后自动恢复外层数据源复杂流程中局部读从库

理解了这套机制,你就能明白为什么这个框架的日常使用如此简单——所有复杂的状态维护都被封装在 AOP 拦截链里,业务代码只需要声明意图。

3. 环境准备:快速接入一个可运行的多数据源项目

3.1 依赖引入与版本选择

先说依赖坐标,这是 Maven 项目里最核心的一步:

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

版本选择这里有一个硬性注意点:如果项目用的是 Spring Boot 2.x,那就用 3.x 版本的 starter;如果已经升级到 Spring Boot 3.x,必须改用 4.x 版本,因为底层换成了 Jakarta 命名空间。Spring Boot 3 项目里硬引 3.x 的 starter,运行时会直接报类找不到异常。

还有一件事需要提前心里有数:引入这个 starter 之后,Spring Boot 默认的数据源自动配置会被覆盖掉。之前写的spring.datasource.url这一整套配置不再直接生效,配置入口变成了spring.datasource.dynamic.datasource。很多第一次接的人会在这个地方卡住,以为依赖没生效,其实只是配置前缀变了。

3.2 配置文件里的关键参数

一份最小可运行的配置长这样:

spring: datasource: dynamic: primary: master strict: true datasource: master: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/master_db?useUnicode=true&characterEncoding=utf8 username: root password: 123456 slave: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/slave_db?useUnicode=true&characterEncoding=utf8 username: root password: 123456

两个参数值得专门展开。

primary表示全局默认数据源。任何没有标注@DS的方法,或者@DS值为空的方法,最终都会走这里。我的建议是生产环境里一定明确指定 primary,而且最好是主库。因为如果不小心写错数据源名,框架有一个兜底行为,下面马上说。

strict是严格模式开关。当@DS("slave_xxx")指定的数据源在配置里不存在时,strict=false会静默走 primary,strict=true会直接抛异常。我见过不少团队为了省事把 strict 关掉,结果某个库名拼错之后流量全部悄悄打到主库,主库连接池被打满,线上事故就是这么来的。所以这个开关我强烈建议开成 true,宁可快速失败,也不要静默兜底。

3.3 第一个带@DS的业务方法

配置写好后,代码层面的使用简单到让人有点不习惯。直接在 Service 方法上标注:

@Service public class OrderQueryService { @Autowired private OrderMapper orderMapper; @DS("slave") public List<Order> listRecentOrders() { return orderMapper.selectRecentOrders(); } }

@DS可以放在 Service 类上、Service 方法上,也可以直接放到 Mapper 接口的方法上。放到 Mapper 上的好处是,即使底层映射文件里写的是自定义 SQL,也同样会被切面拦到。

我当时为了验证切换是否真的生效,在 master 库和 slave 库里各建了一张表,但表里的数据不同。启动项目访问接口,如果返回的是 slave 库里的那条数据,说明切换成功了。如果你想看更直观的日志,可以给框架开启内置的 SQL 日志输出,或者用 p6spy 之类的插件,日志里能看到实际命中哪个库的 URL。

一个小提醒:第一次跑通后,最好把这个"验证工程"保留下来。后面遇到事务、嵌套之类的问题,可以直接在这个最小工程里做复现,比在复杂业务系统里排查高效得多。

4. 进阶玩法:分组、SpEL动态选库与自定义策略

4.1 主从分组与组内负载均衡

如果只有一台从库,master和slave两个名字就够了。但从库通常不止一台,这时候可以配置成一组:

spring: datasource: dynamic: datasource: master: # 主库连接配置 slave_1: # 从库1连接配置 slave_2: # 从库2连接配置

在业务代码里,只需要写@DS("slave"),框架就会自动在slave_1和slave_2之间做负载均衡。默认策略是随机,也可以实现DynamicDataSourceStrategy接口,换成轮询、权重等自定义策略。

这里有一个实际经验:读多写少的报表场景,不同从库的机器配置可能不一样,连接池大小也会不同。默认的随机策略有时候会让性能较差的库被分配过多连接,导致个别的从库连接池被打满。更好的做法是给每个从库单独调连接池参数,然后再配一个带权重的策略,让大连接池的库承接更多请求。连接池参数在数据源节点下可以单独配置,比如:

slave_1: # 连接配置 hikari: maximum-pool-size: 20 minimum-idle: 5 slave_2: # 连接配置 hikari: maximum-pool-size: 40 minimum-idle: 10

4.2 SpEL表达式:从请求头、参数中动态决定数据源

@DS的值不一定非得写死在注解里,它支持 Spring 的 SpEL 表达式。这一特性在多租户、按客户分库的场景里几乎是救命稻草。

典型的写法:

@DS("#tenant.datasourceKey") public void batchInsert(Tenant tenant, List<Order> orders) { // 按租户的数据源key写入对应的库 }

方法入参里的对象字段会被 SpEL 解析出来,作为最终的数据源 key。也可以从请求上下文里取:

@DS("#request.getHeader('X-Data-Source-Key')") public List<Order> queryOrders(HttpServletRequest request) { // 按请求头指定的数据源查询 }

用 SpEL 时有一个极易踩的坑:方法注解里的表达式要访问参数名,而 Java 编译默认不会把参数名写进 class 文件。如果你在 IDE 里运行没问题,但打包部署后一直报IllegalArgumentException,多半就是这个问题。解决办法是在pom.xml里给maven-compiler-plugin加<parameters>true</parameters>,或者给方法参数显式加@Param("tenant")注解。

这个坑的隐蔽之处在于,Spring MVC 的接口参数绑定通常不受影响,Controller 层正常,只有框架内部解析 SpEL 的时候才需要参数名,排查起来需要绕一圈。

4.3 自定义数据源来源与切换策略

框架还留了不少扩展口子。如果不想把所有数据源配置都写在application.yml里,比如连接信息在配置中心,或者需要运行时根据环境动态下发,可以实现DynamicDataSourceProvider接口,在启动时注册自定义数据源。框架自带的YmlDynamicDataSourceProvider就是用来读取spring.datasource.dynamic.datasource配置的。

我的建议是:只有数据源数量不固定、必须运行时新增的情况下才考虑自定义 Provider。绝大多数项目库是固定的,直接写在 YAML 里最直观,也最容易审计。为了一个几乎用不到的动态能力引入复杂的扩展逻辑,会增加维护成本。

5. 事务与数据源切换的经典冲突:从失效到分布式事务

5.1 为什么@DS和@Transactional同方法会"打架"

先说结论:在同一个方法上同时加@DS和@Transactional,大部分情况下@DS不生效。这是使用这个框架时踩坑率最高的一点。

原因在于 Spring 事务的工作机制。方法标注@Transactional后,Spring 在进入方法之前就会向数据源申请连接,并且把这个连接绑定到事务上下文里。而@DS的拦截器是在 AOP 方法执行时才通过 ThreadLocal 决定数据源。顺序上一反,连接已经拿到手了,后设置的 ThreadLocal 也就无能为力了。

打个比方:去超市买菜,决定去哪家超市这个动作发生在进门之前。如果进门之后才说"我们换一家超市吧",菜已经装进购物车,此时重新选超市已经晚了。

所以正确做法是:把跨数据源切换放在事务边界之外。比如 Service A 的普通方法调用 Service B 的@Transactional方法,事务方法内部固定使用一个数据源;或者反过来,先在一个带@DS的方法里拿到数据,再进入事务方法去写。

5.2 @DSTransactional的边界与取舍

针对"方法内多次切换数据源"的本地事务诉求,框架提供了一个@DSTransactional注解。它和 Spring 原生@Transactional不同,更接近编程式事务:方法内切换数据源时,上一个数据源的事务会被提交或回滚,从而保证每个库各自的操作要么成功要么回滚。

但这里有两个硬性注意点,是官方文档里写得不够醒目的地方。

第一,@DSTransactional不能与@Transactional混用,它们的管理机制完全不同,混用会导致事务行为不可预期。第二,这个注解要求底层连接池必须使用 Druid,默认的 HikariCP 在某些版本下不支持这种跨数据源的事务接管。如果你项目里用的是 HikariCP,又确实需要@DSTransactional,那就要同时调整连接池依赖,配置成本会明显上升。

更关键的是,@DSTransactional并不是分布式事务。它只能保证多个独立的本地事务在不同时间点各自提交/回滚,做不到原子性。如果库 A 提交成功、库 B 回滚成功,整体业务状态仍然是"一边成功一边失败"。

5.3 真要跨库写,还是得靠分布式事务

谈到跨数据源写入,这里必须泼一盆冷水:任何本地多数据源框架都解决不了分布式一致性问题。哪怕用@DSTransactional分别管理两个库的本地事务,也做不到整体原子性。网络抖动、某个库宕机,总会出现一边成功一边失败的场景。

业务上常见的替代方案有三个:

  • 不同库之间尽量避免强一致事务,通过消息队列做最终一致性。比如订单创建成功后发一条消息,由消费者异步去加积分。
  • 必须同步一致时,引入 Seata 之类的开源分布式事务框架,dynamic-datasource也做了适配,配置好事务组即可。
  • 最省事的方案是把强相关的表放到同一个库里,用传统的事务解决,让跨库问题根本不存在。

我经历过的项目中,真正需要跨库强一致的业务其实很少,大部分场景最终都改成了异步消息或者接口补偿。所以架构评审阶段就应该想清楚这个决策,别等业务上线了再拿业务数据去填坑。

6. 常见问题与定位思路:从报错信息反查根因

6.1 "数据源不存在"的日志到底在说什么

常见报错长这样:

CannotFindDataSourceException: Cannot find DataSource with key 'slav'

这个异常通常只会在strict=true时出现。定位步骤其实不难,但很多人会走弯路,我自己也经历过大半天的排查。

第一步,对比@DS注解里的值和 YAML 配置里的 key,注意大小写和下划线。slave和slave_1是完全不同的两个 key,Slave也和slave不同。第二步,检查配置是否被 Spring Profile 覆盖,比如某台服务器加载的是application-prod.yml,里面根本没有slave_1这段配置。第三步,检查是否有自定义的DynamicDataSourceProvider过滤掉了部分 key。

如果strict=false,这个报错不会出现,但更危险。因为路由不到时会静默走 primary,表现就是"数据不对"而不是"系统报错"。排查串库数据问题时,先查一遍所有@DS注解的拼写,往往比分析 SQL 更快定位。

6.2 ThreadLocal泄漏与线程池串库

串库的典型表现是:某个请求偶尔拿到别人的数据,日志里同一个方法一会儿走 master 一会儿走 slave,毫无规律。

常见的根源有两个:

一是@Async异步任务。异步线程池不会继承父线程的 ThreadLocal,异步方法里如果依赖了之前设置的数据源上下文,取到的很可能是一个空值或者上一次任务残留的脏值。这个场景下通常的做法是在异步任务入口处显式标注@DS,或者用TransmittableThreadLocal在线程提交时捕获父线程上下文,在任务执行时重新设置。

二是业务代码手动调用DynamicDataSourceContextHolder.push()之后没有在 finally 块里恢复。我后来给自己定的规矩是:能不手动调用就不手动调用,切换数据源全部交给@DS。如果确实需要手动切换,必须用 try-finally 包住,保证清理动作一定会执行。

排查串库问题时,不要一上来就怀疑数据源配置,先看代码里有没有手动 push 和 poll 的操作,再看有没有异步线程池参与了数据库访问,这两个地方占了串库问题的八成以上。

6.3 联动MyBatis-Plus、JPA时的额外注意点

与 MyBatis-Plus 配合使用时,通常不需要额外配置,动态数据源从底层接管了连接获取,框架对这个生态兼容得比较好。

但 Spring Data JPA 的情况不太一样。JPA 的EntityManager会绑定持久化上下文,如果同一个方法内先访问了一个库再切换到另一个库,Session 可能还挂在第一个连接上,表现和行为与事务问题很像。所以使用 JPA 的场景里,我更建议把@DS放在 Repository 的入口方法上,每个 Repository 固定访问一个库,尽量避免在同一个业务方法内来回切换。

如果项目里还接了 ShardingSphere 之类的分库分表中间件,那么层级关系需要想清楚。通常是把dynamic-datasource放在最外层做路由,内层再让 ShardingSphere 做分库分表。这两层之间的版本兼容性需要单独验证,不同版本组合的行为可能有差异,不要想当然认为能直接叠在一起用。

我在实际使用中发现,真正能把这个框架用好的人,往往不是一开始就去研究它的所有扩展点,而是先把默认配置跑通,再在遇到具体问题的时候,回去看它的 AOP 拦截和 ThreadLocal 管理逻辑。多数据源本身不是一个技术难题,难的是事务边界、线程上下文和团队约定这三件事。希望这篇梳理能帮你少踩几个我踩过的坑。

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

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

立即咨询