SpringBoot+MyBatis+MySQL 读写分离实战指南

一、读写分离核心概述

1.1 什么是读写分离

MySQL 读写分离是数据库性能优化的核心方案之一,核心思想是将数据库的读操作和写操作拆分到不同数据库节点执行。通常由一台主库(Master)负责处理新增、修改、删除等写操作,多台从库(Slave)负责处理查询读操作,主库通过 binlog 日志将数据实时同步到从库,保证数据一致性。

1.2 为什么需要读写分离

  • 分担数据库压力:互联网业务大多是读多写少场景,读请求极易造成数据库瓶颈,读写分离可将读写压力拆分,大幅提升数据库并发能力。
  • 提升系统吞吐量:从库可横向扩展,通过多从库分担读请求,有效提升系统查询响应速度和整体吞吐量。
  • 提高数据安全性:主库专注写操作,从库仅提供查询服务,降低误操作、数据篡改风险,同时从库可作为数据备份节点。
  • 实现高可用:主库故障时可快速切换从库,避免单点故障,保障业务持续可用。

1.3 实现方案选型

读写分离分为代码层实现中间件实现,本文采用轻量、无侵入、易落地的代码层动态数据源方案:基于 SpringBoot 动态数据源注解 + AOP 切面,结合 MyBatis 实现读写请求自动路由,无需部署 Sharding-JDBC、MyCat 等中间件,适合中小型项目快速落地。

二、环境准备

本次实战采用稳定主流技术版本,适配绝大多数企业开发场景:

  • JDK:1.8+
  • SpringBoot:2.7.x
  • MyBatis:3.5.x(适配 SpringBoot 自动装配)
  • MySQL:5.7 / 8.0
  • 数据库架构:1主1从(最小集群架构,可扩展多从)

前置要求:已完成 MySQL 主从复制搭建(主库开启 binlog,从库配置同步,保证主从数据实时一致)。

三、项目依赖配置

在 pom.xml 中引入核心依赖,包含 SpringBoot 数据源、MyBatis、AOP、动态数据源核心依赖:

<!-- SpringBoot 核心依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 数据源核心依赖 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.16</version>
</dependency>

<!-- MySQL 驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- MyBatis 适配 SpringBoot -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.2</version>
</dependency>

<!-- AOP 切面依赖(实现动态数据源切换) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!-- 工具类 -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

四、核心配置实现

4.1 多数据源配置(application.yml)

配置主库、从库数据源信息,主库支持读写,从库仅支持读,通过 Druid 配置连接池参数:

spring:
  # 数据源配置
  datasource:
    # 主库数据源(写库)
    master:
      url: jdbc:mysql://127.0.0.1:3306/test_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&allowMultiQueries=true
      username: root
      password: 123456
      driver-class-name: com.mysql.cj.jdbc.Driver
      type: com.alibaba.druid.pool.DruidDataSource
    # 从库数据源(读库)
    slave:
      url: jdbc:mysql://127.0.0.1:3307/test_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&allowMultiQueries=true
      username: root
      password: 123456
      driver-class-name: com.mysql.cj.jdbc.Driver
      type: com.alibaba.druid.pool.DruidDataSource
    # Druid 连接池通用配置
    druid:
      initial-size: 5
      min-idle: 5
      max-active: 20
      max-wait: 60000
      time-between-eviction-runs-millis: 60000
      min-evictable-idle-time-millis: 300000

# MyBatis 配置
mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.example.readwrite.entity
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

4.2 数据源类型枚举

定义数据源枚举,统一管理主库、从库标识,方便切面切换数据源:

package com.example.readwrite.enums;

/**
 * 数据源类型枚举
 */
public enum DataSourceType {
    /**
     * 主库(写)
     */
    MASTER,
    /**
     * 从库(读)
     */
    SLAVE
}

4.3 自定义数据源切换注解

通过注解标记方法,实现动态指定数据源,默认走主库写操作:

package com.example.readwrite.annotation;

import com.example.readwrite.enums.DataSourceType;
import java.lang.annotation.*;

/**
 * 自定义数据源切换注解
 * 优先级:方法级 > 类级
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataSource {
    // 默认使用主库
    DataSourceType value() default DataSourceType.MASTER;
}

4.4 动态数据源上下文工具类

基于 ThreadLocal 存储当前线程数据源类型,保证多线程环境下数据源切换隔离,避免线程安全问题:

package com.example.readwrite.context;

import com.example.readwrite.enums.DataSourceType;

/**
 * 数据源上下文工具类
 * 用于切换、获取、清空当前线程数据源
 */
public class DataSourceContext {

    // 线程本地变量,存储当前数据源类型
    private static final ThreadLocal<DataSourceType> DATA_SOURCE_TYPE = new ThreadLocal<>();

    /**
     * 设置数据源
     */
    public static void setDataSource(DataSourceType type) {
        DATA_SOURCE_TYPE.set(type);
    }

    /**
     * 获取当前数据源,默认主库
     */
    public static DataSourceType getDataSource() {
        return DATA_SOURCE_TYPE.get() == null ? DataSourceType.MASTER : DATA_SOURCE_TYPE.get();
    }

    /**
     * 清空数据源
     */
    public static void clearDataSource() {
        DATA_SOURCE_TYPE.remove();
    }
}

4.5 动态数据源配置类

继承 Spring 抽象动态数据源类,实现数据源动态路由,加载主从数据源并绑定标识:

package com.example.readwrite.config;

