去年把一个跑了三年多的订单中台从 Spring Boot 2.7 往 3.2 上搬最耗时的一段不是 jakarta 包名替换而是动态数据源那一层。Spring Boot 3 整合 MyBatis-Plus 做多数据源切换表面上只是在 yml 里多写几段配置实际动手才发现自动配置链、AOP 执行顺序、事务连接绑定这三件事会互相咬合任何一个环节理解偏了都会出现注解写了但切不过去的诡异现象。这篇就把整套东西从头拆一遍为什么要切、切在哪里、用什么切、切完怎么验证以及我在真实项目里踩过的坑。内容偏实战适合已经会写 Spring Boot 但没系统搞过多数据源的兄弟也适合正在做 Boot 3 升级、被DS折腾过的同学。1. 单数据源配置在多库场景下最先暴露的三个问题1.1 Spring Boot 3 的自动配置链是怎么把数据源焊死的只要你在application.yml里写了spring.datasource.urlDataSourceAutoConfiguration就会给你造一个HikariDataSource然后 MyBatis-Plus 的 starter 再通过MybatisPlusAutoConfiguration把它注入到SqlSessionFactory里最后交给SqlSessionTemplate。整条链路是单例、单引用、不可替换的——换句话说容器里只有一个DataSourceBean所有 Mapper 拿到的都是同一个连接池你想让某个查询走另一个库光靠改配置是做不到的。很多人第一反应是那我配两套spring.datasource.xxx不就完了实际上 Spring Boot 的宽松绑定只认一个前缀你写第二套它直接忽略启动日志里一声不吭这是最坑的地方不报错、不警告只是默默用了第一套。所以第一步要建立正确认知——多数据源的本质是路由不是多配几个 url。1.2 业务侧的三种典型诉求读写分离、多租户、历史库查询我在项目里遇到的多数据源需求基本逃不出三类。第一类是读写分离。主库承担写入和强一致读从库承担报表、列表、导出这类弱一致查询。这类场景的特点是切点明确、流量特征清晰一个DS(slave)标在查询方法上就解决大半。第二类是多租户分库。每个租户一个 schema连接信息存在配置中心或租户表里运行时按请求头里的租户标识去选库。这类场景的关键词是动态——数据源不是启动时确定的而是要能增、能删、能刷新。第三类是历史库/归档库查询。主库只留热数据超过一定时间的数据落在另一套库里用户点查看历史时切过去查。这类场景最容易出问题的不是切换本身而是分页和排序——跨库的order by加limit在两边语义不一样得在应用层合并。把需求归类清楚很重要因为不同类型的选型完全不同。读写分离用注解式路由足够多租户往往要配合自定义DataSource注册与缓存历史库查询则要考虑查询结果归并。1.3 一个反直觉的事实多数据源不是多配一个 url不少人以为多数据源的复杂度在配置多其实真正的复杂度在三处切点在哪一层、什么时候切换、切换状态存在哪里。切点是标在 Mapper 接口上还是 Service 方法上还是靠 SpEL 从参数里算出来时机切换必须发生在获取连接之前一旦连接被事务绑定到线程后面切了也没用。存储当前用的是哪个数据源这个状态要放在 ThreadLocal 里线程池复用时要记得清理否则会串数据源。第三点是我见过最多事故的地方。用过线程池的同学应该都有体会Tomcat 的工作线程是复用的如果一次请求切到 slave 之后没有在finally里清掉下一个请求可能莫名其妙走了 slave读到了主从延迟的数据然后一堆数据不一致的工单就来了。所以后面讲的每一个方案我都会特别强调清理动作。2. 选型对决裸写 AbstractRoutingDataSource 还是直接上 dynamic-datasource 启动器2.1 AbstractRoutingDataSource 的骨架与它的三处硬伤Spring 官方给的方案是AbstractRoutingDataSource它内部维护一个MapObject, DataSource你只需要实现一个方法public class MyRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.get(); } }然后在配置类里把targetDataSources和defaultTargetDataSource塞进去再把Primary标上。这套方案我早期项目里用过能跑但有三个绕不过去的地方。第一是启动即初始化。targetDataSources里的每个数据源在容器启动时就会建好连接池如果某个从库临时不可达整个应用起不来。对多租户场景来说租户是动态增加的这显然不行。第二是没有现成的注解支持。你得自己写Aspect、自己定义注解、自己处理优先级方法注解 类注解 默认还要处理 SpEL 解析。这些代码写一遍不算难但维护起来很烦。第三是事务与多数据源的事务语义要自己兜。跨库一致性怎么处理官方没给任何东西。2.2 dynamic-datasource-spring-boot3-starter 在 Boot 3 下的坐标与版本对应现在主流做法是直接用dynamic-datasource的 starter但 Spring Boot 3 有个大坑artifactId 变了。Boot 2 时代用的是dynamic-datasource-spring-boot-starterBoot 3 必须换成dynamic-datasource-spring-boot3-starter两者代码结构几乎一样但内部依赖的自动配置注册文件路径不同Boot 3 走META-INF/spring/...AutoConfiguration.imports。如果你把 Boot 2 的那个坐标直接贴到 Boot 3 项目里结果是依赖能下下来、编译能过、启动不报错、但DS完全不生效。因为自动配置根本没被加载。同样地MyBatis-Plus 在 Boot 3 下也要用mybatis-plus-spring-boot3-starter。这两个坐标我在下面列个表方便对照。组件Spring Boot 2.x 坐标Spring Boot 3.x 坐标MyBatis-Plusmybatis-plus-boot-startermybatis-plus-spring-boot3-starter动态数据源dynamic-datasource-spring-boot-starterdynamic-datasource-spring-boot3-starter连接池druid-spring-boot-starterdruid-spring-boot-3-starterServlet APIjavax.servletjakarta.servlet版本上MyBatis-Plus 建议 3.5.3.1 以上dynamic-datasource 建议 4.x。版本不匹配时最常见的症状是ClassNotFoundException: javax.servlet.Filter或者NoClassDefFoundError一看就知道是 java 包名没换干净。2.3 什么情况下我还是会手写路由虽然 starter 很好用但有几种场景我会选择回到AbstractRoutingDataSource自己控制。一是路由规则极其复杂。比如按租户 ID 的哈希分片 按时间范围二次路由 灰度名单覆盖这种逻辑写在 SpEL 里会变成一行几十字符的天书不如自己写个DataSourceRouter类来得清晰。二是数据源需要完全自管生命周期。比如数据源信息来自某个注册中心需要在收到变更事件时热加载还要做连接池的优雅关闭。starter 支持addDataSource和removeDataSource但如果你需要更细粒度的控制比如预热、探活、按权重摘除自己封装一层会更顺手。三是项目中已经有一套自研的租户框架。强行接入 starter 反而会出现两套路由逻辑打架的情况这时候统一到一套自己实现的AbstractRoutingDataSource更稳妥。3. 从零跑通依赖、配置文件和第一个 DS 标注3.1 基础环境与依赖清单先明确环境JDK 17、Spring Boot 3.2.x、MyBatis-Plus 3.5.5、dynamic-datasource 4.3.x、MySQL 8。JDK 17 是 Boot 3 的硬门槛别想着用 JDK 8 硬凑编译期就会挂。pom.xml里的核心依赖如下注意我把两者放一起是为了让你一眼看到坐标差异dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot3-starter/artifactId version4.3.0/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency有个细节值得说不要再引入spring-boot-starter-jdbc。dynamic-datasource 内部已经带了 JDBC 相关的依赖重复引入容易造成版本冲突出现DataSource类型转换异常。如果你确实需要JdbcTemplate引入后要记得给它显式指定数据源否则它会拿到动态路由的那个DataSource行为上没问题但你在调试时会分不清走的是哪个库。3.2 application.yml 里 primary、strict、lazy 三个参数的真实含义配置长这样spring: datasource: dynamic: primary: master strict: false lazy: true datasource: master: url: jdbc:mysql://10.0.0.1:3306/order_db?useSSLfalseserverTimezoneAsia/Shanghai username: app_rw password: ${DB_PWD} driver-class-name: com.mysql.cj.jdbc.Driver slave1: url: jdbc:mysql://10.0.0.2:3306/order_db?useSSLfalseserverTimezoneAsia/Shanghai username: app_ro password: ${DB_PWD_RO} driver-class-name: com.mysql.cj.jdbc.Driver这三个参数我逐个说清楚因为它们的默认值都很容易让人误解。primary指定默认数据源不写的话默认就是master名字必须叫 master不是第一个。这里有个小陷阱如果你把第一个数据源命名成main而没写primary启动时会直接报找不到 master 的错误。strict默认是false这是个非常危险的默认值。它的含义是当DS(xxx)指定的数据源不存在时不报错静默回退到 primary。我遇到过线上一个报表模块被标成了DS(report)但配置里那个库因为运维疏漏没加上结果所有报表查询全走主库慢 SQL 把主库拖垮了。所以我在所有项目里都会显式写strict: true让它启动或调用时就报错问题暴露得越早越好。lazy默认也是false意味着容器启动时会初始化所有数据源。这在多租户场景下很致命——租户库一多启动时间线性增长而且任何一个库连不上都会导致启动失败。设成true之后数据源在使用时才创建配合addDataSource动态注册启动速度能压回正常水平。代价是第一次访问会有几百毫秒的额外延迟可以接受。3.3 第一个切换样例注解与手动两种写法注解式最省事标在 Service 方法上Service public class OrderQueryService { DS(slave1) public ListOrderVO listRecentOrders(Long userId) { return orderMapper.selectByUser(userId); } }手动切换适合那些需要在一个方法里访问多个库的场景DynamicDataSourceContextHolder.push(slave1); try { ListOrder list orderMapper.selectByUser(userId); } finally { DynamicDataSourceContextHolder.poll(); }这里必须强调poll()一定要写在finally里。用push和poll而不是set和clear是因为前者是栈结构支持嵌套切换。比如外层切到 slave1内层某个工具方法又切到 slave2执行完poll会自动回到 slave1不会出现状态丢失。3.4 验证切换是否真的生效三个不用猜的办法写完代码别急着提交用下面三种方式至少验证一次。第一种打开 MyBatis 的 SQL 日志在 yml 里加上mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl然后看你连的是哪个库。更直接的办法是在两个库上给同一个表造不同的数据比如主库的测试表只有 10 条从库有 8 条查询返回的条数不一样说明切换成功了。第二种在数据源上打标。写一个临时接口执行select hostname或者SELECT CONNECTION_ID()不同实例返回不一样一眼就能看出走的是哪个。第三种看连接池的活跃连接数。Hikari 可以通过 JMX 或 Actuator 拿到每个池的指标切换成功时被访问的那个池才会有活跃连接。这个方法在排查到底切没切时特别好用。4. DS 到底切在了哪一层AOP 顺序、ThreadLocal 与连接绑定的完整链路4.1 从方法调用到 Connection 的六步链路很多人对DS的理解停留在加个注解就切库但真出问题时不知道从哪查。把链路拆开就清楚了调用方进入被DS标注的方法实际调用的是 Spring 生成的代理对象。DynamicDataSourceAnnotationInterceptor拦截调用解析注解里的 key支持 SpEL。把解析出的 key 通过DynamicDataSourceContextHolder.push(key)压入 ThreadLocal 栈。方法内部执行 MyBatis 查询SqlSessionTemplate从DynamicRoutingDataSource请求连接。DynamicRoutingDataSource.getConnection()调用determineDataSource()从 ThreadLocal 取 key从dataSourceMap里拿到真实的DataSource。方法返回后切面在finally里执行poll()清理 ThreadLocal。关键在于第 3 步必须在第 4 步之前也就是 AOP 切面的执行顺序要早于事务拦截器。dynamic-datasource 的切面 order 设得很靠前正常情况下没问题但如果你自己又加了别的切面并且 order 更小就可能把顺序打乱。这时候的典型症状是偶尔能切、偶尔不能切非常难查。4.2 为什么 self-invocation 一定失效这是 Spring AOP 的通病但在多数据源场景里后果特别严重。看这段代码Service public class OrderService { Autowired private OrderService self; public void outer() { this.queryFromSlave(); // 失效走的是主库 self.queryFromSlave(); // 生效因为走了代理 } DS(slave1) public void queryFromSlave() { ... } }this.queryFromSlave()是对象内部调用不经过代理切面根本没机会执行。self.queryFromSlave()注入的是代理对象所以生效。解决方式有三种注入自身Spring 4.3 之后支持循环依赖但 Boot 2.6 之后默认关闭了循环依赖需要开spring.main.allow-circular-referencestrue、用AopContext.currentProxy()需要开exposeProxy、或者干脆把方法拆到另一个 Service 里。我个人推荐第三种结构最干净也不会引入额外配置。4.3 切点表达式与 SpEL用租户 ID 做数据源 key读写分离用静态 key 就够了多租户必须用动态 key。starter 支持 SpELDS(#tenantId) public ListOrderVO listByTenant(String tenantId) { ... }也支持从 Session 或 Header 里取DS(#session.tenantName) DS(#header.tenantId)用 SpEL 的时候有个容易被忽略的点参数名必须在编译时保留。Java 8 之后-parameters默认不开Maven 编译出来的 class 里参数名变成了arg0、arg1SpEL 就找不到tenantId了。表现为运行时报Cannot resolve identifier tenantId。解决办法是在maven-compiler-plugin里加compilerArgsarg-parameters/arg/compilerArgs或者在注解里用#p0这种位置参数。5. 事务与动态数据源撞车Transactional 把连接提前锁死之后5.1 复现Service 里加了 TransactionalDS 就哑了这个坑我踩过两次第一次排查了大半天。代码大概是这样DS(slave1) Transactional(readOnly true) public ListOrderVO listOrders(Long userId) { // 这里跑的其实是主库 return orderMapper.selectByUser(userId); }为什么因为 Spring 的事务管理器在开启事务时会通过DataSourceUtils.getConnection(dataSource)从数据源拿一个连接然后把这个连接绑定到当前线程TransactionSynchronizationManager。之后这个方法里所有的数据库操作都会复用这个已绑定的连接DynamicRoutingDataSource.getConnection()根本不会被再次调用自然也就不会重新路由。而Transactional的默认 order 是Ordered.LOWEST_PRECEDENCE理论上DS先执行ThreadLocal 里已经有 key 了。所以上面的代码看起来应该是对的。但如果你是通过编程式方式先开了事务再调用带DS的方法就一定会走默认数据源。真正的高危写法是这样Transactional public void doSomething() { orderService.listOrders(userId); // 期望走 slave1实际还是 master }外层方法开了事务连接已经绑定成 master内层DS无论怎么写都无效。这也是为什么官方建议DS要加在最外层方法上不要在事务里面切。5.2 DSTransactional 的能力边界别把它当分布式事务starter 提供了一个DSTransactional它可以管理多个数据源上的事务。但必须说清楚它是本地事务的批量提交不是分布式事务。它不会做两阶段提交也没有回滚日志。实现方式是给每个参与的数据源各开一个连接方法正常返回时统一 commit抛异常时统一 rollback。它的风险在于如果 commit 阶段某个库失败了比如网络抖动已经提交的库无法回滚数据就不一致了。所以我对它的使用原则是——只在多库写入且业务能容忍极小概率不一致的场景用比如日志、埋点、非核心的统计写入。核心的资金、库存、订单状态变更老老实实放在一个库里用本地事务保证。真要做跨库强一致得上 XA 或者 Seata那是另一个话题了。顺带提一句DSTransactional和Transactional不要混用混用时的行为非常难预测我在测试环境里试过几种组合结果差异挺大不值得赌。5.3 跨库写入的几种务实方案结合项目经验我一般这么选能不分库就不分库。很多所谓的多库需求本质是历史数据太多用分区表、归档表就能解决没必要引入跨库事务。必须分库且要求强一致用本地消息表 定时补偿或者引入可靠消息中间件做最终一致。业务上把必须同时成功的写操作收敛到同一个库。必须分库且能接受最终一致用DSTransactional兜住大部分情况再加一层对账任务每天比对两边数据把差异捞出来人工或自动修。这里补一个细节DSTransactional底层依赖ConnectionFactory它要求参与事务的数据源是真实数据源而不是代理数据源。如果你在配置里套了 P6Spy 或者自定义的DataSource包装类可能会报cannot find datasource的错误。这时候要在配置里显式指定p6spy: true让框架知道怎么剥代理。6. MyBatis-Plus 侧被连累的三个配置分页插件、逻辑删除、SQL 注入器6.1 多 SqlSessionFactory 下分页插件必须逐套注册如果你走的是多套 MyBatis-Plus 配置的路线每个数据源一个SqlSessionFactory那分页插件、乐观锁插件、防全表更新插件每一套都要单独注册一次。Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); return interceptor; }只往一个SqlSessionFactory里塞了MybatisPlusInterceptor的效果是那个库上的分页正常另一个库上page()调用不报错但返回全量数据。这个 bug 特别隐蔽因为结果集看起来有数据只有对比总数或者看 SQL 日志才能发现limit没了。所以只要是多个SqlSessionFactory就写个循环统一注入别手写多份。不过我的建议是能用单SqlSessionFactory 动态路由就不要搞多套。MyBatis 的配置只写一份插件只注册一次维护成本低得多。只有在两个库的表结构完全不同、Entity 需要分开扫描时才考虑多套。6.2 逻辑删除字段在多库不一致时的报错现场mybatis-plus.global-config.db-config.logic-delete-field: deleted这类全局配置是全局生效的。也就是说只要你配了所有 Mapper 生成的 SQL 都会自动带上WHERE deleted 0。如果主库的表有这个字段而某个历史库的表没有查询时就会直接抛Unknown column deleted in where clause。我见过一次报错在历史数据查询接口上开发同学第一反应是我没写这个条件啊其实是全局配置偷偷加的。处理方式有两种。一种是保证所有参与多数据源的库表结构一致这是最省事的。另一种是关掉全局配置改成在实体类上单独标TableLogic(value 0, delval 1) private Integer deleted;这样只有标了的实体才会带逻辑删除条件粒度更细但每个实体都要写一遍工作量大。我的选择是——只要是多数据源项目一律用全局配置并且把所有库表结构一致写进建表规范从源头避免。另外提一个容易踩的点logic-delete-value和logic-not-delete-value的默认值是1和0如果你数据库里存的是Y/N或者时间戳一定要显式配上否则查询结果永远为空而且不报错。6.3 自定义 SQL 注入器与实体扫描路径用了自定义ISqlInjector比如自己实现批量插入的同学在多数据源下要注意两点。一是注入器是绑定到SqlSessionFactory的多套工厂就要配多次。二是**MapperScan的扫描路径要覆盖到所有 Mapper**如果不同库的 Mapper 放在不同包下扫描路径要写全否则会报No qualifying bean或者启动时报Invalid bound statement (not found)。这个报错很多人会误以为是 XML 没找到其实是 Mapper 没被注册。7. 排查链路实录DS 不生效时我按这五步走7.1 第一步确认代理有没有生成最直接的判断方式是启动时看日志里有没有DynamicDataSourceAnnotationAdvisor相关的注册信息或者在DS方法上打个断点。如果你用的是 IDE 调试可以在方法入口处看调用栈如果栈里没有DynamicDataSourceAnnotationInterceptor说明切面根本没拦截到——要么是 self-invocation要么是方法不是publicSpring AOP 对非 public 方法默认不代理要么是类没被 Spring 管理。7.2 第二步确认 key 有没有进 ThreadLocal在方法内打印DynamicDataSourceContextHolder.peek()看返回的是不是你期望的 key。返回 null 说明切面没执行返回默认数据源名说明注解没被解析SpEL 报错时框架会静默 fallback这点很坑。SpEL 解析失败时不会抛异常打断业务所以一定要主动验证。7.3 第三步看是不是被事务提前绑定打印TransactionSynchronizationManager.isActualTransactionActive()和getCurrentTransactionName()。如果事务为 true 且当前方法不在最外层那基本就是连接已被绑定。把DS提到外层或者把事务拆开。7.4 第四步strictfalse 的静默回退陷阱检查DS里的 key 和配置里的数据源名是否完全一致包括大小写。DS(Slave1)和配置里的slave1是两个不同的 key。开了strict: true之后这类问题会在第一次调用时直接抛CannotFindDataSourceException比静默回退到主库好得多。7.5 第五步连接池与健康检查的干扰如果用了 Actuator/actuator/health会去调DataSourceHealthIndicator它拿到的是路由数据源可能在启动时就尝试连接并报错。解决办法是management.health.db.enabledfalse或者自定义一个HealthIndicator遍历所有真实数据源做探活。另外注意某些连接池比如 Druid自带的监控 Filter 会包装DataSource导致路由拿到的不是原始对象需要在配置里做透传。8. 上线前我会过一遍的清单8.1 连接池参数的独立配置每个数据源都要有独立的连接池参数不要指望全局配置能覆盖所有。读库的连接数可以大一些查询慢但并发高写库的要小一些避免把主库连接占满。我一般按这个比例配写库maximum-pool-size取单实例 QPS 峰值的 1.5 倍读库取 3 倍connection-timeout统一设 3 秒避免请求堆积。这些值要在压测里验证别照抄。8.2 动态增删数据源的线程安全如果你用了addDataSource注意它内部操作的是ConcurrentHashMap是线程安全的但在遍历数据源列表做批量操作时要自己加锁。另外removeDataSource之后如果还有线程持有旧数据源的连接会报Connection is closed。我的做法是移除前先广播一个下线标记等 N 秒超过最大查询耗时再真正移除做一个延迟摘除。8.3 监控与告警多数据源上线后至少要有三个监控每个数据源的活跃连接数、慢 SQL 数量、切换失败次数。切换失败可以通过在自定义的determineDataSource里埋点统计。我吃过一次亏某个从库挂了两小时因为所有查询都静默回退到主库监控上只看到主库 QPS 涨了没报错等发现时主库已经快撑不住了。开了strict: true之后这类问题会在第一次请求就暴露出来。最后分享一个我自己的小习惯不管多简单的多数据源需求我都会先在两个测试库上造一批可区分的数据写一个/debug/datasource的临时接口返回当前连接的真实库名和连接 ID。后面每次改配置或者升级依赖先跑这个接口几秒钟就能确认路由没坏。这比翻日志快多了。