10. 读写分离与分库分表

数据库读写分离架构设计、分库分表策略、分布式主键生成方案、ShardingSphere 实战与数据迁移最佳实践。

1. 读写分离架构

1.1 为什么需要读写分离

大多数业务场景读多写少(读:写 ≈ 8:1 ~ 50:1)。将读请求分发到多个从库,可线性扩展读性能。

单库架构的问题:

  App ──→ MySQL
            │
            ├── 读请求(80% CPU)
            └── 写请求(20% CPU)
            
  读请求增加 → CPU 100% → 所有请求变慢

读写分离架构:

  写请求 ──→ [Master] ──→ 异步复制 ──→ [Slave 1]
                                   ──→ [Slave 2]
                                   ──→ [Slave 3]
                                   
  读请求 ──→ [负载均衡器] ──→ Slave 1 / Slave 2 / Slave 3

1.2 实现方式

方式原理代表产品侵入性
代理层独立进程解析 SQL 路由MySQL Router、ProxySQL、MaxScale无
中间件分库分表中间件内置读写分离ShardingSphere、MyCat低
应用层代码中配置多数据源Spring RoutingDataSource高
// Spring Boot 应用层读写分离
@Configuration
public class DataSourceConfig {
    @Bean
    public DataSource routingDataSource(
            @Qualifier("masterDataSource") DataSource master,
            @Qualifier("slaveDataSource") DataSource slave) {
        DynamicRoutingDataSource routing = new DynamicRoutingDataSource();
        Map<Object, Object> targets = new HashMap<>();
        targets.put("master", master);
        targets.put("slave", slave);
        routing.setTargetDataSources(targets);
        routing.setDefaultTargetDataSource(master);
        return routing;
    }
}

// Service 方法指定数据源
@Transactional(readOnly = true)  // 走 Slave
public List<Order> getOrders() { ... }

@Transactional  // 走 Master
public void createOrder(Order order) { ... }

1.3 复制延迟问题

复制延迟(Replication Lag)是读写分离架构中最棘手的挑战。当写入主库的数据尚未同步到从库时,用户立即从从库读取会读到旧数据,导致脏读或数据不一致的用户体验问题。

延迟产生的原因:

原因说明缓解方案
大事务单个事务修改大量数据,阻塞后续同步拆分大事务,每批 < 10000 行
从库负载过高读请求争抢 CPU/IO 资源增加从库数量,优化慢查询
网络带宽不足跨机房部署时尤为明显专线连接或就近部署
锁竞争从库执行 SELECT 时阻塞回放启用并行复制(MySQL 8.0)
DDL 操作ALTER TABLE 等大型 DDL 阻塞使用 pt-online-schema-change

半同步复制(Semi-Synchronous Replication)配置:

# MySQL 半同步复制配置(my.cnf)
# 主库配置
plugin-load = rpl_semi_sync_master=semisync_master.so
rpl_semi_sync_master_enabled = ON
rpl_semi_sync_master_timeout = 1000  # 1秒内未收到从库ACK则回退异步
rpl_semi_sync_master_wait_for_slave_count = 1  # 至少1个从库确认

# 从库配置
plugin-load = rpl_semi_sync_slave=semisync_slave.so
rpl_semi_sync_slave_enabled = ON

延迟监控查询:

-- MySQL 查看复制延迟
SHOW SLAVE STATUS\G
-- Seconds_Behind_Master: 0(0表示实时同步)

-- 更精确的延迟检测
SELECT MAX(last_committed) - MAX(sequence_number) AS lag FROM mysql.gtid_executed;

-- PostgreSQL 流复制延迟
SELECT 
    client_addr,
    state,
    sent_lsn, 
    replay_lsn,
    sent_lsn - replay_lsn AS lag_bytes
FROM pg_stat_replication;
写 Master ──→ 复制到 Slave(延迟 1ms ~ 数秒)
                    │
                    └──→ 用户立即读 Slave → 读不到刚写入的数据!

解决策略:
1. 关键读走 Master(写入后立即读取的场景)
2. 缓存补偿(写入后先更新缓存,读时先查缓存)
3. 半同步复制(Semi-Sync:至少一个 Slave 确认后再返回)
4. 会话绑定(同一连接内写后读走 Master)

2. 分库分表

2.1 何时需要分库分表

指标建议分片
单表数据量> 500万行(MySQL)/ > 1亿(PostgreSQL)
单库磁盘> 500GB
单库 QPS> 5000(读)/ > 1000(写)
单表索引大小> 内存容量

2.2 分片策略

