MyBatis动态数据源切换:实现读写分离的完整方案
关键词:MyBatis、动态数据源、读写分离、AbstractRoutingDataSource、AOP、ThreadLocal、主从复制
摘要:本文从「为什么需要读写分离」出发,通过生活场景类比+代码实战的方式,详细讲解如何用MyBatis实现动态数据源切换。涵盖核心概念(如动态数据源路由、ThreadLocal上下文)、关键实现原理(Spring的AbstractRoutingDataSource)、完整代码示例(从配置到AOP切面),并总结实际开发中的常见坑点与解决方案,帮助开发者快速掌握读写分离的落地方法。
背景介绍
目的和范围
在互联网应用中,「读多写少」是典型场景(比如电商商品查询量可能是下单量的100倍)。如果所有请求都打在主数据库上,主库压力会急剧增大,甚至成为性能瓶颈。
本文将教你如何通过「动态数据源切换」实现读写分离:让写操作(INSERT/UPDATE/DELETE)走主库,读操作(SELECT)走从库,从而分摊数据库压力,提升系统吞吐量。
适用范围:基于Spring Boot+MyBatis的Java项目,需要主从数据库架构(如MySQL主从复制)。
预期读者
- 熟悉Spring Boot基础配置(如
application.yml
) - 了解MyBatis的基本使用(Mapper接口、XML映射)
- 对AOP(面向切面编程)有初步认知(知道
@Aspect
、@Pointcut
注解)
文档结构概述
本文从「生活场景类比」引出核心概念,逐步讲解:
- 动态数据源切换的底层原理(Spring的
AbstractRoutingDataSource
) - 如何用AOP+注解实现「读从写主」的智能路由
- 完整代码实战(从配置多数据源到测试验证)
- 常见问题(如事务冲突、主从延迟)的解决方案
术语表
术语 | 解释 |
---|---|
读写分离 | 写操作走主库,读操作走从库,通过主从复制同步数据 |
动态数据源切换 | 根据当前操作类型(读/写)自动选择对应的数据源连接 |
AbstractRoutingDataSource | Spring提供的抽象类,用于实现动态数据源路由(核心工具) |
ThreadLocal | 线程本地变量,用于保存当前线程的数据源标识(如"master"或"slave") |
核心概念与联系
故事引入:快递分拣中心的「动态路由」
想象你是一个快递分拣员,每天要处理两种快递:
- 紧急快递(比如手机、电脑):必须用「主运输线」(主库),确保实时性
- 普通快递(比如书本、日用品):可以用「从运输线」(从库),成本更低
但问题是:快递上没有贴「紧急」或「普通」的标签,你需要根据快递类型自动选择运输线。
这时候,你需要一个「分拣规则」:
- 看到「下单」「修改地址」的快递(写操作),贴「主运输线」标签
- 看到「查询物流」的快递(读操作),贴「从运输线」标签
- 有了标签后,运输系统(数据库连接池)会自动把快递送到对应的运输线(数据源)
这里的「分拣规则」就是本文的核心——动态数据源路由策略,而运输系统的底层工具就是Spring的AbstractRoutingDataSource
。
核心概念解释(像给小学生讲故事)
概念一:动态数据源(Dynamic DataSource)
可以理解为一个「智能连接池」,里面装了多个数据库的连接(主库、从库1、从库2…)。它能根据当前操作类型(读/写),自动选择一个合适的连接。
类比:你家有多个存钱罐(主存钱罐、从存钱罐1、从存钱罐2),动态数据源就像一个「智能手」,需要存钱(写操作)时选主存钱罐,需要取钱(读操作)时选其中一个从存钱罐。
概念二:AbstractRoutingDataSource(路由裁判)
Spring提供的一个「裁判类」,负责决定当前用哪个数据源。你需要告诉它「裁判规则」——重写它的determineCurrentLookupKey()
方法,返回当前要使用的数据源标识(如"master"或"slave")。
类比:学校运动会的「赛道裁判」,根据运动员的项目(100米/400米)分配不同的赛道。
概念三:ThreadLocal(小纸条)
一个线程专用的「小纸条」,可以在当前线程的任意位置记录和读取数据。我们用它保存当前的数据源标识(比如"master"),供AbstractRoutingDataSource
读取。
类比:你去食堂打饭,阿姨给你一张带号码的小纸条(ThreadLocal),取餐时凭小纸条上的号码(数据源标识)拿对应的套餐(数据库连接)。
概念四:AOP(快递贴标签机)
面向切面编程,能在方法执行前后「插入」自定义逻辑。我们用它给「读方法」和「写方法」自动贴「从库」或「主库」的标签(设置ThreadLocal的值)。
类比:快递分拣中心的「自动贴标机」,看到「查询物流」的快递(读方法)自动贴「从库」标签,看到「下单」的快递(写方法)贴「主库」标签。
核心概念之间的关系(用小学生能理解的比喻)
这四个概念就像一个「快递分拣团队」:
- ThreadLocal(小纸条):负责记录当前快递的标签(数据源标识),是团队的「信息传递员」。
- AbstractRoutingDataSource(路由裁判):根据小纸条上的标签,决定用哪个运输线(数据源),是团队的「决策者」。
- 动态数据源(智能连接池):装着各个运输线的连接(主库/从库),是团队的「资源仓库」。
- AOP(贴标机):在快递(方法)进入仓库前,自动贴上正确的标签(设置小纸条内容),是团队的「前置处理员」。
它们的合作流程是:
贴标机(AOP)给快递贴标签→信息传递员(ThreadLocal)记录标签→决策者(AbstractRoutingDataSource)根据标签选运输线→资源仓库(动态数据源)提供对应连接。
核心概念原理和架构的文本示意图
客户端请求 → AOP切面(贴标签:设置ThreadLocal为"master"或"slave")
→ AbstractRoutingDataSource(读取ThreadLocal的标签,选择数据源)
→ 动态数据源(从主库或从库连接池获取连接)
→ MyBatis执行SQL(使用选中的连接)
Mermaid 流程图
核心算法原理 & 具体操作步骤
核心原理:Spring的AbstractRoutingDataSource如何工作?
Spring的AbstractRoutingDataSource
是实现动态数据源的核心工具,它的工作流程如下:
- 当MyBatis需要执行SQL时,会向
DataSource
(数据源)请求一个数据库连接。 AbstractRoutingDataSource
被调用来获取连接,但它自己不直接管理连接,而是「委托」给具体的数据源(主库或从库)。- 委托前,它会调用
determineCurrentLookupKey()
方法,获取当前需要使用的数据源标识(如"master")。 - 根据标识,从「目标数据源集合」(预先配置的主库、从库)中找到对应的数据源,获取连接。
关键代码逻辑(伪代码):
public class AbstractRoutingDataSource extends AbstractDataSource {
// 目标数据源集合(主库、从库)
private Map<Object, DataSource> targetDataSources;
// 默认数据源(如主库)
private DataSource defaultTargetDataSource;
@Override
public Connection getConnection() {
// 1. 获取当前数据源标识(由子类实现)
Object key = determineCurrentLookupKey();
// 2. 根据标识找对应的数据源
DataSource dataSource = determineTargetDataSource(key);
// 3. 返回该数据源的连接
return dataSource.getConnection();
}
protected DataSource determineTargetDataSource(Object key) {
return targetDataSources.get(key); // 从集合中获取
}
// 需要子类重写的方法:决定当前用哪个数据源
protected abstract Object determineCurrentLookupKey();
}
具体操作步骤:如何实现动态切换?
要实现动态数据源切换,需要完成以下步骤:
- 配置多数据源:在
application.yml
中配置主库和从库的连接信息。 - 自定义动态数据源类:继承
AbstractRoutingDataSource
,重写determineCurrentLookupKey()
方法(读取ThreadLocal中的标识)。 - 实现数据源标识的传递:用ThreadLocal保存当前线程的数据源标识(如"master"或"slave")。
- 用AOP自动设置标识:通过自定义注解(如
@DataSource("slave")
)+切面,在方法执行前设置ThreadLocal的值。
接下来,我们通过代码实战详细讲解每一步。
数学模型和公式(简要说明)
动态数据源切换的核心是「根据操作类型选择数据源」,可以用一个简单的条件判断模型表示:
数据源标识
=
{
m
a
s
t
e
r
如果是写操作(INSERT/UPDATE/DELETE)
s
l
a
v
e
如果是读操作(SELECT)
数据源标识 = \begin{cases} master & \text{如果是写操作(INSERT/UPDATE/DELETE)} \\ slave & \text{如果是读操作(SELECT)} \end{cases}
数据源标识={masterslave如果是写操作(INSERT/UPDATE/DELETE)如果是读操作(SELECT)
在代码中,这个判断逻辑由AOP切面实现(比如通过方法名匹配:以query
/get
开头的方法是读操作,其他是写操作)。
项目实战:代码实际案例和详细解释说明
开发环境搭建
环境要求:
- JDK 1.8+
- Spring Boot 2.7.0+
- MyBatis Plus 3.5.0+(简化MyBatis配置)
- MySQL 5.7+(主库和至少1个从库,已配置主从复制)
依赖配置(pom.xml):
<dependencies>
<!-- Spring Boot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- HikariCP 连接池(Spring Boot 默认) -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<!-- AOP 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
源代码详细实现和代码解读
步骤1:配置多数据源(application.yml)
在src/main/resources/application.yml
中配置主库和从库的连接信息:
spring:
datasource:
dynamic:
primary: master # 默认使用主库
datasource:
master:
url: jdbc:mysql://主库IP:3306/your_db?useSSL=false&serverTimezone=UTC
username: 主库用户名
password: 主库密码
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
url: jdbc:mysql://从库IP:3306/your_db?useSSL=false&serverTimezone=UTC
username: 从库用户名
password: 从库密码
driver-class-name: com.mysql.cj.jdbc.Driver
步骤2:自定义动态数据源类(关键!)
创建DynamicDataSource
类,继承AbstractRoutingDataSource
,并重写determineCurrentLookupKey()
方法:
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
public class DynamicDataSource extends AbstractRoutingDataSource {
// 使用ThreadLocal保存当前数据源的标识("master"或"slave")
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
// 重写:返回当前数据源的标识
@Override
protected Object determineCurrentLookupKey() {
return CONTEXT_HOLDER.get();
}
// 提供静态方法:设置当前数据源标识
public static void setDataSourceKey(String key) {
CONTEXT_HOLDER.set(key);
}
// 提供静态方法:清除当前数据源标识(防止内存泄漏)
public static void clearDataSourceKey() {
CONTEXT_HOLDER.remove();
}
}
步骤3:配置数据源路由(Spring配置类)
创建DataSourceConfig
配置类,将主库、从库注册到Spring容器,并配置DynamicDataSource
:
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class DataSourceConfig {
// 读取主库配置(对应application.yml中的spring.datasource.dynamic.datasource.master)
@Bean
@ConfigurationProperties(prefix = "spring.datasource.dynamic.datasource.master")
public DataSource masterDataSource() {
return new HikariDataSource();
}
// 读取从库配置(对应application.yml中的spring.datasource.dynamic.datasource.slave)
@Bean
@ConfigurationProperties(prefix = "spring.datasource.dynamic.datasource.slave")
public DataSource slaveDataSource() {
return new HikariDataSource();
}
// 配置动态数据源(核心)
@Bean
@Primary // 声明为默认数据源,MyBatis会使用它
public AbstractRoutingDataSource dynamicDataSource(DataSource masterDataSource, DataSource slaveDataSource) {
DynamicDataSource dynamicDataSource = new DynamicDataSource();
// 设置目标数据源集合(master和slave)
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource);
targetDataSources.put("slave", slaveDataSource);
dynamicDataSource.setTargetDataSources(targetDataSources);
// 设置默认数据源(主库)
dynamicDataSource.setDefaultTargetDataSource(masterDataSource);
return dynamicDataSource;
}
}
步骤4:自定义注解(标记方法使用的数据源)
创建@DataSource
注解,用于标记某个方法需要使用的数据源(如@DataSource("slave")
表示读从库):
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.TYPE}) // 可标注在类或方法上
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataSource {
String value() default "master"; // 默认使用主库
}
步骤5:AOP切面(自动设置数据源标识)
创建DataSourceAspect
切面类,在方法执行前根据@DataSource
注解设置ThreadLocal的值:
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
@Aspect
@Component
public class DataSourceAspect {
// 定义切点:所有标注了@DataSource的方法或类
@Pointcut("@annotation(com.example.demo.annotation.DataSource) || @within(com.example.demo.annotation.DataSource)")
public void dataSourcePointcut() {}
// 环绕通知:在方法执行前后设置/清除数据源标识
@Around("dataSourcePointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
try {
// 获取当前方法的@DataSource注解值
String dataSourceKey = getDataSourceKey(joinPoint);
// 设置到ThreadLocal中
DynamicDataSource.setDataSourceKey(dataSourceKey);
// 执行目标方法
return joinPoint.proceed();
} finally {
// 清除ThreadLocal(关键!防止线程复用导致的脏数据)
DynamicDataSource.clearDataSourceKey();
}
}
// 辅助方法:获取方法或类上的@DataSource注解值
private String getDataSourceKey(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 优先获取方法上的注解
DataSource methodAnnotation = AnnotationUtils.findAnnotation(method, DataSource.class);
if (methodAnnotation != null) {
return methodAnnotation.value();
}
// 如果方法上没有,获取类上的注解
Class<?> targetClass = joinPoint.getTarget().getClass();
DataSource classAnnotation = AnnotationUtils.findAnnotation(targetClass, DataSource.class);
if (classAnnotation != null) {
return classAnnotation.value();
}
// 默认使用主库
return "master";
}
}
步骤6:MyBatis配置(绑定动态数据源)
在application.yml
中配置MyBatis的configuration
和mapper-locations
,并确保MyBatis使用我们的动态数据源:
mybatis-plus:
configuration:
map-underscore-to-camel-case: true # 开启驼峰命名转换
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印SQL日志(方便测试)
mapper-locations: classpath:mapper/**/*.xml # Mapper XML文件路径
代码解读与分析
- DynamicDataSource类:通过ThreadLocal保存当前线程的数据源标识,
determineCurrentLookupKey()
方法读取该标识,告诉AbstractRoutingDataSource
应该选哪个数据源。 - AOP切面:通过
@Around
环绕通知,在方法执行前设置数据源标识,执行后清除(防止线程池复用线程时,上一次的标识残留)。 - @DataSource注解:灵活标记方法或类使用的数据源,支持细粒度控制(比如某个Service的所有方法默认读从库,个别写方法单独标记主库)。
实际应用场景
场景1:电商系统的商品查询与下单
- 商品查询(读操作):用户浏览商品列表时,调用
ProductService.queryProductList()
方法(标注@DataSource("slave")
),从从库读取数据,减轻主库压力。 - 下单(写操作):用户提交订单时,调用
OrderService.createOrder()
方法(默认使用主库),确保数据实时写入主库,通过主从复制同步到从库。
场景2:社交平台的动态浏览与发布
- 动态浏览(读操作):用户刷朋友圈时,调用
FeedService.getFeeds()
方法(@DataSource("slave")
),从从库读取历史动态。 - 动态发布(写操作):用户发朋友圈时,调用
FeedService.postFeed()
方法(默认主库),确保新动态实时写入主库,避免从库延迟导致其他用户看不到。
工具和资源推荐
工具/资源 | 作用 |
---|---|
MyBatis Plus | 简化MyBatis配置,提供@Mapper 注解、通用CRUD方法 |
HikariCP | 高性能连接池(Spring Boot默认),提升数据库连接效率 |
Druid | 阿里的数据库连接池,支持监控、SQL防火墙(可选) |
MySQL主从复制文档 | 官方文档 |
未来发展趋势与挑战
趋势1:智能路由策略
未来可能结合AI算法,根据从库的负载(CPU、QPS)动态选择从库,避免某个从库压力过大。例如:
- 监控从库的
Threads_connected
(连接数),优先选择连接数最少的从库。 - 检测主从延迟(通过
Seconds_Behind_Master
),延迟过高时自动切换到其他从库或主库。
趋势2:云原生数据源管理
在K8s环境下,数据源可能动态扩缩容(比如新增从库)。动态数据源需要支持「热加载」新数据源,无需重启应用。Spring Cloud的Spring Cloud Alibaba Nacos
可以实现配置的动态刷新,结合本文方案可实现动态数据源的扩展。
挑战1:主从延迟问题
主从复制存在延迟(通常几毫秒到几秒),如果写操作后立即读从库,可能读到旧数据。解决方案:
- 强制读主库:写操作后,后续的读操作在一定时间内强制走主库(通过
ThreadLocal
传递标识)。 - 业务容忍:如果业务允许短暂延迟(如商品详情页),可以忽略;如果不允许(如支付结果查询),必须读主库。
挑战2:跨库事务问题
如果业务需要同时操作主库和从库(如分布式事务),需要使用分布式事务框架(如Seata)。但读写分离场景下,通常写主库、读从库,跨库事务较少,需尽量避免。
总结:学到了什么?
核心概念回顾
- 动态数据源:智能选择主库/从库连接的工具。
- AbstractRoutingDataSource:Spring提供的路由裁判,根据
determineCurrentLookupKey()
的返回值选择数据源。 - ThreadLocal:线程的小纸条,保存当前数据源标识。
- AOP+@DataSource:自动给方法贴标签,实现无感知的数据源切换。
概念关系回顾
AOP切面通过@DataSource
注解设置ThreadLocal的数据源标识→AbstractRoutingDataSource
读取该标识→动态数据源选择对应的主库/从库连接→MyBatis执行SQL。
思考题:动动小脑筋
- 如果从库有多个(slave1、slave2),如何实现「负载均衡」(随机选择一个从库)?(提示:修改
determineCurrentLookupKey()
方法,返回随机的slave标识) - 事务中的读操作应该走主库还是从库?为什么?(提示:事务中的写操作未提交时,从库无法看到变更,读从库可能读到旧数据)
- 如何防止ThreadLocal内存泄漏?(提示:在
finally
块中调用remove()
方法)
附录:常见问题与解答
Q1:数据源未切换,所有请求都走主库?
- 可能原因:AOP切面未生效(未正确添加
@Component
注解,或切面的@Pointcut
表达式错误)。 - 解决方法:检查切面类是否被Spring扫描到(确保在
@ComponentScan
的包路径下),并通过日志验证DataSourceAspect.around()
是否被调用。
Q2:事务中的读操作读到了旧数据?
- 可能原因:主从复制延迟,或事务中的读操作走了从库。
- 解决方法:强制事务内的读操作走主库(在事务方法上标注
@DataSource("master")
),或调整事务隔离级别(如REPEATABLE_READ
)。
Q3:多线程环境下数据源切换混乱?
- 可能原因:ThreadLocal是线程私有的,但如果在子线程中未传递父线程的数据源标识,会导致子线程使用默认数据源。
- 解决方法:使用
InheritableThreadLocal
代替ThreadLocal
,允许子线程继承父线程的数据源标识。
扩展阅读 & 参考资料
- Spring官方文档:AbstractRoutingDataSource
- MyBatis动态数据源实践:MyBatis官方博客
- 主从复制原理:MySQL主从复制详解