第 5 章:数据分片与再均衡——把大象切成小块
结论先行:数据分片决定了”数据怎么分散到多台机器上”,分片策略的选择直接决定了系统的性能上限。Hash 分片均匀但不支持范围查询,Range 分片支持范围查询但容易热点。再均衡则是”系统运行过程中如何搬移数据”,难度堪比”给飞行中的飞机换引擎”。
一个图书馆分馆的故事
假设你管理一个大型图书馆,馆藏 100 万本书。一个馆放不下了,需要分成 3 个分馆。你有两种分法:
方案一:按书名首字母分
- 分馆 A:A-H 开头的书
- 分馆 B:I-P 开头的书
- 分馆 C:Q-Z 开头的书
优点:想找某本书?看首字母就知道去哪个分馆。想找”所有 P 开头的书”?只需要去分馆 B。 缺点:如果最近热门书都是 H 开头的(比如 Harry Potter 系列),分馆 A 会被挤爆而其他分馆空空如也。
方案二:按书的编号取模分
- 编号 % 3 == 0 的书 → 分馆 A
- 编号 % 3 == 1 的书 → 分馆 B
- 编号 % 3 == 2 的书 → 分馆 C
优点:书均匀分散在三个分馆,不会出现某个分馆特别拥挤的情况。 缺点:想找”编号 1000-2000 的书”?三个分馆都得跑一遍。
这两种方案对应了分布式数据库中最基本的两种分片策略:Range 分片和 Hash 分片。
分片基础概念
什么是分片
分片(Sharding)就是把一张大表的数据按某个规则拆分到多台机器上。拆分的依据叫分片键(Shard Key / Distribution Key / Partition Key)——通常是表中的某一列或几列。
用户表(1000万行)
┌────┬──────────┬──────┐
│ id │ username │ city │
├────┼──────────┼──────┤
│ 1 │ alice │ 北京 │
│ 2 │ bob │ 上海 │
│ 3 │ charlie │ 广州 │
│ ...│ ... │ ... │
└────┴──────────┴──────┘
按 id 分片到 3 台机器:
机器1: id 1-333万 (分片1)
机器2: id 334万-666万 (分片2)
机器3: id 667万-1000万 (分片3)
分片键的选择至关重要
错误的分片键会导致数据热点——所有请求都涌向同一台机器:
错误示例:用 created_date 作为分片键
机器1: 2024年的数据(几乎没人查)
机器2: 2025年的数据(偶尔有人查)
机器3: 2026年的数据(所有新数据都写这里!)← 热点!
新写入全部涌向机器3,其他两台几乎空闲
好的分片键应该满足:
- 高基数:有很多不同的值(避免只有几个值导致分片不均)
- 分布均匀:数据大致均匀分散到各个分片
- 查询对齐:最常用的查询条件包含分片键(避免全分片扫描)
Hash 分片:追求均匀
工作原理
Hash 分片用一个哈希函数把分片键的值映射到分片编号:
shard_id = hash(分片键值) % 分片数量
例如:hash("alice") = 2847593
2847593 % 3 = 0 → 分片0
hash("bob") = 1938274
1938274 % 3 = 1 → 分片1
优点和缺点
优点:数据分布极其均匀。只要哈希函数质量好,每个分片的数据量几乎相同,不会出现热点。
缺点:范围查询变成全分片扫描。WHERE id BETWEEN 100 AND 200 这样的查询,由于哈希打散了 id 的顺序,必须查所有分片才能找全数据。
Vitess 的 Hash Vindex
Vitess 使用 Vindexes(Virtual Indexes)来定义分片策略。其中最常用的就是 Hash Vindex:
{
"vindexes": {
"hash": {
"type": "hash"
}
},
"tables": {
"users": {
"column_vindexes": [
{ "column": "user_id", "name": "hash" }
]
}
}
}
go/vt/vtgate/vindexes/hash.go 实现了这个哈希函数。它不是简单的取模——Vitess 使用了一种特殊的哈希方式,保证 keyspace 范围可以被均匀分割:
Vitess 的 keyspace 范围: 0x0000...0000 到 0xFFFF...FFFF
shard "-80": 0x0000...0000 到 0x7FFF...FFFF (前半段)
shard "80-": 0x8000...0000 到 0xFFFF...FFFF (后半段)
hash(user_id) → 映射到这个 16 进制范围中的某个点
这种设计让 resharding(重新分片)变得优雅——把一个 shard 分成两个,只需要在 keyspace 范围的中间切一刀。
OpenTenBase 的 Hash 分片
OpenTenBase 在 src/backend/pgxc/locator/locator.c 中实现了多种分片策略,其中包括 Hash 和 Modulo:
// locator.c 中的分片策略选择(简化)
typedef struct Locator {
LocatorType locatorType; // HASH, MODULO, RANGE 等
AttrNumber partAttrNum; // 分片键的列号
LocatorHashFunc locatefunc; // 分片函数指针
} Locator;
// Hash 分片的定位逻辑
Datum hash_func(Datum value) {
// 计算 hash 值
uint32 hash = hash_any(value);
// 映射到 Datanode
return hash % num_datanodes;
}
注意 locatefunc 是一个函数指针——这意味着 OpenTenBase 支持可插拔的分片策略。你可以自定义分片函数。
Range 分片:保持顺序
工作原理
Range 分片按分片键的值范围来划分。每个分片负责一个连续的值区间:
Range 分片示例(按 user_id):
分片1: user_id [1, 10000)
分片2: user_id [10000, 20000)
分片3: user_id [20000, 30000)
查询 WHERE user_id BETWEEN 15000 AND 25000
→ 只需要查分片2和分片3(不需要查分片1)
优点和缺点
优点:范围查询高效——只需要查涉及的分片,不需要全分片扫描。对于有时间序列或有序 ID 的数据特别友好。
缺点:容易产生热点。如果分片键是单调递增的(如自增 ID、时间戳),新数据总是写入最后一个分片,形成写热点。
CockroachDB 的自动 Range 分裂
CockroachDB 使用 Range 分片,但它的精彩之处在于自动分裂——当一个 Range 的数据量超过阈值(默认 512MB)或负载过高时,系统自动把它分成两个 Range。
初始状态:
Range 1: [a, +∞) ← 所有数据都在一个 Range
数据增长到 512MB 后自动分裂:
Range 1: [a, m) ← 前半段
Range 2: [m, +∞) ← 后半段
Range 2 继续增长后再次分裂:
Range 1: [a, m)
Range 2: [m, t)
Range 3: [t, +∞)
pkg/kv/kvserver/split_queue.go 负责检测需要分裂的 Range,load_based_splitter.go 根据负载模式选择最优的分裂点——不是简单地从中间切,而是选择能最好地分散负载的切点。
TiDB 的 Region 自动分裂
TiDB 的 TiKV 存储层使用类似的策略——数据按 key 范围划分为 Region(类似 CockroachDB 的 Range),每个 Region 默认 96MB。Region 过大时自动分裂,过小时自动合并。
TiKV 的 Region 管理:
PD(Placement Driver)是大脑
PD 监控每个 Region 的状态:
- 数据量 > 96MB → 触发分裂
- 数据量太小 + 相邻 Region 也很小 → 触发合并
- 某个 TiKV 节点负载过高 → 触发 Region 迁移
Vitess 的 VSchema:声明式分片的独创设计
Vitess 在分片策略上做了一个重要的创新:VSchema(Virtual Schema)——用声明式的 JSON 配置来定义分片规则,而不是在代码或 SQL 中硬编码。
VSchema 的核心概念
Keyspace:逻辑数据库。一个 Keyspace 可以是 unsharded(单分片)或 sharded(多分片)。
Vindex:虚拟索引,定义了如何从列值映射到 shard。Vindex 有多种类型:
常用的 Vindex 类型:
hash — 对值做哈希,均匀分布
numeric — 直接用数值映射
consistent_lookup — 一致性查找索引
(用一张额外的表维护 column→shard 的映射)
Table Vindex:把 Vindex 绑定到具体的表和列。
一个完整的 VSchema 示例
{
"sharded": true,
"vindexes": {
"hash": { "type": "hash" },
"user_lookup": {
"type": "consistent_lookup",
"params": {
"table": "user_lookup_idx",
"from": "email",
"to": "user_id"
}
}
},
"tables": {
"users": {
"column_vindexes": [
{ "column": "user_id", "name": "hash" }
]
},
"orders": {
"column_vindexes": [
{ "column": "user_id", "name": "hash" }
]
}
}
}
这个 VSchema 的含义:
users表按user_id做 Hash 分片orders表也按user_id做 Hash 分片——这意味着同一个用户的 users 和 orders 数据在同一个 shard 上(Co-located),它们之间的 JOIN 不需要跨 shard
这个”协同分片”的设计非常关键——如果你经常需要 SELECT * FROM users JOIN orders ON users.user_id = orders.user_id,那么让两张表按同一个键分片可以避免跨 shard 的分布式 JOIN。
Consistent Lookup Vindex:二级路由
有时候你需要用非分片键来查数据。比如 users 表按 user_id 分片,但你经常需要按 email 查用户。
Vitess 的解决方案是 Consistent Lookup Vindex——维护一张额外的映射表,记录 email → user_id 的关系。查询时先查映射表找到 user_id,再用 user_id 的 Hash Vindex 定位到正确的 shard。
按 email 查用户的流程:
1. 查 user_lookup_idx 表: email → user_id
SELECT user_id FROM user_lookup_idx WHERE email = 'alice@example.com'
→ user_id = 42
2. 用 hash(42) 找到 shard
hash(42) → shard "-80"
3. 在 shard "-80" 上查用户数据
SELECT * FROM users WHERE user_id = 42
OpenTenBase 的 Shard Map
OpenTenBase 的分片信息存储在 Shard Map 中(src/backend/pgxc/shard/shardmap.c)。当用户创建分布式表时,需要指定分布策略:
-- OpenTenBase 创建分布式表
CREATE TABLE users (
user_id INT,
username TEXT,
city TEXT
) DISTRIBUTE BY HASH(user_id) TO NODE (dn1, dn2, dn3);
-- 也支持其他分布策略
CREATE TABLE logs (
log_time TIMESTAMP,
content TEXT
) DISTRIBUTE BY MODULO(log_time) TO NODE (dn1, dn2, dn3);
Coordinator 收到查询时,会查询 Shard Map 确定数据在哪些 Datanode 上:
查询: SELECT * FROM users WHERE user_id = 42
Coordinator 的处理流程:
1. 解析 SQL,识别出涉及 users 表
2. users 表的分布策略是 HASH(user_id)
3. 计算 hash(42) % 3 = 0 → 数据在 dn1 上
4. 把查询发送给 dn1 执行
5. 收集结果返回给用户
在线再均衡:给飞行中的飞机换引擎
分片设置好之后不是一劳永逸的。随着数据增长,某些分片可能变得过大或过热。添加新机器时,需要把部分数据迁移到新机器上。删除旧机器时,需要把它的数据搬走。
这些操作统称为再均衡(Rebalancing),挑战在于:数据迁移过程中,系统不能停止服务。
CockroachDB 的 Raft Learner 迁移
CockroachDB 用一种精巧的方式做 Range 迁移:
目标:把 Range 3 从节点 A 迁移到节点 D
步骤1:在节点 D 上创建一个 Raft Learner
节点A(Leader) ──► 节点B(Follower)
──► 节点C(Follower)
──► 节点D(Learner) ← 新加的,不参与投票
Learner 开始追赶 Leader 的日志
步骤2:等 Learner 追上 Leader(数据同步完成)
节点D 的数据追上了 节点A
步骤3:原子切换——Learner 提升为 Voter,同时移除节点A
节点A(移除)
节点B(Follower)
节点C(Follower)
节点D(Voter) ← 提升为正式成员
整个过程中,读写操作完全不受影响!
这个设计的巧妙之处在于:Learner 不参与投票,所以添加 Learner 不影响现有的多数派;数据追上后的切换是原子的(一次 Raft 配置变更),不会出现”数据只搬了一半”的中间态。
Vitess 的 VReplication
Vitess 的在线 resharding(go/vt/wrangler/resharder.go)使用 VReplication——在后台持续复制数据到新分片,切换时原子更新路由规则:
Resharding: 把 shard "-80" 拆分成 "-40" 和 "40-80"
阶段1: 创建目标 shard 并开始 VReplication
shard "-80" ──VReplication──► shard "-40"(只复制 hash < 0x40 的数据)
──VReplication──► shard "40-80"(只复制 hash >= 0x40 的数据)
阶段2: 等数据追上(catch-up)
VReplication 持续同步增量变更
阶段3: 原子切换流量
VTGate 更新路由规则:
之前: user_id → hash → shard "-80"
之后: user_id → hash → shard "-40" 或 "40-80"
切换过程只需要几秒钟的只读时间
阶段4: 清理旧 shard
VReplication 是 Vitess 的核心技术之一——它不仅用于 resharding,还用于跨数据中心复制、schema 迁移等场景。
再均衡的核心挑战
一致性:在数据迁移过程中,同一条数据可能同时存在于旧分片和新分片上。如何保证在任意时刻读到的数据都是正确的?
性能影响:数据迁移会消耗网络带宽和磁盘 IO。如何最小化对正常业务的影响?
故障恢复:如果迁移过程中某台机器宕机了,如何从中断的地方继续,而不是从头开始?
再均衡中最棘手的场景:
1. 数据正在从节点A迁移到节点B
2. 一条查询要读这条正在迁移的数据
3. 数据现在在节点A上(还没搬完)
4. 但路由规则可能已经指向节点B了
怎么办?
CockroachDB: 用 Raft Learner,数据追上前不切换路由
Vitess: VReplication 追上后才原子切换路由
OpenTenBase: 需要手动管理,迁移期间的路由由 DBA 控制
分片与查询的关系
分片策略直接影响查询性能。让我们看几个典型场景:
场景一:分片键等值查询(最快)
SELECT * FROM users WHERE user_id = 42;
无论是 Hash 还是 Range 分片,只要查询条件包含分片键的等值条件,就能精确定位到一个分片。
Hash 分片: hash(42) % 3 = 0 → 只查分片0
Range 分片: 42 在 [1, 10000) 范围内 → 只查分片1
场景二:分片键范围查询
SELECT * FROM users WHERE user_id BETWEEN 100 AND 200;
Range 分片:可能只涉及 1-2 个分片(取决于范围与分片边界的关系)。 Hash 分片:必须查所有分片(因为 hash 打乱了顺序)。
场景三:非分片键查询(最慢)
SELECT * FROM users WHERE city = '北京';
无论哪种分片策略,都必须查所有分片——因为 city 不是分片键,系统不知道”北京”的用户在哪些分片上。
非分片键查询 = Scatter-Gather(散射-收集)
Coordinator
├── 发给分片1: SELECT * FROM users WHERE city='北京'
├── 发给分片2: SELECT * FROM users WHERE city='北京'
└── 发给分片3: SELECT * FROM users WHERE city='北京'
收集所有分片的结果,合并后返回
延迟 = 最慢那个分片的响应时间
这就是为什么分片键的选择如此重要——选错了分片键,大量常用查询都会变成全分片扫描。
协同分片(Co-location):让相关数据住在一起
如果两张表经常需要 JOIN,把它们按同一个键分片可以避免跨分片 JOIN:
users 按 user_id Hash 分片
orders 按 user_id Hash 分片(与 users 相同)
结果:同一个 user_id 的 users 和 orders 在同一个分片上
查询: SELECT u.name, o.total
FROM users u JOIN orders o ON u.user_id = o.user_id
WHERE u.user_id = 42
→ 只需要在一个分片上执行本地 JOIN!不需要跨分片。
Citus 对协同分片有特别好的支持——它称之为 co-located tables:
-- Citus 创建协同分片表
SELECT create_distributed_table('users', 'user_id');
SELECT create_distributed_table('orders', 'user_id',
colocate_with => 'users');
常见误区
误区一:”分片数越多性能越好” → 正确理解:分片太多会增加元数据管理开销和跨分片查询的概率。通常每个分片 1-10GB 是一个合理的范围。CockroachDB 默认 Range 大小 512MB。
误区二:”分片之后不需要索引了” → 正确理解:分片解决的是数据分布问题,索引解决的是数据查找问题。每个分片内部仍然需要合适的索引来加速查询。
误区三:”Hash 分片一定比 Range 分片好” → 正确理解:Hash 分片均匀性好,Range 分片支持范围查询。选择取决于你的查询模式。如果你的应用大量使用范围查询(如时间范围、ID 范围),Range 分片更合适。
读完本章你能做什么
- 用”图书馆分馆”的类比解释 Hash 和 Range 分片的区别
- 评估一个分片键的好坏(高基数、分布均匀、查询对齐)
- 解释 Vitess VSchema 的工作原理和 Consistent Lookup Vindex 的用途
- 解释 CockroachDB 的 Raft Learner 迁移和 Vitess 的 VReplication 如何实现在线再均衡
- 理解协同分片(Co-location)如何避免跨分片 JOIN
导读目录:README.md 上一章:第 4 章 · 共识与复制——让多台机器达成一致 下一章:第 6 章 · 分布式查询优化——比单机复杂 10 倍的执行计划