☰
dynamic-datasource多数据源切换实践与避坑指南
2026/10/3 4:12:29 网站建设 项目流程

最近在做一个老项目的改造,数据库从一个库拆成了六个库。第一次启动看到配置里躺着一排数据源的时候,我脑子里蹦出来的第一个想法是:这要是每个 Service 里都去手动指定 DataSource,代码还能看吗?后来接触了 dynamic-datasource-spring-boot-starter,才觉得多数据源这件事,终于有人把脏活累活都干完了。这篇文章就基于我实际使用中的经验,聊一聊这个 starter 到底解决了什么问题、核心设计是怎么运转的,以及上手必踩的坑。适合那些项目里已经有多库、主从、租户隔离场景,又天天跟 Spring Boot 打交道的人。我会尽量把原理和配置都拆开讲,不藏着掖着。

1. 多数据源的痛,远比你想的严重

1.1 什么场景会把你逼到多数据源这条路上

很多人一开始都是单库走天下,等系统跑起来才发现,数据库不是你想拆就能不拆的。最常见的就是业务拆分:订单数据量大了,单独拆一个订单库;用户体系独立出来,丢到用户库;日志和埋点数据量大、写入频繁,必须跟核心业务库隔离开,否则一个慢查询能拖垮整个交易链路。这时候你在一个 Spring Boot 应用里就同时面对好几个库了。

读写分离也特别常见。主库负责写入,从库扛住查询,一个读多写少的业务系统不这么做,主库CPU分分钟被打满。读写分离本质上也是一种多数据源场景——同一个业务,不同的连接指向不同角色的库。

还有一个容易被忽略的场景是多租户。SaaS 系统里每个客户的数据是隔离的,有的按 schema 隔离,有的直接按独立数据库隔离。如果你们团队选择的是“一个租户一个库”这种架构,那应用启动的时候就要知道几百个库的地址,还得根据请求头里的租户标识动态去连对应的库。

这些场景落到代码层面,都是一个需求:同一个方法里,我要知道当前该用哪个数据源,而且在调用链路上能灵活切换。

1.2 不用专门方案,代码是怎么一步步变烂的

最开始大家都会走一条老路:在配置里定义多个 DataSource Bean,然后在 Service 里用 @Qualifier 指定注入。比如这样:

@Autowired @Qualifier("orderDataSource") private DataSource orderDataSource; @Autowired @Qualifier("userDataSource") private DataSource userDataSource;

表面上看挺清晰,可业务一复杂就撑不住了。每个方法都要小心翼翼地记住自己用的是哪个数据源,新增一个库就要改动一堆注入代码。更麻烦的是,如果你在 Service A 里调 Service B,而 B 内部用了另一个数据源,整个调用链的数据源归属就很难追踪。我记得有次排查线上问题,翻代码翻到深夜,最后发现是一个工具方法在不知不觉中把连接池切到了错误的库,查出来的数据怎么对都对不上。

还有人会想到用 MyBatis 的多环境配置来做,或者在 Mapper XML 里写死不同的 connection,但这些都是旁门左道。最核心的问题在于:数据源的切换本质上是“路由”问题,路由规则应该集中管理,而不是散落在业务代码里。

1.3 市面上的多数据源方案,为什么我最终选了它

谈到多数据源,很多人第一反应是 ShardingSphere。确实,ShardingSphere-JDBC 也很强,既能分库分表又能读写分离,但它的定位是重方案。如果你只是“连几个不同的库,然后按业务切换”,引入 ShardingSphere 就像是开着一台挖掘机去拧一颗螺丝,配置复杂度、规则引擎的学习成本、团队的上手门槛都不低。

Spring 原生也提供了一个 AbstractRoutingDataSource,它允许你维护一个 key 到 DataSource 的映射,动态路由到目标数据源。思路是对的,但用起来相当原始——你要自己维护 targetDataSources 的注册,自己写切 key 的逻辑,还要处理 AOP 拦截和线程上下文传递。说白了,它是一块半成品积木,离“开箱即用”还差很远。

我自己实际体验下来,dynamic-datasource-spring-boot-starter 最打动我的地方是:它把 AbstractRoutingDataSource 背后的那套思想做成了完整的产品。你不需要关心 targetDataSources 怎么维护、AOP 怎么织入、线程变量什么时候清理,它全部封装好了,你只需要写一个注解。而且它是苞米豆开源生态的一员,跟 MyBatis-Plus 配合得很顺,很多项目都在用,坑已经被人踩得差不多了。

我把几种方案的取舍整理成了一个表格,方便大家对比:

