主题
高可用与扩容:主从、分库分表的决策框架
本文是 MySQL 系统学习系列的 L3 实战篇。前置:索引的本质:B+ 树、页与查找过程。 学完可以配合面试题食用:13-master-slave-replication-and-delay-investigation、14-sharding-sphere-vs-mycat、26-replication-async-semisync-modes
单机扛不住了,怎么办
一个 MySQL 实例跑所有流量,迟早会碰到天花板。天花板来自三个方向:
- 单点故障:机器挂了,整个服务不可用
- 写入瓶颈:单机写入吞吐有限,主库的磁盘 IO 和 CPU 先到顶
- 数据量过大:单表几千万行,B+ 树四层五层,写入变慢
这三个问题对应三类解法:主从复制(高可用)、读写分离(读扩展)、分库分表(写扩展)。但解法不是越复杂越好——很多团队在单表 500 万行时就急着拆库,结果引入分布式事务和跨分片 join 的坑,比没拆之前更痛苦。
高可用:从异步复制到 MGR
MySQL 的内置复制是搭建高可用的基础。同一个 binlog 同步到多个节点,主挂了切一个从上来。
mermaid
flowchart LR
A[主库<br/>写入] -->|binlog| B[IO线程]
B -->|relay log| C[SQL线程]
C --> D[从库<br/>应用]
D -->|binlog| E[下一级从库]三种复制模式对应不同的 RPO(丢多少数据):
异步复制(默认):主库写 binlog 就返回,不确认从库有没有收到。如果主库崩溃时 binlog 还没传到从库,数据就丢了。RPO > 0,适合对数据一致性要求不高的场景(日志、非关键统计)。
半同步复制:主库等至少一个从库确认收到 binlog 后才提交。需要在主库安装 rpl_semi_sync_master、从库安装 rpl_semi_sync_slave 插件。RPO = 0(至少一个从库完整),但写入延迟会增大,因为多了网络往返。如果所有从库超时,自动降级回异步。
MGR(Group Replication):基于 Paxos 的多数派共识。写入需要集群内多数节点确认,Paxos 保证数据一致性。单点故障自动切换,不用外部仲裁工具。缺点是写入延迟更大(至少三次消息往返),且要求表必须有主键。
选型建议:核心业务用半同步,容忍 1-2 秒写入延迟;需要自动故障转移且写入量不大的用 MGR;异步复制只用在非关键链路。
主从延迟:一根硬骨头
即使半同步保证了 binlog 不丢,从库应用 binlog 仍然有延迟。一个常见场景:业务写入后立刻读从库,读不到刚写的数据。
延迟来源:
- 单线程 SQL 线程(5.6 及之前):主库写入速度远超从库回放速度
- 大事务:一个
DELETE FROM huge_table在主库上跑 10 秒,从库也要跑 10 秒,期间全部事务堆积 - DDL 加锁:
ALTER TABLE在主库瞬间完成,从库执行时可能因为 MDL 锁争用被卡住
MySQL 5.7 引入的并行复制解决了核心瓶颈:slave_parallel_workers 开多线程,slave_parallel_type=LOGICAL_CLOCK 让同一组事务内并行回放。8.0 的 WRITESET 方式更进一步,只要事务修改的行集不冲突就能并行。
业务侧缓解延迟三板斧:
- 写后读强制走主库:用户提交后的跳转页,读自己的数据走主库
- 缓存兜底:写后 Redis 写一份,读从库异常时读缓存
- 延迟监控告警:
Seconds_Behind_Master虽然不精确(从库心跳间隔决定),但仍然是运维最直接的指标
分库分表:什么时候真需要
分库分表是运维复杂度最高的解法,不是因为代码难写,是因为跨分片修改的代价是线性的。
先问自己三个问题:
- 单表行数超过 2000 万了吗?—— 没有就先缓冲
- 写入吞吐超过单机 IOPS 上限了吗?—— 没有就先优化
- 索引、归档、读写分离都试过了吗?—— 没有就先试
垂直拆分(按业务):把用户表、订单表、商品表拆到不同的库。这个业务上天然隔离,相对简单,但跨库事务变成分布式事务(XA 或 TCC),跨库 join 得靠应用层或数据汇总。
水平拆分(按行):同一个表按某种规则分散到多个实例。触发线通常看三个指标同时接近上限:单表行数(2000 万+)、单库磁盘(500GB+)、写入 tps(2000+)。
分片键与基因法
分片键的选择关系到后续所有的查询路由。选 user_id 分片,订单查询按订单号就得广播。
基因法解决这个问题:order_id 里编码 user_id 的分片信息。
java
public class ShardingGene {
// 假设分 16 库(4 位二进制)
private static final int DB_COUNT = 16;
private static final int GENE_BITS = 4;
/**
* 生成带分片基因的订单 ID
* @param userId 用户 ID
* @param seq 自增序列
* @return 订单 ID(高位+低位)
*/
public static long generateOrderId(long userId, long seq) {
// 取 user_id 低 4 位作为分片基因
long gene = userId & (DB_COUNT - 1);
// 序列左移 4 位,嵌入基因,高位留时间戳位
return (seq << GENE_BITS) | gene;
}
/**
* 从订单 ID 还原分片编号
* @param orderId 订单 ID
* @return 分片编号 0-15
*/
public static int shardByOrderId(long orderId) {
return (int) (orderId & (DB_COUNT - 1));
}
/**
* 从 user_id 也得到同样的分片编号
*/
public static int shardByUserId(long userId) {
return (int) (userId & (DB_COUNT - 1));
}
}这样,不管是按 order_id 还是按 user_id 查询,都能直接定位到同一台分片,不用广播。
扩容时如果分片数倍增(16→32),基因位数从 4 变 5,历史数据必须迁移。这时候有两种策略:
- 双写迁移:旧库写入的同时往新库写一份,读走旧库,经过一段时间后切换读
- 影子表同步:新库和旧库保持同步,数据一致后切换
后者更安全,但需要额外的同步工具(如 Canal + 自定义消费者)。
ShardingSphere 引入的成本
中间件让分库分表看起来"透明",但代价不是开发阶段付的,是运维阶段。
sql
-- 跨分片查询:分页+排序,每片取 N 条,归并排序后再取
-- 每片数据量越大,归并开销越大
SELECT * FROM orders WHERE status = 'paid' ORDER BY create_time DESC LIMIT 20;
-- 跨分片事务:引入 XA 或 Seata AT,性能下降明显
-- 业务上应尽量避免跨分片事务引入 ShardingSphere 前对照清单:
- 跨分片查询必须带上分片键 —— 否则全分片扫描
- 分页越深越慢 —— 需要改写为游标分页
- 分布式事务每笔增加 2-5ms 延迟
- 升级中间件版本需要全链路回归
- 所有分片的路由规则变更需要重新规划数据迁移
常见误区与小结
- 误区 1:复制延迟只看 Seconds_Behind_Master。这个值基于从库心跳时间,大事务期间可能不准。配合
SHOW SLAVE STATUS的Retrieved_Gtid_Set和Executed_Gtid_Set比较更可靠。 - 误区 2:半同步复制保证不丢数据。它保证的是"至少一个从库收到了 binlog",但如果主库和收到 binlog 的从库同时宕机,数据仍然可能丢。多数派方案(MGR/MySQL Cluster)才能做到真正的 RPO=0。
- 误区 3:分库分表后查询变慢是中间件的问题。大部分情况是分片键没选好,导致查询变成全分片广播。先确认 WHERE 条件是否匹配分片键。
- 误区 4:分库分表能解决所有性能问题。分库分表解决的是写入扩展和单库容量,查询性能反而可能下降(跨分片 join/聚合/排序)。读性能问题优先用读写分离+缓存。
- 误区 5:扩容就是加机器。加机器后需要重新分片,数据迁移是重活。扩容前先评估是否能用索引优化、归档历史数据、或者冷热分离来解决。
小结:高可用和扩展是一个阶梯决策——先确认是否需要跨出当前层级。单机扛不住先看索引和慢 SQL,然后是主从+读写分离,再是垂直拆分,最后才是水平拆分。每一步都增加运维复杂度,确保每层的方案能解决当前的问题再做下一步。
下一篇将进入设计模式模块,从系统学习转向常见模式的设计思路与代码实现。
参考
参考:MySQL 官方手册《Replication》、ShardingSphere 官方文档《Sharding-Proxy 架构》、丁奇《MySQL 实战 45 讲》