import com.alibaba.druid.pool.DruidDataSource;
import com.example.readwrite.context.DataSourceContext;
import com.example.readwrite.enums.DataSourceType;
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 DynamicDataSourceConfig {

    /**
     * 主库数据源
     */
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.master")
    public DataSource masterDataSource() {
        return new DruidDataSource();
    }

    /**
     * 从库数据源
     */
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.slave")
    public DataSource slaveDataSource() {
        return new DruidDataSource();
    }

    /**
     * 动态路由数据源
     * Primary:优先使用该数据源
     */
    @Bean
    @Primary
    public AbstractRoutingDataSource dynamicDataSource() {
        DynamicRoutingDataSource dynamicDataSource = new DynamicRoutingDataSource();
        // 配置多数据源映射关系
        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put(DataSourceType.MASTER, masterDataSource());
        dataSourceMap.put(DataSourceType.SLAVE, slaveDataSource());

        // 设置默认数据源(主库)
        dynamicDataSource.setDefaultTargetDataSource(masterDataSource());
        // 设置所有数据源
        dynamicDataSource.setTargetDataSources(dataSourceMap);
        return dynamicDataSource;
    }

    /**
     * 自定义数据源路由规则
     */
    public static class DynamicRoutingDataSource extends AbstractRoutingDataSource {
        @Override
        protected Object determineCurrentLookupKey() {
            // 从上下文获取当前数据源类型
            return DataSourceContext.getDataSource();
        }
    }
}

4.6 AOP 切面实现数据源自动切换

通过切面拦截带有 @DataSource 注解的方法,自动切换对应数据源,方法执行后清空上下文,避免污染后续请求:

package com.example.readwrite.aspect;

import com.example.readwrite.annotation.DataSource;
import com.example.readwrite.context.DataSourceContext;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

/**
 * 数据源切换切面
 */
@Aspect
@Component
@Order(1) // 优先级高于事务切面
@Slf4j
public class DataSourceAspect {

    @Around("@annotation(dataSource)")
    public Object around(ProceedingJoinPoint point, DataSource dataSource) throws Throwable {
        try {
            // 设置当前方法对应的数据源
            DataSourceContext.setDataSource(dataSource.value());
            log.info("切换数据源成功,当前数据源:{}", dataSource.value().name());
            // 执行目标方法
            return point.proceed();
        } finally {
            // 清空数据源上下文,防止线程复用导致数据源错乱
            DataSourceContext.clearDataSource();
        }
    }
}

五、业务层实战使用

核心规则:写操作(增删改)默认走主库,查询操作添加 @DataSource(DataSourceType.SLAVE) 注解走从库

5.1 Service 层代码示例

package com.example.readwrite.service.impl;

import com.example.readwrite.annotation.DataSource;
import com.example.readwrite.enums.DataSourceType;
import com.example.readwrite.entity.User;
import com.example.readwrite.mapper.UserMapper;
import com.example.readwrite.service.UserService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;

@Service
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;

    /**
     * 写操作:默认主库,无需手动指定注解
     */
    @Override
    public int addUser(User user) {
        return userMapper.insert(user);
    }

    /**
     * 读操作:指定从库查询
     */
    @Override
    @DataSource(DataSourceType.SLAVE)
    public List<User> listUser() {
        return userMapper.selectList(null);
    }

    /**
     * 读操作:单条查询走从库
     */
    @Override
    @DataSource(DataSourceType.SLAVE)
    public User getUserById(Long id) {
        return userMapper.selectById(id);
    }
}

5.2 Mapper 层接口

package com.example.readwrite.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.readwrite.entity.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends BaseMapper<User> {
    // 继承 MyBatis-Plus 通用CRUD方法,无需手写SQL
}

六、关键问题解决与注意事项

6.1 事务数据源优先级问题

Spring 事务切面优先级默认高于自定义切面,会导致事务开启后无法切换数据源。解决方案:手动设置数据源切面优先级高于事务切面(上文代码中 @Order(1) 已实现)。

6.2 主从延迟问题

MySQL 主从复制存在毫秒级延迟,极端场景下会出现写数据后立即查询,从库未同步数据的问题。解决方案:

  • 实时性要求高的查询,强制走主库;
  • 开启 MySQL 半同步复制,缩短同步延迟;
  • 核心业务读写均走主库,非核心查询走从库。

6.3 多从库负载均衡

本文实现1主1从架构,若需多从库,可改造动态数据源逻辑,通过随机、轮询算法分发读请求到不同从库,实现读负载均衡。

6.4 数据源切换失效场景

同一事务内,第一次切换数据源后,后续所有SQL都会复用当前数据源,无法重复切换。因此禁止在同一个事务中混合读写操作

七、测试验证

  1. 写操作测试:调用新增用户接口,查看日志,数据源为 MASTER,数据写入主库并同步到从库;
  2. 读操作测试:调用查询用户接口,日志显示数据源为 SLAVE,请求路由到从库执行;
  3. 并发测试:高并发查询场景下,读请求全部分摊到从库,主库仅处理写请求,数据库压力有效拆分。

八、总结

本文基于 SpringBoot + MyBatis 实现了无中间件、轻量高效的 MySQL 读写分离方案,通过 AOP 切面 + 自定义注解 实现数据源动态自动切换,完美适配读多写少的业务场景。核心亮点如下:

  • 轻量无侵入:无需部署中间件,代码耦合度低,易于维护和扩展;
  • 灵活可控:通过注解精准控制读写数据源,适配不同业务场景;
  • 线程安全:基于 ThreadLocal 实现数据源隔离,避免并发错乱问题;
  • 落地性强:适配绝大多数中小型项目,可快速上线使用。

后续可基于该方案扩展多从库负载均衡、数据源故障自动切换、动态数据源热更新等功能,进一步提升系统稳定性和并发能力。


作 者:南烛
链 接:https://www.itnotes.top/archives/1436
来 源:IT笔记
文章版权归作者所有,转载请注明出处!


上一篇
下一篇