方案配置成本代码侵入功能覆盖适合场景
手动注入多个 DataSource低高极低只有两个库、改动极少的系统
AbstractRoutingDataSource中中低熟悉原理、愿意自己造轮子的团队
ShardingSphere-JDBC高中高需要分库分表、读写分离、分布式事务的复杂系统
dynamic-datasource-starter极低极低中高多库切换、主从分离、多租户,不需要分表

2. dynamic-datasource 的核心设计,一条注解背后的链路

2.1 数据源分组:一套配置背后的设计巧思

我第一次看它的配置格式时,差点没反应过来。它跟原生 Spring 的 DataSource 配置长得不一样,最明显的区别是引入了“组”的概念。

spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://192.168.1.100:3306/master_db username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver slave_1: url: jdbc:mysql://192.168.1.101:3306/master_db username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver order: url: jdbc:mysql://192.168.1.102:3306/order_db username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver

这里有两个细节很多人没搞懂,我当初也是一脸懵。第一,master和slave_1为什么能自动组成一个主从组?规则是数据源 key 用下划线分隔,下划线前的部分相同,就归为一组。master和master_1、master_2会组成主库组,slave_1、slave_2组成从库组。你用 @DS("slave") 就能在这个组里轮询切换。

第二,order没有下划线分组,就自动归入默认组。默认组里如果多个库,你直接用 @DS("order") 切换到具体那个库。而primary指定的是默认数据源——当没有任何 @DS 注解时,所有操作都走这里。这一点特别关键,它保证了老代码不需要改动,新加的库只是增量引入。

还有一个strict参数,我强烈建议在生产环境打开。不启用时,如果你 @DS 指向了一个不存在的 key,starter 会默默降级回 primary 数据源,这会导致数据读写到错误的地方,那个 bug 极难排查。开启 strict 后,找不到对应数据源会直接抛异常,问题能第一时间暴露。

2.2 @DS 注解的完整链路:AOP、ThreadLocal 和动态路由

@DS 是日常使用最多的注解,但它背后做的事很多人未必清楚。我在排查问题的时候把源码链路翻了一遍,整个流程其实可以拆成五步:

第一步,Spring 容器启动时,starter 从配置中解析所有数据源,构建 key 到 DataSource 对象的映射,并注册一个基于 AbstractRoutingDataSource 的动态路由数据源作为主 DataSource。

第二步,业务方法上标注 @DS("order") 后,starter 里的 AOP 切面会拦截这个方法调用,解析注解里的数据源 key,然后把这个 key 写入当前线程的 ThreadLocal。

第三步,进入到 MyBatis 或者 JdbcTemplate 的底层操作时,它们会从 DataSource 接口获取连接,而这个接口的实现就是动态路由数据源。路由数据源在获取连接时调用 determineCurrentLookupKey 方法,从 ThreadLocal 中取出刚才写入的 key,然后从映射表中拿到真正目标数据源。

第四步,从目标数据源获取连接,执行 SQL。

第五步,方法执行完毕,切面在 finally 块中清理 ThreadLocal,避免线程池复用导致数据源串库。

sig这段链路听起来复杂,但用户能感知到的只有一个注解。我花时间读这部分源码,主要是因为后来遇到一个诡异问题——线程池里跑批任务时,一批数据偶尔会写到错误的库里去。最终定位到的原因就是 ThreadLocal 没有清理干净(当时用的是老版本里的一个边界场景),从那以后我对清理时机这件事就格外敏感。如果你也在代码里这么做,记得检查 starter 版本,新版对清理逻辑做了很多加固。

2.3 三种典型业务场景,映射到 @DS 的用法

多业务库切换最简单。订单相关的逻辑加 @DS("order"),用户相关的逻辑加 @DS("user"),互不干扰。需要注意,注解可以加在类上,也可以加在方法上,方法上的优先级高于类上。我习惯在类上写默认值,在特殊方法上单独覆盖。

读写分离的玩法是动态数据源最具价值的地方。比如一个查询接口,正常情况下读从库,但遇到实时性要求极高的场景,可以临时指定读主库。你可以直接 @DS("master") 强制走主库,或者配置负载均衡策略,让 @DS("slave") 在多个从库之间轮询。对查询量特别大的系统,这比在代码里做读写分离逻辑要省力太多。

多租户的玩法最有意思。租户信息通常在请求头里,你可以在拦截器里解析租户ID,把它映射成一个数据源 key,再通过 @DS 注解里支持 SpEL 表达式的方式动态选择。这个能力把它从一个“多库切换工具”变成了一个“运行时路由框架”,后面我会专门讲。