策略实现优点缺点
哈希分片hash(key) % N数据均匀扩容需迁移数据
范围分片按 ID 或时间区间范围查询友好热点风险
列表分片按枚举值映射灵活可控需人工维护
一致性哈希哈希环 + 虚拟节点扩容迁移少实现复杂
// 按用户 ID 哈希分表
String tableName = "order_" + (userId % 8);  // order_0 ~ order_7

// 按月份分表
String tableName = "log_" + YearMonth.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
// log_202601, log_202602, ...

2.4 全局ID生成性能对比

分库分表后,自增 ID 不再适用。不同全局 ID 方案在性能和高可用方面差异显著:

方案TPS延迟高可用时钟依赖
Snowflake> 100K~1μs高(本地生成)是
号段模式(Leaf)> 100K~500ns中(依赖号段服务)否
Redis INCR~50K~1ms中(主从切换风险)否
数据库步长~10K~5ms中否
UUID (v4)> 100K~1μs高否

Leaf 号段模式实现原理:

Leaf 号段分配机制:

┌─────────────┐      ┌─────────────┐      ┌─────────────┐
│   业务服务   │      │  Leaf Server │      │   DB 号段表  │
└──────┬──────┘      └──────┬──────┘      └──────┬──────┘
       │                    │                    │
       │ 申请ID            │ 检查本地缓存        │
       │─────────── 缓存空? ──── 是 → 从DB获取新号段
       │                    │                    │
       │ 批量获取N个IDs      │ ← UPDATE biz_tag 
       │← CAST(id) FROM id_seq│  SET max_id = max_id + step
       │ 缓存在本地内存        │  WHERE biz_tag = 'order'
       │                    │                    │
       │ 本地直接分配         │ 缓存充足时直接使用  │
       │ ← 内存自增(无DB依赖)│                    │

2.5 分库分表常见问题与解决思路

问题现象解决方案
跨分片JOIN分页结果集合并不完整宽表反冗余、应用层聚合、Federated 表
全局排序ORDER BY 在分片后失去全局意义多路归并排序、限制返回结果集、Elasticsearch
分布式事务跨库操作一致性难保证TCC/SAGA、Seata、最终一致性
热点数据某一分片负载远超其他虚拟桶再分片、热点数据缓存、范围+哈希混合
分页偏移量大LIMIT 100000,10 性能极差ES 替代、游标分页、业务层面限制

3.3 ShardingSphere 实战

# shardingsphere-config.yaml
dataSources:
  ds_0: !!com.zaxxer.hikari.HikariDataSource
    jdbcUrl: jdbc:mysql://db0:3306/shop
  ds_1: !!com.zaxxer.hikari.HikariDataSource
    jdbcUrl: jdbc:mysql://db1:3306/shop

shardingRule:
  tables:
    orders:
      actualDataNodes: ds_${0..1}.orders_${0..7}
      tableStrategy:
        inline:
          shardingColumn: user_id
          algorithmExpression: orders_${user_id % 8}
      databaseStrategy:
        inline:
          shardingColumn: user_id
          algorithmExpression: ds_${user_id % 2}
      keyGenerator:
        type: SNOWFLAKE
        column: order_id

3. 分布式主键

方案原理优点缺点
Snowflake41位时间+10位机器+12位序列趋势递增、高性能依赖时钟
Leaf(美团)号段模式稳定、可控需维护号段表
Redis 自增INCR简单单点、网络开销
数据库步长auto_increment_offset简单扩展性差
// Snowflake 实现(简化)
public class SnowflakeIdGenerator {
    private final long workerId;
    private long sequence = 0;
    private long lastTimestamp = -1;
    
    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("Clock moved backwards");
        }
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & 0xFFF;  // 12 bit
            if (sequence == 0) timestamp = tilNextMillis();
        } else {
            sequence = 0;
        }
        lastTimestamp = timestamp;
        return ((timestamp - EPOCH) << 22) | (workerId << 12) | sequence;
    }
}

4. 数据迁移

双写迁移方案(不停机):

阶段 1:双写
  应用同时写入旧库和新库(新库异步)
  读仍然走旧库

阶段 2:数据校验 + 补平
  对比旧库和新库数据差异
  修复不一致数据

阶段 3:灰度切读
  1% 流量读新库 → 10% → 50% → 100%
  监控错误率和延迟

阶段 4:切写
  写操作切换到新库
  停止旧库写入

阶段 5:下线旧库
  保留一段时间后归档删除

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 锁与阻塞分析
  2. COPY 与批量数据加载优化
  3. pgvector 向量检索与混合查询