SpringBoot3 实战:3 步实现读写分离,从原理到落地避坑全解析

SpringBoot3 实战:3 步实现读写分离,从原理到落地避坑全解析

作为一名互联网开发工程师,你是否遇到过这样的场景:线上项目用户量激增后,数据库查询压力越来越大,即使加了索引,高峰期仍会出现慢查询告警;想做数据库扩展,却又担心代码改动量太大,影响现有业务稳定性?

实则,解决这类问题最经典且低成本的方案之一,就是读写分离。尤其在 SpringBoot3 成为主流开发框架的当下,实现读写分离早已不是复杂的技术难题。今天这篇文章,我会从原理拆解到实战落地,带你用 3 步完成 SpringBoot3 项目的读写分离改造,最后再分享 5 个生产环境避坑要点,让你看完就能直接复用。

先搞懂:读写分离为什么能缓解数据库压力?

在开始写代码前,我们得先明确读写分离的核心逻辑 —— 毕竟技术选型不能只看 “别人都在用”,得知道它到底能解决什么问题。

1.1 读写分离的核心原理

大多数互联网项目的业务场景都符合 “读多写少” 的特点:列如用户查看商品详情、获取个人订单列表、浏览新闻资讯等操作,都是 “读” 操作;而用户下单、修改密码、提交表单等操作,才是 “写” 操作。根据统计,许多项目的读写比例甚至能达到 8:2,甚至 9:1。

如果所有读写操作都聚焦在一台数据库服务器上,“读” 操作的高频查询就会占用大量数据库连接和 CPU 资源,进而导致 “写” 操作(如订单创建)响应变慢,严重时甚至会出现数据插入超时。

读写分离的解决方案很直接:

  • 主库(Master):专门处理 “写” 操作(INSERT、UPDATE、DELETE),保证数据的实时性和一致性;
  • 从库(Slave):专门处理 “读” 操作(SELECT),分担主库的查询压力;
  • 数据同步:通过数据库自身的复制机制(如 MySQL 的主从复制),将主库的新增 / 修改数据同步到从库,确保从库数据与主库一致。

这样一来,“读” 和 “写” 的压力被拆分到不同服务器,既能缓解主库负担,又能通过增加从库数量进一步提升 “读” 性能,是应对高并发查询的 “性价比之王” 方案。

1.2 SpringBoot3 实现读写分离的关键:动态数据源

传统项目中,如果要区分主从库,可能需要手动在代码中判断 “当前是读操作还是写操作”,再切换对应的数据库连接 —— 这种方式不仅代码冗余,还容易出错,后期维护成本极高。

SpringBoot3 通过动态数据源(Dynamic DataSource) 机制,完美解决了这个问题:它能根据当前执行的 SQL 类型(读 / 写),自动切换到对应的数据源(从库 / 主库),开发者无需在代码中手动切换,实现 “无感知” 的读写分离。

核心逻辑是:

  1. 配置主库和从库的数据源信息;
  2. 定义 “数据源路由规则”:写操作路由到主库,读操作路由到从库;
  3. 通过 AOP(面向切面编程)拦截 SQL 执行,根据规则自动切换数据源。

接下来,我们就用 3 步实现这个过程,全程基于 SpringBoot3.2 和 MySQL8.0,确保代码可直接复用。

实战:3 步实现 SpringBoot3 读写分离

在开始前,先确认你的开发环境:JDK17(SpringBoot3 最低要求)、Maven3.6+、MySQL8.0(需提前配置好主从复制,文末附简易配置教程)。

第一步:引入核心依赖

第一在pom.xml中引入 3 个关键依赖:

  • SpringBoot Starter Web(基础 Web 支持);
  • SpringBoot Starter JDBC(数据源基础支持);
  • Dynamic Datasource(阿里开源的动态数据源框架,轻量且稳定,比原生 Spring 动态数据源更好用);
  • MySQL 驱动(适配 MySQL8.0)。
<!-- SpringBoot Web基础依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 动态数据源核心依赖(阿里开源) -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
    <version>3.6.1</version> <!-- 适配SpringBoot3的稳定版本 -->
</dependency>

<!-- MySQL驱动(适配MySQL8.0) -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- 可选:MyBatis-Plus(如果用MyBatis,可替换为对应的依赖) -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.5</version>
</dependency>

这里为什么选择阿里的dynamic-datasource?由于它已经封装好了数据源切换、负载均衡(多从库场景)等核心功能,开发者只需简单配置就能使用,避免重复造轮子。

第二步:配置主从数据源和路由规则

2.1 配置 application.yml(核心)


src/main/resources/application.yml中,配置主库(master)、从库(slave)的连接信息,以及动态数据源的规则:

