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

- 缓解读压力:在大多数应用中,读操作(
SELECT)的数量远大于写操作(INSERT,UPDATE,DELETE),大量的读请求会消耗数据库服务器大量的 CPU 和 I/O 资源,成为整个系统的瓶颈。 - 提升系统吞吐量:通过将读请求分发到多个从库,可以并行处理,从而大幅提升系统的整体读吞吐量。
- 高可用性:可以配置一个或多个从库作为备份,当主库发生故障时,可以快速切换一个从库作为新的主库,保证服务的连续性。
- 数据备份:从库可以专门用于数据备份,避免备份操作影响主库的性能。
基本架构图:
+----------------+ +----------------+ +----------------+
| 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。 | - 对应用透明:应用代码无需任何修改。 - 功能强大:可以实现分库分表、负载均衡等高级功能。 - 集中管理:所有路由规则在代理层配置,统一管理。 |
- 单点故障风险:代理层本身可能成为瓶颈或故障点。 - 增加部署复杂度:需要额外部署和维护代理服务。 |
大型、复杂的系统,希望对应用完全解耦。 |
| 框件集成方案 | 使用支持读写分离的持久层框架,如 MyBatis 或 Hibernate,并通过其插件机制(如 MyBatis 的 DynamicDataSourceRouter)来实现。 |
- 轻量级:相比代理层,更轻量,无需额外中间件。 - 代码侵入性较低:主要在配置层面实现。 |
- 功能有限:通常只支持基础的读写分离,不支持复杂的分库分表。 - 与框架强耦合:一旦更换框架,方案需要重写。 |
中小型项目,或已经在使用 MyBatis/Hibernate 的项目。 |
| JDBC 层方案 | 在 JDBC 连接层进行改造,使用自定义的 DataSource 和 Connection,结合 AOP(面向切面编程)技术。 |
- 灵活性高:可以完全控制路由逻辑,实现复杂的读写分离策略。 - 无框架依赖:不依赖于任何 ORM 框架。 |
- 开发成本高:需要自己实现一套完整的数据源管理和路由逻辑。 - 维护成本高:代码复杂,需要处理线程安全、事务传播等问题。 |
对性能和路由策略有极高定制化要求的特殊场景。 |
| 中间件方案 | 使用成熟的数据库中间件,如 ShardingSphere (包含 JDBC 和 Proxy 两种模式)。 | - 功能全面:集成了分库分表、读写分离、数据加密、分布式事务等多种功能。 - 生态完善:社区活跃,文档齐全,稳定可靠。 |
- 学习曲线:功能强大也意味着配置相对复杂。 - 有一定侵入性(JDBC模式)。 |
现代微服务架构,需要一套完整的数据库治理解决方案。 |
核心组件:动态数据源路由
无论采用哪种方案,其核心思想都是 动态数据源路由,也就是说,系统需要根据当前执行的 SQL 语句是读还是写,来动态选择一个合适的数据源(主库或从库)。
这通常包含以下几个关键部分:

- 数据源上下文:一个线程安全的工具类(通常使用
ThreadLocal),用于在当前线程中保存当前使用的数据源标识(如master或slave)。 - 动态数据源:一个实现了
javax.sql.DataSource接口的类,它不直接创建连接,而是根据ThreadLocal中的标识,去从一个真实的数据源池(如HikariCP)中获取对应的Connection。 - 路由切面:使用 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();
}
}
创建动态数据源

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);
}
重要问题与最佳实践
-
主从复制延迟
- 问题:写操作在主库执行后,数据会异步复制到从库,这个复制过程需要时间,期间从库的数据是“旧”的,如果在一个事务中先写后读,可能会读到旧数据。
- 解决方案:
- 强制读主:对于需要强一致性的读操作(比如刚插入完数据后马上就要查询),可以使用
@Master注解强制从主库读取。 - 应用层缓存:对于一些不要求强实时性的数据,可以引入 Redis 等缓存,先读缓存,缓存未命中再读数据库。
- 优化复制:确保主从复制的网络通畅,并采用半同步复制等机制来减少延迟。
- 强制读主:对于需要强一致性的读操作(比如刚插入完数据后马上就要查询),可以使用
-
事务管理
- 问题:在一个事务中,Spring 的
TransactionManager会在事务开始时获取一个Connection并绑定到当前线程,如果这个事务中包含了读写操作,那么整个事务期间,所有数据库操作都会使用同一个Connection,也就无法实现读写分离。 - 解决方案:上述 AOP 方案中,
@Order(-1)的设置就是为了保证我们的数据源切换切面在 Spring 事务切面之前执行,但在一个事务内,数据源只会被切换一次(在事务开始时),后续操作不会再次切换。一个事务内不能混合读写分离,如果业务逻辑要求在一个事务内既有写又有读,那么所有操作都必须走主库。
- 问题:在一个事务中,Spring 的
-
负载均衡
- 问题:如果有多个从库,如何分配读请求?
- 解决方案:可以在
DataSourceAspect中实现一个负载均衡算法,如:- 轮询:依次访问
slave1,slave2,slave1... - 随机:随机选择一个从库。
- 权重:根据从库的性能和负载情况,分配不同的权重。
- 轮询:依次访问
| 方案 | 核心思想 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|---|
| 代理层 | 应用 -> 代理 -> 数据库 | 对应用透明,功能强大 | 增加部署复杂度,有单点风险 | ⭐⭐⭐⭐⭐ (大型项目) |
| 框架集成 | 利用 MyBatis/Hibernate 插件 | 轻量,侵入性低 | 功能有限,与框架耦合 | ⭐⭐⭐⭐ (中小型 MyBatis 项目) |
| AOP + 自定义 DS | 在 JDBC 层动态路由 | 灵活,无框架依赖 | 开发/维护成本高 | ⭐⭐⭐ (学习或特殊需求) |
| ShardingSphere | 一站式分布式数据库中间件 | 功能全面,生态好 | 学习曲线,有一定侵入性 | ⭐⭐⭐⭐⭐ (现代架构首选) |
对于新项目,特别是微服务架构,强烈推荐使用 ShardingSphere,它提供了 JDBC 和 Proxy 两种模式,既能满足轻量级需求,也能应对复杂的分布式场景,对于现有项目,如果不想引入太重的中间件,基于 AOP + 自定义 DataSource 的方案是一个很好的选择。
