分库分表方案设计
# 09.分库分表方案设计
本篇定位:分库分表是数据库扩展的"最后大招"——也是最容易做错的一招。本文从一次失败的分库分表故事讲起,回答三个核心问题——什么时候必须分?怎么选分片键?已经在跑的业务怎么平滑迁移?
# 目录介绍
# 01.一次失败的分库分表
# 1.1 团队的过度自信
某团队订单表数据量到了 800 万,CTO 拍板:"上分库分表!"。技术团队选了 16 库 64 表 + sharding-jdbc。一个月后上线。
过度自信的部分:
- 没有评估业务实际查询模式
- 没考虑跨分片查询的工作量
- 没有预演迁移过程
- 把分片数定到了 64 表(10 年容量),结果反而增加了复杂度
# 1.2 上线后的 5 大坑
| 坑 | 现象 | 影响 |
|---|---|---|
| 跨分片查询 | "查商家所有订单"按 user_id 分片后,要扫 64 个表 | 慢 8 倍 |
| JOIN 失效 | 订单和商品分别在不同库,无法 JOIN | 业务代码全改 |
| 事务边界 | 创建订单+扣库存+扣余额跨 3 个库 | 引入分布式事务,性能下降 |
| 数据迁移耗时 | 800w 数据迁移 + 校验跑了 5 天 | 期间双写,故障频发 |
| 运维变重 | 备份 / 监控 / 故障定位都要面对 64 个分片 | DBA 工作量翻倍 |
# 1.3 反思教训
复盘时发现一个尴尬的事实:那张订单表当时实际 QPS 不到 5000,单 MySQL 实例完全可以扛 10 倍以上。这次分库分表不仅没解决问题,反而引入了新问题。
真正的教训:分库分表是"治大病的猛药",没病乱吃就是慢性自杀。
# 02.要解决的核心矛盾
# 2.1 单库的瓶颈
单 MySQL 实例的物理上限大约在:
| 维度 | 经验值 | 触发瓶颈表现 |
|---|---|---|
| 单表行数 | 2000w-5000w | 索引深度增加,查询变慢 |
| 单表大小 | 50GB-100GB | DDL 极慢、备份困难 |
| 写入 QPS | 5w-10w(机械盘)/ 50w(NVMe) | 写入延迟上升 |
| 连接数 | 1000-3000 | 连接竞争 |
| 磁盘容量 | 受限于服务器规格 | 无法继续扩展 |
只有当业务真的逼近这些上限,分库分表才有必要。
# 2.2 拆分的代价
mindmap
root((分库分表代价))
复杂度
架构变重
代码侵入
运维多倍
性能
跨片查询慢
分布式事务慢
聚合统计慢
一致性
跨片事务弱化
数据迁移期间双写
回退困难
上了下不来
迁回单库成本高
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
铁律:分库分表是单向的——上去容易下来难。
# 2.3 一致性挑战
单库时,所有写入都是 ACID 的。分库后:
- 跨库事务无法直接靠数据库保证
- 中间状态可能被读取
- 主从延迟更复杂(每个分片各自延迟)
需要引入分布式事务或最终一致性补偿。
# 2.4 分库分表的本质
分库分表 = 用"业务复杂度"换"数据库扩展性"
它不是性能优化,是容量扩展。能用其他手段(缓存、读写分离、归档)解决的,绝对不上分库分表。
# 03.业界主流方案
# 3.1 三种分片层次
应用层分片(嵌入 SDK) 代表:sharding-jdbc / sharding-sphere(应用模式)。在应用层拦截 SQL,路由到目标分片。
- 优点:轻量、性能好
- 缺点:每个语言要单独适配
代理层分片(中间件代理) 代表:MyCat / Apache ShardingSphere-Proxy / Vitess。前置一个代理服务,伪装成 MySQL。
- 优点:跨语言、应用无侵入
- 缺点:多一跳网络、代理本身高可用要保证
数据库层分片(NewSQL) 代表:TiDB / OceanBase / CockroachDB。数据库自身就是分布式的。
- 优点:对应用透明,最少改造
- 缺点:替换数据库,迁移成本高
flowchart LR
A[应用层分片<br/>SDK 嵌入] --> B[代理层分片<br/>中间件] --> C[数据库层分片<br/>NewSQL]
A1[轻量<br/>语言绑定] -.- A
B1[跨语言<br/>有性能损耗] -.- B
C1[最优雅<br/>迁移成本最高] -.- C
style A fill:#e3f2fd
style B fill:#e8f5e8
style C fill:#fff3e0
2
3
4
5
6
7
8
9
10
# 3.2 横向对比矩阵
| 维度 | sharding-jdbc | ShardingSphere-Proxy | MyCat | TiDB |
|---|---|---|---|---|
| 类型 | SDK | 代理 | 代理 | NewSQL |
| 语言 | 仅 Java | 任何 | 任何 | 任何 |
| 性能 | 高(无代理) | 中(多一跳) | 中 | 高 |
| 运维 | 应用维护 | 独立服务 | 独立服务 | 独立集群 |
| 分布式事务 | XA / Seata | 同上 | XA | 原生支持 |
| 自动扩容 | 手动 | 手动 | 手动 | 自动 |
| 学习曲线 | 中 | 中 | 中 | 平 |
| 代表用户 | 京东 / 一些金融 | 通用 | 早期国内 | PingCAP / 字节 |
# 3.3 典型架构案例
案例:某电商订单表分库分表
graph TB
App[应用集群] --> SDK[sharding-jdbc]
SDK -->|按 user_id hash| DB1[DB 0<br/>订单表 0-15]
SDK -->|按 user_id hash| DB2[DB 1<br/>订单表 16-31]
SDK -->|按 user_id hash| DB3[DB 2<br/>订单表 32-47]
SDK -->|按 user_id hash| DB4[DB 3<br/>订单表 48-63]
SDK -->|商家维度| ES[Elasticsearch<br/>商家订单聚合]
style SDK fill:#fff3e0
style ES fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
关键设计:
- 主路径按 user_id 分片(用户查自己订单 → 单分片命中)
- 商家维度异步同步到 ES(商家查所有订单 → 走 ES 不走 DB)
这就是经典的"主分片 + 异构索引"模式。
# 04.设计核心原则
# 4.1 推迟原则
永远把分库分表当作最后选择。优先尝试:
flowchart TD
Start([单库性能不够]) --> Q1{先试: 优化 SQL+索引}
Q1 -->|够用| Done1[搞定]
Q1 -->|不够| Q2[再试: 缓存 / 读写分离]
Q2 -->|够用| Done2[搞定]
Q2 -->|不够| Q3[再试: 升级硬件 / 归档历史数据]
Q3 -->|够用| Done3[搞定]
Q3 -->|不够| Q4[考虑: NewSQL TiDB OceanBase]
Q4 -->|可接受| Done4[替换数据库]
Q4 -->|不可| Final[最后才上分库分表]
style Done1 fill:#e8f5e8
style Done2 fill:#e8f5e8
style Done3 fill:#e8f5e8
style Done4 fill:#fff3e0
style Final fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 4.2 单维度原则
铁律:只能按一个维度分片。
如果业务真的有多个查询维度(如订单要"按用户查"也要"按商家查"),主路径选一个维度分片,其他维度通过:
- 异构索引(如 ES)
- 数据冗余双写
- 离线统计
来兜底。强行要求一个分片键满足所有查询是不可能的。
# 4.3 容量预留原则
经验公式:分片数 = 当前数据量 × 5 / 单分片承载量
当前 1000w 数据
预期 5 年后 5000w
单分片承载 1000w(5000w / 5)
预留 1 倍 = 10 个分片
2
3
4
关键提醒:
- 分片数最好是 2 的幂次(16 / 32 / 64),方便扩容
- 但不要一开始就分到 64 / 128,那是 10 年后的需求
- 常见错误是"分多了"而不是"分少了"——分多了是永久浪费,分少了还能扩容
# 4.4 平滑迁移原则
生产环境从单库迁移到分库分表,最大风险是"过程不可逆"。必须有:
- 双写期可独立读写两边
- 任何时刻可一键切回老数据库
- 数据校验工具完备
- 灰度切流可分批
# 05.分片策略选型
# 5.1 范围分片
按主键范围拆分,如 ID 1-1000w 在 DB0、1000w-2000w 在 DB1。
优点:扩容简单(加新区间)、范围查询友好 缺点:热点严重(最新数据集中在最后一个分片)
graph LR
DB0[DB0: id 0-1kw<br/>冷数据] --> DB1[DB1: id 1kw-2kw<br/>冷数据]
DB1 --> DB2[DB2: id 2kw-3kw<br/>热数据 100% QPS]
style DB2 fill:#ffebee
2
3
4
5
适用:日志型数据、按时间归档场景。
# 5.2 哈希分片
shard = hash(user_id) % N。最常见的方案。
优点:数据分布均匀、无热点 缺点:扩容困难(取模数变化要全部重算)
# 5.3 一致性哈希
graph TB
subgraph "哈希环"
N0[节点A 位置 0]
N1[节点B 位置 90]
N2[节点C 位置 180]
N3[节点D 位置 270]
end
K1[key1 hash=45] -->|顺时针| N1
K2[key2 hash=120] -->|顺时针| N2
K3[key3 hash=200] -->|顺时针| N3
K4[key4 hash=350] -->|顺时针| N0
style N0 fill:#e3f2fd
style N1 fill:#e8f5e8
style N2 fill:#fff3e0
style N3 fill:#f3e5f5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
优点:扩容时只影响相邻节点,迁移数据少 缺点:实现复杂、单点扩容仍可能不均(虚拟节点解决)
适用:动态扩缩容频繁的场景。
# 5.4 基因法分片
问题:用 user_id 分片,但订单需要按 order_id 查询怎么办?
基因法:让 order_id 包含 user_id 的"分片基因"。
// 假设分片数 16(4 bit)
fun generateOrderId(userId: Long): Long {
val gene = userId and 0x0F // 取 user_id 低 4 位作为基因
val rawId = snowflake.nextId() and 0xFFFFFFFFFFFFFFF0 // 雪花 ID 低 4 位置 0
return rawId or gene // 拼接基因
}
// 查询订单时直接从 order_id 提取分片
fun getShardByOrderId(orderId: Long): Int {
return (orderId and 0x0F).toInt()
}
2
3
4
5
6
7
8
9
10
11
这样用 user_id 或 order_id 都能定位到同一分片,避免了二级索引。
# 5.5 选型对比
| 维度 | 范围 | 哈希 | 一致性哈希 | 基因法 |
|---|---|---|---|---|
| 数据均匀 | ❌ 易热点 | ✅ | ✅ | ✅ |
| 扩容容易 | ✅ | ❌ | ✅ | ❌ |
| 范围查询 | ✅ | ❌ | ❌ | ❌ |
| 多键查询 | ❌ | ❌ | ❌ | ✅ |
| 实现复杂度 | 简单 | 简单 | 中 | 中 |
实战推荐:绝大多数场景用哈希分片。需要多键查询时叠加基因法。
# 06.关键问题解决
# 6.1 多维度查询
场景:订单按 user_id 分片后,商家查"我的所有订单"怎么办?
方案:
flowchart TD
A[商家查询订单] --> B{要不要实时?}
B -->|秒级实时| C[异构索引<br/>ES / 商家维度副本]
B -->|分钟级| D[准实时同步<br/>binlog → 商家维度库]
B -->|分钟以上| E[离线计算<br/>Hive / 报表库]
style C fill:#fff3e0
style D fill:#e8f5e8
style E fill:#e3f2fd
2
3
4
5
6
7
8
9
主流方案:用 binlog 把订单数据同步到 ES,商家查询走 ES。
# 6.2 跨库 JOIN
问题:订单库和商品库不在一起,怎么查"订单 + 商品"?
解决思路:
| 方案 | 做法 | 适用 |
|---|---|---|
| 冗余字段 | 下单时把商品名 / 价格冗余到订单表 | 不变信息冗余 |
| 应用层拼接 | 先查订单再批量查商品 | 灵活但代码复杂 |
| 数据宽表 | 异步把订单+商品打成宽表落 ES | 报表 / 检索 |
| CTE / 内存 JOIN | 应用层用流处理拼接 | 小批量 |
# 6.3 分布式 ID
分库分表后不能再用数据库自增 ID(会重复)。主流方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| UUID | 简单、全局唯一 | 36 位字符串、索引差 |
| 数据库号段 | 趋势递增 | 单点风险 |
| 雪花算法(Snowflake) | 64 位、趋势递增、高吞吐 | 时钟回拨问题 |
| 美团 Leaf | 号段 + 雪花混合 | 实现复杂 |
| 百度 UidGenerator | 雪花改进版 | 复杂 |
实战推荐:雪花算法 + 时钟回拨保护最通用。
# 6.4 分布式事务
经典三方案:
graph TB
subgraph "强一致 - 慢"
XA[2PC / XA<br/>两阶段提交]
end
subgraph "弱一致 - 快"
TCC[TCC<br/>Try-Confirm-Cancel]
Saga[Saga<br/>正反操作]
MQ[消息事务<br/>最终一致]
end
style XA fill:#ffebee
style TCC fill:#fff3e0
style Saga fill:#e8f5e8
style MQ fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| 方案 | 一致性 | 性能 | 复杂度 | 适用 |
|---|---|---|---|---|
| 2PC/XA | 强 | 差 | 中 | 小流量金融 |
| TCC | 准强 | 中 | 高 | 大额支付 |
| Saga | 最终 | 好 | 中 | 多步业务流程 |
| 消息事务 | 最终 | 最好 | 低 | 异步补偿 |
实战推荐:90% 场景用消息事务(最终一致),特殊场景用 TCC / Saga。
# 07.数据迁移方案
# 7.1 双写迁移
sequenceDiagram
participant App as 应用
participant Old as 旧库(单库)
participant New as 新库(分片)
participant Sync as 历史数据同步
Note over App,New: 阶段1: 双写
App->>Old: 写入
App->>New: 同步写入
Note over Sync,New: 阶段2: 历史数据迁移
Sync->>Old: 读历史
Sync->>New: 批量写入
Note over App,New: 阶段3: 双读对比
App->>Old: 读
App->>New: 读
App->>App: 对比一致性
Note over App,New: 阶段4: 切流
App->>New: 主读新库
App->>Old: 影子读对比
Note over App,Old: 阶段5: 下线旧库
App-->>Old: 不再访问
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
完整迁移流程一般跨度 1-3 个月。
# 7.2 数据校验
迁移过程必须有全量校验工具:
| 维度 | 校验方法 |
|---|---|
| 总行数 | SELECT COUNT(*) 对比 |
| 抽样字段 | 随机抽样比对每个字段 |
| 关键统计 | 总金额 / 总订单数等业务指标 |
| 增量数据 | binlog 对比 |
铁律:没经过校验的迁移就是事故的引信。
# 7.3 灰度切流
0.1% → 1% → 5% → 20% → 50% → 100%
每一档观察至少 24 小时
2
每一档都要监控:
- 业务错误率
- 数据一致性
- 性能指标
# 7.4 回滚兜底
任何时刻都要能回滚:
| 阶段 | 回滚方式 |
|---|---|
| 双写期 | 直接停掉新库写入 |
| 切流期 | 一键切回 100% 老库 |
| 完全切完后 | 老库保留 30+ 天可查 |
# 08.常见陷阱与反例
# 8.1 过早分片反例
反例:开篇那个 800w 订单团队过度自信分片,结果业务变重无收益。
教训:过早分片 = 用 5 年后的复杂度换今天不存在的问题。
# 8.2 分片键选错反例
反例:某社交 App 按 user_id 分片消息表,结果群消息查询要扫所有分片。
教训:分片键必须是最高频的查询条件。如果业务 80% 查询走另一个维度,应该选另一个维度。
# 8.3 容量评估错误反例
反例:某团队按 1 年增量算分片数(4 库 8 表),结果 18 个月后又要二次分片,数据迁移地狱。
教训:分片数评估按 5 年容量做,但不要超过 10 年。预留过多也是浪费。
mindmap
root((三大反例))
过早分片
还没到瓶颈
引入复杂度
ROI 为负
分片键选错
高频查询跨片
80% 走错路径
性能反而下降
容量评估错
预留过少二次分片
预留过多增加复杂度
没考虑业务增长
2
3
4
5
6
7
8
9
10
11
12
13
14
# 09.总结与决策
# 9.1 启动前检查表
启动分库分表项目前对照这张清单:
- [ ] 确认单库已经触及瓶颈(不是猜的)
- [ ] 已经尝试过缓存、读写分离、归档
- [ ] 业务团队对"跨片查询变慢"有共识
- [ ] 主分片键已经评估(覆盖 ≥ 80% 查询)
- [ ] 异构索引方案已设计(覆盖剩余 20%)
- [ ] 分布式 ID 方案就位
- [ ] 分布式事务方案就位
- [ ] 数据迁移工具开发完成
- [ ] 数据校验工具开发完成
- [ ] 回滚预案已演练
- [ ] 监控大盘已就位(每个分片独立 + 总)
- [ ] 容量按 5 年规划,分片数为 2 的幂次
# 9.2 选型决策树
flowchart TD
Start([单库扛不住了]) --> Q1{真的扛不住?<br/>看监控数据}
Q1 -->|不确定| Test[先做压测和容量评估]
Q1 -->|确认是| Q2{是查询慢还是容量满?}
Q2 -->|查询慢| Q3[先优化 SQL/索引/缓存]
Q3 -->|不够| Q4[读写分离]
Q4 -->|不够| Final
Q2 -->|容量满| Q5{有没有大量历史数据?}
Q5 -->|是| Archive[归档冷数据先]
Archive -->|不够| Final
Q5 -->|否| Final
Final[考虑分库分表] --> Q6{愿意换数据库吗?}
Q6 -->|愿意| NewSQL[TiDB / OceanBase<br/>对应用最透明]
Q6 -->|不愿意| Q7{Java 单语言?}
Q7 -->|是| SDK[sharding-jdbc<br/>性能好]
Q7 -->|否| Proxy[ShardingSphere-Proxy<br/>跨语言]
style Test fill:#e3f2fd
style Q3 fill:#e8f5e8
style Q4 fill:#e8f5e8
style Archive fill:#fff3e0
style NewSQL fill:#fff3e0
style SDK fill:#ffebee
style Proxy fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
最后一句话:分库分表不是技术炫技,是业务被逼到墙角的最后选择。开篇那个团队的悲剧在于他们把"猛药"当成"补品"。
好的分库分表 = 能用其他方案就别用它,用了就要做对所有细节。