spring:
  # 动态数据源配置
  datasource:
    dynamic:
      # 1. 全局默认数据源(未指定时默认用主库,防止读操作路由失败时无数据源可用)
      primary: master
      # 2. 数据源列表(master=主库,slave=从库,多从库可配置slave1、slave2...)
      datasources:
        # 主库(写操作)
        master:
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: jdbc:mysql://192.168.1.100:3306/test_db?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
          username: root
          password: 123456
        # 从库(读操作)
        slave:
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: jdbc:mysql://192.168.1.101:3306/test_db?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
          username: root
          password: 123456
      # 3. 数据源切换规则(AOP拦截SQL,根据SQL类型自动切换)
      strategy:
        # 读操作路由到从库(SELECT语句,除了SELECT LAST_INSERT_ID()这类特殊SQL)
        read:
          - SELECT
        # 写操作路由到主库(INSERT/UPDATE/DELETE/DDL语句)
        write:
          - INSERT
          - UPDATE
          - DELETE
          - CREATE
          - ALTER
          - DROP
  # MySQL配置(可选,优化连接池)
  jdbc:
    template:
      query-timeout: 3000 # 查询超时时间
    datasource:
      hikari:
        maximum-pool-size: 10 # 最大连接数
        minimum-idle: 5 # 最小空闲连接数
        idle-timeout: 300000 # 连接空闲超时时间(5分钟)

关键配置说明

  • primary: master:默认数据源设为主库,防止某些特殊场景(如从库宕机)下读操作无数据源可用;
  • 多从库场景:如果有多个从库(如 slave1、slave2),只需在datasources下新增配置,框架会自动实现从库负载均衡(默认轮询策略);
  • strategy:核心路由规则,通过 SQL 关键字判断操作类型,无需手动写 AOP 拦截逻辑 —— 这是dynamic-datasource框架的核心优势。

2.2 启动类添加注解(关键)

在 SpringBoot 启动类上添加@EnableDynamicDatasource注解,开启动态数据源功能:

import com.baomidou.dynamic.datasource.annotation.EnableDynamicDatasource;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@EnableDynamicDatasource // 开启动态数据源
public class SpringBootReadWriteSplitApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringBootReadWriteSplitApplication.class, args);
    }
}

这一步很容易被忽略 —— 如果不添加该注解,动态数据源配置不会生效,所有操作都会默认使用主库。

第三步:编写代码验证读写分离

配置完成后,我们通过一个简单的 “用户管理” 案例,验证读写分离是否生效。

3.1 数据库表结构(MySQL)

先在主库创建user表,主从复制会自动同步到从库:

CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `age` int DEFAULT NULL COMMENT '年龄',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

3.2 编写 Service 层代码(核心验证)

我们编写一个 Service,包含 “新增用户”(写操作,应走主库)和 “查询用户列表”(读操作,应走从库)两个方法,通过日志打印当前使用的数据源:

import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import com.example.demo.mapper.UserMapper;
import com.example.demo.entity.User;

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

    /**
     * 新增用户(写操作,默认走主库)
     * 注:@DS注解可手动指定数据源,优先级高于全局规则;不写则按全局规则自动切换
     */
    @Override
    public boolean addUser(User user) {
        // 打印当前使用的数据源(方便验证)
        System.out.println("当前数据源:" + com.baomidou.dynamic.datasource.DynamicDataSourceContextHolder.peek());
        return save(user);
    }

    /**
     * 查询用户列表(读操作,默认走从库)
     */
    @Override
    public List<User> getUserList() {
        System.out.println("当前数据源:" + com.baomidou.dynamic.datasource.DynamicDataSourceContextHolder.peek());
        return list(new QueryWrapper<User>());
    }
}

3.3 编写 Controller 层代码(测试接口)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import java.util.List;

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    // 新增用户(POST请求,写操作)
    @PostMapping("/add")
    public String addUser(@RequestBody User user) {
        boolean result = userService.addUser(user);
        return result ? "新增成功" : "新增失败";
    }

    // 查询用户列表(GET请求,读操作)
    @GetMapping("/list")
    public List<User> getUserList() {
        return userService.getUserList();
    }
}

3.4 验证读写分离效果

启动项目,用 Postman 调用POST /user/add接口,新增一个用户:

  • 控制台会打印:当前数据源:master(说明写操作走了主库);
  • 查看主库user表,会新增一条数据;等待 1-2 秒(主从复制延迟),查看从库user表,数据会同步过来。

调用GET /user/list接口,查询用户列表:

  • 控制台会打印:当前数据源:slave(说明读操作走了从库);
  • 即使停掉从库,框架会自动切换回主库(由于primary: master),不会导致查询失败。