3. 从零配置一个能跑的多数据源项目,照着抄就行

3.1 引入依赖,注意版本别翻车

引入依赖本身不复杂,一个 Maven 坐标就搞定:

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

真正容易翻车的是版本适配。这个 starter 的版本演进跟 Spring Boot 的大版本强相关:如果你用的是 Spring Boot 2.x 和 JDK 8,用 3.5.x 系列是最稳的;如果你已经升级到 Spring Boot 3.x,那一定要用 4.x 系列,因为 Spring Boot 3 从 javax 命名空间迁移到了 jakarta,老版本 starter 里的反射代码和自动配置类会遇到兼容问题。

我见过的最典型的报错是启动时提示 ClassNotFoundException,指向 javax.sql 或者其他 javax 包下的类。遇到这种问题别急着怀疑代码,大概率是 starter 版本和 Boot 版本不匹配。升级到 Spring Boot 3 的项目,顺手把 dynamic-datasource 也升到 4.x,问题直接消失。

顺带提一句,我看到不少人在问 IntelliJ IDEA 社区版能不能开发 Spring Boot。答案是完全没问题,社区版现在对 Spring Boot 的支持已经很好了,跑这个多数据源项目、看 Bean 的依赖关系、Debug 都没什么障碍,别为了一个 IDE 去纠结收费版。

3.2 写一份覆盖主从和多业务库的配置

配置看似简单,但这里有几个参数值得仔细调。我给出一个生产环境级别的参考配置:

spring: datasource: dynamic: primary: master strict: true lazy: true hikari: max-pool-size: 20 min-idle: 5 connection-timeout: 30000 datasource: master: url: jdbc:mysql://192.168.1.100:3306/master_db?useSSL=false&characterEncoding=utf8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver slave_1: url: jdbc:mysql://192.168.1.101:3306/master_db?useSSL=false&characterEncoding=utf8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver slave_2: url: jdbc:mysql://192.168.1.102:3306/master_db?useSSL=false&characterEncoding=utf8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver order: url: jdbc:mysql://192.168.1.103:3306/order_db?useSSL=false&characterEncoding=utf8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver

这里我说几个容易被忽略的点。

lazy: true表示懒加载数据源。如果你有几十个租户库,每个库对应一个 HikariCP 连接池,而 Spring Boot 启动时会初始化所有连接池,那启动时间会爆炸。打开懒加载后,只有第一次被使用时才初始化连接池,能大大缩短应用启动时间。

HikariCP 的max-pool-size不是越大越好。连接池大小跟业务并发数和数据库最大连接数强相关,很多人统一配 50,结果数据库连接数被打满,比连接不够还惨。我的习惯是核心库配 20 左右,非核心库配 10,先观察监控再逐步调。

还有个 master 库和下划线的问题。如果你只有一个主库,直接叫 master 就行,不用写 master_0,因为默认组里 primary 指向的就是它。只有当需要主主互备、多主写入的时候,才需要 master_1、master_2 这组概念。

3.3 在代码里用 @DS 切换数据源

配置写好后,代码层面的使用简洁得让人不太适应。最简单的用法是加在 Service 实现类上:

@Service @DS("order") public class OrderServiceImpl implements OrderService { @Resource private OrderMapper orderMapper; @Override public List<OrderDO> listOrders() { return orderMapper.selectList(null); } }

这样这个 Service 里所有方法默认走 order 库。如果有一个方法需要走主库,单独覆盖一下:

@Override @DS("master") public OrderDO getOrderByNo(String orderNo) { return orderMapper.selectOne(...); }

读写分离场景下,查询方法直接标注走从库:

@Service public class ReportServiceImpl implements ReportService { @Resource private ReportMapper reportMapper; @DS("slave") public List<ReportDO> dailyReport(String date) { return reportMapper.queryDailyReport(date); } }

注意这里的slave不需要指定 slave_1 还是 slave_2,它会在从库组内自动负载均衡。默认是轮询策略,如果想改成随机策略,可以在配置里调整负载均衡的算法。

3.4 怎么确认你真的切到了目标库

这个建议我每次都要强调:配置完多数据源之后,第一步不是写业务代码,而是验证切换是否生效。曾经有人配好了然后就闷头开发,等联调的时候发现所有请求都打到了默认库,排查了很久。

最简单直接的验证方式,是在代码里临时注入动态数据源,打印当前线程的数据源 key:

@RestController public class TestController { @Resource private DataSource dataSource; @GetMapping("/ds") public Object testDs() { if (dataSource instanceof DynamicRoutingDataSource) { DynamicRoutingDataSource ds = (DynamicRoutingDataSource) dataSource; return ds.getCurrentDataSource(); } return dataSource.getClass().getName(); } }

