MyBatis动态数据源切换:实现读写分离的完整方案

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注解)

文档结构概述

本文从「生活场景类比」引出核心概念,逐步讲解:

  1. 动态数据源切换的底层原理(Spring的AbstractRoutingDataSource
  2. 如何用AOP+注解实现「读从写主」的智能路由
  3. 完整代码实战(从配置多数据源到测试验证)
  4. 常见问题(如事务冲突、主从延迟)的解决方案

术语表

术语解释
读写分离写操作走主库,读操作走从库,通过主从复制同步数据
动态数据源切换根据当前操作类型(读/写)自动选择对应的数据源连接
AbstractRoutingDataSourceSpring提供的抽象类,用于实现动态数据源路由(核心工具)
ThreadLocal线程本地变量,用于保存当前线程的数据源标识(如"master"或"slave")

核心概念与联系

故事引入:快递分拣中心的「动态路由」

想象你是一个快递分拣员,每天要处理两种快递:

  • 紧急快递(比如手机、电脑):必须用「主运输线」(主库),确保实时性
  • 普通快递(比如书本、日用品):可以用「从运输线」(从库),成本更低

但问题是:快递上没有贴「紧急」或「普通」的标签,你需要根据快递类型自动选择运输线。
这时候,你需要一个「分拣规则」:

  1. 看到「下单」「修改地址」的快递(写操作),贴「主运输线」标签
  2. 看到「查询物流」的快递(读操作),贴「从运输线」标签
  3. 有了标签后,运输系统(数据库连接池)会自动把快递送到对应的运输线(数据源)

这里的「分拣规则」就是本文的核心——动态数据源路由策略,而运输系统的底层工具就是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 流程图

读操作
写操作
获取主库连接
获取从库连接
客户端请求
AOP切面
设置ThreadLocal=slave
设置ThreadLocal=master
AbstractRoutingDataSource
动态数据源
写SQL执行
读SQL执行
结果返回

核心算法原理 & 具体操作步骤

核心原理:Spring的AbstractRoutingDataSource如何工作?

Spring的AbstractRoutingDataSource是实现动态数据源的核心工具,它的工作流程如下:

  1. 当MyBatis需要执行SQL时,会向DataSource(数据源)请求一个数据库连接。
  2. AbstractRoutingDataSource被调用来获取连接,但它自己不直接管理连接,而是「委托」给具体的数据源(主库或从库)。
  3. 委托前,它会调用determineCurrentLookupKey()方法,获取当前需要使用的数据源标识(如"master")。
  4. 根据标识,从「目标数据源集合」(预先配置的主库、从库)中找到对应的数据源,获取连接。

关键代码逻辑(伪代码):

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();
}

具体操作步骤:如何实现动态切换?

要实现动态数据源切换,需要完成以下步骤:

  1. 配置多数据源:在application.yml中配置主库和从库的连接信息。
  2. 自定义动态数据源类:继承AbstractRoutingDataSource,重写determineCurrentLookupKey()方法(读取ThreadLocal中的标识)。
  3. 实现数据源标识的传递:用ThreadLocal保存当前线程的数据源标识(如"master"或"slave")。
  4. 用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的configurationmapper-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。


思考题:动动小脑筋

  1. 如果从库有多个(slave1、slave2),如何实现「负载均衡」(随机选择一个从库)?(提示:修改determineCurrentLookupKey()方法,返回随机的slave标识)
  2. 事务中的读操作应该走主库还是从库?为什么?(提示:事务中的写操作未提交时,从库无法看到变更,读从库可能读到旧数据)
  3. 如何防止ThreadLocal内存泄漏?(提示:在finally块中调用remove()方法)

附录:常见问题与解答

Q1:数据源未切换,所有请求都走主库?

  • 可能原因:AOP切面未生效(未正确添加@Component注解,或切面的@Pointcut表达式错误)。
  • 解决方法:检查切面类是否被Spring扫描到(确保在@ComponentScan的包路径下),并通过日志验证DataSourceAspect.around()是否被调用。

Q2:事务中的读操作读到了旧数据?

  • 可能原因:主从复制延迟,或事务中的读操作走了从库。
  • 解决方法:强制事务内的读操作走主库(在事务方法上标注@DataSource("master")),或调整事务隔离级别(如REPEATABLE_READ)。

Q3:多线程环境下数据源切换混乱?

  • 可能原因:ThreadLocal是线程私有的,但如果在子线程中未传递父线程的数据源标识,会导致子线程使用默认数据源。
  • 解决方法:使用InheritableThreadLocal代替ThreadLocal,允许子线程继承父线程的数据源标识。

扩展阅读 & 参考资料

  1. Spring官方文档:AbstractRoutingDataSource
  2. MyBatis动态数据源实践:MyBatis官方博客
  3. 主从复制原理:MySQL主从复制详解
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值