杰瑞科技汇

Java MySQL读写分离如何实现?

为什么需要读写分离?

在理解如何实现之前,我们首先要明白为什么需要它,核心原因是为了解决数据库的性能瓶颈高可用性问题。

Java MySQL读写分离如何实现?-图1
(图片来源网络,侵删)
  1. 缓解读压力:在大多数应用中,读操作(SELECT)的数量远大于写操作(INSERT, UPDATE, DELETE),大量的读请求会消耗数据库服务器大量的 CPU 和 I/O 资源,成为整个系统的瓶颈。
  2. 提升系统吞吐量:通过将读请求分发到多个从库,可以并行处理,从而大幅提升系统的整体读吞吐量。
  3. 高可用性:可以配置一个或多个从库作为备份,当主库发生故障时,可以快速切换一个从库作为新的主库,保证服务的连续性。
  4. 数据备份:从库可以专门用于数据备份,避免备份操作影响主库的性能。

基本架构图:

+----------------+      +----------------+      +----------------+
|    Java App    |----->|    Master      |----->|   Replication  |
| (Read/Write)   |      | (Write Only)   |      |    to Slaves   |
+----------------+      +----------------+      +----------------+
        ^                       |                       |
        |                       |                       |
        |                       v                       v
        |               +----------------+      +----------------+
        +---------------|    Slave 1     |<-----+----------------+
                        | (Read Only)    |      |      ...       |
                        +----------------+      +----------------+
                                |
                                v
                        +----------------+
                        |    Slave 2     |
                        | (Read Only)    |
                        +----------------+

实现读写分离的核心方案

在 Java 应用中实现读写分离,主要有以下几种方案,它们各有优缺点:

方案 实现方式 优点 缺点 适用场景
代理层方案 在应用和数据库之间部署一个代理层,如 MySQL Router, MyCat, ShardingSphere-Proxy,应用连接代理,由代理负责路由 SQL。 - 对应用透明:应用代码无需任何修改。
- 功能强大:可以实现分库分表、负载均衡等高级功能。
- 集中管理:所有路由规则在代理层配置,统一管理。
- 单点故障风险:代理层本身可能成为瓶颈或故障点。
- 增加部署复杂度:需要额外部署和维护代理服务。
大型、复杂的系统,希望对应用完全解耦。
框件集成方案 使用支持读写分离的持久层框架,如 MyBatisHibernate,并通过其插件机制(如 MyBatis 的 DynamicDataSourceRouter)来实现。 - 轻量级:相比代理层,更轻量,无需额外中间件。
- 代码侵入性较低:主要在配置层面实现。
- 功能有限:通常只支持基础的读写分离,不支持复杂的分库分表。
- 与框架强耦合:一旦更换框架,方案需要重写。
中小型项目,或已经在使用 MyBatis/Hibernate 的项目。
JDBC 层方案 在 JDBC 连接层进行改造,使用自定义的 DataSourceConnection,结合 AOP(面向切面编程)技术。 - 灵活性高:可以完全控制路由逻辑,实现复杂的读写分离策略。
- 无框架依赖:不依赖于任何 ORM 框架。
- 开发成本高:需要自己实现一套完整的数据源管理和路由逻辑。
- 维护成本高:代码复杂,需要处理线程安全、事务传播等问题。
对性能和路由策略有极高定制化要求的特殊场景。
中间件方案 使用成熟的数据库中间件,如 ShardingSphere (包含 JDBC 和 Proxy 两种模式)。 - 功能全面:集成了分库分表、读写分离、数据加密、分布式事务等多种功能。
- 生态完善:社区活跃,文档齐全,稳定可靠。
- 学习曲线:功能强大也意味着配置相对复杂。
- 有一定侵入性(JDBC模式)。
现代微服务架构,需要一套完整的数据库治理解决方案。

核心组件:动态数据源路由

无论采用哪种方案,其核心思想都是 动态数据源路由,也就是说,系统需要根据当前执行的 SQL 语句是读还是写,来动态选择一个合适的数据源(主库或从库)。

这通常包含以下几个关键部分:

