37.3 分片与分区
一句话
分片是水平扩展的唯一出路,但它把“一个事务、一次查询、一条唯一约束”都变成分布式问题;决定该不该分片的不是数据量,是单点写能力与单表维护成本。
四个词,先分清谁解决什么
| 术语 | 切的是什么 | 边界在哪 | 解决什么 | 代价 |
|---|---|---|---|---|
| 分区(partition) | 一张表在同一个库实例内切成多个物理段 | SQL 仍打到一个库,事务、JOIN、唯一索引全在库内 | 单表太大导致的扫描与维护变慢;冷数据可按分区直接 drop | 不解决单库写能力,也不解决连接数打满 |
| 分片(sharding) | 同一张逻辑表的数据按分片键放到不同库实例 | 跨片即跨网络 | 单库写入 QPS、单库容量、单库 IOPS 的上限 | 跨片查询、跨片事务、全局唯一 ID |
| 分库分表 | 工程口语,通常就是分片的落地形态(水平拆库 + 拆表) | 有时把垂直拆库也算进来 | 同上 | 同上,且路由层多一层 |
| 单元化(Set 化) | 以用户为单位把应用、数据、中间件整体切份 | 一个用户的所有请求只进一个单元 | 故障隔离与就近访问 | 机房级成本,跨单元数据要同步(见 36.5 单元化 / Set 化架构) |
一句话记:分区不动网络,分片动网络,单元化动机房。
分区键怎么选、哈希怎么落、环怎么建,见 19.1 分区键设计、19.2 哈希分区、19.4 一致性哈希;建表语句、中间件配置、迁移脚本见 44.14 分库分表。扩展性视角只回答三件事:该不该分、分几片、代价是什么。
破除“单表 2000 万行就必须分”
| 误区 | 现实 |
|---|---|
| 2000 万行是硬门槛 | 这个数来自“B+ 树 3 层刚好装进内存”的粗算。行宽 100B 的表,2000 万行只有 2GB;行宽 2KB 的表,2000 万行是 40GB。 |
| 行数决定查询代价 | 决定查询代价的是索引层级与回表次数。10 亿行但查询都走覆盖索引,比 500 万行但每条都全表扫快得多 |
| 分片能解决慢查询 | 慢查询多半是索引缺失或回表过多。分片后每片仍慢,还多一次跨片归并 |
| 早分比晚分好 | 分片键一旦上线,改它等于重做一次分片。早分是把没想清楚的决定提前锁死 |
真正的判据是五个数,不是行数。下表阈值是单主库、NVMe、8–16 核的经验量级,不是标准;换集群要换成自己的实测曲线。
| 维度 | 该分的阈值(量级) | 没到就别分的理由 |
|---|---|---|
| 单表写入 QPS | 持续 > 3000–5000 | 单主库单表的瓶颈是行锁与 WAL 落盘。没到就是事务写长了或索引建多了,先优化写入路径 |
| 单表容量 | > 1–2TB | 1TB 全量备份按 200MB/s 要 1.4 小时,恢复要更久;先归档冷数据、按时间拆历史表 |
| 加索引 / 改表的 DDL 时长 | > 2 小时 | 10 亿行加一个二级索引要全表扫一遍并重建索引,业务等不起;先做分区,把 DDL 换成 drop partition |
| 单库连接数 | 持续打满 3000–5000 | 先查连接池配置与慢查询是不是占住了连接,多数“连接不够”实际是查询太慢 |
| 单库 IOPS | 持续 > 磁盘 IOPS 的 70% | 如果是读打满,加只读副本更便宜(见 37.4 读写分离);读不是分片能解决的问题 |
🔑 分片解决的是“单点写不动”和“单表维护不动”。
分片键:四个判据,一条否决
| 判据 | 要问的问题 | 不满足的后果 |
|---|---|---|
| 基数 | 不同取值的数量是否远大于分片数? | 基数小于片数,必然有片永远空着,扩容也没用 |
| 分布均匀 | 最热一片的负载是均值的几倍? | 倾斜 3 倍,等于有效容量只剩三分之一,热点片先崩 |
| 覆盖主要查询 | 占比最高的三条查询是否都带这个键? | 不带键的查询要扇出到全部分片,分片收益归零 |
| 与事务边界一致 | 一个事务要改的行是否落在同一片? | 单机事务变跨片事务,要么两阶段提交,要么改业务 |
按这四条筛,用户 ID 通常是对的(基数亿级、哈希后均匀、“我的订单”必带、下单只碰一个用户),订单号次之(基数够但“我的订单”不带它),时间最差。
| 选错的方式 | 立刻看到的后果 | 半年后看到的后果 |
|---|---|---|
按时间分片(created_at) | 写入全压在最新一片,热片 100%,其余空转 | 换个维度查就得扫全片;冷热迁移要重写全量 |
按低基数枚举(status / type) | 两三个值决定全部路由,天然倾斜 | 加一个新枚举值就要改路由规则 |
| 按订单号分片,主查询却是“我的订单” | 每次查列表扇出全部分片 | 分页归并成本随片数线性涨,翻页越深越慢 |
| 分片键会变(按手机号,用户可换号) | 改一次号等于搬一次数据 | 迁移脚本变成常驻服务 |
还有一种漏网的热点:分片键本身分布均匀,但某个 key 的访问量是别人的一万倍。分片管不了单 key 热点,那要在缓存层拆 key,见 41.6 热点 key 与分片。
选分片键的时间,要花在“未来三年主要查询长什么样”上,而不是花在“现在哪张表最大”上。
分几片:一次到位,还是逐步扩
两种扩容动作的迁移量可以精确算:
| 扩容方式 | 搬家的 key 比例 | 4→5 | 8→9 | 为什么 |
|---|---|---|---|---|
| 取模 N→N+1 | N/(N+1) | 80% | 88.9% | 桶号 = h mod N,N 变 N+1 后只有 1/(N+1) 的 key 桶号恰好不变 |
| 取模 N→2N | 50% | — | — | 新旧桶号奇偶各半 |
| 一致性哈希 +1 节点 | 1/(N+1) | 20% | 11.1% | 新节点只接管环上属于自己那一段 |
换算成时间和带宽。库总量 8TB,按 100MB/s 迁移带宽:
| 场景 | 搬家数据量 | 迁移耗时 |
|---|---|---|
| 4→5 取模 | 80% × 8TB = 6.4TB | 64000s ≈ 17.8 小时 |
| 4→5 一致性哈希(20%) | 1.6TB(实测 1.94TB) | 4.4 小时(实测 5.4) |
| 4→8 取模(翻倍) | 50% × 8TB = 4TB | 40000s ≈ 11.1 小时 |
| 4→8 一致性哈希(翻倍) | 4TB | 11.1 小时 |
| 一次到位(2 的幂 + 4–8 倍余量) | 逐步扩 | |
|---|---|---|
| 片数 | 现在 4 片够用就上 16 片,每片只跑 1/4 负载 | 先 4 片,不够再加 |
| 迁移次数 | 0 次;真要扩也是翻倍,单次搬 50% | 每次加一片,搬 N/(N+1) |
| 单次迁移量 | 50% | 80%(4→5)、88.9%(8→9) |
| 运维成本 | 一上线就要管 16 个实例 | 从 4 个开始,逐年涨 |
| 适合 | 增长可预测、有自动化运维 | 增长不确定,实例数本身就是成本 |
⚠️ 最常被搞反的一条:“一致性哈希迁移量小”只在“加一台”时成立。如果片数取 2 的幂,每次扩容都是翻倍,取模和一致性哈希都要搬 50%,打平。反过来说,只要你受得了“从来不扩 5 片、9 片”,取模路由就够用,不必上环。
4 片 × 2TB 的容量账:8TB 库分 4 片,每片 2TB。其中订单表 10 亿行、行均 1KB,约 1TB,按用户 ID 分 4 片后每片 2.5 亿行、250GB。
单表 10 亿行、3 个二级索引,维护成本可以算出来(行均 1KB,索引项按“键 8B + 主键 8B + 记录头与页开销,70% 填充率”折算 57B):
| 项 | 算法 | 结果 |
|---|---|---|
| 数据页 | 10 亿行 × 1KB | 1TB |
| 单个二级索引 | 10 亿 × 57B | 57GB |
| 3 个二级索引 | 3 × 57GB | 171GB,占容量 17% |
| 加第 4 个索引(online DDL) | 扫 1TB + 排序 + 写 57GB,有效吞吐按 150MB/s | ≈1.85 小时 |
| 全量备份 | 1TB @ 200MB/s | ≈1.4 小时 |
每多一个索引,容量涨 17%、加索引窗口涨近 2 小时。到第 3 个索引,“改表”已经是要排期、要停机窗口、要备 57GB 临时空间的运维动作。分片后每片只有 2.5 亿行,同样的 DDL 降到 ≈28 分钟,分片缩短的不只是写入压力,还有维护窗口。
代价是:水平扩展本身有上限,节点多到一定程度,跨节点一致性开销会让总吞吐掉头向下(那条 32 节点峰值的曲线见 37.1 垂直扩展与水平扩展)。分片的价值在于把全局共享拆成片内共享,让那条曲线往后推,而不是把 64 个节点塞进同一个共享池。
跨片之后多出来的五件事
| 问题 | 为什么跨片就变难 | 解法 | 代价 |
|---|---|---|---|
| 分页 / 排序 | 每片只能给出“自己片内的前 K 条”,全局前 K 不在任何一片里 | 各片取 offset+limit 条,路由层归并再截断;深翻页改游标 | 读取量放大 (offset+limit)/limit × 片数 |
| 聚合 | count/sum 可合并,avg 不能直接平均 | 并行下发 + 二次归并;avg 拆成 sum 与 count 两个可合并量 | 多一轮网络往返,尾延迟取最慢那片 |
| JOIN | 两张表不在同一片,无法本地 JOIN | 冗余字段(把用户名写进订单)、宽表、应用层拼装 | 冗余字段要同步,宽表写入放大 |
| 分布式事务 | 一个事务跨两片,本地事务失效 | 首选避免:把事务边界对齐分片键。避免不了用 24.5 Saga / 24.4 TCC / 24.6 本地消息表 | 一致性与延迟同时变差 |
| 全局唯一 ID 与唯一约束 | 自增主键在每片独立递增会撞号;唯一索引只在单库内生效 | 号段或雪花发号;去重表(按唯一键自己分片)、布隆过滤器预判 | 多一次写;布隆有假阳性,要兜底查一次 |
分页这一项可以量化(8 片、每页 20 条,实测):
| 翻到第几页 | 每片取多少条 | 8 片共读 | 实际返回 | 放大倍数 |
|---|---|---|---|---|
| 第 3 页(offset 40) | 60 | 480 | 20 | 24 倍 |
| 第 11 页(offset 200) | 220 | 1760 | 20 | 88 倍 |
| 第 51 页(offset 1000) | 1020 | 8160 | 20 | 408 倍 |
| 第 501 页(offset 10000) | 10020 | 80160 | 20 | 4008 倍 |
放大倍数 = (offset+limit) × 片数 / limit。offset 越大越离谱,所以跨片分页必须改成游标翻页(where created_at < last_ts order by created_at desc limit 20),把 offset 消掉,每片只取 20 条。
路由层放哪:三种放法
| 客户端直连(SDK) | 代理中间件 | 数据库原生分区 | |
|---|---|---|---|
| 代表产品 | ShardingSphere-JDBC | ShardingSphere-Proxy、Vitess VTGate、ProxySQL | MySQL PARTITION、TiDB 的 Region |
| 侵入性 | 高(业务引 SDK、配多数据源) | 低(业务只连一个地址) | 最低(SQL 透明) |
| 跨语言 | 差(每种语言一套 SDK) | 好(走 MySQL 协议) | 好 |
| 运维成本 | 低(没有额外进程) | 中(代理集群自己要高可用) | 低 |
| 故障点 | 少一跳,但 SDK 升级要跟着应用发版 | 多一跳,代理挂则全挂 | 少,但受数据库产品能力约束 |
我的判断是:多语言栈选代理,单语言且延迟抠到毫秒选 SDK,能用原生分区或分布式数据库解决就别自己写路由——路由层是最容易写出隐蔽 bug 的一层。
扩容:双写与灰度迁移六步
- 建新片:规格、参数、监控对齐老片;只建表结构,不接流量。
- 开双写:应用同时写老片与新片,以老片为准,新片写失败只记日志、不阻塞主流程。
- 补历史:按主键区间分批(每批 1000–5000 行),限速到迁移带宽的 70%,记录位点。
- 追增量:从位点开始追双写期间的变更,追到延迟稳定在秒级再往下走。
- 校验:按分片键抽样比对行数与关键字段校验和。差异率超过 0.01% 就停下来查,不要带病切流。
- 切读 → 切写 → 下线:先 1% 读,看 P99 与错误率;再 10%、50%、100%;然后写切到新片,老片只读一周后下线。
三个开关必须能一键回退:双写开关、读路由比例、老片写开关。缺一个就不要开始。
可运行的迁移量测算
package main
import (
"fmt"
"hash/crc32"
"sort"
)
const keyCount = 1_000_000 // 100 万个 key,模拟用户 ID 的哈希值
// 方案 A:取模路由 bucket = hash % n
func modRoute(h uint32, n int) int { return int(h % uint32(n)) }
// 方案 B:一致性哈希 + 虚拟节点
type ring struct {
points []uint32 // 环上的哈希位置,升序
owner []int // 每个位置归属的物理节点
}
func newRing(nodes, vnodes int) *ring {
type p struct {
h uint32
n int
}
all := make([]p, 0, nodes*vnodes)
for n := 0; n < nodes; n++ {
for v := 0; v < vnodes; v++ {
// 虚拟节点名 "node-<n>#<v>",哈希后散布到环上
h := crc32.ChecksumIEEE([]byte(fmt.Sprintf("node-%d#%d", n, v)))
all = append(all, p{h, n})
}
}
sort.Slice(all, func(i, j int) bool { return all[i].h < all[j].h })
r := &ring{}
for _, x := range all {
r.points = append(r.points, x.h)
r.owner = append(r.owner, x.n)
}
return r
}
// 顺时针找第一个 >= h 的虚拟节点
func (r *ring) get(h uint32) int {
i := sort.Search(len(r.points), func(i int) bool { return r.points[i] >= h })
if i == len(r.points) {
i = 0
}
return r.owner[i]
}
// 统计从 oldN 扩到 newN 时,搬家的 key 占比
func movedMod(hashes []uint32, oldN, newN int) float64 {
moved := 0
for _, h := range hashes {
if modRoute(h, oldN) != modRoute(h, newN) {
moved++
}
}
return float64(moved) / float64(len(hashes)) * 100
}
func movedRing(hashes []uint32, oldN, newN, vnodes int) float64 {
a, b := newRing(oldN, vnodes), newRing(newN, vnodes)
moved := 0
for _, h := range hashes {
if a.get(h) != b.get(h) {
moved++
}
}
return float64(moved) / float64(len(hashes)) * 100
}
// 各物理节点的负载占比,用来判断"分布是否均匀"
func loadSpread(hashes []uint32, r *ring, nodes int) (min, max float64) {
cnt := make([]int, nodes)
for _, h := range hashes {
cnt[r.get(h)]++
}
min, max = 100, 0
for _, c := range cnt {
share := float64(c) / float64(len(hashes)) * 100
if share < min {
min = share
}
if share > max {
max = share
}
}
return
}
// 跨片分页:每片必须取 offset+limit 条,归并后只留 limit 条
func pageAmplification(shards, offset, limit int) (perShard, total int, amp float64) {
perShard = offset + limit
total = shards * perShard
return perShard, total, float64(total) / float64(limit)
}
func main() {
// 固定种子:用 crc32 生成 100 万个稳定的 key 哈希
hashes := make([]uint32, keyCount)
for i := 0; i < keyCount; i++ {
hashes[i] = crc32.ChecksumIEEE([]byte(fmt.Sprintf("user-%d", i)))
}
fmt.Println("== 扩容搬家比例(100 万 key 实测)==")
fmt.Printf("%-10s %-14s %-18s %-14s\n", "扩容", "取模搬家", "一致性哈希(100vn)", "理论值")
for _, c := range []struct{ old, new int }{{4, 5}, {4, 8}, {8, 9}, {8, 16}} {
m := movedMod(hashes, c.old, c.new)
ch := movedRing(hashes, c.old, c.new, 100)
theory := ""
if c.new == c.old+1 {
theory = fmt.Sprintf("%.1f%%", float64(c.old)/float64(c.new)*100)
} else if c.new == 2*c.old {
theory = "50.0%"
}
fmt.Printf("%d→%-8d %-14s %-18s %-14s\n", c.old, c.new,
fmt.Sprintf("%.2f%%", m), fmt.Sprintf("%.2f%%", ch), theory)
}
fmt.Println("\n== 虚拟节点数对分布均匀度的影响(4 节点,100 万 key)==")
for _, vn := range []int{1, 10, 100, 1000} {
r := newRing(4, vn)
mn, mx := loadSpread(hashes, r, 4)
fmt.Printf(" %5d 虚拟节点/台 → 单节点负载 %.1f%% ~ %.1f%%,最大/均值 = %.2fx\n",
vn, mn, mx, mx/25.0)
}
fmt.Println("\n== 跨片分页的放大倍数(8 片、每页 20 条)==")
for _, offset := range []int{40, 200, 1000, 10000} {
per, total, amp := pageAmplification(8, offset, 20)
fmt.Printf(" offset=%-6d → 每片取 %5d 条,8 片共 %6d 条,只为返回 20 条,放大 %8.1f 倍\n",
offset, per, total, amp)
}
}
本机实跑(cd /tmp/luban37/shard && GO111MODULE=off go run main.go,Apple M1 / 8 核 / Go 1.27.1):
== 扩容搬家比例(100 万 key 实测)==
扩容 取模搬家 一致性哈希(100vn) 理论值
4→5 79.96% 24.31% 80.0%
4→8 50.00% 53.48% 50.0%
8→9 88.87% 8.87% 88.9%
8→16 49.91% 50.97% 50.0%
== 虚拟节点数对分布均匀度的影响(4 节点,100 万 key)==
1 虚拟节点/台 → 单节点负载 0.1% ~ 99.0%,最大/均值 = 3.96x
10 虚拟节点/台 → 单节点负载 3.2% ~ 50.6%,最大/均值 = 2.02x
100 虚拟节点/台 → 单节点负载 21.0% ~ 28.6%,最大/均值 = 1.15x
1000 虚拟节点/台 → 单节点负载 24.1% ~ 25.4%,最大/均值 = 1.02x
== 跨片分页的放大倍数(8 片、每页 20 条)==
offset=40 → 每片取 60 条,8 片共 480 条,只为返回 20 条,放大 24.0 倍
offset=200 → 每片取 220 条,8 片共 1760 条,只为返回 20 条,放大 88.0 倍
offset=1000 → 每片取 1020 条,8 片共 8160 条,只为返回 20 条,放大 408.0 倍
offset=10000 → 每片取 10020 条,8 片共 80160 条,只为返回 20 条,放大 4008.0 倍
四条解读:
- 4→5 取模实测 79.96%,与 N/(N+1)=80% 吻合到小数点后两位;一致性哈希只搬 24.31%,比 20% 高——因为 100 个虚拟节点时第 5 台恰好分到 24.31% 的 key,搬的就是它自己那份。想让均值收敛到 20%,把虚拟节点加到 1000。
- 4→8 时两者都搬 50%(取模 50.00%、一致性哈希 53.48%)。翻倍扩容下取模不吃亏,这是“片数取 2 的幂”的真正理由。
- 虚拟节点数是均匀度的旋钮:1 个虚拟节点时最冷节点只拿到 0.1%、最热 99.0%(差 3.96 倍于均值),1000 个虚拟节点收敛到 24.1%~25.4%。分片键的“分布均匀”判据,落到实现参数上就是这个数。
- 跨片分页的放大随 offset 线性增长:第 3 页 24 倍,offset 10000 时 4008 倍。8 片、20 条一页看着不多,翻到第 501 页要读 8 万条。
反模式
| 反模式 | 后果 |
|---|---|
| 按数据量而不是按写入能力分片 | 表大但只读,白付跨片代价;或者表不大但写入早到瓶颈,分完还是分不动 |
| 分片键选时间 | 写入永远打最新一片,其余片空转,热点片先崩 |
| 分片后还做跨片 JOIN | 每次查询全片扇出,延迟取最慢那片,分片收益归零 |
| 片数不是 2 的幂 | 每次扩容搬 N/(N+1)(4→5 搬 80%),迁移量是翻倍扩容的 1.6 倍 |
| 一次性上 64 片 | 64 个实例的备份、监控、DDL、连接池都要人管,运维成本比机器成本高一个数量级 |
| 没有全局 ID 方案 | 各片自增主键撞号,上线后才发现订单号重复 |
| 分片键选完不留重分布预案 | 业务维度一变只能全量重刷,等于停服 |
| 用分片解决慢查询 | 每片仍慢,还多一次归并 |
关联
自检
Q1 单表 3 亿行、行均 1KB、日增 20 万行、写入峰值 800 QPS,查询只按主键和用户 ID。该不该分片?
▸ 答案:不该。3 亿行约 300GB,日增 20 万行约 200MB,容量与 DDL 时长都在阈值内;800 QPS 写入远低于 3000–5000 的判据。该做的是确认两条查询都有覆盖索引、把三年前的冷数据归档。判据挂在写入速率与维护成本上,不在行数上——行数只是“维护成本”的一个间接信号。
Q2 4→5 扩容时取模要搬 80%,一致性哈希只搬 20% 左右。那为什么还推荐“片数取 2 的幂”?
▸ 答案:取模的桶号是 h mod N,N 变 N+1 后几乎所有 key 都要重算,只有 1/(N+1) 恰好不变,所以搬 N/(N+1)=80%。一致性哈希只把新节点接管的那一段搬走,约 1/(N+1)。但片数取 2 的幂之后,每次扩容都是翻倍,而翻倍时取模也只搬 50%(实测 50.00%),与一致性哈希打平。省下的是“再也不做 5 片、9 片这种扩容”,不是省在算法上——把这两件事混为一谈,就会为了省迁移量去上一套自己维护不了的哈希环。
Q3 分片后“我的订单”按时间倒序翻页,第 1 页 50ms,第 501 页 8 秒。原因和解法?
▸ 答案:跨片分页每片必须取 offset+limit 条,8 片归并后丢掉前面 offset 条。offset=10000 时 8 片共读 80160 条只为返回 20 条,放大 4008 倍,而且归并要在路由层做全排序。解法是改游标翻页(where created_at < last_ts order by created_at desc limit 20),每片只取 20 条、共 160 条归并;更彻底的做法是把该用户的订单都路由到同一片,让列表查询变成单片查询。
Q4 路由层用代理中间件,业务零侵入,看起来最优。什么情况下它是错的选择?
▸ 答案:三种情况。① 延迟敏感:代理多一跳网络加 SQL 解析,通常加 0.3–1ms,P99 抠到 10ms 以内的链路承受不起;② 规模小:代理集群自身要高可用,几个人的团队养一套代理,运维成本高于收益;③ 需要复杂归并:跨片 JOIN、深度分页、跨片事务,代理能做的有限,最后仍要在业务层写归并,等于两头都付了成本。多语言栈、有中间件运维能力时选代理;单语言且延迟极致选 SDK。