第 28 章

分库分表实战

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 分片键选择原则

  1. **查询路由率高:**80% 以上查询都携带该字段(否则每次都要全分片扫描)
  2. **数据分布均匀:**避免热点(某个分片数据量远大于其他)
  3. **业务自然聚合:**同一个用户的数据尽量落在同一分片(便于单分片内关联查询)
  4. **不频繁修改:**分片键值变更意味着跨库迁移数据,代价极高

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│ └─────────┴──────────────────────┴────────┴──────────────┘

4.2 其他方案对比

方案 优点 缺点 适用场景
UUID v4 无需中心节点 128位,随机无序(InnoDB 写入性能差) 非主键场景
UUID v7 时间有序,InnoDB 友好 仍然 128 位 新系统推荐
数据库号段(Leaf) 简单可靠 依赖 DB,需高可用 美团 Leaf 方案
Redis INCR 简单高性能 Redis 重启需持久化 小规模系统
雪花算法 高性能,趋势递增 时钟依赖 主流选择

5. 分片中间件选型

5.1 ShardingSphere

优点
缺点

5.2 Vitess(Google/YouTube)

优点
缺点

5.3 选型建议

场景 推荐
Java 系,中小规模 ShardingSphere JDBC 模式
多语言,代理模式 ShardingSphere Proxy 或 ProxySQL(仅读写分离)
Kubernetes 环境,大规模 Vitess
云原生 SaaS PlanetScale(托管 Vitess)
Go 语言,自研 直接按分片键哈希路由到不同 DB 连接池

6. 跨库查询的挑战与解法

6.1 常见问题

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 详细步骤

  1. 建立新分片集群
  2. **代码双写(写旧也写新):**用 feature flag 控制,失败降级不影响主流程
  3. **历史数据迁移(离线):**用 pt-archiver 或自写脚本分批迁移,控制速率避免影响主库
  4. **数据一致性校验:**对比新旧数据,允许少量延迟差异
  5. **读流量切换:**灰度切一部分读到新集群,观察指标
  6. **写流量切换:**原子切换,保留旧库只读一段时间作回滚预案
  7. 旧库下线

关键工具:pt-archiver(Percona Toolkit) 可以以低影响方式迁移数据,支持限速、事务大小控制、断点续传。Canal/Debezium 可用于增量数据同步保持新旧一致。

本章评分
4.5  / 5  (4 评分)

💬 留言讨论