Java MySQL读写分离如何实现?-图2
(图片来源网络,侵删)
  1. 数据源上下文:一个线程安全的工具类(通常使用 ThreadLocal),用于在当前线程中保存当前使用的数据源标识(如 masterslave)。
  2. 动态数据源:一个实现了 javax.sql.DataSource 接口的类,它不直接创建连接,而是根据 ThreadLocal 中的标识,去从一个真实的数据源池(如 HikariCP)中获取对应的 Connection
  3. 路由切面:使用 Spring AOP,在执行 DAO 或 Service 层方法之前,拦截 SQL 语句或方法注解,判断是读操作还是写操作,然后调用数据源上下文工具类,设置对应的数据源标识。

实践示例:基于 AOP + 自定义 DataSource 的方案

这是一个非常经典且易于理解的实现方式,能让你深入理解读写分离的原理。

项目依赖

<!-- Spring Boot Starter -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Starter AOP -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- MyBatis -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.2</version>
</dependency>
<!-- JDBC Driver & Connection Pool -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
</dependency>

配置文件 (application.yml)

配置主库和多个从库的信息。

spring:
  datasource:
    # 主库配置
    master:
      jdbc-url: jdbc:mysql://master-host:3306/mydb?useSSL=false&serverTimezone=UTC
      username: root
      password: password
      driver-class-name: com.mysql.cj.jdbc.Driver
      hikari:
        maximum-pool-size: 20
    # 从库配置 (可以配置多个)
    slave1:
      jdbc-url: jdbc:mysql://slave1-host:3306/mydb?useSSL=false&serverTimezone=UTC
      username: root
      password: password
      driver-class-name: com.mysql.cj.jdbc.Driver
      hikari:
        maximum-pool-size: 20
    slave2:
      jdbc-url: jdbc:mysql://slave2-host:3306/mydb?useSSL=false&serverTimezone=UTC
      username: root
      password: password
      driver-class-name: com.mysql.cj.jdbc.Driver
      hikari:
        maximum-pool-size: 20

核心代码实现

创建数据源枚举和上下文

public enum DataSourceType {
    MASTER, SLAVE
}
public class DataSourceContextHolder {
    // 使用 ThreadLocal 保证线程安全
    private static final ThreadLocal<DataSourceType> contextHolder = new ThreadLocal<>();
    public static void setDataSourceType(DataSourceType type) {
        contextHolder.set(type);
    }
    public static DataSourceType getDataSourceType() {
        return contextHolder.get() != null ? contextHolder.get() : DataSourceType.MASTER; // 默认主库
    }
    public static void clearDataSourceType() {
        contextHolder.remove();
    }
}

创建动态数据源

Java MySQL读写分离如何实现?-图3
(图片来源网络,侵删)
public class DynamicDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        // 从 ThreadLocal 中获取数据源类型
        return DataSourceContextHolder.getDataSourceType();
    }
}

配置数据源并注入到 Spring

创建一个配置类,将主库和从库的 DataSource 实例化,并设置到 DynamicDataSource 中。

@Configuration
public class DataSourceConfig {
    @Bean
    @ConfigurationProperties("spring.datasource.master")
    public DataSource masterDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }
    @Bean
    @ConfigurationProperties("spring.datasource.slave1")
    public DataSource slave1DataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }
    @Bean
    @ConfigurationProperties("spring.datasource.slave2")
    public DataSource slave2DataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }
    @Bean
    public DataSource dynamicDataSource() {
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceType.MASTER, masterDataSource());
        targetDataSources.put(DataSourceType.SLAVE1, slave1DataSource());
        targetDataSources.put(DataSourceType.SLAVE2, slave2DataSource());
        DynamicDataSource dataSource = new DynamicDataSource();
        dataSource.setDefaultTargetDataSource(masterDataSource()); // 默认主库
        dataSource.setTargetDataSources(targetDataSources);
        return dataSource;
    }
}

创建 AOP 切面进行路由

