数据库读写分离方案详解 ⭐⭐⭐

🎯 面试题:如何实现数据库读写分离?如何解决主从延迟问题?

读写分离是解决数据库读性能瓶颈最直接、最成熟的方案。面试中不仅会问原理,更会深挖主从延迟、数据一致性问题以及实际落地选型。


一、为什么需要读写分离

单体架构的问题:
  ┌─────────────────┐
  │   应用服务器     │
  │   100 台        │
  └────────┬────────┘
           │ 10000 QPS
           ↓
  ┌─────────────────┐
  │   MySQL 单机     │ ← 瓶颈:单机 QPS 上限 3000~5000
  └─────────────────┘

读写分离后的架构:
  ┌─────────────────┐     ┌──────────────┐
  │   写请求 (10%)   │────→│  MySQL 主库  │
  └─────────────────┘     └──────┬───────┘
                                 │ 同步
                    ┌────────────┼────────────┐
                    ↓            ↓            ↓
            ┌──────────┐  ┌──────────┐  ┌──────────┐
            │ MySQL 从1 │  │ MySQL 从2 │  │ MySQL 从3 │
            └────┬─────┘  └────┬─────┘  └────┬─────┘
                 ↓              ↓              ↓
  ┌─────────────────────────────┐
  │   读请求 (90%) 均衡分发     │
  └─────────────────────────────┘

核心价值:


二、MySQL 主从复制原理

binlog 三种格式

格式 内容 优点 缺点
ROW 记录每行数据变化(完整行数据) 精确,无主从数据不一致 binlog 大,复制慢
STATEMENT 记录 SQL 语句 binlog 小,逻辑清晰 函数/存储过程可能不一致
MIXED 智能选择 ROW 或 STATEMENT 平衡 -
-- 查看当前 binlog 格式
SHOW VARIABLES LIKE 'binlog_format';

-- 推荐使用 ROW 格式(数据一致性最好)
SET GLOBAL binlog_format = 'ROW';

主从复制流程(异步)

主库侧:
  1. 事务提交 → 写 binlog(顺序写磁盘,性能高)
  2. 事务提交 → 返回客户端

从库侧(IO Thread):
  3. 从库连接主库,告知从哪个 position 开始拉取
  4. 主库收到拉取请求,从 binlog 读取对应内容发送给从库
  5. 从库 IO Thread 接收,写入 relay log(中继日志)

从库侧(SQL Thread):
  6. 从库 SQL Thread 读取 relay log
  7. 在从库上重放(执行)SQL
  8. 更新从库数据

GTID 模式(推荐)

-- GTID = Global Transaction Identifier
-- 格式:server_uuid:transaction_id
-- 示例:3E11FA47-71CA-11E1-9E33-C80AA9429562:23

-- GTID 优势:自动找位点,不需要手动指定 binlog position
-- 配置 GTID
server-id = 1
gtid_mode = ON
enforce_gtid_consistency = ON

半同步复制(保证数据不丢)

异步复制的问题:
  主库提交 → 写 binlog → 返回客户端
  从库还没同步 → 主库挂了 → 数据丢失 ❌

半同步复制:
  主库提交 → 写 binlog → 等待至少 1 个从库 ACK → 返回客户端
  从库没 ACK → 主库提交失败 → 返回错误
  
配置:
  INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
  SET GLOBAL rpl_semi_sync_master_enabled = 1;

三、主从延迟问题

延迟原因

主从延迟的三大原因:

1. 主库并发写入,从库串行重放
   → 写入快,重放慢,造成积压

2. 大事务
   → 主库一个事务操作 10 万行
   → 从库也要执行 10 万行
   → 主库秒完,从库可能要 10 秒

3. 从库硬件差(CPU/磁盘性能差)
   → 重放速度跟不上

监控延迟:
  SHOW SLAVE STATUS\G
  Seconds_Behind_Master: 5  ← 延迟 5 秒