到这里,SpringBoot3 的读写分离就已经实现了 —— 整个过程没有复杂的代码编写,核心是通过dynamic-datasource框架简化了数据源切换逻辑,开发者只需关注业务代码即可。

生产环境避坑:5 个必须注意的问题

许多开发者在测试环境实现读写分离后,上线时会遇到各种问题 —— 列如数据不一致、从库延迟导致查询不到新数据等。下面这 5 个避坑要点,是我在多个生产项目中总结的经验,必定要牢记:

1. 主从复制延迟问题:如何避免 “刚写就查不到”?

问题场景:用户刚提交订单(写主库),立即跳转到订单列表页(读从库),但此时主从复制还没完成,导致用户看不到刚创建的订单 —— 这是读写分离最常见的问题。

解决方案

关键业务手动指定主库:对于 “写后立即读” 的场景(如订单创建后查详情),用@DS(“master”)注解强制读主库,示例:

@DS("master") // 强制读主库,解决主从延迟问题
@Override
public User getUserById(Long id) {
    return getById(id);
}

优化主从复制配置:MySQL 主从复制默认是 “异步复制”,可改为 “半同步复制”(需要额外配置),减少复制延迟到毫秒级;

业务妥协:非核心业务(如商品列表)可接受 1-2 秒延迟,无需特殊处理。

从库宕机:如何自动切换到其他数据源?

问题场景:如果从库服务器故障,读操作会报错 “数据源不可用”,影响用户体验。

解决方案

  • 配置多从库:在application.yml中配置多个从库(slave1、slave2),框架会自动实现负载均衡和故障转移 —— 当 slave1 宕机时,会自动切换到 slave2;
  • 配置默认数据源:primary: master的配置必定要加,确保所有从库都宕机时,读操作会自动切换到主库,避免服务不可用。

3. 特殊 SQL 的路由问题:避免 “读操作走主库”

问题场景:有些 SQL 虽然是SELECT语句,但实际需要操作主库(如SELECT LAST_INSERT_ID()、SELECT @@identity),如果被路由到从库,会导致数据查询错误。

解决方案

  • 在application.yml的strategy.read中排除特殊 SQL:
strategy:
  read:
    - SELECT
    - !SELECT LAST_INSERT_ID() # 排除该SQL,使其走主库
    - !SELECT @@identity
  • 手动用@DS(“master”)指定数据源:对于特殊 SQL,直接在方法上标注@DS(“master”),强制走主库。

事务中的数据源一致性问题:避免 “事务内切换数据源”

问题场景:在一个事务中,如果既有写操作(走主库),又有读操作(按规则走从库),会导致 “同一事务内操作不同数据源”,出现数据不一致(由于事务只对主库生效,从库的读操作不受事务控制)。

解决方案

  • 事务内所有操作强制走主库:在事务方法上添加@DS(“master”),确保事务内的所有读写操作都走主库,示例:
@Transactional // 事务注解
@DS("master") // 事务内强制走主库,避免数据源切换
@Override
public void updateAndQuery(User user) {
    // 写操作(更新用户)
    updateById(user);
    // 读操作(查询用户,此时也走主库)
    User dbUser = getById(user.getId());
}
  • 避免在事务内做非必要的读操作:尽量将 “读操作” 移出事务,减少主库压力。

连接池配置优化:避免数据库连接耗尽

问题场景:读写分离后,主库和从库各有一个连接池,如果连接池配置不合理(如最大连接数太小),高并发时会出现 “连接耗尽” 错误。

解决方案

合理配置连接池参数:根据服务器 CPU 核心数(如 4 核 8G 服务器)和数据库性能,提议主库maximum-pool-size设为 10-20(写操作频率低但需保证实时性),从库设为 20-30(读操作高频,需更多连接支撑)。同时配置connection-timeout(连接超时时间,提议 3000ms),避免连接等待过久:

spring:
  datasource:
    dynamic:
      datasources:
        master:
          hikari:
            maximum-pool-size: 15
            connection-timeout: 3000
            idle-timeout: 300000
        slave:
          hikari:
            maximum-pool-size: 25
            connection-timeout: 3000
            idle-timeout: 300000

监控连接池状态:通过 SpringBoot Actuator 监控主从库的连接

management:
  endpoints:
    web:
      exposure:
        include: hikarihealth,metrics # 暴露Hikari连接池健康状态和指标

启动项目后,访问
http://localhost:8080/actuator/hikarihealth,可实时查看主从库连接池的 “活跃连接数”“空闲连接数”,当活跃连接接近最大连接数时,及时扩容服务器或调整连接池参数。

避免长连接占用:禁止在代码中手动持有数据库连接(如不关闭 ResultSet、PreparedStatement),确保 ORM 框架(如 MyBatis-Plus)自动管理连接生命周期,使用完后及时归还连接池。