打印出来的结果如果是当前方法预期的数据源 key,说明注解生效了。另一个办法是看日志,HikariCP 连接池初始化时会在日志里打出连接池的名称,不同库的连接池名称是不同的,一眼就能分辨。

如果你的项目接了 Spring Boot Actuator,也可以把数据源的指标信息暴露出来,通过健康检查接口观察各个连接池的状态。很多运维团队会顺手把 Spring Boot Admin 接上,直接在页面上看连接池状态和 SQL 监控,多数据源项目配合这些监控能力,线上排查问题会轻松很多。

4. 事务和连接池,两个藏得最深的坑

4.1 @DS 和 @Transactional 同时出现,谁先谁后

这个坑我在初期踩得最惨,也值得所有用这个 starter 的人注意。先说结论:直接在同一个方法上同时加 @DS("order") 和 @Transactional,切换大概率不生效,或者在事务里执行的 SQL 全部打到默认库去。

原因在于 Spring 的事务管理和切面代理的执行顺序。@Transactional 的事务拦截器优先级比 @DS 的切面高,方法进入时,事务拦截器先开启事务并拿到数据库连接,此时连接已经被绑定到当前线程上。等 @DS 切面再设置数据源 key,事务管理器已经不管这个 key 了,它用的还是事务刚开始时那个连接。

我之前在订单模块里写过这样的代码,测试时发现插入的数据跑到主库里去了,查了半天才意识到是事务把连接绑死了。

正确的做法是分层拆分:外层方法负责数据源切换,内层方法负责事务控制。具体来说:

// 正确的做法 @DS("order") public void createOrder(OrderDTO orderDTO) { // 调用内层方法,事务会基于 order 数据源开启 orderTxService.insertOrder(orderDTO); } @Service public class OrderTxService { @Transactional(rollbackFor = Exception.class) public void insertOrder(OrderDTO orderDTO) { orderMapper.insert(orderDTO); } }

如果业务比较简单,也可以用动态数据源提供的 @DSTransactional 注解,它是专门处理事务与数据源切换的,解决了原生 Spring 事务和 @DS 互相打架的问题。但建议先理解上面这个原理,因为不管用什么注解,本质都是“让事务在正确的数据源上下文中开启”。

另外一个跟主从相关的问题是:主从架构下,从库上的查询是只读的。我见过有人往从库 insert 数据,然后报错说 readonly transaction,其实是因为把写操作挂在了从库上。写操作记得在方法上显式指定 @DS("master")。

4.2 连接池和 SPI:为什么默认是 HikariCP,能否换 Druid

starter 默认使用 HikariCP,因为 Spring Boot 2.x 之后默认的 DataSource 就是 HikariCP,它性能好、轻量、稳定,没有必要重复造轮子。但国内很多团队用 Druid 习惯了,依赖了阿里的连接池。如果想换,不需要改代码,只需要加依赖然后做配置:

<dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.20</version> </dependency>
spring: datasource: dynamic: druid: initial-size: 5 max-active: 20 min-idle: 5 validation-query: SELECT 1

它的内部通过 SPI 机制扩展了连接池的创建逻辑,所以你不需要自己 new 连接池。这个设计我很喜欢,它把所有数据源连接池的创建都收口到框架层,业务代码拿到的永远是一个统一的 DataSource 对象,底层是 HikariCP 还是 Druid,对上层完全透明。

如果你对连接池有特别定制需求,比如需要配置多个连接池且各自的参数不同,也可以单独给某个数据源覆盖参数:

spring: datasource: dynamic: datasource: order: url: jdbc:mysql://... hikari: max-pool-size: 50

这个特性在多业务库场景下特别实用。比如订单库并发高,你希望它的连接池资源给得更多,而日志库并发低,给它 5 个连接就够。通过局部配置覆盖,比在全局统一参数上纠结要合理得多。

说到 Druid,顺带再多说一句。如果你用的是 MyBatis-Plus,Druid 的 SQL 监控和慢 SQL 拦截配合起来效果很好。多数据源环境下,一个库里慢 SQL 影响到其他库的场景很常见,建议至少接一套监控体系,否则出了问题你连是哪个库的哪个 SQL 拖垮了系统都不知道。

4.3 多租户动态切换:从固定注解到运行时决策

前面讲的 @DS 都是固定在代码里的,但真实的多租户系统不可能给每个租户单独写一个方法。这时候就要用动态解析数据源 key 的能力。

