简介这是一份面向Spring Boot与MyBatis开发者的多数据源配置指南专门解决主从模式或分库业务中需要连接多个数据库的配置痛点。与常见的JPA复杂配置或Spring自带AOP动态切换方案相比文中给出的方法更加轻量直接适合希望快速搭建双数据源的中级开发者。资源为单文件PDF共1个文件大小仅54KB内容紧凑但覆盖完整。目前已有2356人学习下载是较受关注的多数据源实现参考。PDF围绕最简方案展开完整呈现了基于Configuration、MapperScan、Primary等注解的数据源配置过程先创建DataSource再注入SqlSessionFactory随后配置事务管理器最后封装SqlSessionTemplate并明确指定主库与分库的mapper扫描路径、XML映射文件位置。文中还涉及如何按库拆分dao层与xml目录、事务管理要点等关键细节并配有可直接参考的代码示例。读者可按此思路快速迁移到自己的项目中降低多源配置的调试与维护成本。1. springbootmybatis多数据源为什么需要一个最简方案先破除两个连接池的惯性我第一次做 springbootmybatis 多数据源时第一反应是配两个SqlSessionFactory、两套Mapper、两个事务管理器。结果配置类写了十几个 Bean启动还偶尔循环依赖XML 文件路径稍不留神就扫错。后面换成了基于AbstractRoutingDataSource的动态路由方案代码量砍掉大半但同事接手时又开始怀疑“只靠一个线程变量切换库是不是玄学”。这个标题要解决的正是这件事把多数据源收敛成“一个入库连接池 若干个业务库连接池运行时按路由键选库”。它不是新框架而是组合 Spring Boot 自动装配和 MyBatis 的现有机制适合读写分离、同构分库、或者临时接一个报表库的场景。如果你不想引入额外重依赖又想让每次切换都肉眼可见这篇应该能给你一套可以照抄的骨架。2. 动态数据源路由原理一个 SqlSessionFactory 带动多个库Spring Boot 自动装配帮了什么忙2.1 AbstractRoutingDataSource 的 lookupKey 机制黑匣子里的两根线Spring 的AbstractRoutingDataSource继承了AbstractDataSource内部维护了两张表targetDataSources和defaultTargetDataSource。当应用代码调用getConnection()时它先执行determineCurrentLookupKey()用返回的 key 去targetDataSources里找目标DataSource找不到就用默认库。所以你要做的只是两件事往 map 里塞数据源以及重写那个 key 取值方法。这个类本身不关心 key 从哪里来可以是 ThreadLocal、请求头、甚至随机数真正的工作方式是一个很薄的路由器。这个机制在 MyBatis 里的工作流是SqlSessionFactory只需要绑定这一个路由数据源mapper接口和 XML 里的 SQL 不感知数据库变化。每次执行 SQL 时MyBatis 向SqlSession拿连接SqlSession向DataSourceUtils要连接这个请求落到AbstractRoutingDataSource.getConnection()上路由才真正发生。换句话说Spring Boot 自动装配负责把数据源注入容器MyBatis 的SqlSessionFactoryBean负责把数据源固定到会话工厂上剩下的切换逻辑从determineCurrentLookupKey()开始。调用链Mapper 方法 - SqlSessionTemplate - DataSourceUtils.getConnection - AbstractRoutingDataSource.getConnection - determineCurrentLookupKey - 从 targetDataSources 拿到目标库连接这个链路解释了为什么一旦事务开始、连接被绑定到当前线程后面再改路由 key 也来不及了。后面第 4 章会专门说这个坑。现在的关键是先理解这个方案中全局只存在一个DataSource类型的 Bean 被 MyBatis 使用其余业务库连接池都“藏”在路由表里。2.2 ThreadLocal 与 AOP 的职责边界为什么不在 Controller 层硬切有人会问既然 key 可以来自任何地方为什么不直接用请求参数因为业务代码并不都在 Controller 里一个请求可能经过 Service、异步线程、定时任务把 key 当参数层层传下去会让每个方法都多一个形参改造量巨大。用ThreadLocal保存当前线程要用的库名是最贴合“沿着调用链传播”的做法。它本质上是一个线程私有变量同一个线程里Supplier、Service、Mapper都能读到同一个值直到你显式清掉。但 ThreadLocal 只是存储不是切换动作。真正让 key 生效的是 AOP 切面在目标方法执行前setKey执行后clear。这比手动在每个方法第一行写DataSourceContextHolder.setKey(slave)更可靠因为finally清理可以在异常时也执行。如果你把切面类标注为Order(0)让它先于 Spring 事务切面执行就能保证事务创建连接时路由 key 已经就位。这里很多人翻车后面避坑章节会展开。方法调用 1. AOP 切面 Before - ThreadLocal.set(key) 2. 业务方法执行事务管理器获取连接 - 路由数据源按 key 选库 3. 方法返回或抛异常 - After - ThreadLocal.remove()2.3 最小依赖清单与 Spring Boot 自动装配的介入点先说依赖。最简方案不需要额外的多数据源框架只需要dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId /dependency连接池默认使用 HikariCP因为spring-boot-starter-jdbc已经把它包进来了零额外配置。Spring Boot 自动装配的介入点在于DataSourceAutoConfiguration只要 classpath 下有一个DataSource它就会自动创建 JdbcTemplate 等组件。如果你再自定义DynamicDataSource就和自动配置的数据源产生竞争要么设置Primary要么直接排除自动配置。我一般会选择排除DataSourceAutoConfiguration然后自己定义所有数据源避免启动时出现“发现多个 DataSource 却不知道注入哪个”的报错。需要用到一个SpringBootApplication(exclude DataSourceAutoConfiguration.class)或spring.autoconfigure.exclude。至于 MyBatis 的自动装配建议也关掉它的数据源注入逻辑只保留MybatisAutoConfiguration的 Mapper 扫描能力或者干脆手动定义SqlSessionFactory。这个选择在第 3 章写法里会写在代码注释中。spring.autoconfigure.excludeorg.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration为什么这里特别强调自动装配因为很多人看到“最简”就直接在application.yml里写spring.datasource.url结果一堆配置被自动装配接到同一个 Hikari 池里动态路由完全不生效。自动装配解决的问题是“少写 Bean”但在多数据源场景下它更需要的不是接管数据源而是把SqlSessionFactory的创建权力交给你。弄清楚介入点后面的代码才不会越写越乱。3. 落地实现用注解 AbstractRoutingDataSource 搭出多数据源骨架3.1 配置文件与数据源定义一份 YAML 管理两个连接池先不要动 Java 代码把连接参数收敛到一个自定义前缀下。为什么不用spring.datasource因为 Spring Boot 默认会把所有spring.datasource.*绑定到唯一数据源冲突太多。自定义前缀干净得多spring: autoconfigure: exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration custom: datasource: master: url: jdbc:mysql://127.0.0.1:3306/master_db?useSSLfalseserverTimezoneAsia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 slave: url: jdbc:mysql://127.0.0.1:3306/slave_db?useSSLfalseserverTimezoneAsia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000这里master和slave是自定义的 key与路由表里的 key 一一对应。driver-class-name写不写都行HikariCP 会从 JDBC URL 推断但显式写出来可以提前暴露驱动缺失的问题。serverTimezoneAsia/Shanghai是为了避免 MySQL 8 的时区报错这句是血泪经验不加的话大多数本地连接直接抛The server time zone value CST is unrecognized。接下来通过ConfigurationProperties把 YAML 里的两个配置块映射成两个独立的DataSourceBeanConfiguration public class DataSourceConfig { Bean ConfigurationProperties(prefix custom.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix custom.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } }这里有一个容易忽略的点DataSourceBuilder.create().build()默认使用当前 classpath 下的连接池实现。因为我们只有 HikariCP所以得到的是 HikariDataSource。ConfigurationProperties会把hikari子属性映射到连接池内部不需要手写setMaximumPoolSize。如果你以后要换成 Druid只需要替换依赖并确保DataSourceBuilder能识别它。绑定完成后这两个 Bean 会被动态路由类取用。3.2 路由键上下文与动态数据源类determineCurrentLookupKey 的兜底逻辑先定义一个枚举来规范 key避免字符串散落在代码里public enum DataSourceType { MASTER(master), SLAVE(slave); private final String key; DataSourceType(String key) { this.key key; } public String getKey() { return key; } }然后是 ThreadLocal 上下文。这里的ThreadLocal必须用remove()而不是set(null)因为remove()会真正清理当前线程的变量映射防止线程池复用时的脏数据public class DataSourceContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void setKey(DataSourceType dataSourceType) { CONTEXT.set(dataSourceType.getKey()); } public static String getKey() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }动态数据源核心类继承AbstractRoutingDataSource。创建时把默认数据源和路由表传进去重写determineCurrentLookupKey()public class DynamicDataSource extends AbstractRoutingDataSource { public DynamicDataSource(DataSource defaultDataSource, MapObject, Object targetDataSources) { super.setDefaultTargetDataSource(defaultDataSource); super.setTargetDataSources(targetDataSources); super.afterPropertiesSet(); } Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getKey(); } }这里的兜底逻辑是当getKey()返回 null比如某个方法没加DataSource注解AbstractRoutingDataSource会回退到默认库。所以我通常把master作为默认库这样所有没标注的方法默认走主库不会因为 null 去报错。afterPropertiesSet()是父类提供的初始化方法它会遍历targetDataSources并把每个连接池启动这行忘了写的话运行时才会报“DataSource is null”非常隐蔽。3.3 DataSource 注解和 AOP 切面方法级切换的完整代码先定义注解本身默认值给MASTERTarget(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) Documented public interface DataSource { DataSourceType value() default DataSourceType.MASTER; }AOP 切面负责设置和清理上下文。这里的关键是Order必须让切面的执行顺序先于事务切面Aspect Component Order(0) public class DataSourceAspect { Before(annotation(dataSource)) public void switchDataSource(JoinPoint joinPoint, DataSource dataSource) { DataSourceContextHolder.setKey(dataSource.value()); } After(annotation(dataSource)) public void restoreDataSource(JoinPoint joinPoint, DataSource dataSource) { DataSourceContextHolder.clear(); } }为什么Order(0)如此重要Spring 事务切面的默认顺序是Ordered.LOWEST_PRECEDENCE如果你不给切面排序事务切面可能先执行。事务一旦开启DataSourceTransactionManager就会从路由数据源里拿到连接这个连接被绑定到当前事务的ThreadLocal资源中。如果你的切面后执行key 虽然被设置了但连接已经按旧的 key 或默认 key 建立了后续所有 SQL 都走错库。把切面排序设成 0能保证在事务创建连接之前路由 key 已经放好。有时你还会遇到DataSource和Transactional标在同一个方法上的情况。我的习惯是能在内层 Service 方法标注就尽量标内层事务边界放在外层。如果事务必须横跨多个库这个最小方案并不合适建议直接考虑分布式事务方案。3.4 绑定 MyBatisSqlSessionFactory 与 Mapper 扫描的三种写法三种写法对应三种维护量等级。第一种最省事提供一个Primary的DynamicDataSourceBean让MybatisAutoConfiguration自动创建SqlSessionFactory。前提是排除DataSourceAutoConfiguration并且只存在一个DataSource类型的 Bean。如果你按第 3.1 节的代码定义了masterDataSource和slaveDataSource还需要再定义一个DynamicDataSource作为PrimaryBean Primary public DynamicDataSource dynamicDataSource( Qualifier(masterDataSource) DataSource masterDataSource, Qualifier(slaveDataSource) DataSource slaveDataSource) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(DataSourceType.MASTER.getKey(), masterDataSource); targetDataSources.put(DataSourceType.SLAVE.getKey(), slaveDataSource); return new DynamicDataSource(masterDataSource, targetDataSources); }然后启动类或配置类上写MapperScan(com.example.mapper)MyBatis 自动把dynamicDataSource注入SqlSessionFactory。这种方式代码量最少适合配置整齐的新项目。第二种是手动创建SqlSessionFactory适合需要控制 MyBatis 属性驼峰映射、二级缓存、XML 路径的场景Configuration MapperScan(basePackages com.example.mapper) public class MyBatisConfig { Bean public SqlSessionFactory sqlSessionFactory( Qualifier(dynamicDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean factory new SqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setMapperLocations( new PathMatchingResourcePatternResolver().getResources(classpath:mapper/**/*.xml)); org.apache.ibatis.session.Configuration config new org.apache.ibatis.session.Configuration(); config.setMapUnderscoreToCamelCase(true); config.setCacheEnabled(false); factory.setConfiguration(config); return factory.getObject(); } Bean public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory); } }第三种是分包扫描不同库即每个库一套 Mapper 接口对应两个SqlSessionFactory。这已经不属于“最简”范畴但它能解决两个库表结构不一致的问题。如果你真的遇到异构库放弃动态路由反而更合适。三种写法对比 | 方案 | 维护量 | 适用场景 | | 自动注入 | 低 | 多库表结构一致共用 Mapper | | 手动 SqlSessionFactory | 中 | 需要调缓存/XML/类型处理器 | | 分包多工厂 | 高 | 多库表结构不一致必须各自独立 Mapper |回到最简方案我通常选第二种因为它把 MyBatis 的缓存和 XML 行为显式暴露出来。多数据源下的缓存非常容易出问题下面这章就专门说它。4. 事务、缓存、主从延迟多数据源切换后的连锁反应4.1 事务注解为什么会让路由键失效SqlSession 复用与连接绑定很多人的第一次翻车发生在DataSource(slave)注解了查询方法但日志显示还是走了主库。问题不在注解本身而在事务。Transactional开启后DataSourceTransactionManager在doBegin里调用dataSource.getConnection()然后把连接保存到TransactionSynchronizationManager的资源 map 中。之后同一个事务里的所有SqlSession都从这个 map 取连接不会再走AbstractRoutingDataSource.determineCurrentLookupKey()。如果你把切面顺序修正为Order(0)key 在连接创建前已经存在事务拿到的就是正确库的连接后续就不需要再切换了。但要是你在事务方法内部根据某个参数想要动态切换到另一个库那就行不通了因为SqlSessionUtils会返回已有连接。这个场景下最直接的做法是不要在同一个事务里切换库把跨库操作拆成两个独立事务。如果必须同事务操作两个库那应该考虑分布式事务而不是动态数据源。另外事务管理器本身要基于动态数据源来创建否则事务拿到的连接可能来自默认库Bean Primary public DataSourceTransactionManager transactionManager( Qualifier(dynamicDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }这里需要强调如果你在配置类里给每个物理数据源都定义了一个DataSourceTransactionManagerSpring 会根据Primary选一个有参 Bean但那个管理器持有的连接池可能不是我想要的。我见过项目里把DataSourceTransactionManager绑定到masterDataSource上导致Transactional永远走主库、DataSource(slave)在事务内失效。统一使用dynamicDataSource才是正确姿势。4.2 MyBatis 一级缓存与二级缓存在多库下的脏读风险MyBatis 一级缓存是SqlSession范围内的默认开启。同一个SqlSession里执行两次select * from user where id 1第二次会直接命中缓存不查数据库。在多数据源场景下如果两个连接来自不同库问题就来了。举一个具体的例子第一次查询走 slave 库查询结果缓存到SqlSession的LocalCache中第二次同一个SqlSession被切到 master 库但因为一级缓存命中了返回的还是 slave 库的旧数据。在 Spring 管理下SqlSessionTemplate每次执行都会从连接绑定关系里取SqlSession如果没有事务它每次新建SqlSession所以一级缓存问题相对小。一旦你开启事务SqlSession被绑定到线程一级缓存就可能串库。二级缓存是 namespace 级别的按 Mapper 的 namespace 缓存同一个 Mapper 接口查出的结果会被所有 SqlSession 共享。如果这个 Mapper 既能查 master 库又能查 slave 库第二次去 slave 库查询时二级缓存命中主库旧数据的概率会更高而且很难复现。最稳妥的做法是多数据源项目一律关闭二级缓存。在application.yml中设置mybatis: configuration: cache-enabled: false或者在手动配置SqlSessionFactory时调用config.setCacheEnabled(false)。这条配置保证统一关闭省得有人偷偷在 XML 里加cache。一级缓存可以靠设置localCacheScope: STATEMENT来规避让每条 SQL 结束就清空缓存mybatis: configuration: local-cache-scope: statement代价是同一个SqlSession内的重复查询会多次访问数据库但对多数据源正确性来说这是必要的取舍。生产环境里正确性永远比性能优化重要这算是最实在的后悔药。4.3 主从/读写分离场景的延迟与重试策略多数据源最常见用途是读写分离。一个坑是写库后立即读从库可能读不到刚写入的数据因为主从同步延迟。最简方案不会帮你解决这个但你可以通过一套约定来规避写操作标注DataSource(master)。强一致读也标注DataSource(master)。允许弱一致的查询才标DataSource(slave)。如果业务上确实需要刚写完就读从库可以在写入后主动等待一小段时间或直接查询主库。另一个策略是使用 ThreadLocal 记录当前线程是否发生过写操作如果发生过本次请求内后续读都强制走 master直到请求结束。这个逻辑可以放到 AOP 切面中但会增加复杂度。个人建议先从 SQL 和索引上缩短主从延迟而不是在代码里重试因为重试读对幂等操作有用对非幂等操作容易产生重复消费。还有一点很多人忽略从库连接池的大小。如果读流量大而 slave 的maximum-pool-size设得很小会出现“连接池等待超时但主库空闲”的现象。我在 YAML 示例里给 slave 设了 10一般从库需要比主库更大而不是更小因为读多写少是常态。这个参数最好结合监控调整不要拍脑袋。5. 避坑指南5 条能救命的多数据源排错记录5.1 第一个请求正常第二个请求串库现象/slave接口第一次返回的是 slave 库第二次却返回 master 库。原因切面里的After没有执行或者clear()没被调用。Spring 的 Tomcat 线程池会复用线程ThreadLocal 里的 key 被带到下一个请求里但下一个请求用的是新DataSourceContextHolder.setKey(...)正常情况下会覆盖。真正的问题往往出在异步方法上子线程里设置了 key但父线程的清理无法覆盖子线程的 ThreadLocal导致子线程的 key 残留。解决在切面的After里用finally保证清理同时排查是否在异步方法里用了DataSource。如果异步任务必须切换库建议在任务方法内部自己try-finallytry { DataSourceContextHolder.setKey(DataSourceType.SLAVE); // 执行查询 } finally { DataSourceContextHolder.clear(); }5.2 DataSource 注解失效SQL 还是走默认库现象DataSource(slave)标在没有事务的方法上但 MyBatis 日志显示的库还是 master。原因切面没有被 Spring 代理。通常有三种切面类没有被扫描到EnableAspectJAutoProxy缺失Spring Boot 自动开但某些自定义配置会关闭方法被同类内部调用AOP 拦截不了自调用。解决确认切面类在ComponentScan路径下检查方法调用是不是this.slaveMethod()形式改成从外部 Bean 调用。如果用了 Spring Cloud Feign 或异步线程还要注意 RPC/异步场景下注解不传递需要单独处理。5.3 事务回滚不生效错误日志提示“No transaction aspect-managed”现象Transactional方法内抛异常数据没有回滚日志里有No transaction aspect-managed或回滚失败提示。原因事务管理器没有关联到动态数据源。比如手动配置了DataSourceTransactionManager(masterDataSource)而业务 Bean 用的是dynamicDataSource注入的 MyBatis事务管理器的异常回滚只会针对 master 连接动态路由选中的 slave 连接不在事务管理范围内。解决把事务管理器统一改成上面第 4.1 节中的写法注入dynamicDataSource。并且确认Transactional(transactionManager transactionManager)没有写错。如果你真需要为不同库配置不同事务管理器那就不要用动态路由而是拆开多个 SqlSessionFactory那已经不是本方案建议的方向。5.4 Mapper 扫描把两个库的 Mapper 全绑到了一个 SqlSessionFactory现象启动不报错但某个 Mapper 查询时用了错误的数据源而且无法通过DataSource纠正。原因两个库表结构不同却共用同一个 Mapper 接口和 XMLSQL 里的表名、字段名在两个库不一致导致查错表或列不存在。动态数据源只能解决“选哪个库连接”不能帮你翻译 SQL 方言。解决如果两个库是读写分离关系表结构必须一致就共用一套 Mapper如果两个库表结构不同那就退回到分包模式不要强行使用动态路由。最小方案的前提是“同构多库”这一点要提前跟业务确认。常见做法是给 master 和 slave 建同名表职责是读写分离如果第二个库是报表库表结构完全不同需要独立的 Mapper、独立的SqlSessionFactory。5.5 Oracle/MySQL 时间类型和方言差异导致查询结果对不上现象同一个 mapper 在 MySQL 正常切到 Oracle 后时间字段变成字符串或者 LIMIT 分页 SQL 直接语法错误。原因多数据源动态路由只切换连接不改变 MyBatis 方言。LIMIT、SYSDATE、字段类型映射都由数据库方言决定MySQL 的 SQL 拿到 Oracle 上自然失败。解决多数据源方案只适用于同构数据库。如果异构需要单独处理两套 XML 或 SQL。也可以考虑在application.yml里给每个连接配置不同方言但 MyBatis 的DatabaseIdProvider需要额外实现。最省事的方案是避免异构多数据源或者在代码里把异构查询拆到不同 Mapper 上。另外时间类型映射问题可以通过注册自定义TypeHandler解决但这属于另一个话题不要指望路由方案帮你兜底。6. 验证手段怎么确认当前线程真正切到了目标库6.1 用 MyBatis 拦截器打印路由键与库名切换是否生效不能靠猜。我最早验证时会直接在 Service 里打印DataSourceContextHolder.getKey()但那只说明 ThreadLocal 值改了不代表连接真的来自对应库。更可靠的验证方式是做一个 MyBatis 拦截器在每次查询前拿到当前数据源的路由 key并打印出物理连接对应的 URL。Component Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class DataSourceRouteInterceptor implements Interceptor { private static final Logger log LoggerFactory.getLogger(DataSourceRouteInterceptor.class); Override public Object intercept(Invocation invocation) throws Throwable { String routeKey DataSourceContextHolder.getKey(); DataSource dataSource DataSourceContextHolder.getDataSource(); if (dataSource instanceof AbstractRoutingDataSource) { AbstractRoutingDataSource routingDataSource (AbstractRoutingDataSource) dataSource; Connection connection routingDataSource.getConnection(); String url connection.getMetaData().getURL(); log.info(routeKey{}, physicalUrl{}, routeKey, url); connection.close(); } return invocation.proceed(); } }这段代码的核心在于调用AbstractRoutingDataSource.getConnection()时它会根据当前 ThreadLocal 的 key 选择目标连接所以从连接元数据里拿到的 URL 就是真实的物理库地址。注意用完要 close不然连接会被占用。拦截器只用于测试环节上线前建议删掉或降级成 debug 日志。6.2 一个可以自测切换是否成功的 Controller 测试用例最简单的验证接口长这样RestController public class DataSourceCheckController { Autowired private TestMapper testMapper; DataSource(DataSourceType.MASTER) GetMapping(/db/master) public String checkMaster() { return testMapper.selectDatabaseName(); } DataSource(DataSourceType.SLAVE) GetMapping(/db/slave) public String checkSlave() { return testMapper.selectDatabaseName(); } }TestMapper里写一句 MySQL 上报当前库名的 SQLselect idselectDatabaseName resultTypestring SELECT DATABASE() /select然后分别请求/db/master和/db/slave。如果返回的库名对应配置里的master_db和slave_db说明切换链路是通的。更严格一点你可以模拟多线程同时请求这两个接口观察返回结果是否跟线程绑定而不是互相干扰Test void testRouteWithConcurrentRequests() throws Exception { ExecutorService pool Executors.newFixedThreadPool(4); for (int i 0; i 10; i) { pool.submit(() - { String result restTemplate.getForObject(/db/slave, String.class); assertEquals(slave_db, result); }); } }我现在的习惯是任何新增 Mapper 方法上线前都会先跑一遍这个自测。如果某个方法没加DataSource它回落到默认库DATABASE()返回 master 库名这个行为也符合预期。总之这套最简方案的价值不只在代码少更在于路由逻辑肉眼可见、容易验证。希望这个骨架能帮你少踩几个我踩过的坑。本文还有配套的精品资源点击获取