延迟的解决方案

方案一:关键读走主库
  业务判断:下单后立即查订单 → 强制读主库
  代码:@ReadOnly(false) 或 Hints.forceMasterRead()

方案二:延迟检测 + 降级
  延迟 < 1s → 读从库
  延迟 > 1s → 降级读主库
  
方案三:读写多线程并行重放
  从库 SQL Thread → 多个 Worker Thread 并行重放
  配置:slave_parallel_workers = 8

方案四:缓存补偿
  写操作后 → 写缓存 → 读操作先查缓存
  缓存有数据 → 直接返回(绕过从库延迟)

四、ShardingSphere-JDBC 读写分离

为什么选 ShardingSphere-JDBC?

方案对比:

代理模式(MyCat/Codis):
  应用 → MyCat → MySQL
  缺点:多一跳网络,需要独立部署,维护复杂

ShardingSphere-JDBC(推荐):
  应用(含 JDBC 插件) → MySQL
  优点:无额外网络开销,对应用透明,可无缝升级到分库分表
  
ShardingSphere-Proxy:
  应用 → ShardingSphere-Proxy → MySQL
  优点:多语言支持(Python/Go),缺点:多一跳

完整配置

spring:
  shardingsphere:
    datasource:
      ds-master:
        url: jdbc:mysql://192.168.1.1:3306/seckill?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
        username: root
        password: xxx
        driver-class-name: com.mysql.cj.jdbc.Driver
      ds-slave-1:
        url: jdbc:mysql://192.168.1.2:3306/seckill?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
        username: root
        password: xxx
        driver-class-name: com.mysql.cj.jdbc.Driver
      ds-slave-2:
        url: jdbc:mysql://192.168.1.3:3306/seckill?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
        username: root
        password: xxx
        driver-class-name: com.mysql.cj.jdbc.Driver

    rules:
      readwrite-splitting:
        data-sources:
          readwrite_ds:
            type: Static
            props:
              write-data-source-name: ds-master
              read-data-source-names: ds-slave-1, ds-slave-2
            load-balancer:
              type: ROUND_ROBIN  # 轮询负载均衡
              # 可选:RANDOM / WEIGHT

强制读主库场景

// 场景一:下单后立即查订单(必须读主库,否则可能查到旧数据)
@Service
public class OrderService {

    // 方法一:注解强制主库
    @Transactional
    public Order createOrder(OrderRequest request) {
        // 先创建订单(写主库)
        Order order = orderMapper.insert(request);

        // 立即查询订单详情(强制读主库,防止主从延迟)
        HintManager.getInstance().setWriteRouteOnly();
        return orderMapper.selectById(order.getId());
    }

    // 方法二:手动 Hint
    public Order getOrderDirectly(Long orderId) {
        HintManager hm = HintManager.getInstance();
        hm.setWriteRouteOnly(); // 强制主库
        try {
            return orderMapper.selectById(orderId);
        } finally {
            hm.close();
        }
    }
}

// 场景二:读从库(默认行为)
public List<Item> listItems(Long categoryId) {
    // 不做特殊处理,默认走从库(负载均衡)
    return itemMapper.selectByCategoryId(categoryId);
}

五、主从复制延迟监控

// 监控从库延迟,超过阈值触发告警
@Service
public class ReplicationMonitor {

    @Autowired
    private DataSource dataSource;

    @Scheduled(fixedDelay = 30000)  // 每 30 秒检查一次
    public void checkReplicationLag() {
        try (Connection conn = dataSource.getConnection();
             Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery("SHOW SLAVE STATUS")) {

            if (rs.next()) {
                long lagSeconds = rs.getLong("Seconds_Behind_Master");
                String slaveIO = rs.getString("Slave_IO_Running");
                String slaveSQL = rs.getString("Slave_SQL_Running");

                // 告警条件
                if (lagSeconds > 5 || !"Yes".equals(slaveIO)) {
                    sendAlert(lagSeconds, slaveIO, slaveSQL);
                }

                log.info("从库延迟: {}秒, IO: {}, SQL: {}",
                    lagSeconds, slaveIO, slaveSQL);
            }
        } catch (SQLException e) {
            log.error("监控从库延迟失败", e);
        }
    }
}