我目前的项目是一个 SaaS 平台,几十个租户,每个租户一个独立库。我的做法是定义一个请求上下文,在拦截器里解析请求头中的租户ID,然后通过动态数据源的策略接口把租户ID映射成对应的数据源 key。

这里给一个最小示例。假设你的租户配置存在一个 Map 结构里(实际项目中通常是查配置中心或者数据库),那么在业务方法里会这样用:

@DS("#tenantContext.dataSourceKey") public List<BizDO> queryBizData() { return bizMapper.selectList(null); }

SpEL 表达式#tenantContext.dataSourceKey会在运行期从当前 Bean 的上下文中取值。TenantContext 是一个 ThreadLocal 持有当前请求的租户信息。拦截器里提前设置好:

public class TenantInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String tenantId = request.getHeader("X-Tenant-Id"); // 根据 tenantId 映射到数据源 key,设置到上下文 TenantContext.set(tenantId); return true; } }

这套组合拳让代码里完全没有 if-else 的租户判断逻辑,数据源的选择变成了一次纯粹的路由决策。重点是注意把 TenantContext 的生命周期管理好,建议用 OncePerRequestFilter 统一设置和清理,避免线程池复用导致租户信息串了。

如果租户ID和数据源 key 的映射需要更复杂的策略,比如按用户ID哈希到不同的库,也可以用 starter 的动态数据源策略 SPI 自己实现一个策略类,重写 key 的解析逻辑。这些都是比较高级的玩法,但要想清楚一个问题:动态解析虽然灵活,调试的时候也会变得更难,日志里务必打印出每次请求最终解析出的数据源 key。

5. 常见问题排查实录,踩过的坑都在这了

5.1 高频问题速查表

很多问题其实都是重复出现的,我整理了一份速查表,方便大家直接对照。

现象常见原因解决方案
@DS 注解完全不生效方法被 this 自调用,绕过了代理;或者类上没加注解且方法上写了注解但没生效确保调用是从代理对象发起的,或者把注解提到类上
事务方法里切换数据源无效事务拦截器已经提前绑定了连接拆分方法,让事务在 @DS 之后开启;或使用 @DSTransactional
切到不存在的 key 时没报错strict 未开启,默默降级到 primary配置中开启 strict: true
启动报 javax.* 类找不到starter 版本与 Spring Boot 3.x 不兼容升级 starter 到 4.x 系列
连到从库的数据总是旧的主从复制延迟,或路由走了从库对实时性要求高的方法强制 @DS("master")
数据源连接池启动卡死数据库地址不可达,或连接池参数过大检查网络和配置,考虑把 lazy 设为 true
插入数据库报只读错误写操作走了从库在写方法上显式 @DS("master")
线程池执行任务数据串库ThreadLocal 未清理或被线程池复用检查切面清理逻辑,升级 starter 版本

5.2 我实际踩过的三个坑

第一个坑是懒加载引发的启动假死。当时在一个有二十多个数据源的项目里配置了 lazy: false(默认就是立即加载),结果每次启动都要初始化几十个连接池,每个连接池都要去数据库建连接,启动一次要五分钟,期间还偶发超时。后来把 lazy 打开,启动瞬间从五分钟降到二十秒,这是立竿见影的收益。

第二个坑是严格模式没开,导致数据写错库。那是一次联调环境的诡异问题:开发人员在一个需要切到测试库的接口上,注解 key 写错了,因为 strict 是 false,系统静默走了 primary 数据源,把测试数据写到了主库的测试表里。好在只是测试环境,但也足够吓出一身冷汗。从那以后,所有环境我都强制 strict: true。

第三个坑跟版本升级有关。项目从 Spring Boot 2.7 升级到 3.2 时,starter 没有同步升级,启动直接报错。当时以为是哪个配置写错了,折腾了半天,最后才发现是 javax 到 jakarta 的命名空间切换导致自动配置类加载失败。升级 starter 到 4.x 后一切恢复。这个坑其实是最没技术含量却最耗时间的,版本适配问题一定优先排查。

这套方案我用了大半年,最大的感受是:多数据源不该成为业务代码的负担。用之前,代码里到处是数据源的影子;用之后,数据源切换变成了一个注解、一次路由决策。不过我也得说一句实话,动态数据源解决的是“连接哪个库”的问题,它不会帮你解决分布式事务和数据一致性的根本难题。如果业务真的需要跨多个库保持强一致,还是要认真调研分布式事务方案。关于这个方向,以及数据源策略的更多自定义玩法,我下篇可以继续展开聊,这里先分享到这儿。

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

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

立即咨询