MyBatis 动态数据源切换在 Spring Boot 环境下的实现方案
第一章 需求背景与技术选型
1.1 多数据源场景概述
在大型企业级应用中,单一数据库往往无法满足高并发和多业务线的需求,因此需要引入 多数据源 的架构设计。常见的多数据源场景包括:读写分离、多租户、分库分表以及数据源负载均衡等。
读写分离:在此场景下,通常有一个主库(Master)用于写操作,以及一个或多个从库(Slave)用于读操作。应用层可以将写请求路由到主库,读请求路由到从库,以减轻主库负载并提高读写吞吐量。例如,在电子商务系统中,用户下单会写入主库,而商品浏览、列表查询等场景可以走从库,从而提高整体性能。
多租户:在 SaaS 系统中,不同租户的数据需要隔离。实现方式有独立库、多库共享架构等。通常根据当前租户ID动态选择相应的数据源,以达到数据隔离的目的。比如一个 CRM 系统,不同公司(租户)使用相同代码,但数据存储在不同的数据库中。
分库分表(Sharding):为了处理海量数据,会将数据水平切分到多个库或表中。按照业务切分规则(如用户ID、时间等)路由到对应的数据源。这样可以并行扩展数据库容量和查询吞吐率。
负载均衡:在微服务或高可用场景下,可能部署多个相同功能的数据库实例。可以通过策略(轮询、权重等)将请求均匀分布到不同数据源,提高可用性和并发处理能力。
此外,在某些复杂场景中,还可能需要支持多种数据库类型(关系型、NoSQL 混合)或动态增加新数据源的能力。抽象的数据源路由策略能够统一管理数据源选取逻辑,而不侵入业务代码,这就是 AbstractRoutingDataSource
等技术产生的背景。
1.2 MyBatis 与 Spring Boot 生态的集成优势
随着 Java 微服务和云原生架构的普及,Spring Boot+MyBatis 已成为常见的开发栈。Spring Boot 提供自动化配置和丰富生态,如 spring-jdbc
、spring-tx
等;MyBatis 提供简洁灵活的 ORM 框架,将 SQL 与 Java 对象映射无缝结合。两者集成有以下优势:
易用的配置:Spring Boot 自动化装配机制可以简化数据源、事务管理和 MyBatis 的配置。只需引入
spring-boot-starter-jdbc
、mybatis-spring-boot-starter
等依赖并在application.yml
中配置数据源即可使用。灵活的插件机制:MyBatis 支持插件(Interceptor)机制,可以在执行 SQL 之前或之后插入自定义逻辑。这为动态切换数据源提供了另一种实现思路(如自定义 MyBatis 拦截器来切换 DataSource)。
丰富的事务支持:Spring 框架提供统一的事务管理抽象,包括声明式事务注解和编程事务 API,可与动态数据源无缝集成;例如 Spring DataSource 事务管理可以自动适配切换后的目标数据源。
生态插件和库:除了手工实现,还有成熟的开源项目支持动态数据源管理,如 MyBatis-Plus 动态数据源插件、Apache ShardingSphere 等,可用于更高级的数据路由和分片需求。
通过 Spring Boot 和 MyBatis 的集成,我们可以在保持业务代码简洁的同时,利用框架特性来做数据源路由和切换,而不需要在每个 DAO 调用中手工传递 DataSource 信息。
1.3 动态数据源切换的必要性
在多数据源场景下,硬编码数据源会导致配置僵化和代码侵入。动态数据源切换提供一种透明、非侵入式的方式:业务代码仅需关注要执行的业务操作,由底层框架根据当前上下文动态选择合适的数据源。这样做的必要性包括:
解耦业务与数据源逻辑:业务层无需感知当前要使用哪个库,调用 DAO 接口即可。通过切换逻辑(如通过注解、上下文参数等)将路由策略集中管理,提高代码可维护性。
灵活应对需求变化:运行时可以根据配置或上下文动态添加新数据源、读写比例调整、租户增减等。无需停服重新部署即可扩展数据源体系。
满足性能与隔离需求:对于读写分离场景,通过切换到从库可以缓解主库压力;对于多租户场景,根据租户上下文路由到对应数据库,保证数据隔离。
减少重复实现:不需要在每个 Service/DAO 中手写切换逻辑。使用统一的
AbstractRoutingDataSource
或 AOP 切面等方案,可以复用切换代码。
总之,动态数据源切换是为了解决多数据库环境下灵活路由和管理数据源的需求。下文将深入分析底层实现原理,并以 Spring Boot 为例给出生产级的实践方案。
第二章 核心组件与实现原理
2.1 AbstractRoutingDataSource 源码解析
Spring Framework 提供了一个核心组件 AbstractRoutingDataSource
用于数据源路由。根据官方文档的描述,它是“一个抽象的 DataSource 实现,用于基于查找键(lookup key)将 getConnection()
调用路由到多个目标 DataSource 之一”。核心实现逻辑如下:
路由数据源配置:
AbstractRoutingDataSource
拥有一个targetDataSources
映射,其中键是查找键(Object,一般为 String、Enum 等),值是实际的 DataSource 对象。还可以配置一个默认数据源defaultTargetDataSource
。查找键获取:
AbstractRoutingDataSource
定义了一个抽象方法determineCurrentLookupKey()
,子类需重写此方法来决定当前调用应使用哪个键。通常,这个键信息会从某种线程上下文中获取(如 ThreadLocal 存储的租户ID或读写标识)。DataSource 路由:在每次调用
getConnection()
时,AbstractRoutingDataSource
会首先调用determineCurrentLookupKey()
获取当前键,然后根据这个键在targetDataSources
中查找对应的实际 DataSource。如果找不到匹配的数据源,则会根据lenientFallback
属性决定是抛出异常还是使用默认数据源。初始化与刷新:
AbstractRoutingDataSource
实现了InitializingBean
接口,其中的afterPropertiesSet()
方法会解析配置,将指定的数据源对象解析为实际的DataSource
实例,并存入内部结构供路由使用。
简而言之,AbstractRoutingDataSource
是一个“路由器”,它本身也实现了 DataSource
接口,但内部并不存储连接,而是根据当前上下文查找真正的目标 DataSource,然后将 getConnection()
的调用委派给该目标数据源。这就像一个中间层:业务代码通过此代理 DataSource 调用数据库,代理会根据规则动态决定链接到哪个实际数据库。这个原理类似于春季博客所说的:“路由DataSource作为中介,真正的 DataSource 可以在运行时动态确定”。
AbstractRoutingDataSource 工作流程
应用启动时,Spring 容器会实例化
AbstractRoutingDataSource
的子类,调用其afterPropertiesSet()
方法解析配置文件中定义的所有目标数据源并存入一个 Map。业务层注入的是这个
AbstractRoutingDataSource
代理对象,而非单一的 DataSource。在执行数据库操作时(例如通过 MyBatis 打开 SqlSession 或 Spring 的
JdbcTemplate
调用),会触发AbstractRoutingDataSource.getConnection()
。该方法调用
determineCurrentLookupKey()
获取当前数据源的键。例如,在多租户场景下,determineCurrentLookupKey()
可能返回当前线程所对应的租户ID。根据查找键从预先配置的 Map 中取得对应的实际 DataSource,并调用其
getConnection()
获得 JDBC 连接。如果查找键没有对应的数据源,则根据
lenientFallback
决定:若允许回退,则使用默认数据源,否则抛出异常。
可以说,AbstractRoutingDataSource
将数据源选取逻辑与应用业务逻辑解耦。用户只需关注如何获取当前上下文的键,而实际的数据库连接细节由框架完成。
2.2 ThreadLocal 上下文管理机制
在动态数据源切换中,通常需要一种机制来保存当前线程所需使用的数据源标识。Java 提供了 ThreadLocal
类,用于在线程之间隔离存储变量:每个线程都会持有一份 ThreadLocal
变量的副本。典型的做法是使用 ThreadLocal
来存放当前线程要使用的数据源名称或Key。
以常见的设计模式为例:定义一个 DataSourceContextHolder
(或称 DynamicDataSourceContextHolder
)类,其内部维护一个 ThreadLocal
变量(有的实现用 Deque
来支持嵌套调用)。调用方法通常是:在进入需要切换数据源的逻辑前,先将目标数据源Key压入 ThreadLocal
;业务代码执行时,AbstractRoutingDataSource
会调用 determineCurrentLookupKey()
方法,此方法从 ThreadLocal
中取出当前Key。执行完毕后,再清除或弹出 ThreadLocal
中的Key,避免污染后续请求。
例如,在开源项目中,数据源上下文管理器可能是这样的结构:
public final class DynamicDataSourceContextHolder {private static final ThreadLocal<Deque<String>> CONTEXT_HOLDER = ThreadLocal.withInitial(ArrayDeque::new);public static String peek() {Deque<String> deque = CONTEXT_HOLDER.get();return deque.peek();}public static void push(String ds) {CONTEXT_HOLDER.get().push(ds);}public static void poll() {Deque<String> deque = CONTEXT_HOLDER.get();deque.poll();}
}
上例使用 ThreadLocal<Deque<String>>
来维护一个堆栈,支持嵌套切换,这在多层服务调用需要多次切换时很有用。每个线程都会有自己的 Deque
,因此数据源的设置互不干扰。多个实现示例表明,核心思路都是“通过 ThreadLocal
管理数据源标识,然后在执行SQL前获取当前标识并完成切换”。
需要注意的是,使用 ThreadLocal
时应谨慎清理。Web 应用中线程复用(如线程池)会导致如果不在请求结束时清空 ThreadLocal
,下一个请求可能错误地继承了上一个请求的标识(即所谓的ThreadLocal 污染问题,本章后续会讨论)。因此,常在切面或拦截器的 finally
代码块中弹出/清除 ThreadLocal
数据,以保证线程干净。
2.3 AOP 拦截器与 MyBatis 拦截器的对比
实现动态数据源切换时,常用的方法有基于 Spring AOP 切面注解以及基于 MyBatis 插件 的拦截。两者的原理和使用场景有所不同:
Spring AOP 切面:通过自定义注解(如
@DataSource
)标记在业务方法或类上,然后编写一个 AOP 切面(Aspect),在切面中通过@Around
通知来在方法执行前设置线程上下文中的数据源,然后执行方法,最后在finally
中清除上下文。Spring AOP 本质上使用动态代理(JDK 或 CGLIB)来拦截对 Spring 管理 Bean 的方法调用,适用于需要在 Service 层方法调用时切换数据源的场景。优点是使用简单、可读性好;缺点是仅对由 Spring 容器管理的 Bean 生效,对内部调用(self-invocation)或非 Spring 托管对象不可用。MyBatis 插件拦截:MyBatis 提供了插件机制,可以拦截 Executor、StatementHandler、ResultSetHandler、ParameterHandler 等对象的执行方法。可以编写一个 MyBatis
Interceptor
(实现org.apache.ibatis.plugin.Interceptor
并用@Intercepts
注解指定拦截点),在intercept()
方法中根据当前上下文选择数据源。这样做的优点是作用于 MyBatis 层,可以拦截所有通过 MyBatis 执行的 SQL 请求,不依赖 Spring AOP;缺点是实现较复杂,且 MyBatis 插件拦截点一般在执行 SQL 语句时才起作用,无法像 AOP 那样轻松在方法入口处处理业务逻辑。动态代理模式:动态数据源本身就是一种代理模式——
AbstractRoutingDataSource
就是对目标数据源的代理。另外,Spring AOP 使用的正是动态代理机制,对目标 Bean 生成代理对象,对外提供切面功能。从性能角度看,代理调用会带来微小开销,但通常可以忽略。正如社区讨论所说,“使用基于代理的 AOP,每应用一个切面只会多一次方法调用,性能开销几乎可以忽略”。MyBatis 插件也是通过包装底层对象实现拦截,性能差异很小。除非在极端性能要求的场景下,一般不需要担心 AOP vs 插件 vs 代理的性能差异(每次切换仅相当于额外几次方法调用或反射调用,耗时在纳秒级)。
下面举个对比总结:
切面(AOP):基于业务逻辑层,灵活使用注解进行数据源切换,代码可读性好,易于集成事务。但仅拦截 Spring 管理的 Bean 方法,不适用于 Mapper 接口的内部调用。
MyBatis 插件:直接作用于 SQL 执行层,可拦截所有 MyBatis 调用,适合在 Mapper 级别强制切换数据源(比如读操作全部拦截到从库)。配置繁琐度较高,一般配合 ThreadLocal 使用。
动态代理:本质机制,与 AOP 类似。Spring AOP 在 bean 层使用 JDK/CGlib 动态代理技术;MyBatis 插件在 Executor 层使用代理。总体上,都带来很小的调用开销。
在实际应用中,最常用的方案是自定义注解 + Spring AOP 切面 的组合。这种方案直观且结合 Spring Boot 注解驱动编程体验好。另一个常见做法是使用成熟的动态数据源框架(如 MyBatis-Plus 提供的 @DS
注解),其内部也利用 AOP + ThreadLocal 实现。当然,根据业务需要,也可以在 MyBatis 插件层面实现切换,但需要开发者深入理解 MyBatis 拦截原理。
第三章 Spring Boot 实现方案
本章我们将以 Spring Boot 为例,从零开始演示如何实现动态数据源切换,包括配置数据源、定义注解和切面、编写测试等。
3.1 多数据源配置类编写
首先,需要在 Spring Boot 项目中配置多个数据库连接。在 application.yml
中定义多个数据源,如 master 和 slave:
spring:datasource:master:url: jdbc:mysql://localhost:3306/master_dbusername: rootpassword: passworddriver-class-name: com.mysql.cj.jdbc.Driverslave:url: jdbc:mysql://localhost:3306/slave_dbusername: rootpassword: passworddriver-class-name: com.mysql.cj.jdbc.Driver
然后,在 Java 代码中定义两个 DataSource
Bean。例如:
@Configuration
public class DataSourceConfig {@Bean(name = "masterDataSource")@ConfigurationProperties(prefix = "spring.datasource.master")public DataSource masterDataSource() {return DataSourceBuilder.create().build();}@Bean(name = "slaveDataSource")@ConfigurationProperties(prefix = "spring.datasource.slave")public DataSource slaveDataSource() {return DataSourceBuilder.create().build();}
}
这里使用 @ConfigurationProperties
从 YAML 注入属性,DataSourceBuilder
可以自动选择 HikariCP 或 Druid 等连接池。接下来需要定义一个 动态路由数据源,继承 AbstractRoutingDataSource
并实现 determineCurrentLookupKey()
:
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {@Overrideprotected Object determineCurrentLookupKey() {return DynamicDataSourceContextHolder.peek();}
}
同时,编写配置将上述两个数据源注入到动态路由数据源中:
@Configuration
public class DynamicDataSourceConfig {@Autowired@Qualifier("masterDataSource")private DataSource masterDataSource;@Autowired@Qualifier("slaveDataSource")private DataSource slaveDataSource;@Beanpublic DynamicRoutingDataSource dynamicDataSource() {Map<Object, Object> targetDataSources = new HashMap<>();targetDataSources.put("master", masterDataSource);targetDataSources.put("slave", slaveDataSource);DynamicRoutingDataSource ds = new DynamicRoutingDataSource();ds.setTargetDataSources(targetDataSources);ds.setDefaultTargetDataSource(masterDataSource); // 设置默认数据源return ds;}// 配置 SqlSessionFactory,使 MyBatis 使用动态数据源@Beanpublic SqlSessionFactory sqlSessionFactory(DynamicRoutingDataSource dynamicDataSource) throws Exception {SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();factoryBean.setDataSource(dynamicDataSource);// 可设置 MyBatis 配置、映射文件等return factoryBean.getObject();}
}
在上述配置中,我们创建了两个命名的 DataSource Bean,再用 DynamicRoutingDataSource
将它们放到一个 Map 中,key 为数据源标识(如 "master"、"slave"),并设置默认数据源。DynamicRoutingDataSource
继承 AbstractRoutingDataSource
,会在运行时根据 determineCurrentLookupKey()
返回的键决定使用哪个子数据源。我们再把 DynamicRoutingDataSource
作为 MyBatis 的 SqlSessionFactory 的数据源,确保所有 MyBatis 操作都经过它。
Spring Boot 项目结构示例:
src/main/java/com/example/dynamicds/config/DataSourceConfig.java // 配置 master/slave DataSourceDynamicDataSourceConfig.java // 配置 DynamicRoutingDataSource、SqlSessionFactoryannotation/DataSource.java // 自定义注解aspect/DataSourceAspect.java // AOP 切面holder/DynamicDataSourceContextHolder.java // ThreadLocal 上下文mapper/UserMapper.java // MyBatis Mapper 接口entity/User.java // 实体类service/UserService.java // 业务层接口及实现DynamicDataSourceApplication.java // 启动类
src/main/resources/application.yml // 数据源配置mapper/UserMapper.xml // MyBatis 映射文件
pom.xml // 依赖配置
pom.xml(部分示例依赖):
<dependencies><!-- Spring Boot 及其 Starter --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency><!-- MyBatis Spring Boot Starter --><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.2.2</version></dependency><!-- 数据库连接池(可选 HikariCP)--><dependency><groupId>com.zaxxer</groupId><artifactId>HikariCP</artifactId></dependency><!-- MySQL 驱动 --><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><!-- Spring AOP --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
</dependencies>
上面代码中核心在于将多个 DataSource
注入到 DynamicRoutingDataSource
中,通过 setTargetDataSources
指定路由映射。此外,我们使用 @Bean
注册了 SqlSessionFactory
,将 DataSource
设置为我们的动态数据源。这样,后续所有的数据库操作都会走 DynamicRoutingDataSource
,由其决定实际连接哪个库。
3.2 自定义 @DataSource
注解与 AOP 切面实现
为了在业务层灵活选择数据源,我们可以定义一个自定义注解(如 @DataSource
),并通过 Spring AOP 切面来拦截带该注解的方法。在切面中,我们将注解中的数据源名称压入 DynamicDataSourceContextHolder
的 ThreadLocal
。
自定义注解 DataSource.java
:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataSource {String value() default "master";
}
该注解可标注在类或方法上,value
用于指定使用的数据源名称。
上下文持有器 DynamicDataSourceContextHolder.java
:
public class DynamicDataSourceContextHolder {private static final ThreadLocal<Deque<String>> CONTEXT_HOLDER = ThreadLocal.withInitial(ArrayDeque::new);// 获得当前线程的数据源public static String peek() {Deque<String> deque = CONTEXT_HOLDER.get();return deque.peek();}// 将数据源压入栈顶public static void push(String ds) {CONTEXT_HOLDER.get().push(ds);}// 弹出当前数据源public static void poll() {CONTEXT_HOLDER.get().poll();}
}
如前所述,这个 ThreadLocal
栈结构允许嵌套调用时恢复前一个数据源。
AOP 切面 DataSourceAspect.java
:
@Aspect
@Component
public class DataSourceAspect {@Pointcut("@annotation(com.example.dynamicds.annotation.DataSource) || @within(com.example.dynamicds.annotation.DataSource)")public void dataSourcePointCut() {}@Around("dataSourcePointCut()")public Object around(ProceedingJoinPoint point) throws Throwable {// 解析注解上的数据源名称MethodSignature signature = (MethodSignature) point.getSignature();Method method = signature.getMethod();String ds = "master"; // 默认数据源if (method.isAnnotationPresent(DataSource.class)) {DataSource annotation = method.getAnnotation(DataSource.class);ds = annotation.value();} else {// 如果方法上没有,查看类上是否有注解Class<?> targetClass = point.getTarget().getClass();if (targetClass.isAnnotationPresent(DataSource.class)) {DataSource annotation = targetClass.getAnnotation(DataSource.class);ds = annotation.value();}}try {// 切换数据源DynamicDataSourceContextHolder.push(ds);return point.proceed();} finally {// 切换完毕后,弹出数据源DynamicDataSourceContextHolder.poll();}}
}
上面代码说明:切面拦截标注了 @DataSource
注解的方法(或类),优先取方法上的注解值,否则取类上的。获取到的 ds
就是要使用的数据库标识(如 "slave")。在执行业务方法前,将该标识 push
到 ThreadLocal
栈;方法执行完成后,再弹出,确保后续线程不受影响。
这样就形成了完整的 注解驱动 + AOP 切面 的动态切换方案:当调用一个被 @DataSource("slave")
标记的方法时,切面会把 ThreadLocal
里当前数据源设置为 "slave",从而使 determineCurrentLookupKey()
返回 "slave",动态数据源路由到从库。
示例业务代码:
@Service
public class UserService {@Autowiredprivate UserMapper userMapper;// 默认使用 masterpublic List<User> listUsers() {return userMapper.selectAll();}@DataSource("slave")public List<User> listUsersFromSlave() {return userMapper.selectAll();}
}
在这个例子中,listUsersFromSlave()
方法被标记为使用 "slave" 数据源,调用时会路由到从库执行查询。核心逻辑完全由切面和路由组件完成,业务层无需关心数据源切换的细节。
3.3 动态数据源切换的测试用例设计
为了确保动态数据源切换正确,可以编写单元测试或集成测试验证。思路是:在测试中通过设置上下文(类似切面方式)切换数据源,执行 CRUD 操作,并检查结果是否来自预期的库。
示例测试:
@SpringBootTest
public class DynamicDataSourceTest {@Autowiredprivate UserMapper userMapper;@Testpublic void testMasterSlaveSwitch() {// 准备:在 master 和 slave 中插入可区分的数据// 假设在 masterDB 中有 user {id=1, name="A"},slaveDB中有 user{id=2, name="B"}// 默认使用 masterList<User> masterUsers = userMapper.selectAll();assertTrue(masterUsers.stream().anyMatch(u -> u.getName().equals("A")));// 切换到 slaveDynamicDataSourceContextHolder.push("slave");try {List<User> slaveUsers = userMapper.selectAll();assertTrue(slaveUsers.stream().anyMatch(u -> u.getName().equals("B")));} finally {DynamicDataSourceContextHolder.poll();}}
}
上例使用了手动 push
/poll
的方式模拟切换。在更高层的测试框架下,可以直接调用带注解的方法来进行测试。关键在于验证:在切换前后得到的结果应该明显不同,以证明数据是从不同的数据源中读取。
另一个常见测试是多线程环境的切换:确保在并发执行切换时,各线程能够正确分辨使用不同的数据源且互不干扰。可以使用多线程测试框架或模拟请求的方式,验证线程安全性。
测试注意点:
在测试数据库中插入明确可区分的数据(不同库中插入不同内容)进行验证。
在单元测试结束后清理
ThreadLocal
,或者在测试类中加上@After
注解清理上下文。如果使用 Spring 事务,需要特别注意默认事务配置下切面是否按预期执行(可能需要设置事务为
REQUIRES_NEW
或测试中手动处理事务)。
通过上述配置和测试,基础的动态数据源切换功能就能正确运行。在此基础上,下一章我们将探讨更高级的功能和优化。
第四章 进阶实践与优化
4.1 数据源自动注册与动态加载
在生产环境中,有时需要运行时动态新增或移除数据源。例如多租户平台中租户随时注册或注销,或者需要在不重启服务的情况下将新数据库接入系统。实现思路是利用 AbstractRoutingDataSource
的内部结构:在运行时修改其内部的目标数据源映射(targetDataSources
),并调用 afterPropertiesSet()
刷新路由。
以前述 DynamicRoutingDataSource
为例,我们可以扩展接口来动态添加:
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {// 存放所有数据源实例private Map<Object, DataSource> dataSourceMap = new ConcurrentHashMap<>();public void addDataSource(String dsKey, DataSource dataSource) {dataSourceMap.put(dsKey, dataSource);// 添加到 AbstractRoutingDataSource 的 targetDataSourcessuper.setTargetDataSources(dataSourceMap);super.afterPropertiesSet(); // 刷新解析}@Overrideprotected Object determineCurrentLookupKey() {return DynamicDataSourceContextHolder.peek();}
}
在上述例子中,当需要新增数据源时,可以通过 dynamicRoutingDataSource.addDataSource("newDs", newDataSource)
来实现。注意调用 afterPropertiesSet()
以让 AbstractRoutingDataSource
重新解析新的配置。同样地,可以实现 removeDataSource
方法从 Map 中删除并刷新。
这种动态注册方式允许我们在运行时基于配置中心、后台管理等方式来管理数据源。例如,可以在服务中添加一个管理接口:
@RestController
public class DataSourceController {@Autowiredprivate DynamicRoutingDataSource dynamicDataSource;@PostMapping("/datasource")public String addDataSource(@RequestBody DataSourceProperties props) {DataSource ds = DataSourceBuilder.create().driverClassName(props.getDriver()).url(props.getUrl()).username(props.getUsername()).password(props.getPassword()).build();dynamicDataSource.addDataSource(props.getName(), ds);return "DataSource added";}
}
以上代码接收一个 POST 请求,传递新的数据库配置,动态创建并注册数据源。需要注意线程安全和异常处理(例如若添加重复键应抛出错误)。动态加载最大的挑战是:确保在切换发生过程中,所有组件(事务管理、SqlSession 等)都能感知到更新过的路由表,避免并发时出现找不到数据源的情况。
4.2 数据源切换异常的兜底策略
在多数据源路由中,常见的问题是找不到目标数据源,或者没有配置默认数据源时的处理。Spring 的 AbstractRoutingDataSource
提供了 宽松回退(lenientFallback) 机制。其含义是:当 determineCurrentLookupKey()
返回的键在 targetDataSources
中没有匹配时,如果开启宽松回退,则会自动使用默认数据源;否则会抛出 IllegalStateException
。
默认情况下,lenientFallback
为 true,即非严格模式,只有当查找键对应的 DataSource 为空才回落到默认数据源。可通过配置类或 XML 调用 setLenientFallback(false)
,在查找键未配置时抛出异常,以便快速定位问题。无论如何,推荐在系统启动时至少提供一个默认数据源,可以防止在切换出错时导致所有操作都失败。
如果决定关闭回退(lenientFallback=false
),需要注意:
如果切换的
ThreadLocal
键发生意外错误(如未设置或清理不当导致为空),则会抛出异常,因此生产代码中应保证在使用前必须设置键,或在注解切面中做默认保护。对于不使用
@DataSource
注解的方法(未明确切换),我们一般让其走默认数据源。可以在上下文取值时做判断,如String ds = DynamicDataSourceContextHolder.peek(); if (ds == null) ds="master";
。
另外,当数据源不存在时,还可以自定义兜底逻辑。例如,捕获抛出的 DataAccessException
或 SQLException
并记录错误信息或发送告警。而对于读写分离场景,可以制定“失败重试”策略:若切换到从库后读操作失败,再自动重试主库。
总之,需要合理配置默认源和回退策略,并在代码层面做好异常处理,避免生产时因为数据源名错误而全局不可用。Spring 参考答案也曾提到,如果完全不想出现默认数据源,可考虑禁用 Hibernate 的自动 DDL 和元数据校验,因为初始化阶段可能会触发数据库连接,否则可以通过配置 spring.jpa.hibernate.ddl-auto=none
、spring.jpa.properties.javax.persistence.validation.mode=none
等避免启动时的连接尝试。
4.3 性能调优技巧(连接池参数配置)
动态数据源切换本身并不会显著影响性能,但多数据源环境下需要特别关注每个数据源连接池参数的配置。合理配置可提升并发能力和稳定性。以下是一些常见的优化建议:
连接池类型与并发能力:Spring Boot 默认使用 HikariCP 作为连接池(若引入
spring-boot-starter-jdbc
)。HikariCP 以高性能著称,可通过调整spring.datasource.hikari.maximum-pool-size
来控制最大连接数。默认值为 10;在高并发场景下可以根据服务器资源适当增大,比如 50、100。但要避免配置过大导致数据库压力。连接超时与空闲超时:
connectionTimeout
(获取连接等待时间)默认 30 秒。可根据业务需求调整,防止线程长时间阻塞等待连接。idleTimeout
(连接空闲超时)和maxLifetime
(连接最大存活时间)也应合理设置,避免频繁创建销毁连接;推荐一个较长的maxLifetime
(如 30 分钟或更高),防止频繁重建。多环境配置分离:开发、测试环境可以使用较小的连接池规模;生产环境可根据实际负载增加连接数。同时生产中若读写分离,应为从库和主库分别配置连接池(例如主库承载写请求,可配置更大连接数;从库承载读请求,可根据读请求量配置)。
监控连接池状态:集成监控工具(如 HikariCP 自带的指标、或 Micrometer+Prometheus)来实时观察各个数据源的连接使用情况,及时调整参数。
SQL 调优与批量操作:对于动态路由逻辑应尽量减少切换开销,避免在批量操作中频繁切换数据源。对于大量写操作,推荐批量提交或使用批处理方式。
缓存与命中率:如果使用 Redis 等缓存减轻数据库压力,同样需要根据多数据源环境配置缓存键策略,避免不同库间数据污染。
最后,可以引用工具链的诊断数据来验证优化效果。例如使用 Spring Boot 提供的 Actuator 监控数据源状态,或通过 select * from information_schema.processlist
查看连接情况。正确的连接池参数可以显著减少连接建立/关闭的开销,提高持续负载下的稳定性。
第五章 生产环境注意事项
5.1 线程安全与 ThreadLocal 污染问题
如前文所述,动态数据源切换依赖 ThreadLocal
来保存当前线程的数据源标识。生产环境通常使用线程池(如 Tomcat 池、WebFlux 线程池等)来复用线程,此时ThreadLocal 污染风险需要特别关注:如果在线程执行完成后未及时清理 ThreadLocal
,下一个任务复用该线程时可能误用上一个任务的数据源标识。
常见防范方法:
切面 finally 清理:在 AOP 切面中,务必使用
try...finally
结构,在finally
块中调用DynamicDataSourceContextHolder.poll()
(弹出当前数据源)或remove()
来清空标识。确保无论业务逻辑是否异常结束,都能清理上下文。检查默认值:在上下文管理类中,如
DynamicDataSourceContextHolder.peek()
返回null
或空字符串时,应该默认使用主库。这可以作为保险机制,避免因清理不当导致 lookupKey 为空时抛出异常。避免同一线程并行处理多任务:在特殊场景下,比如使用 CompletableFuture、ForkJoinPool 等框架时,一个线程可能并行处理多个任务或等待任务结果,最好不要在异步回调中依赖 ThreadLocal。需要时可使用
Executor
代理或ThreadLocal
继承(InheritableThreadLocal)来传递上下文,但要格外谨慎。定期审查日志:当出现请求访问错误数据库或返回数据错乱时,怀疑是线程污染时,应在日志中记录切换和清理操作,比如在切面中加入日志:
try {DynamicDataSourceContextHolder.push(ds);logger.debug("Switch DataSource to [{}]", ds);return point.proceed(); } finally {DynamicDataSourceContextHolder.poll();logger.debug("Revert DataSource"); }
这样在异常情况可查看是否某条请求没有执行清理而影响了后续请求。
总之,“线程安全”是动态数据源方案必须关注的。ThreadLocal 本身是线程隔离的,只要使用正确的编程模式(及时清理、避免跨线程传播),在多线程环境下仍可安全使用动态切换。反之,若忽视清理,就会出现“线程池复用导致数据源标识错乱”的问题。我们可以将这种情况比喻为“快递分拣系统”:每辆车(线程)在发出新的路线指令(数据源)时,必须清除之前的路线,否则货物(数据)会送错地方。
5.2 数据源切换与事务管理的兼容性
在 Spring 中使用动态数据源切换时,事务管理也是一个重点难点。常见问题包括:事务边界应在数据源切换之后设定,以及跨数据源事务的兼容。
事务与切换顺序:通常我们希望切换数据源后再开启事务。因为事务管理器会绑定到具体数据源连接上。如果先开启事务(Spring 的
@Transactional
注解默认在切面之后执行),再切换数据源,则事务仍然作用在旧数据源上。解决方法有两种:切换逻辑早于事务:可以使用
@Transactional
注解的@EnableTransactionManagement(order = X)
属性调整切面执行顺序,确保数据源切换的 AOP 执行顺序高于事务 AOP。通常给切换切面较高优先级(较低 order 值)。声明式事务使用动态事务管理器:配置
AbstractRoutingDataSource
作为 DataSource,使得 Spring 事务管理通过它获取连接,这样即使事务切面在切换切面之后,也会从路由数据源中获取正确的实际连接。
多数据源事务:如果业务逻辑需要同时操作多个库(如跨租户查询),则单个事务无法跨多个数据源管理。解决方案是:
使用分布式事务:如使用 Seata、Atomikos 等第三方分布式事务管理框架,协调多个数据源事务。
自行管理事务:在最简单的场景下,可以明确不同数据源的操作由不同事务管理,并在业务层分别开启事务,然后手动协调(这种方式较为复杂且容易出错,不推荐)。
只读事务与写事务:结合读写分离场景,如果在只读方法上使用
@Transactional(readOnly = true)
,可以让其默认切换到从库。而在写方法上使用默认事务,可以切换到主库。这一做法需要我们在切面逻辑中可判断事务属性,例如:boolean readOnly = ((MethodSignature)point.getSignature()).getMethod().getAnnotation(Transactional.class).readOnly(); if (readOnly) {DynamicDataSourceContextHolder.push("slave"); } else {DynamicDataSourceContextHolder.push("master"); }
结合
@Transactional
的readOnly
标志来自动切换,是一种常见的优化策略。需要确保事务配置优先于切换切面的次序,或者在切面中根据事务信息设置。事务回滚注意:当发生异常回滚时,由于事务可能会重新获取连接或释放连接,
ThreadLocal
不要在事务回滚时才进行清理(事务异常可提前触发切面 finally 块清理)。同时,在使用分布式事务时,需要根据所用框架的规范进行切换管理。
综上,动态数据源切换与事务管理需要配合使用。最佳实践是:动态切换逻辑在 Spring AOP 配置为比事务切面更高优先级,确保在获取连接前已切换到正确的 DataSource;并在切面清理后让事务正常关闭连接。这样可以保证事务逻辑应用到预期的数据库上。
5.3 日志记录与监控埋点
为了运维和排查问题,建议在动态数据源切换的关键位置加入日志和监控埋点:
数据源切换日志:在 AOP 切面中增加日志输出,如前述示例所示,当每次切换发生时记录目标数据源。日志内容可包含类名、方法名和数据源标识,方便定位哪个服务调用产生切换。
连接获取跟踪:在使用
AbstractRoutingDataSource
时,可以开启 JDBC 连接池的日志或监控,监控每个数据源的连接池状态。例如 HikariCP 支持 JMX,可以定期查看连接使用情况。SQL 监控:可集成 MyBatis 的 SQL 日志(Spring Boot 支持
logging.level.com.example.mapper=DEBUG
)或使用像 P6Spy 这样的工具,在日志中标记当前使用的是哪一个数据源执行的 SQL。动态数据源切换框架(如dynamic-datasource-spring-boot-starter
)通常提供 SPI 接口,可以在连接获取时输出额外信息。监控埋点:如果项目使用了 APM(例如 SkyWalking、Pinpoint)或自定义埋点工具,可以在切面中埋点,以跟踪跨库调用。记录数据源切换、事务执行、SQL 执行时长等关键指标。
通过完善的日志和监控,运维人员可以快速发现配置错误(如数据源名称写错导致使用了默认库)、性能瓶颈(例如某个库连接池满载)等问题。例如,“如果发现请求日志中某些方法频繁切换到错误的数据源”,则说明切面逻辑或配置有问题;如果“某个数据源的连接使用率常年接近上限”,则需要扩容或优化查询。
第六章 扩展场景与替代方案
6.1 分库分表场景下的数据源路由
在分库分表场景下,通常会将数据按某种规则拆分到多个数据库或表中。例如将用户数据按地区或用户ID范围分到不同库。动态数据源切换可作为分库路由的基础,但一般仅能处理库级别的切换。如果同时需要分表,则需进一步结合 MyBatis-Plus、Sharding-Sphere 等中间件。
实现思路:首先确定分库规则,如通过计算 (userId % N)
确定目标数据库,然后在业务或 MyBatis 层将 ThreadLocal
切换到对应数据库。再配合分表插件(如 MyBatis-Plus 动态表名插件或 ShardingSphere 的表策略)实现表级路由。
例如,手动实现分库:
// 计算应当使用哪个库
String dbKey = (userId % 2 == 0) ? "db0" : "db1";
DynamicDataSourceContextHolder.push(dbKey);
try {userMapper.insert(user);
} finally {DynamicDataSourceContextHolder.poll();
}
如果仅靠 AOP 注解不方便,也可使用 MyBatis-Plus 的分库分表插件,它支持根据租户ID或表键自动路由。MyBatis-Plus 的 MultiDataSource
插件也支持多租户场景,兼顾分库分表配置。
6.2 动态数据源与 MyBatis-Plus 的集成
MyBatis-Plus 提供了开源的 dynamic-datasource-spring-boot-starter
(以下简称 DynamicDataSource),在 Spring Boot 项目中非常流行。这个组件内置了我们上面自定义的功能,包括注解、切面、数据源注册等。它特点如下:
注解使用:提供
@DS
注解(类似我们上面定义的@DataSource
),功能相近。自动配置:扫描
spring.datasource.dynamic.*
下的配置,自动注入所有子数据源;支持分组数据源(配置多个从库别名为组名)。增强功能:除了动态切换,还提供如 数据源加密(ENC())、动态刷新(热更新数据源)、独立初始化表结构 等特性。
与 MyBatis-Plus 兼容:直接支持 MyBatis-Plus,无需额外配置;也可以和 Quartz 等库兼容。
多租户支持:可以自定义租户ID获取器,实现租户自动注入逻辑。
集成方法大致如下:
引入依赖:根据 Spring Boot 版本选择
com.baomidou:dynamic-datasource-spring-boot-starter
或其 Boot 3 版本。在
application.yml
中添加多数据源配置,结构同前述,只是键名可能略有差异(以下划线分组)。使用注解
@DS("slave1")
切换数据源。
MyBatis-Plus 方案对开发者透明度很高,如果只是需要常规的读写分离和多数据源切换功能,直接使用它会比手写更快捷。它的源码同样采用了 AbstractRoutingDataSource
和 ThreadLocal,并做了丰富的边缘处理,建议阅读其官方文档了解更多。
6.3 其他框架方案对比(例如 ShardingSphere)
除了上述手写和 MyBatis-Plus 插件方案,还有一些第三方中间件支持数据源动态路由:
Apache ShardingSphere:ShardingSphere-JDBC(原 Sharding-JDBC)是一个开源分布式数据库中间件。它本身能作为一个提供分片能力的 JDBC 驱动,支持水平分库分表、读写分离、分布式事务等。与 Spring Boot 集成时,将数据源 URL 改为
jdbc:shardingsphere:...
即可使用。ShardingSphere 支持配置多数据源,读写分离策略,以及复杂的分表规则(如提示键、标准分片算法等)。不过,使用 ShardingSphere 需要额外的 YAML 或 API 配置,学习成本和运维成本相对较高。它更适用于大规模分库分表的场景。对于简单的读写分离或少量数据源切换,可能用手写切面更轻量。官方文档说明:“可以直接将 ShardingSphereDataSource 配合 ORM 框架使用”(如 MyBatis)。Drools / 自定义路由:有些团队也会实现自定义的数据源路由插件,或者使用 AOP+SPI 的方式,类似于 Spring 的
AbstractRoutingDataSource
变体。但这通常是极少数场景的定制方案。Spring Cloud 组件:对于微服务架构,还可以使用 Spring Cloud 提供的配置管理中心(如 Config Server)动态推送数据源配置,配合自定义上下文更新。
总的来说,各方案优缺点为:
手写 + Spring AOP(本章介绍的方案):灵活可控,完全掌握实现细节,适合团队有定制需求时使用。缺点是需要自行维护代码、处理边界情况。
MyBatis-Plus DynamicDatasource:快速上手,功能丰富,适合常规场景;依赖第三方库升级。
ShardingSphere 等中间件:功能全面(分库+分表+分布式事务),可视化程度高。适合对分库分表、读写分离有复杂需求的项目。缺点是学习曲线陡峭,配置复杂。
在选择上,如果项目只需要简单的多数据源切换,且团队熟悉 Spring 技术栈,那么手写或 MyBatis-Plus 即可;如果项目中对分片、事务、监控要求较高,可以考虑 ShardingSphere 或类似的方案。