19.3 范围分区
一句话
范围分区按 key 的有序区间切分,天然支持范围扫描与有序遍历;代价是写入和热点读都集中在一个「当前区间」,扩容以区间为单位分裂、搬迁。
原理:一张路由表 + 有序存储
分区路由表
[0, 62,500) → shard 0
[62,500, 125,000) → shard 1
...
[937,500, 1,000,000) → shard 15
每个分片内部按 key 有序存放(B+ 树或 LSM 的 SSTable),所以:
- 点查:先在路由表二分找到分片,再在分片内二分;
- 范围查:定位起点所在分片,顺序读到区间末尾,必要时跨到相邻分片;
- 分裂:把一个区间一分为二,只搬走一半数据;
- 合并:相邻小区间合并,减少元数据。
路由表本身就是元数据:分片数、区间边界、副本位置。它必须一致地传播给所有客户端/路由层,否则会读错分片(48.1 配置中心 这类系统常被用来存路由表)。
优点很硬:范围查询只有它能做好
| 能力 | 范围分区 | 哈希分区 |
|---|---|---|
WHERE ts BETWEEN a AND b | ✅ 只扫相关区间 | ❌ 全分片扫描 |
ORDER BY id LIMIT 10 | ✅ 从一端顺序读 | ❌ 归并所有分片 |
前缀查询 LIKE 'abc%' | ✅ 连续区间 | ❌ 散落各片 |
| 按时间删除/归档 | ✅ 直接删整个区间 | ❌ 逐条删 |
| 分页稳定性 | ✅ 游标沿区间走 | ❌ 需要全局排序 |
| 热点分散 | ❌ 当前区间最热 | ✅ 哈希打散 |
算一笔账:范围分区的「存储均匀、写入集中」
模拟:16 个分片,每个范围 6.25 万个 ID,范围按 ID 单调递增切分。
package main
import "fmt"
const (
shards = 16
width = 62500 // 每个范围 6.25 万个 ID
)
func shard(id int) int { return (id / width) % shards }
func main() {
// 1) 全量 1600 万 ID:每个范围都装满,存储均匀
var all [shards]int
for id := 0; id < 16000000; id++ {
all[shard(id)]++
}
fmt.Print("全量 1600 万 ID 的存储分布:")
for _, v := range all {
fmt.Printf("%d ", v)
}
fmt.Println()
// 2) 写窗口:1 万次连续插入,全部落在当前活跃范围
var window [shards]int
for id := 5000000; id < 5010000; id++ {
window[shard(id)]++
}
fmt.Print("1 万次连续插入的写分布:")
for _, v := range window {
fmt.Printf("%d ", v)
}
fmt.Println()
// 3) 读热点:80% 的读打最近 0.4% 的 ID(约 6.25 万,恰好一个范围)
var reads [shards]int
seed := uint64(7)
next := func() float64 {
seed = seed*6364136223846793005 + 1442695040888963407
return float64(seed>>11) / float64(1<<53)
}
for i := 0; i < 100000; i++ {
var id int
if next() < 0.8 {
id = 15937500 + int(next()*62500)
} else {
id = int(next() * 15937500)
}
reads[shard(id)]++
}
fmt.Print("10 万次读(80% 命中最近 0.4%)的分布:")
for _, v := range reads {
fmt.Printf("%d ", v)
}
fmt.Println()
// 4) 前缀盐:用 id 的低 4 位做前缀,写分散到 16 个范围
var salted [shards]int
for id := 5000000; id < 5010000; id++ {
salted[id%shards]++
}
fmt.Print("16 路前缀盐后的写分布:")
for _, v := range salted {
fmt.Printf("%d ", v)
}
fmt.Println()
}
输出:
全量 1600 万 ID 的存储分布:1000000 1000000 ... 1000000(16 片各 100 万)
1 万次连续插入的写分布:10000 0 0 ... 0(全在第 1 片)
10 万次读(80% 命中最近 0.4%)的分布:1272 ... 81128(第 16 片 81.1%)
16 路前缀盐后的写分布:625 625 ... 625(16 片各 625)
三个结论:
- 存储最终是均匀的:1600 万 ID 全量写完后,每片 100 万;
- 写入永远只打一个分片:任意时刻只有「当前区间」在接收写,1 万次连续插入 100% 落在同一片;
- 读热点也一样:80% 的读集中在最近 0.4% 的数据上,这一片承担 81.1% 的读;加 16 路前缀盐后写均匀到 16 片,但按 ID 的范围查询要 fan-out 16 次,全局有序也丢了。
🔑 范围分区的瓶颈从来不是「总量」,而是「当前」:写入的当前区间、读取的热时间窗。容量规划要按峰值单分片算,不能按平均值算。
单调键怎么办:五种手段与代价
| 手段 | 做法 | 写分布 | 范围查询 | 适用 |
|---|---|---|---|---|
| 预分片 | 事先切好多个区间 | 仍然只打一个 | ✅ | 只能避免「一个分片装不下」,不解决热点 |
| 反转时间戳 | MAX_TS - ts | 仍然只打一个(换到另一端) | ✅ 逆序 | 不解决写热点 |
| 加盐/前缀盐 | hash(user_id)%16 + ts | 均匀到 16 片 | ❌ 需 fan-out | 写吞吐优先 |
| 复合键 | (tenant_id, ts) | 按租户分散 | ✅ 租户内连续 | 多租户、按租户查询 |
| 冷热分离 | 热数据单独分片/内存表 | 热片仍集中,但可独立扩容 | ✅ | 时序、日志、监控 |
雪花 ID、自增 ID、created_at 都属于单调键,按它们做范围分区都会遇到同一个问题:写入集中。复合键里的第一个字段必须是非单调、高基数的维度(租户、用户、设备),时间放在后面。
复合键的两种常见形态
| 形态 | 分区内有序 | 跨分片分布 | 典型场景 |
|---|---|---|---|
(tenant_id, created_at) | 租户内按时间有序 | 租户之间分散 | 多租户订单、日志 |
(series_id, bucket) | 序列内按桶有序 | 序列之间分散 | 监控指标、IoT |
(region, user_id) | 区域内按用户有序 | 区域间分散 | 合规/就近访问 |
(hash(user_id)%16, ts) | 分桶内按时间有序 | 均匀 | 单用户数据量极大、写吞吐优先 |
分裂、合并与再平衡
范围分区的迁移单位是一段连续区间,这带来两个特点:
- 搬迁效率高:一段区间可以顺序扫描、批量传输,比随机 key 迁移更友好;
- 颗粒度粗:分裂点选不好会出现「一个巨大区间 + 一堆小空间」,需要按负载动态 split/merge。
| 策略 | 触发条件 | 优点 | 风险 |
|---|---|---|---|
| 预分片 | 建库时按容量预估 | 简单、无运行期抖动 | 预估错误就长期倾斜 |
| 按大小分裂 | 区间超过阈值(如 10 GiB) | 存储均衡 | 不一定是访问热点 |
| 按负载分裂 | QPS/延迟超过阈值 | 解决热点 | 频繁分裂导致元数据抖动 |
| 合并 | 相邻区间都很小 | 减少元数据 | 合并期间短暂不可用 |
工程实现里,split 通常分两步:先分裂元数据(秒级生效),再异步搬迁数据;搬迁完成后才删除旧区间的数据。这个过程中读要能同时命中新旧位置(双读/路由重定向),否则会出现「分裂期间查不到」。
范围分区 vs 哈希分区
| 维度 | 范围分区 | 哈希分区 |
|---|---|---|
| 点查 | 路由表二分 + 分片内二分 | 哈希取模,一步定位 |
| 范围扫描 | ✅ 连续 | ❌ fan-out |
| 写入分布 | ❌ 集中当前区间 | ✅ 均匀(key 数量) |
| 扩容单位 | 区间(可分裂/合并) | 分片(取模需全量搬) |
| 元数据 | 区间边界表,随分裂增长 | 分片映射,通常固定 |
| 热点治理 | 分裂/加盐/冷热分离 | 加盐/副本/缓存 |
| 典型系统 | HBase、TiKV、Spanner、时序库 | Redis Cluster、Cassandra、MongoDB hashed |
💡 很多系统两种都支持:建表时选
RANGE还是HASH。如果主查询是「按时间/ID 区间」,选范围;如果主查询是「按用户点查 + 高写入」,选哈希;两者都要,就用范围分区 + 分片内二级索引,或哈希分区 + 全局索引。
反模式
| 反模式 | 后果 |
|---|---|
| 用纯时间戳做范围分区键 | 写入永远打最新分片,磁盘/CPU 单点过载 |
| 预分片后不再分裂 | 数据增长后区间大小失衡,热点片无法拆分 |
| 分裂只改元数据不搬数据 | 路由与数据不一致,读不到或读重 |
| 分裂期间不做双读/重定向 | 迁移窗口内查询失败 |
| 用范围分区还要高并发随机写 | 所有写排在一个区间,锁竞争/合并压力大 |
| 复合键把时间放第一位 | 退化成纯时间分区,热点照旧 |
| 忽略全局有序的代价 | 跨分片分页要归并,深分页性能崩 |
自检
Q1 范围分区「全量存储均匀」,为什么还会出现单分片 100% 写入?
▸ 答案:存储均匀是「历史总量」的均匀:每个 ID 区间最终都装满 6.25 万条,所以 1600 万条写完后每片 100 万。但写入是「按时间/ID 顺序到达」的,任意时刻只有当前区间在接收新数据,其他区间只读不写;所以写吞吐完全压在一个分片上。要分散写,必须把非单调维度(哈希前缀、租户)放进分区键。
Q2 反转时间戳能解决写热点吗?为什么?
▸ 答案:不能。反转只是把「最新时间」映射到「最小键」,写入仍然只落在区间的一端(原来是尾部,现在是头部),单调性没有消失,只是方向变了。它解决的是「按时间倒序分页」的索引友好性,不是写分散。
Q3 16 路前缀盐把写均匀到 16 片,代价具体是什么?
▸ 答案:① 范围查询要 fan-out 到 16 个分片再归并,延迟和复杂度上升;② 全局有序丢失,分页/游标要跨片合并;③ 数据写入要按盐值路由,跨盐的事务/批量操作变复杂;④ 盐值本身成为新的热点维度(如果盐分布不均)。它适合「写吞吐优先、范围查询少」的场景。
Q4 一个多租户日志系统,用 (tenant_id, ts) 做范围分区键。大租户写入仍然打一个分片,怎么办?
▸ 答案:大租户的 tenant_id 固定,ts 单调,所以永远落在该租户区间的末尾。三种做法:① 把键改成 (tenant_id, hash(ts)%B, ts),在租户内再分 B 个桶,写入分散到 B 片,代价是租户内范围查询要 fan-out B 次;② 给大租户单独建分区/独立集群(冷热与大小客户分离);③ 用哈希分区按 (tenant_id, bucket) 打散,范围查询走预聚合/索引。选择取决于租户内范围查询的比例。
Q5 范围分区扩容时,「先分裂元数据、再异步搬数据」为什么需要双读?
▸ 答案:分裂后,新写入可能按新边界路由到新区间,而旧数据还在原分片;如果读只按新路由表查,就会漏掉尚未搬迁的旧数据。双读(先查新位置,未命中再查旧位置)或路由重定向(让原分片代理旧数据请求)能覆盖迁移窗口。搬迁完成并校验后,才能删旧数据、撤掉双读。
跨篇相关:分区键的选择判据见 19.1 分区键设计;哈希分区与取模扩容见 19.2 哈希分区;范围键打散后的环实现见 19.4 一致性哈希、19.5 虚拟节点;分裂/搬迁流程见 19.7 再平衡与数据迁移;数据倾斜与热点见 19.8 热点与数据倾斜;分片内有序存储(B+ 树/LSM)见 44.1 存储引擎:B+ 树与 LSM 树;索引与范围扫描见 44.8 索引原理 (B+ 树 / 哈希 / 覆盖索引 / 最左前缀);时序数据库的时间分区见 44.17 时序数据库;分库分表见 44.14 分库分表;路由元数据见 48.1 配置中心。