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. 分布式主键
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Snowflake | 41位时间+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:下线旧库
保留一段时间后归档删除
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。