分库分表实战
MySQL 分库分表完全指南
分库分表是 MySQL 扩展性的终极解决方案,也是复杂度最高的架构选型之一。本章系统讲解分片时机、分片策略、主流中间件选型、跨库查询、分布式事务,以及最关键的数据迁移方案。
**架构原则:**分库分表是最后手段,不是第一选择。在真正需要分片之前,先用索引优化、读写分离、缓存、垂直分库(微服务)解决问题。过早分片带来的复杂度是灾难性的。
1. 何时需要分片?
1.1 单机 MySQL 的上限
单台高性能服务器(32核/256GB/NVMe SSD)的 MySQL 极限大约是:
| 指标 | 参考上限 | 说明 |
|---|---|---|
| QPS(简单查询) | ~50,000 | 带索引的点查询 |
| 写入 TPS | ~5,000-10,000 | 有 fsync 的事务写入 |
| 单表行数 | 1-5 亿 | 超过 2000 万建议关注查询性能 |
| 单库数据量 | 1-5 TB | 超过 Buffer Pool 大小后性能下降 |
1.2 分片决策树
单表超过 2000 万行? ├── 否 → 先优化索引和查询 └── 是 → 查询变慢了吗? ├── 否 → 继续观察(5000 万可能也没问题) └── 是 → 尝试过以下优化? ├── 加索引/优化 SQL → 先做 ├── 增大 Buffer Pool → 先做 ├── 读写分离(主从)→ 先做 ├── 垂直分库(业务拆分)→ 先做 └── 以上都做了还不够 → 考虑水平分片
**现实案例:**很多公司单表 5000 万行通过合理索引完全够用。真正需要分片的通常是日志类(每天亿级写入)或电商订单(历史数据持续积累)等场景。
2. 拆分类型
2.1 垂直分库(推荐优先做)
按业务将不同表分到不同数据库,通常对应微服务拆分:
单体数据库 垂直分库后 ┌─────────────────┐ ┌──────────────┐ │ users │ │ user-db │ │ orders │ →拆分→ │ users │ │ products │ │ addresses │ │ inventory │ └──────────────┘ │ payments │ ┌──────────────┐ │ reviews │ │ order-db │ │ ... │ │ orders │ └─────────────────┘ │ payments │ └──────────────┘ ┌──────────────┐ │ product-db │ │ products │ │ inventory │ └──────────────┘
2.2 水平分片(Sharding)
同一张表的数据按某个键值分散到多个库/表:
orders 表 1 亿行 水平分片后 ┌─────────────────┐ ┌─────────────────┐ │ id user_id amt │ →分片→ │ db0: orders_0 │ user_id % 4 = 0 │ 1 10001 99 │ │ db1: orders_1 │ user_id % 4 = 1 │ 2 10002 199 │ │ db2: orders_2 │ user_id % 4 = 2 │ ... │ │ db3: orders_3 │ user_id % 4 = 3 └─────────────────┘ └─────────────────┘
3. 分片键设计
3.1 分片键选择原则
- **查询路由率高:**80% 以上查询都携带该字段(否则每次都要全分片扫描)
- **数据分布均匀:**避免热点(某个分片数据量远大于其他)
- **业务自然聚合:**同一个用户的数据尽量落在同一分片(便于单分片内关联查询)
- **不频繁修改:**分片键值变更意味着跨库迁移数据,代价极高
3.2 常见分片策略对比
| 策略 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| Hash 取模 | user_id % 4 | 分布均匀,实现简单 | 扩容需要重分片,取模后业务查询必须带分片键 |
| 一致性 Hash | 虚拟节点环 | 扩容只迁移部分数据 | 实现复杂,可能数据不均 |
| Range 范围 | id 1-1000万 → db0 | 扩容简单(新增分片) | 可能热点(最新数据都在最后分片) |
| 时间范围 | 按年/月分表 | 历史数据自然归档 | 写入热点在当前月分片 |
| 枚举映射表 | region → db 映射 | 灵活,可自定义路由 | 维护映射表,扩容需手动调整 |
3.3 避免热点的技巧
-- 问题:按 create_time 范围分片,最新数据全在最后一个分片
-- 解法:在 hash 前加随机前缀(trade-off:无法按时间顺序全局排序)
shard_id = (user_id * 31 + random_suffix) % shard_count
-- 或者使用复合分片键
shard_id = hash(user_id || YYYYMM) % shard_count
4. 全局唯一 ID
分库后 AUTO_INCREMENT 只在单库内唯一,需要全局唯一 ID 方案:
4.1 雪花算法(Snowflake)
64 位整数结构: ┌─────────┬──────────────────────┬────────┬──────────────┐ │ 1 bit │ 41 bits │ 10 bits│ 12 bits │ │ 符号位 │ 毫秒时间戳 │ 机器ID │ 序列号 │ │ (0) │ (69年不重复) │ │ (每ms最多4096│ └─────────┴──────────────────────┴────────┴──────────────┘
- **优点:**趋势递增(InnoDB 友好)、纯内存生成、高性能(每秒 400 万+)
- **缺点:**依赖时钟,时钟回拨会产生重复 ID(需要监控和防护)
4.2 其他方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| UUID v4 | 无需中心节点 | 128位,随机无序(InnoDB 写入性能差) | 非主键场景 |
| UUID v7 | 时间有序,InnoDB 友好 | 仍然 128 位 | 新系统推荐 |
| 数据库号段(Leaf) | 简单可靠 | 依赖 DB,需高可用 | 美团 Leaf 方案 |
| Redis INCR | 简单高性能 | Redis 重启需持久化 | 小规模系统 |
| 雪花算法 | 高性能,趋势递增 | 时钟依赖 | 主流选择 |
5. 分片中间件选型
5.1 ShardingSphere
优点
- JDBC Driver 模式(无代理,低延迟)
- 也支持 Proxy 模式(语言无关)
- Apache 顶级项目,社区活跃
- 支持分布式事务(XA, Seata)
- 支持读写分离、数据加密
缺点
- SQL 支持有限制(复杂 JOIN 受限)
- 学习曲线较高
- 运维复杂度增加
5.2 Vitess(Google/YouTube)
优点
- Kubernetes 原生,云原生设计
- 连接池管理极强(数千连接→几十 MySQL 连接)
- Query 重写和路由透明
- Online Schema Change 内置
- PlanetScale 背后技术
缺点
- 架构复杂,部署成本高
- VSchema 配置学习成本高
- SQL 方言限制较多
5.3 选型建议
| 场景 | 推荐 |
|---|---|
| Java 系,中小规模 | ShardingSphere JDBC 模式 |
| 多语言,代理模式 | ShardingSphere Proxy 或 ProxySQL(仅读写分离) |
| Kubernetes 环境,大规模 | Vitess |
| 云原生 SaaS | PlanetScale(托管 Vitess) |
| Go 语言,自研 | 直接按分片键哈希路由到不同 DB 连接池 |
6. 跨库查询的挑战与解法
6.1 常见问题
- **跨分片 JOIN:**分片键不一致的表无法在数据库层 JOIN,需应用层合并
- **跨分片 ORDER BY + LIMIT:**每个分片取 LIMIT N,汇总后再排序取前 N(归并排序)
- **跨分片 COUNT / GROUP BY:**分片内聚合,结果再汇总
- **全局一致性查询:**几乎不可能做到强一致,通常用最终一致+ES 搜索
6.2 解决方案
-- 跨分片分页(Scatter-Gather 模式)
-- 应用层逻辑:每个分片取 page * page_size 条,汇总后取第 page 页
-- 更优:用 Elasticsearch 做多维度搜索,MySQL 只做存储
-- 用 Canal/Debezium 将 MySQL 变更实时同步到 ES
-- 用 ES 做复杂查询,拿到 ID 后回 MySQL 取完整数据
6.3 宽表冗余设计
通过字段冗余减少跨库查询:
-- 订单表冗余买家信息,避免跨库 JOIN users 表
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL, -- 分片键
user_name VARCHAR(64), -- 冗余:下单时快照
user_phone VARCHAR(20), -- 冗余:快照
product_name VARCHAR(256), -- 冗余:快照
amount DECIMAL(12,2),
created_at DATETIME
);
-- 代价:数据更新时冗余字段可能不一致(接受最终一致)
7. 分布式事务
7.1 为什么分布式事务难
单机事务靠 InnoDB 的 ACID 保证。跨库事务无法依赖单一存储引擎,需要分布式协调协议。
7.2 主流方案对比
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| XA 两阶段提交 | 强一致 | 低(锁持有时间长) | 中 | 对一致性要求极高(金融) |
| Seata AT 模式 | 最终一致 | 中 | 中 | Java 系,业务侵入低 |
| TCC(Try-Confirm-Cancel) | 最终一致 | 高 | 高(需业务实现三接口) | 高并发,可接受补偿 |
| 消息队列(本地消息表) | 最终一致 | 高 | 中 | 推荐,工程可行性最高 |
| SAGA 模式 | 最终一致 | 高 | 高 | 长流程,微服务编排 |
7.3 本地消息表方案(推荐)
-- 1. 业务操作和写消息在同一个本地事务
BEGIN;
UPDATE orders SET status = 'PAID' WHERE order_id = 123;
INSERT INTO outbox_messages (id, topic, payload, status)
VALUES (UUID(), 'order.paid', '{"order_id":123}', 'PENDING');
COMMIT;
-- 2. 后台任务轮询 outbox_messages,发送到消息队列
-- 3. 消费者幂等消费(唯一键约束或显式去重)
-- 4. 发送成功后更新 status = 'SENT'
-- 优点:无需分布式事务框架,最终一致,高可用
8. 数据迁移策略
8.1 双写迁移方案(推荐,零停机)
阶段 1:双写 阶段 2:验证 阶段 3:切流 ┌─────────┐ ┌─────────┐ ┌─────────┐ │ 应用 │ │ 应用 │ │ 应用 │ │ 写→新+旧│ ─历史数据迁移→ │ 读→旧 │ ─数据比对验证→ │ 读写→新 │ │ 读→旧 │ │ 双写 │ │ │ └─────────┘ └─────────┘ └─────────┘
8.2 详细步骤
- 建立新分片集群
- **代码双写(写旧也写新):**用 feature flag 控制,失败降级不影响主流程
- **历史数据迁移(离线):**用 pt-archiver 或自写脚本分批迁移,控制速率避免影响主库
- **数据一致性校验:**对比新旧数据,允许少量延迟差异
- **读流量切换:**灰度切一部分读到新集群,观察指标
- **写流量切换:**原子切换,保留旧库只读一段时间作回滚预案
- 旧库下线
关键工具:pt-archiver(Percona Toolkit) 可以以低影响方式迁移数据,支持限速、事务大小控制、断点续传。Canal/Debezium 可用于增量数据同步保持新旧一致。