资讯动态

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 指向了一个不存在的 keystarter 会默默降级回 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 groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot-starter/artifactId version3.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?useSSLfalsecharacterEncodingutf8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver slave_1: url: jdbc:mysql://192.168.1.101:3306/master_db?useSSLfalsecharacterEncodingutf8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver slave_2: url: jdbc:mysql://192.168.1.102:3306/master_db?useSSLfalsecharacterEncodingutf8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver order: url: jdbc:mysql://192.168.1.103:3306/order_db?useSSLfalsecharacterEncodingutf8 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 ListOrderDO 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 ListReportDO dailyReport(String date) { return reportMapper.queryDailyReport(date); } }注意这里的slave不需要指定 slave_1 还是 slave_2它会在从库组内自动负载均衡。默认是轮询策略如果想改成随机策略可以在配置里调整负载均衡的算法。3.4 怎么确认你真的切到了目标库这个建议我每次都要强调配置完多数据源之后第一步不是写业务代码而是验证切换是否生效。曾经有人配好了然后就闷头开发等联调的时候发现所有请求都打到了默认库排查了很久。最简单直接的验证方式是在代码里临时注入动态数据源打印当前线程的数据源 keyRestController 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能否换 Druidstarter 默认使用 HikariCP因为 Spring Boot 2.x 之后默认的 DataSource 就是 HikariCP它性能好、轻量、稳定没有必要重复造轮子。但国内很多团队用 Druid 习惯了依赖了阿里的连接池。如果想换不需要改代码只需要加依赖然后做配置dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.20/version /dependencyspring: 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-PlusDruid 的 SQL 监控和慢 SQL 拦截配合起来效果很好。多数据源环境下一个库里慢 SQL 影响到其他库的场景很常见建议至少接一套监控体系否则出了问题你连是哪个库的哪个 SQL 拖垮了系统都不知道。4.3 多租户动态切换从固定注解到运行时决策前面讲的 DS 都是固定在代码里的但真实的多租户系统不可能给每个租户单独写一个方法。这时候就要用动态解析数据源 key 的能力。我目前的项目是一个 SaaS 平台几十个租户每个租户一个独立库。我的做法是定义一个请求上下文在拦截器里解析请求头中的租户ID然后通过动态数据源的策略接口把租户ID映射成对应的数据源 key。这里给一个最小示例。假设你的租户配置存在一个 Map 结构里实际项目中通常是查配置中心或者数据库那么在业务方法里会这样用DS(#tenantContext.dataSourceKey) public ListBizDO 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 后一切恢复。这个坑其实是最没技术含量却最耗时间的版本适配问题一定优先排查。这套方案我用了大半年最大的感受是多数据源不该成为业务代码的负担。用之前代码里到处是数据源的影子用之后数据源切换变成了一个注解、一次路由决策。不过我也得说一句实话动态数据源解决的是“连接哪个库”的问题它不会帮你解决分布式事务和数据一致性的根本难题。如果业务真的需要跨多个库保持强一致还是要认真调研分布式事务方案。关于这个方向以及数据源策略的更多自定义玩法我下篇可以继续展开聊这里先分享到这儿。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