多数据源切换实战:从业务场景到3种实现方案全解析
在微服务、SaaS、高并发等复杂架构下,单一数据源往往无法满足业务需求。多数据源动态切换已经成为后端开发必备技能。本文将结合真实业务场景,由浅入深地讲解三种主流的实现方案,并通过流程图、类图辅助理解,助你彻底掌握多数据源切换技术。
一、为什么要多数据源?三个典型场景
场景①:SaaS 运营端操作租户应用库
- 业务描述:SaaS 平台的运营端(库A)为租户手动开通某个应用账号时,需要在租户的应用库(库B)中插入一条用户数据。库A和库B物理隔离,访问库B必须切换数据源。
- 难点:运营端代码需要动态切换目标库,且租户数量可能成百上千,数据源需支持运行时注册。
场景②:MySQL 读写分离
- 业务描述:写操作走主库(保证一致性),读操作走从库(分担查询压力)。系统需要根据操作类型自动切换数据源。
- 难点:如何无侵入地拦截 DAO 方法,识别读写操作并选择对应数据源。
场景③:管理端 + 独立订单库
- 业务描述:管理端操作管理库(配置、用户等),但订单表因数据量庞大独立为一个订单库。管理端查询订单时需切换到订单库。
- 难点:同一个 Service 中可能混合调用不同库的 Mapper,数据源切换必须线程安全且精确。
二、多数据源切换的核心原理
所有切换方案的本质都是:在运行时动态选择数据源,并保证同一个线程内后续数据库操作使用选定的数据源。
写操作
读操作
租户A
租户B
应用请求
判断目标数据源
主库
从库
租户A库
租户B库
执行SQL
返回结果
Spring 提供的 AbstractRoutingDataSource 是大多数自定义方案的基石。它维护了一个 数据源映射表 (Map<Object, DataSource>),并通过 determineCurrentLookupKey() 方法返回当前线程应该使用的数据源的 key。
uses
AbstractRoutingDataSource
-Map<Object, DataSource> targetDataSources
-Object defaultTargetDataSource
+determineCurrentLookupKey() : Object
+getConnection() : Connection
DynamicDataSource
+determineCurrentLookupKey() : Object
DataSourceContextHolder
-ThreadLocal<String> contextHolder
+set(String key)
+get() : String
+clear()
核心流程:
- 在请求进入时(如 Controller 或 Service 层)确定目标数据源的标识(如
"master","slave","tenant_001")。 - 将该标识存入
ThreadLocal。 - 调用
AbstractRoutingDataSource.getConnection()时,内部调用determineCurrentLookupKey()从ThreadLocal获取 key,并返回对应数据源的连接。 - 操作完成后清理
ThreadLocal(防止线程池复用导致串库)。
下面我们逐一解析三种落地方案。
三、方案一:手动继承 AbstractRoutingDataSource + AOP+注解
这是最灵活、最贴近原理的实现方式,适合对 Spring 源码有掌控力的团队。
3.1 步骤1:定义数据源上下文持有器
java
public class DataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
public static void setDataSource(String dataSource) {
CONTEXT_HOLDER.set(dataSource);
}
public static String getDataSource() {
return CONTEXT_HOLDER.get();
}
public static void clear() {
CONTEXT_HOLDER.remove();
}
}
3.2 步骤2:自定义动态数据源类
java
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSource();
}
}
3.3 步骤3:配置数据源(以多租户为例)
java
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.ops")
public DataSource opsDataSource() { ... }
@Bean
@ConfigurationProperties("spring.datasource.tenant1")
public DataSource tenant1DataSource() { ... }
@Bean
public DynamicDataSource dynamicDataSource() {
DynamicDataSource dynamicDataSource = new DynamicDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("ops", opsDataSource());
targetDataSources.put("tenant1", tenant1DataSource());
dynamicDataSource.setTargetDataSources(targetDataSources);
dynamicDataSource.setDefaultTargetDataSource(opsDataSource()); // 默认运营库
return dynamicDataSource;
}
}
3.4 步骤4:用 AOP + 注解实现无侵入切换
定义注解:
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataSource {
String value(); // 数据源名称
}
AOP 切面:
java
@Aspect
@Component
public class DataSourceAspect {
@Before("@annotation(dataSource)")
public void switchDataSource(JoinPoint point, DataSource dataSource) {
DataSourceContextHolder.setDataSource(dataSource.value());
}
@After("@annotation(dataSource)")
public void restoreDataSource(DataSource dataSource) {
DataSourceContextHolder.clear();
}
}
使用示例:
java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@DataSource("orderDB")
public List<Order> queryFromOrderDB() {
return orderMapper.selectList();
}
}
3.5 流程图:AOP+注解切换过程
Service DB DynamicDataSource ThreadLocal AOP Controller Client Service DB DynamicDataSource ThreadLocal AOP Controller Client 请求调用 queryFromOrderDB() 拦截 @DataSource 注解 set("orderDB") 执行目标方法 调用 orderMapper get() "orderDB" 获取 orderDB 的连接 返回数据 方法执行完毕 clear() 返回结果
优点 :完全掌控,可扩展(如支持动态注册新租户数据源)。
缺点:需要手写大量配置和切面,易出错。
四、方案二:MyBatis-Plus 动态数据源(推荐)
MyBatis-Plus 官方提供了 dynamic-datasource 模块,开箱即用,支持读写分离、多库切换,是目前中小型项目最常用的方案。
4.1 引入依赖
xml
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>4.3.0</version>
</dependency>
4.2 配置文件(application.yml)
yaml
spring:
datasource:
dynamic:
primary: master # 默认数据源
datasource:
master: # 主库
url: jdbc:mysql://localhost:3306/master_db
username: root
password: 123456
slave_1: # 从库1
url: jdbc:mysql://localhost:3306/slave_db
username: root
password: 123456
order_db: # 独立订单库
url: jdbc:mysql://localhost:3306/order_db
username: root
password: 123456
4.3 使用注解 @DS
java
@Service
public class OrderService {
@DS("order_db")
public List<Order> getOrders() {
return orderMapper.selectList();
}
@DS("slave_1")
public List<User> getUsersFromSlave() {
return userMapper.selectList();
}
@DS("master") // 也可省略,因为 primary = master
public void updateUser(User user) {
userMapper.updateById(user);
}
}
4.4 读写分离自动识别(可选)
配合 dynamic-datasource 的读写分离插件,只需配置从库名称规则:
yaml
spring.datasource.dynamic:
primary: master
strategy: round # 负载均衡策略
datasource:
master:
url: ...
slave_1:
url: ...
slave_2:
url: ...
然后在方法上使用 @DS("slave") 即可自动轮询从库。更高级的用法:配置 mybatis-plus 拦截器,根据 SQL 类型(SELECT / INSERT/UPDATE/DELETE)自动切换,连注解都省了。
4.5 原理简析
dynamic-datasource 本质上也是基于 AbstractRoutingDataSource,但封装了:
- 动态数据源加载与刷新
@DS注解解析(支持类、方法级别)- 底层使用
DynamicDataSourceContextHolder管理 ThreadLocal - 配合 Spring 事务时,自动保证同一个事务内使用同一个数据源(避免跨库事务)
有
无
业务方法
是否有 @DS 注解?
解析注解值
使用 primary 数据源
存入 DynamicDataSourceContextHolder
执行 SQL
清除 ThreadLocal
优点 :配置简单,注解清爽,支持多租户动态添加数据源,社区活跃。
缺点:对跨数据源事务(如同时操作 master 和 order_db)支持较弱,需配合 Seata 等分布式事务方案。
五、方案三:ShardingSphere-JDBC(适合分库分表+读写分离)
当你的场景不仅仅是多数据源切换,还涉及水平分表、分库、分布式事务时,ShardingSphere-JDBC 是更强大的选择。
5.1 引入依赖
xml
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.3.2</version>
</dependency>
5.2 配置读写分离 + 多库
yaml
spring:
shardingsphere:
datasource:
names: master,slave0,orderds
master: # 主库
type: com.zaxxer.hikari.HikariDataSource
url: jdbc:mysql://localhost:3306/master_db
slave0: # 从库
url: jdbc:mysql://localhost:3306/slave_db
orderds: # 订单库
url: jdbc:mysql://localhost:3306/order_db
rules:
readwrite-splitting:
data-sources:
ms:
type: Static
props:
auto-aware-data-source-name: master
write-data-source-name: master
read-data-source-names: slave0
sharding: # 如果需要对 order_db 进行分表,可在此配置
tables:
t_order:
actual-data-nodes: orderds.t_order_$->{0..1}
table-strategy:
inline:
sharding-column: order_id
algorithm-expression: t_order_$->{order_id % 2}
props:
sql-show: true
5.3 代码中零感知切换
ShardingSphere 会拦截所有 SQL,自动根据规则路由到对应的真实数据源或表。对于读写分离,它会自动识别 SELECT 语句走从库,INSERT/UPDATE/DELETE 走主库。
java
@Service
public class OrderService {
// 无需任何注解,直写标准 MyBatis-plus Mapper 即可
public void createOrder(Order order) {
orderMapper.insert(order); // 自动走主库
}
public List<Order> listOrders() {
return orderMapper.selectList(); // 自动走从库(轮询)
}
public Order getFromShardingTable(Long orderId) {
// 根据 orderId 路由到 orderds.t_order_0 或 t_order_1
return orderMapper.selectById(orderId);
}
}
5.4 架构图
SQL
解析
路由规则
写
读
分片键
应用
ShardingSphere JDBC
逻辑SQL
路由
主库
主从复制
订单库分表
优点 :功能最强,统一处理分库分表、读写分离、分布式事务,对业务代码几乎无侵入。
缺点:配置复杂,性能有一定损耗(SQL 解析与路由),学习成本高。
六、三种方案对比总结
| 特性 | 方案一:手动继承 + AOP | 方案二:MyBatis-Plus 动态数据源 | 方案三:ShardingSphere-JDBC |
|---|---|---|---|
| 实现复杂度 | 高(需手写大量基础设施) | 低(依赖 starter,几乎零配置) | 中高(需了解分片规则) |
| 功能范围 | 仅多数据源切换 | 多数据源 + 读写分离 + 动态加载 | 分库分表 + 读写分离 + 分布式事务 |
| 对业务代码侵入 | 中(需要加注解或手动 set) | 低(@DS 注解) | 极低(完全透明) |
| 支持动态增加数据源 | 需自行实现 | 支持(通过 API 动态添加) | 支持(通过配置中心动态刷新) |
| 跨库事务 | 不支持(需借助 JTA 或柔性事务) | 不支持(建议避免) | 支持(集成 Seata) |
| 适用场景 | 学习原理、简单多库切换(<5个库) | 80% 的中小型项目(SaaS、读写分离) | 大数据量、分库分表、复杂路由策略 |
| 推荐指数 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
七、最佳实践与避坑指南
-
事务与数据源切换的冲突
Spring
@Transactional开启事务时,会提前绑定连接(数据源)。此时即便你在方法内切换数据源,也不会生效。解决方案:- 避免在事务方法内切换数据源;
- 或者使用声明式事务 +
@DS放在事务方法上(MyBatis-Plus 支持); - 或者使用编程式事务(
TransactionTemplate)。
-
ThreadLocal 内存泄漏
务必在请求结束后(如 AOP
@After或拦截器afterCompletion)中调用clear(),否则在线程池复用时会串库。 -
动态添加数据源
如果使用方案二,可以调用
DynamicRoutingDataSource.addDataSource(key, dataSource)实现运行时添加数据源,适合多租户场景。 -
读写分离主从延迟
对于实时性要求高的读操作(如刚写完就查),可通过强制走主库:
@DS("master")或使用HintManager。
八、总结
多数据源切换是大型项目绕不开的课题。根据业务复杂度选择方案:
- 入门/学习 :手写
AbstractRoutingDataSource理解原理; - 快速开发/SaaS/读写分离:MyBatis-Plus 动态数据源,性价比最高;
- 超大规模/分库分表:ShardingSphere-JDBC,一劳永逸。
🧩 思考题:如果系统中已经使用了 ShardingSphere,还想动态添加租户库(非分片库),该如何设计?欢迎留言讨论。