六、主从架构选型

一主一从(最小配置)

适用:小规模系统,低成本方案

主库:承担写 + 大部分读
从库:承担读 + 备份

问题:主库挂了需要手动切换

一主多从(最常用)

适用:中等规模,读请求占比 > 80%

优点:读能力线性扩展
缺点:主库仍是单点

建议:从库数量 2~3 个(太多管理复杂)

双主双从(高可用)

适用:大规模系统,写请求也较高

主1 ←→ 主2(互为主从)
从1(跟随主1)从2(跟随主2)

写入分散到两个主库
读取分散到四个从库

优点:写入也可扩展,无单点
缺点:架构复杂,双向同步需处理冲突

七、MHA 高可用方案

MHA 工作原理

MySQL 主库宕机:
  MHA Manager 监控到主库不可达
  → 自动从从库中选择最新的一个
  → 将差异的 binlog 从其他从库拉过来补齐
  → 将新主库切换为可写
  → 将其他从库指向新主库
  → 整个过程 30 秒内完成
  
配置示例(manager.cnf):
  [server default]
  manager_workdir=/var/log/mha/app1/
  manager_log=/var/log/mha/app1/manager.log
  remote_root=root
  ssh_user=root
  
  [server1]
  hostname=192.168.1.1
  candidate_master=1
  
  [server2]
  hostname=192.168.1.2
  candidate_master=1

八、高频面试题

Q1: MySQL 主从复制的原理是什么?

核心依赖 binlog:主库事务提交时将变更写入 binlog;从库 IO Thread 连接主库,拉取 binlog 并写入 relay log;然后从库 SQL Thread 读取 relay log,在从库上重放 SQL。三种 binlog 格式:STATEMENT(记录 SQL)、ROW(记录行变化)、MIXED(混合)。推荐用 GTID 模式,自动追踪位点,避免手动管理 binlog position。

Q2: 主从延迟是怎么产生的?如何解决?

延迟产生原因:① 主库并发写入,从库串行重放,能力不对等;② 大事务,一个事务改 10 万行,主库秒完从库要 10 秒;③ 从库硬件差。解决方案:① 关键读走主库(下单后查订单);② 监控延迟,超过阈值降级读主库;③ 从库开启多线程并行重放(并行度 = CPU 核数);④ 写操作后同步写缓存,读操作优先读缓存。

Q3: ShardingSphere-JDBC 和 ShardingSphere-Proxy 有什么区别?

ShardingSphere-JDBC 是一个 JDBC 增强包,集成在应用中,直接拦截 JDBC 调用,无需独立部署,对应用透明,适合 Java 技术栈。ShardingSphere-Proxy 是独立部署的中间件服务,应用通过普通 JDBC 连接它,再由它连接 MySQL,支持多语言,适合异构系统或不想改代码的场景。中小型项目推荐 JDBC 方案。

Q4: 如何保证读写分离后的数据一致性?

完全实时一致无法保证(主从复制本身有延迟)。实际做法:① 主从延迟 < 1 秒的普通场景,直接读从库,接受短暂不一致;② 写操作后需要立即读到结果的场景,用 HintManager 强制读主库;③ 数据一致性要求极高的场景(如支付),不用读写分离,直接读主库。

Q5: 为什么从库比主库性能更好能扛更多读请求?

主库承担写操作,事务提交、锁等待、binlog 写入都会消耗资源,而且写入需要维护索引;而从库只有纯读取操作,没有锁冲突,可以配置更大的 buffer pool 而且可以关闭 binlog 日志。多个从库分担读请求,总读能力等于各从库之和,可以线性扩展。