多数据源这东西平时开发不一定会遇到但一旦遇到能把人折腾到怀疑人生。我最近在两个项目里分别接手了读写分离和分库拆表的改造最后都统一用 dynamic-datasource-spring-boot-starter 把动态切换做掉了。这个组件是什么一句话它解决的是 Spring Boot 应用里“同一个接口或 Service 调用中怎么按照业务规则自动选择不同数据库连接”的问题。适合正在做多库、读写分离、多租户隔离的 Spring Boot 开发者也适合被 Spring 原生 AbstractRoutingDataSource 和手动 ThreadLocal 折腾过的同学。这篇文章不打算只丢一份配置而是从问题出发把中间的原理链路、落地步骤和踩坑过程都摊开讲一遍。顺便说一句这次先讲基础和核心机制也就是“上篇”。等把原理讲透了下篇再聊一些更进阶的玩法比如基于参数的动态路由、动态新增数据源以及项目里常见的坑位扩展。如果你现在正在被多数据源切换搞得头疼这篇文章应该能帮你省不少时间。1. 为什么多数据源这么麻烦原生方案的真实痛点1.1 你迟早会遇到的多数据源场景很多人觉得多数据源就是“连两个库”配置两个 DataSource 不就行了真这么简单就不会有这篇文章了。实际业务里更常见的是这么几种场景读写分离主库负责写入从库负责查询查询量大时还需要在多个从库之间分摊压力垂直分库用户、订单、商品、日志分别在独立库里服务层做按需访问多租户每个租户一个库登录信息里带上租户标识请求进来后动态路由到对应库统计报表业务库与统计库分离定时任务或临时分析需要切到统计库执行。这几种场景的共同点是不是“启动时定死一个库”而是“每个请求、每个方法有自己该用的数据源”。如果把数据源连接池写死那代码里就得手动从多个连接池中挑一个拿 Connection再自己管理事务。你很快就会遇到“这个线程上次用的连接怎么还在”、“事务回滚怎么没生效”、“并发一高一堆 ThreadLocal 串数据”之类的诡异问题。1.2 Spring 原生 AbstractRoutingDataSource 的局限Spring 本身其实提供了一个抽象类 AbstractRoutingDataSource它的思路是定义一组目标数据源再通过一个路由 key 决定当前该用哪个。大概长这样public class MyRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return MyDataSourceContext.get(); } }配置上MyRoutingDataSource routingDataSource new MyRoutingDataSource(); MapObject, Object targetDataSources new HashMap(); targetDataSources.put(master, masterDataSource); targetDataSources.put(slave, slaveDataSource); routingDataSource.setDefaultTargetDataSource(masterDataSource); routingDataSource.setTargetDataSources(targetDataSources);这个方案确实能工作但你自己动手就会感受到几个问题所有切换逻辑都得自己维护没有一个统一的地方告诉 Spring“这个 Service 方法用哪个库”数据源是提前写死在一张 Map 里的想运行时动态注册新库没那么直观路由 key 存哪一般就是 ThreadLocal但 ThreadLocal 的存取、清理、嵌套切换都要自己写好漏一处就是线上事故和事务绑定、和连接池监控、和 MyBatis 等框架的配合全都需要额外胶水代码。所以原生的存在很有意义但直接拿来用更像是“给你一堆零件让你自己造车”。多数项目需要的是一个开箱即用的轮子。1.3 自己写动态切换为什么容易翻车我见过不少团队一开始是自己封装一个 ThreadLocal 切面做一个 DataSource 注解看起来也没那么复杂。但运行一段时间后问题就来了。第一个坑是线程池。Spring 里大量用到线程池执行异步任务比如 Async、定时任务。如果你在任务提交前往 ThreadLocal 里塞了数据源 key任务真正执行时可能已经拿不到或者拿到的还是上一个任务遗留的 key那结果就是 A 租户查到了 B 租户的数据。第二个坑是事务和连接的绑定时机。Spring 事务开启时会从当前数据源拿一个连接并绑定到事务中。如果你先进入了事务再切数据源 key事务里拿到的还是老连接。很多同事以为切库是在“执行 SQL 的瞬间”发生的实际上事务已经焊死了连接。第三个坑是清理不干净。ThreadLocal 里的 key 如果方法结束后没及时 remove在 Web 应用线程池复用的场景下下一个请求会读到上一个请求残留的数据源 key。轻则查询报错重则数据串库而且很难排查。这些坑正是 dynamic-datasource 这个组件要帮你解决的。它不是简单把 ThreadLocal 封装了一下而是把“注解定义路由规则 AOP 拦截 上下文传递 动态路由 连接池管理 事务协同”这些环节都串成了一条完整的链路。2. dynamic-datasource 的核心设计注解、切面与上下文传递2.1 DS 注解的约定先定规则再执行dynamic-datasource 的使用核心是一个注解DS。它可以放在类上也可以放在方法上。放在类上表示这个类的所有方法默认走某个数据源方法上的注解优先级更高会覆盖类上的配置。DS(order) Service public class OrderService { // 走 order 库 public void createOrder(Order order) { ... } DS(product) public void checkProductStock() { // 这个方法切到 product 库 } }这里有一个容易忽略的设计注解的 value 其实是一个数据源标识对应你配置文件里 datasource 列表中的某个 key。比如你配了 names 为 master、slave那 DS(slave) 就是路由到 slave 连接池。另外 DS 的值还支持 SpEL 表达式典型场景是方法参数里带了租户 id运行时根据租户 id 拼出数据源名。这种用法在下篇聊动态路由时会展开但你要知道它并不只是写死字符串。2.2 切面是怎么把 key 塞进上下文的有了注解接下来就得有人去读取它。dynamic-datasource 内部实现了一个 DataSourceAspect 切面专门拦截带 DS 的方法。整体逻辑很直接进入方法前从方法或类上解析 DS 注解得到目标数据源 key把这个 key push 到 DynamicDataSourceContextHolder 里执行业务方法方法结束后从上下文中把当前 key poll 掉避免泄漏。如果你阅读过源码会发现这个切面在方法进入时会做一层判断如果已经存在同一个 key就不重复压栈如果在执行过程中发现上下文为空再从默认数据源取。这样设计是为了保证嵌套调用时不会把栈搞乱。这个切面本身是一个标准的 Spring AOP 切面所以它也有一个大家都知道的前提必须走 Spring 代理。也就是说你的方法要被外部 Bean 调用而不是同类内部直接 this 调用。这个后面排查章节会重点说。2.3 ThreadLocal 栈与嵌套切换为什么不只是个 Map你说不就是一个 String 的 ThreadLocal 吗其实不是它内部用的不是简单字符串而是一个 Deque也就是栈结构。为什么要栈因为业务方法会嵌套。假设外层 DS(master) 调用了内层 DS(slave)执行顺序大概是外层方法开始push(master)调用内层方法push(slave)内层方法结束poll 掉 slave外层方法继续执行栈顶回到 master外层方法结束poll 掉 master如果只是简单 set/get内层切换会把外层 key 覆盖掉恢复不出来。栈就是为了支持这种“嵌套后还能回到上一层数据源”的诉求。每次 poll 时组件会检查栈是否为空。如果栈空了说明当前线程没有显式的数据源切换需求路由时就回落到 primary 配置的默认数据源。2.4 事务到底怎么和动态路由兼容的这是整个设计里最值得讲的部分也是很多人踩坑的根源。Spring 的 Transactional 一旦开启事务就会从当前路由数据源拿到一个 Connection并把这个 Connection 绑定到当前事务上下文。你后面再切换数据源 key当前事务手里的连接并不会变因为事务已经开始不可能中途换连接。dynamic-datasource 本身是一个基于 AbstractRoutingDataSource 的动态路由实现。真正获取连接时routingDataSource 会调用 determineCurrentLookupKey从 ThreadLocal 栈顶取当前 key再从缓存的目标数据源 Map 中找到真正的 DataSource通过 DataSourceUtils 获取连接。所以正确协作的前提是先确认数据源再开启事务。这也是为什么 DS 应该放在事务方法的外层或者至少先于事务边界被执行。如果你在事务方法内部切库那大概率切了个寂寞。为了解决这种切库和事务打架的问题组件额外提供了 DSTransactional 注解。它和 Spring 的 Transactional 思路不太一样它允许你在同一个业务方法里多次切换数据源并且让切到的每个数据源都在同一个“多数据源事务”中登记提交时按顺序提交回滚时全部回滚。注意这种方案不是分布式事务它没有二阶段提交一致性窗口是存在的。如果业务对跨库强一致要求很高还是得引入 Seata 这类分布式事务方案。不过大多数场景下我们要的只是“读走从库、写走主库且一个业务方法里不要随意跨库写”。这时候只要遵守事务边界规则Transactional 和 DS 配合使用是没问题的。3. 从零接入十分钟把 dynamic-datasource 跑起来3.1 Maven 依赖与版本选择引入依赖很简单Maven 里加一个坐标dependency groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot-starter/artifactId version3.6.1/version /dependency版本这里要注意一点3.5.x 之后的版本对 Spring Boot 3 和 Jakarta 命名空间支持更完整。如果你用的是 Spring Boot 2.x老版本也能用如果是 Spring Boot 3建议直接上 3.6.x 或者最新 release。具体版本号以 Maven 中央仓库为准不要盲目追新但也不要停留在过于古老的版本。这个 starter 本身会自动引入 HikariCP并和 Spring Boot 的数据源自动配置做整合。引入后Spring Boot 默认的 DataSourceAutoConfiguration 会被 dynamic-datasource 接管你需要在配置里改写成它的多数据源结构。3.2 配置文件多数据源可以这么写最基础的配置长这样spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://localhost:3306/db_master username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://localhost:3306/db_slave username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver这里的 master 和 slave 是 key不是固定名。你可以改成 order、user 等等DS 注解里写的字符串和你这里配置的 key 保持一致就行。primary 指定默认数据源。如果代码里没有 DS也没有上下文 key就会走 primary。我的习惯是 primary 一定指向主库因为大多数写操作、事务操作默认都应该在主库执行避免误切。strict 是严格模式。如果开启 strict当 DS 指定了一个不存在的 key 时会直接抛异常关闭 strict则回落到 primary。我建议开发环境开启 strict上线前能及时发现拼错的数据源名。3.3 一个能直接跑的切库示例配合 MyBatis-Plus 或者普通 MyBatis代码只需要两步。第一步在启动类或者配置类上确保扫描到 MapperSpringBootApplication MapperScan(com.example.demo.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }第二步在 Service 里加 DSService public class UserService { jakarta.annotation.Resource private UserMapper userMapper; DS(slave) public ListUser listFromSlave() { return userMapper.selectList(null); } DS(master) public void insertUser(User user) { userMapper.insert(user); } }接口层不需要关心数据源直接调 UserService 就行。调用 listFromSlave 时AOP 会把 slave 压入上下文SQL 执行时连接从 slave 连接池取出方法结束上下文恢复。如果需要更细粒度可以把 DS 放在 Mapper 接口方法上。不过不建议这么做数据源路由本质是业务层的事务边界问题放 Mapper 太散不好管理而且一旦一个 Service 方法里调用多个不同库的 Mapper你都不知道该信谁。3.4 关键配置项一览除了上面最基础的两个数据源实际项目里还会用到一些配置项。整理一个我日常会用到的清单配置项说明备注spring.datasource.dynamic.primary默认数据源 key建议固定为主库spring.datasource.dynamic.strict未匹配 key 时是否抛异常建议 truespring.datasource.dynamic.lazy懒加载数据源多数据源很多时可以开启spring.datasource.dynamic.p6spy开启 SQL 日志插件需要另行引入 p6spy 依赖spring.datasource.dynamic.seata集成 Seata仅在需要分布式事务时配置spring.datasource.dynamic.datasource.*各数据源连接池参数支持 hikari、druid 等连接池参数可以在每个数据源下单独配置比如设置 max-pool-size、min-idle 等。HikariCP 的参数和 Spring Boot 单数据源时的写法基本一致把 datasource 换成 master/slave 的子级即可。如果你用了 Druid可以在全局配 druid 相关参数也可以在每个数据源下单独配。多数据源场景下最忌讳把所有连接池参数混在一起不同库的负载不一样配同一套参数大概率会出问题。比如报表库查询量巨大连接池大一点业务库写入频繁连接池反而不用开太大。4. 实战中的高频坑与排查思路4.1 DS 不生效内部方法调用是头号杀手动态数据源基于 Spring AOP所以它只能在代理对象的方法调用上生效。最常见的失效场景是同一个类里 this 调用Service public class OrderService { public void handle() { // 这里调用的是 this.queryFromSlave() // 不是 Spring 代理调用 this.queryFromSlave(); } DS(slave) public void queryFromSlave() { ... } }因为 this 指向原始对象而不是代理对象所以 DS 不会被切面解析。你可能会看到日志里根本没有数据源切换记录SQL 直接走了默认库。解决方式有三种把要切库的方法单独放到另一个 Service / Bean 里或者注入自身代理或者用 AopContext.currentProxy()。最干净的做法是拆类。把数据源差异明显的逻辑拆成独立的 Service这样不仅 DS 能生效事务边界也清晰。4.2 事务把数据源焊死了DS 必须在事务外层这个坑我见过不止一次。代码大概是Transactional public void doBiz() { insertMaster(); querySlave(); // DS(slave)以为会切库 } DS(slave) public void querySlave() { ... }doBiz 开启了事务Spring 事务管理器先从默认数据源拿到连接并绑定到线程。之后就算 querySlave 上的 DS 把上下文 key 改成 slave事务内部所有 SQL 还是使用原先那个连接。这不是 bug而是 Spring 事务的语义如此一个事务一个连接中途不允许换连接。要规避需要做两件事确认你确实需要同一个事务处理两个库如果是那就别指望 Transactional DS 切库应该用 DSTransactional 或者分布式事务如果只是读操作把 DS(slave) 放到事务方法外面比如拆成两个 service 方法让读方法单独走代理调用并保证读方法不在写事务范围内。实际业务里很多“切库失败”根本不是路由问题而是事务边界问题。排查时先看方法栈上有没有 Transactional再看调用链是不是走到了代理对象。4.3 跨库事务别裸奔多数据源最容易让人误用的一点既然能切来切去那跨两个库写数据是不是也能靠 DS 自动搞定不是。两个不同的数据库之间没有本地事务可言。你用 Transactional 包住 A 库写和 B 库写实际效果是 A 库提交成功、B 库失败时A 库不会自动回滚。dynamic-datasource 提供 DSTransactional 的初衷是解决“一个业务方法内多个数据源写操作”的本地事务问题但它的实现思路是统一管理多个连接提交时依次提交回滚时遍历回滚。它没有真正的分布式事务协调也没有 XA 两阶段提交。对一致性要求严格的场景比如订单和库存、账户和流水不要靠它硬扛。如果你确定业务涉及跨库强一致应该引入 Seata 这类分布式事务中间件并把数据源纳入 Seata 的代理管理。dynamic-datasource 的 seata 配置可以配合使用但那是另一个话题需要单独设计全局事务表、回滚日志等。总之普通的 DS 只是路由不是分布式事务的银弹。4.4 排查技巧日志能告诉你一切多数据源切换出问题时第一件事不是猜是打开日志。我通常会在开发环境配这么一行logging: level: com.baomidou.dynamic.datasource: debug开启 debug 后组件会打印当前路由到的数据源 key、是否存在、使用的是哪个连接池等信息。你可以清楚看到每一次切换的入栈出栈过程。如果某一次判断错了日志会非常直观地暴露出来。另一个小技巧是在业务代码里临时打印当前上下文DynamicDataSourceContextHolder.push(slave); try { String current DynamicDataSourceContextHolder.peek(); log.info(current datasource key: {}, current); } finally { DynamicDataSourceContextHolder.poll(); }注意 peek 只是查看栈顶不会清空push 和 poll 必须成对出现最好用 try-finally 包住。生产环境不建议临时打印这么细开发调试时很有用。4.5 命名、初始化与连接池的隐藏坑最后记录几个容易被忽略的点。数据源 key 命名尽量只用字母、数字、下划线不要用中文也不要用带点的字符串。有的框架或者监控组件会把 key 拼进日志和指标名带点特别容易看串。多数据源数量很大时建议开启 lazy 懒加载。否则应用启动时会一口气创建所有连接池数据库压力会瞬间拉高启动时间也会变长。开了 lazy 后连接池会在第一次被路由到时才初始化启动体验会好很多。连接池的心跳、检测参数也要按库分别调。比如某个老库网络不稳就给它较长的 connection-timeout新库性能好就提高 max-pool-size。把一套参数复制到所有库上往往会出现主库连接不够用、从库连接闲得发慌的情况。最后说一点关于 primary 默认库的体会。我见过有项目把 primary 配成一个不用的库想强制所有代码必须显式 DS。这个思路出发点是好的但实际效果是大量报错团队成员天天排查“为什么没加注解”。我更推荐 primary 指向最核心的主库然后靠 Code Review 和测试来保证关键方法显式切库。说实话这套组件我最初看源码时心里是有点惊讶的它没有用多复杂的技术核心就是 AbstractRoutingDataSource ThreadLocal 栈 AOP 拦截。真正值钱的是把所有边界情况都考虑进去了嵌套切换、异常恢复、事务同步、严格模式。这些东西如果自己攒一套短期可能能用但长期维护成本非常高。我个人在实际项目里的做法是把 DS 优先放在 Service 的公开方法上而不是 Mapper 上所有跨库写操作必须画出调用链明确事务边界开发环境开启 strict 和 debug 日志让路由错误尽早暴露。如果你也想在项目里引入动态数据源建议先照着这篇文章把最小示例跑通再逐步替换掉手写的 ThreadLocal 和路由切面。等基础链路稳定了下篇我们再聊按参数动态路由、运行时新增数据源、和 Seata 配合这些进阶内容。
