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 的一层。

扩容:双写与灰度迁移六步

  1. 建新片:规格、参数、监控对齐老片;只建表结构,不接流量。
  2. 开双写:应用同时写老片与新片,以老片为准,新片写失败只记日志、不阻塞主流程。
  3. 补历史:按主键区间分批(每批 1000–5000 行),限速到迁移带宽的 70%,记录位点。
  4. 追增量:从位点开始追双写期间的变更,追到延迟稳定在秒级再往下走。
  5. 校验:按分片键抽样比对行数与关键字段校验和。差异率超过 0.01% 就停下来查,不要带病切流。
  6. 切读 → 切写 → 下线:先 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 倍

四条解读:

  1. 4→5 取模实测 79.96%,与 N/(N+1)=80% 吻合到小数点后两位;一致性哈希只搬 24.31%,比 20% 高——因为 100 个虚拟节点时第 5 台恰好分到 24.31% 的 key,搬的就是它自己那份。想让均值收敛到 20%,把虚拟节点加到 1000。
  2. 4→8 时两者都搬 50%(取模 50.00%、一致性哈希 53.48%)。翻倍扩容下取模不吃亏,这是“片数取 2 的幂”的真正理由。
  3. 虚拟节点数是均匀度的旋钮:1 个虚拟节点时最冷节点只拿到 0.1%、最热 99.0%(差 3.96 倍于均值),1000 个虚拟节点收敛到 24.1%~25.4%。分片键的“分布均匀”判据,落到实现参数上就是这个数。
  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。