@Aspect
@Component
@Order(-1) // 保证在事务切面之前执行
public class DataSourceAspect {
    // 定义切入点,比如所有在 com.example.demo.mapper 包下的方法
    @Pointcut("execution(* com.example.demo.mapper..*.*(..))")
    public void dsPointCut() {}
    @Before("dsPointCut()")
    public void beforeSwitchDS(JoinPoint point) {
        // 获取到当前执行的方法
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        // 如果方法上有 @Master 注解,则切换到主库
        if (method.getAnnotation(Master.class) != null) {
            DataSourceContextHolder.setDataSourceType(DataSourceType.MASTER);
            return;
        }
        // 如果方法上有 @Slave 注解,则切换到从库
        if (method.getAnnotation(Slave.class) != null) {
            DataSourceContextHolder.setDataSourceType(DataSourceType.SLAVE1); // 简单起见,固定使用 slave1
            // 在实际项目中,可以实现一个负载均衡算法,如轮询
            return;
        }
        // 默认使用主库
        DataSourceContextHolder.setDataSourceType(DataSourceType.MASTER);
    }
    @After("dsPointCut()")
    public void afterSwitchDS() {
        // 方法执行完毕后,清理 ThreadLocal
        DataSourceContextHolder.clearDataSourceType();
    }
}

创建自定义注解

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Master {
}
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Slave {
}

在 Mapper 接口上使用注解

@Mapper
public interface UserMapper {
    @Master // 强制使用主库
    int insert(User user);
    @Slave // 强制使用从库
    User selectById(Long id);
    // 默认使用主库
    User selectByUsername(String username);
}

重要问题与最佳实践

  1. 主从复制延迟

    • 问题:写操作在主库执行后,数据会异步复制到从库,这个复制过程需要时间,期间从库的数据是“旧”的,如果在一个事务中先写后读,可能会读到旧数据。
    • 解决方案
      • 强制读主:对于需要强一致性的读操作(比如刚插入完数据后马上就要查询),可以使用 @Master 注解强制从主库读取。
      • 应用层缓存:对于一些不要求强实时性的数据,可以引入 Redis 等缓存,先读缓存,缓存未命中再读数据库。
      • 优化复制:确保主从复制的网络通畅,并采用半同步复制等机制来减少延迟。
  2. 事务管理

    • 问题:在一个事务中,Spring 的 TransactionManager 会在事务开始时获取一个 Connection 并绑定到当前线程,如果这个事务中包含了读写操作,那么整个事务期间,所有数据库操作都会使用同一个 Connection,也就无法实现读写分离。
    • 解决方案:上述 AOP 方案中,@Order(-1) 的设置就是为了保证我们的数据源切换切面在 Spring 事务切面之前执行,但在一个事务内,数据源只会被切换一次(在事务开始时),后续操作不会再次切换。一个事务内不能混合读写分离,如果业务逻辑要求在一个事务内既有写又有读,那么所有操作都必须走主库。
  3. 负载均衡

    • 问题:如果有多个从库,如何分配读请求?
    • 解决方案:可以在 DataSourceAspect 中实现一个负载均衡算法,如:
      • 轮询:依次访问 slave1, slave2, slave1...
      • 随机:随机选择一个从库。
      • 权重:根据从库的性能和负载情况,分配不同的权重。
方案 核心思想 优点 缺点 推荐度
代理层 应用 -> 代理 -> 数据库 对应用透明,功能强大 增加部署复杂度,有单点风险 ⭐⭐⭐⭐⭐ (大型项目)
框架集成 利用 MyBatis/Hibernate 插件 轻量,侵入性低 功能有限,与框架耦合 ⭐⭐⭐⭐ (中小型 MyBatis 项目)
AOP + 自定义 DS 在 JDBC 层动态路由 灵活,无框架依赖 开发/维护成本高 ⭐⭐⭐ (学习或特殊需求)
ShardingSphere 一站式分布式数据库中间件 功能全面,生态好 学习曲线,有一定侵入性 ⭐⭐⭐⭐⭐ (现代架构首选)

对于新项目,特别是微服务架构,强烈推荐使用 ShardingSphere,它提供了 JDBC 和 Proxy 两种模式,既能满足轻量级需求,也能应对复杂的分布式场景,对于现有项目,如果不想引入太重的中间件,基于 AOP + 自定义 DataSource 的方案是一个很好的选择。

分享:
扫描分享到社交APP
上一篇
下一篇