附:MySQL8.0 主从复制简易配置(5 分钟搞定)

要实现读写分离,必须先配置 MySQL 主从复制(主库数据同步到从库),以下是基于 Linux 系统的快速配置步骤,新手也能轻松操作:

主库(Master)配置

步骤 1:修改 MySQL 配置文件my.cnf

# 编辑主库配置文件
vim /etc/my.cnf

添加以下内容(开启二进制日志,指定主库 ID):

[mysqld]
# 开启二进制日志(主从复制依赖二进制日志)
log-bin=mysql-bin
# 主库唯一ID(1-255,不能与从库重复)
server-id=1
# 只同步test_db数据库(按需配置,不配置则同步所有数据库)
binlog-do-db=test_db
# 忽略系统数据库同步
binlog-ignore-db=mysql
binlog-ignore-db=information_schema

步骤 2:重启 MySQL 服务

systemctl restart mysqld

步骤 3:创建主从复制账号并授权

登录 MySQL 主库,执行 SQL:

-- 创建用于复制的账号(用户名:repl,密码:123456)
CREATE USER 'repl'@'%' IDENTIFIED BY '123456';
-- 授予复制权限(只能从从库IP访问,%表明所有IP,生产环境提议指定从库IP)
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
-- 刷新权限
FLUSH PRIVILEGES;

-- 查看主库二进制日志状态(记录File和Position值,从库配置需要)
SHOW MASTER STATUS;

执行结果示例(需记录File=mysql-bin.000001和Position=156):

File

Position

Binlog_Do_DB

Binlog_Ignore_DB

mysql-bin.000001

156

test_db

mysql,information_schema

从库(Slave)配置

步骤 1:修改 MySQL 配置文件my.cnf

vim /etc/my.cnf

添加以下内容(指定从库 ID,关闭二进制日志(可选)):

[mysqld]
# 从库唯一ID(不能与主库重复,如2)
server-id=2
# 关闭从库二进制日志(若从库不作为其他从库的主库,可关闭)
skip-log-bin
# 只同步test_db数据库(与主库保持一致)
replicate-do-db=test_db

步骤 2:重启 MySQL 服务

systemctl restart mysqld

步骤 3:配置从库连接主库

登录 MySQL 从库,执行 SQL(替换主库 IP、File 和 Position 值):

-- 停止从库复制进程(首次配置可忽略)
STOP SLAVE;

-- 配置主库信息
CHANGE MASTER TO
MASTER_HOST='192.168.1.100', -- 主库IP
MASTER_USER='repl', -- 主库创建的复制账号
MASTER_PASSWORD='123456', -- 复制账号密码
MASTER_LOG_FILE='mysql-bin.000001', -- 主库SHOW MASTER STATUS中的File值
MASTER_LOG_POS=156; -- 主库SHOW MASTER STATUS中的Position值

-- 启动从库复制进程
START SLAVE;

-- 查看从库复制状态(关键看Slave_IO_Running和Slave_SQL_Running是否为Yes)
SHOW SLAVE STATUSG;

若执行结果中出现以下两行,说明主从复制配置成功:

Slave_IO_Running: Yes
Slave_SQL_Running: Yes

验证主从复制

在主库test_db的user表中插入一条数据,1-2 秒后查看从库一样表,若数据同步成功,主从复制配置完成。

总结:读写分离的适用场景与后续优化方向

1. 哪些项目适合用读写分离?

  • 读多写少场景:如电商商品详情页、新闻资讯平台、博客系统(读写比例 > 7:3);
  • 高并发查询场景:日均 PV 超 10 万,数据库查询耗时超过 100ms 的项目;
  • 数据安全性要求高的场景:主库故障时,从库可作为备用库,减少数据丢失风险。

不适用场景:写操作密集的项目(如秒杀订单创建、实时统计系统),这类项目更适合分库分表或使用分布式数据库(如 ShardingSphere)。

2. 后续优化方向

  • 从库读写分离升级:若从库查询压力仍大,可增加从库数量(如 3 个从库),框架自动实现轮询负载均衡;
  • 读写分离与分库分表结合:当单库数据量超 1000 万时,在读写分离基础上增加分库分表(按用户 ID 哈希分库),进一步提升性能;
  • 引入缓存减少数据库访问:在从库前增加 Redis 缓存(如缓存商品列表、用户信息),热点数据直接从缓存读取,减少从库查询压力。

最后,留给大家一个思考题:如果项目中同时使用了事务和读写分离,当事务回滚时,从库已经同步的数据该如何处理?欢迎在评论区分享你的解决方案,也可以提出文中遇到的问题,我会逐一解答~

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
超好运小鱼的头像 - 鹿快
评论 共1条

请登录后发表评论