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)

三个结论:

  1. 存储最终是均匀的:1600 万 ID 全量写完后,每片 100 万;
  2. 写入永远只打一个分片:任意时刻只有「当前区间」在接收写,1 万次连续插入 100% 落在同一片;
  3. 读热点也一样: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 配置中心。