
做后台系统这几年单数据源的项目我见过不少但业务稍微往前走两步读写分离、多业务库并存几乎是躲不掉的。Druid 作为 Java 生态里用得最多的连接池之一配合多数据源动态切换算是这套需求里最经典的一块拼图。这篇博客就围绕 Drud 多数据源配置与 Datasource 动态切换这个主题把我在实际项目里落地读写分离和多库切换的完整过程梳理出来。从依赖怎么引、YAML 怎么配到 DS 注解怎么用、底层切换原理是什么再到事务边界怎么卡、Druid 监控怎么开最后把我踩过的那几个坑一并摆出来。适合刚接触多数据源、被动态切换折腾过、或者想搞明白连接池和数据源关系到底怎么回事的同学。1. 项目背景什么场景下需要多数据源动态切换1.1 需求场景拆解多数据源不是一个为了技术而技术的东西它背后通常是三类真实需求读写分离主库承担写入从库承担查询降低单库压力。这是最常见的诉求。多业务库比如用户库、订单库、日志库分开部署一个服务要同时访问多套库。异构数据源MySQL、PostgreSQL甚至还有团队会把 ES、ClickHouse 也纳入统一管理。我这次做得比较典型的是一个后台管理系统核心业务数据在主库报表查询量特别大于是单独拆了一个从库来抗查询另外还独立了一个日志库存放操作日志。三个数据源要求接口层按需切换还要保证 Druid 的连接池监控能力不受影响。这种组合在中小团队里很常见不复杂但细节非常多。刚开始我也纠结过要不要上分库分表中间件后来评估下来属于过度设计系统规模远没到那个量级多数据源加读写分离就能解决 90% 的问题成本却低一个数量级。这也是我写这篇博客的初衷——先判断需求再选方案不要在第一步就给自己上难度。1.2 方案选型自研切换 vs 开源组件面对这个需求业内主要有两条路自己基于 AbstractRoutingDataSource 写一套配合 ThreadLocal、自定义注解和 AOP 实现。直接用动态数据源组件dynamic-datasource-spring-boot-starter它在 Spring 官方机制的基础上封装好了 DS 注解和 AOP 切面。两条路我都试过。自研的好处是每一行代码都心里有数坏处是边界情况太多。嵌套切换、事务内切换、方法调用不经过代理导致切面失效这些坑都得自己补。开源组件起步快、文档全数据源分组、懒加载这些实用特性都内置了。我最终的结论是没有特殊定制需求比如必须自定义复杂的路由策略直接用组件。省下来的时间够把业务逻辑写好几遍。如果团队有明确禁止引入非必要依赖的要求再看第四节补充的自研思路核心原理搞清楚后自己写也只是时间问题。2. 环境准备Spring Boot Druid 依赖引入的完整姿势2.1 基础工程与版本选择先交代一下我这次用的版本组合测试过是可以稳定工作的组件版本JDK1.8生产环境很多还停留在这一代Spring Boot2.7.xdynamic-datasource-spring-boot-starter3.5.2druid-spring-boot-starter1.2.16MySQL Connector/J8.0.xSpring Boot 2.7 是目前生产里比较稳的一个版本线组件兼容性也成熟。如果你还在用 2.3、2.4 这种老版本注意动态数据源组件的 3.x 版本虽然支持 Spring Boot 2.x但和 1.x 版本相比 API 有差异不要拿老教程硬套。另外如果你已经上了 Spring Boot 3组件版本要选对应 Jakarta 适配的新版本配置方式差别不大但依赖坐标会有变化。2.2 Maven 依赖组合与冲突处理用 Spring Boot 原生的数据源2.x 默认会带上 HikariCP。这次要换成 Druid并且让动态数据源组件把底层连接池也交给 Druid 来管依赖就要分两步处理!-- 第一步引入动态数据源核心 -- dependency groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot-starter/artifactId version3.5.2/version exclusions !-- 排除自带的 HikariCP避免连接池冲突 -- exclusion groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId /exclusion /exclusions /dependency !-- 第二步引入 Druid 连接池 -- dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.16/version /dependency这里有个很多人容易忽略的细节动态数据源组件本身不直接包含连接池实现它默认会走 Spring Boot 的数据源自动配置。你把 HikariCP 排除掉、同时加入 Druid 的 starter 之后组件在创建每个子数据源的时候会自动使用 DruidDataSource。这一步做对了后面在监控页看到的数据源类型才是 DruidDataSource。注意不要同时保留 HikariCP 和 Druid 依赖。虽然应用能启动但你在监控页看到的数据源类型会变得不可控排查问题时非常误导。依赖引对之后接着就是配置的主战场。3. 数据源配置从一份 YAML 看多数据源的核心参数3.1 动态数据源的基础配置我用的是 YAML 配置三个数据源master主库、slave从库、logdb日志库。核心配置如下spring: datasource: dynamic: # 默认数据源DS 不指定时走这里 primary: master # 严格模式找不到指定数据源时直接报错而不是退回 primary strict: true datasource: master: url: jdbc:mysql://127.0.0.1:3306/app_main?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://127.0.0.1:3306/app_report?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver logdb: url: jdbc:mysql://127.0.0.1:3306/app_log?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driverprimary决定默认走哪个库strict: true是我强烈建议你开启的。不开的话一旦代码里写了不存在的 DS(xxx)组件会静默退回主库你根本发现不了 SQL 跑错了库。开了之后会直接抛异常虽然第一次上线时可能会炸几个接口但总比上线半年后对不上账单好。顺便说一下密码加密的问题。真实项目里密码别明文写在 YAML 里Druid 支持 ConfigFilter 做密码解密也可以借助配置中心托管。这个属于安全加固范畴本篇不展开但如果你用的是公有云环境务必处理一下。3.2 Druid 连接池参数与监控配置多数据源场景下每个数据源都可以单独设置 Druid 连接池参数写在各自的 datasource 节点下面spring: datasource: dynamic: datasource: master: # ... 基础连接信息 druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 6000 validation-query: SELECT 1 test-while-idle: true test-on-borrow: false test-on-return: false pool-prepared-statements: true max-pool-prepared-statement-per-connection-size: 50 filters: stat,wall,log4j2 slave: # ... 基础连接信息 druid: initial-size: 3 min-idle: 3 max-active: 10这里几个参数我说下个人经验initial-size建议不小于 min-idle否则启动后大量请求第一次访问数据库都要现场建连接响应时间会出现尖刺。test-on-borrow: false这个默认值不要随便改。改成 true 虽然能保证每次拿到的连接一定可用但每拿一次连接就要多一次校验高吞吐接口性能掉得厉害。正确做法是保持 false开test-while-idle让空闲连接在后台被定时探测。filters里的顺序有讲究stat 是监控统计wall 是 SQL 防注入。如果业务里有一些特殊 SQL 被 wall 拦截误伤可以只保留 stat或者为对应数据源单独配置 filters。max-wait单位是毫秒指的是从连接池获取连接的最大等待时间。并发高的时候如果连接池被占满超过这个时间会抛异常设置太短容易误报设置太长请求会堆积。Druid 自带的监控页很有用尤其是在多数据源场景下能直接看到每个数据源各自的活跃连接数、SQL 执行数。开启方式spring: datasource: druid: stat-view-servlet: enabled: true url-pattern: /druid/* login-username: admin login-password: admin123 filter: stat: enabled: true log-slow-sql: true slow-sql-millis: 1000注意这里有一个很多人会踩的误解动态数据源场景下spring.datasource.druid.*这层配置在只有一个数据源时才完整生效。多数据源时连接池参数要用spring.datasource.dynamic.datasource.xxx.druid.*而监控 Servlet 的配置还是走全局的spring.datasource.druid.stat-view-servlet。这个错位是新手最容易懵的地方后面第六节我会再详细说。3.3 配置生效的原理与常见失效场景为什么监控配置要分两层因为动态数据源组件会接管 spring.datasource 下面的数据源构建过程。它先创建动态数据源对象再把每个子数据源通过 DruidDataSource 构建出来。如果你把连接池参数写到了全局层那些配置要么不生效要么套用到所有数据源上。用一个我实际遇到的现象说明某次我把 max-active: 20 写在全局 druid 节点结果三个数据源全用了 20日志库根本没必要的连接池资源也被占满。当时查了半天最后是看 Druid 监控页才发现每个库的 max-active 都变成了 20。所以记住一句话动态数据源组件的配置层级是先动态、后连接池连接池参数必须缩进在具体数据源名称之下。配置层拆清楚了下一步就是让代码能真正切到不同库。4. 动态切换机制DS 注解背后的核心原理4.1 AbstractRoutingDataSource 的工作原理要理解切换为什么能生效必须先搞懂 Spring 提供的核心类 AbstractRoutingDataSource。它维护着一个 MapObject, Object targetDataSources本质上是一个路由器它自己不直接管理连接而是通过 determineCurrentLookupKey() 方法返回一个 key然后用这个 key 从 map 里取出真正的 DataSource。我习惯把它比作车站的调度中心所有班车数据库连接停在不同的车道子数据源乘客代码请求到了调度中心调度员先看你要去哪个站点key再把你带到对应车道。动态数据源组件的底层就是继承并增强这个类用 ThreadLocal 保存当前线程要使用的数据源 key。线程内切换、线程间隔离天然不会串库。这也是为什么多数据源方案里 ThreadLocal 几乎是标配——它保证了每个请求的数据源选择是独立的互不干扰。4.2 动态数据源组件的切换流程与栈设计组件把 DS(slave) 注解配合 AOP 切面在执行目标方法前把 key 压入一个栈结构方法执行结束后再从栈里弹出。用栈而不是简单赋值核心原因是支持嵌套切换DS(master) public void outerMethod() { // 当前是 master queryUserList(); // 如果 queryUserList 标了 DS(slave) // 方法内会切到 slave执行完自动回到 master }这种嵌套能力在实际代码里非常实用。比如一个总控方法管理主库事务中途调用了其他 Service 的查询方法内部注解切到从库调用结束之后必须回到主库上下文。如果是简单赋值回来就丢了后续 SQL 全都会跑错库。组件还支持数据源分组比如你配置了group: _sharding再加上slave_1、slave_2组件会在组内做负载均衡选择。这个特性做读写分离时很好用不过要确认自己用的版本支持该配置项。4.3 自研切换方案的实现思路如果你有条件自己实现核心就是三件套AbstractRoutingDataSource 子类、ThreadLocal、自定义注解加 AOP。给一个最小可运行的自研骨架public class DynamicDataSource extends AbstractRoutingDataSource { private static final ThreadLocalString CONTEXT_HOLDER new ThreadLocal(); public static void setDataSource(String key) { CONTEXT_HOLDER.set(key); } public static void clear() { CONTEXT_HOLDER.remove(); } Override protected Object determineCurrentLookupKey() { return CONTEXT_HOLDER.get(); } }配置类里把多个数据源注册进 targetDataSources再写一个切面在方法执行前调用 setDataSource(注解值)执行后 finally 里 clear。自研方案的好处是自由度高可以随时在 setDataSource 里加灰度路由、读写权重分配等逻辑代价就是嵌套切换、事务边界、自调用失效这些坑全都要自己处理。我自己早期写的一版就因为 Service 内部方法之间调用this 调用导致 AOP 切面没有触发动态切换完全失效最后花了一整个下午才定位到是自调用不走代理这个经典问题。如果你打算自研这个坑一定提前避开。5. 实操过程读写分离落地与事务边界控制5.1 具体代码实现与接口层切换实践配置到位后代码层其实非常简单。在 Service 方法或类上直接标 DSService public class OrderQueryService { DS(slave) public ListOrderVO listRecentOrders() { // 这里的所有 SQL 都走从库 return orderMapper.listRecentOrders(); } DS(master) public void createOrder(OrderDTO dto) { // 写操作走主库 orderMapper.insert(dto); } }我更推荐把注解放在方法级别而不是类级别原因有三个一个 Service 里通常读写操作都有类级注解会让所有方法绑定同一个数据源反而失去切换的意义。方法级注解可以配合不同业务场景做细粒度路由比如报表接口强制走从库运营后台允许走主库直查。从可读性来说方法上的 DS 就是一份这条路会访问哪张表的活文档。如果你的数据源名字有强业务含义可以把 DS 的值定义成常量类避免字符串散落各处后续库名调整时只需要在一处修改。比如public interface DataSourceKey { String MASTER master; String SLAVE slave; String LOG_DB logdb; }另外讲一个性能细节如果某些接口并发极高但只读可以把奴隶 s 库的连接池参数调大主库保持较小值让资源分配更贴合真实负载。不要三个库都用同一套连接池参数。5.2 事务与切换的边界问题DSTransactional这里是我认为全篇最应该注意的部分。Spring 的事务是通过 AOP 在方法进入时先获取数据库连接并把它绑定到当前线程上。问题来了如果一个方法先开启了 Transactional然后在方法内部再通过 DS 切库此时连接已经拿好了切换根本不会生效。更具体地说事务已经用主库连接开启了事务内的 SQL 无论怎么切都会继续走这个主库连接。所以我的建议是不要把 DS 和 Transactional 放在同一个方法上。如果必须放在同一个方法那么 DS 的切换动作发生在事务开启前还有救但是事务内的二次切换就不要想了。把开启事务和切换数据源拆到两个 Service 方法里。外层方法负责事务内层方法标 DS 负责切库。动态数据源组件也提供了DSTransactional注解它是一个同时处理切换和事务的组合注解使用方式DSTransactional DS(master) public void doBizWithTx() { // 业务逻辑内部允许 DS 切换并保持各自事务边界 }如果项目对一致性要求不是特别高我实测下来 DSTransactional 在大多数场景是可用的。但它毕竟不是完整的分布式事务方案。如果三个库之间的写入要求强一致就得考虑分布式事务框架或者事后对账不要指望一个注解包打天下。5.3 从零检查数据源是否真的切换成功切换到底有没有生效很多人只看日志里 SQL 有没有报错其实不严谨。我的检查方法很简单给每个数据源里建一张只有自己知道内容的表或者用一个能区分环境的查询语句。更专业的做法是开 Druid 的监控页看当前请求对应的 SQL 落在哪个数据源上。从/druid/sql.html能看到每条 SQL 被哪个数据源执行了如果一条本应跑 slave 的查询出现在 master 的统计列表里那八成是切库没有生效。另外排问题时可以在代码里临时打点验证DS(slave) public void debugSwitch() { // 通过组件上下文获取当前数据源 key String lookupKey DynamicDataSourceContextHolder.peek(); System.out.println(当前数据源: lookupKey); }这个方法依赖组件的内部上下文类生产环境不建议留但排问题时是真的好用。用完记得删掉。6. 坑点实录Druid 多数据源常见问题与排查6.1 常见问题速查表现象可能原因处理方式DS 注解不生效SQL 全走默认库方法内部自调用切面未触发拆分到独立 Bean或通过 AopContext.currentProxy() 调用事务方法内切库无效事务先于切换拿到连接拆方法使用 DSTransactional抛找不到数据源异常strict 模式开启但 key 拼写不对检查 DS 值与 YAML 中数据源名完全一致Druid 监控页打不开stat-view-servlet 未配置或网关拦截确认 YAML 全局配置已开启 servlet连接池参数不生效参数写到了全局 druid 节点而非子数据源节点移动到 spring.datasource.dynamic.datasource.xxx.druid启动报 HikariCP 冲突没排除组件自带连接池pom.xml 中排除 HikariCP6.2 我踩过的几个坑第一个坑是自调用导致切换失效。我在同一个 Service 的公开方法 A 里直接调了同类的方法 BB 标了 DS(slave)结果 B 方法体内执行的 SQL 全部走主库。原因前面已经解释过Spring AOP 基于代理实现同类内部调用走的是 this 对象根本不会经过代理。排查了一下午最后通过把方法拆到另一个 Bean或者开启 exposeProxy 属性解决。第二个坑是全局 Druid 配置覆盖了子数据源。一开始图省事把所有连接池参数都写在 spring.datasource.druid 下面结果发现从库的连接池配置完全无效。后来把配置按子数据源拆开每个库独立参数监控页上终于能对上了。第三个坑是 validation-query 没有配置时偶发连接假死。线上出现过几次接口突然变慢排查发现是 MySQL 主动断开长时间空闲连接后连接池还拿着旧连接不放。加上 test-while-idle: true 和 validation-query: SELECT 1 之后问题消失。第四个坑是日志库切换太频繁连接池被打满。排查发现一个报表查询方法里用了 DS(logdb)但并发没有控制。后来给日志数据源单独设置了更小的 max-active再配合方法级限流才稳定下来。这个案例说明每个数据源连接池参数一定要按实际负载设计不能一套参数走天下。6.3 监控与排查建议多数据源场景下一定要养成先看监控页再猜代码的习惯。Druid 监控页除了 SQL 统计还能看每个数据源的连接池实时状态连接数是否一直顶着 max-active。如果是先看并发高还是连接泄漏。SQL 执行次数分布是否合理。主库和从库的负载是否和预期一致。慢 SQL 列表里有没有本应走从库却出现在主库里的查询。这通常意味着切库配置有遗漏。生产环境如果不想暴露监控页给 /druid/* 加一层网关拦截或者内网限制访问都可以但建议不要完全关闭。数据源故障排查时监控数据能帮你节省大量时间。最后再补充一个小的调试技巧如果多个数据源在同一个事务里出现了数据不一致的脏读迹象先不要急着怀疑组件先用上面第 5.3 节的方法确认切换是否真的发生。很多时候问题出在业务代码悄悄绕过了 DS而不是组件本身。我个人把动态数据源从自研折腾到组件化最大的体会是多数据源本身不难难的是把路由规则和事务边界想清楚。事务与切库的先后顺序、组件与手工配置的分层这两点一旦理顺后面就是配置和注解的体力活。最后再分享一个小技巧上线前写一个专门的数据源巡检接口遍历所有配置的数据源拿 SELECT 1 测试连通性再对比 Druid 监控页里的数据源列表。这个接口放到测试环境跑一遍比我当年在线上靠报错日志反推问题要高效得多。多数据源不是银弹但配置得当它确实能让系统在数据量增长时多扛很长一段时间。