mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5169 字
13 分钟
数据分区:范围、哈希与再平衡
2024-09-25

数据复制中,我们解决了”如何让同一份数据在多台机器上可用”的问题——复制提供了冗余与读取扩展能力。但复制有一个根本局限:每台机器都持有完整数据集。当数据量持续增长,单机无法承载时,需要另一种扩展策略——将数据拆散到不同机器上,让每台机器只负责一部分数据。这就是数据分区(Partitioning),也常被称为分片(Sharding)

分区与复制是正交的:分区解决”数据放哪台机器”,复制解决”数据如何冗余”。在实际系统中,每个分区通常会被复制到多个节点上,两者协同工作。本章将聚焦分区策略本身——范围分区、哈希分区、一致性哈希、二级索引分区、再平衡策略与热点缓解。

前置知识#

Note

范围分区(Google Bigtable,2006)和哈希分区(Amazon Dynamo,2007)是两大主流策略。一致性哈希由 MIT 的 David Karger 在 1997 年提出,最初用于 CDN 负载均衡,后被 Dynamo 发扬光大。

一、为什么需要分区#

1.1 单机的扩展天花板#

无论怎样优化单机——加内存、换 SSD、调参数——总会触达物理极限:

瓶颈维度典型上限触达时的表现
存储容量单机 10~20 TB SSD数据无法写入,扩磁盘成本指数增长
写入吞吐单机 5~10 万 QPS写入延迟飙升,连接池耗尽
读取吞吐单机 10~20 万 QPS缓存命中率下降,磁盘 I/O 饱和
网络带宽10~25 Gbps大查询或备份抢占带宽,影响在线流量

当任何一个维度触顶,纵向扩展(Scale-Up)的成本将急剧上升——从普通服务器到高端小型机,价格可能翻 10 倍,而性能只提升 2~3 倍。分区则提供了横向扩展(Scale-Out)的路径:增加更多普通机器,每台只承载 1/N 的数据与流量。

1.2 分区的核心目标#

分区的设计需要在多个目标之间取得平衡:

graph TB GOAL["分区设计目标"] GOAL --> G1["数据均匀分布<br/>避免热点"] GOAL --> G2["查询局部性<br/>减少跨节点请求"] GOAL --> G3["再平衡最小化<br/>节点增减时少搬数据"] GOAL --> G4["运维简洁性<br/>易于理解与排查"] G1 -.->|"冲突"| G2 G2 -.->|"冲突"| G3 style GOAL fill:#e3f2fd,stroke:#1565c0 style G1 fill:#e8f5e9,stroke:#2e7d32 style G2 fill:#fff3e0,stroke:#e65100 style G3 fill:#fce4ec,stroke:#c62828 style G4 fill:#f3e5f5,stroke:#6a1b9a
  • 数据均匀分布:每台机器承载大致相等的数据量与请求量,避免某台机器成为瓶颈
  • 查询局部性:相关数据尽量落在同一分区,减少跨节点查询的开销
  • 再平衡最小化:增加或移除节点时,只需移动最少量的数据
  • 运维简洁性:分区规则易于理解,问题易于排查
Note

数据均匀分布与查询局部性常常冲突——范围分区有利于查询局部性(相邻数据在同一分区),但容易产生热点;哈希分区有利于均匀分布,但破坏了数据的范围局部性。理解这一张力是选择分区策略的关键。

1.3 分区与复制的关系#

分区和复制通常组合使用。一个常见的架构是:先将数据按分区键拆分到 N 个分区,再将每个分区复制到 M 个节点。这样,整个集群有 N × M 个分区副本,兼顾了扩展性与可用性。

graph LR subgraph 数据集 D1["分区 1<br/>A-M"] D2["分区 2<br/>N-Z"] end D1 --> N1["节点 1<br/>主"] D1 --> N2["节点 2<br/>从"] D1 --> N3["节点 3<br/>从"] D2 --> N4["节点 4<br/>主"] D2 --> N5["节点 5<br/>从"] D2 --> N6["节点 6<br/>从"] style D1 fill:#e3f2fd,stroke:#1565c0 style D2 fill:#e8f5e9,stroke:#2e7d32

关于复制的详细机制(单主、多主、无主复制,以及复制延迟的处理),请参考数据复制。本章聚焦分区策略本身。

二、范围分区#

2.1 基本原理#

范围分区(Range Partitioning)将分区键的值域划分为连续的区间,每个区间对应一个分区。分区键可以是数值、时间戳、字符串等任何可排序的类型。

-- PostgreSQL 范围分区示例:按月分区订单表
CREATE TABLE orders (
id BIGINT,
user_id BIGINT,
amount DECIMAL(10,2),
status VARCHAR(20),
created_at TIMESTAMP
) PARTITION BY RANGE (created_at);
CREATE TABLE orders_2026_01 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE orders_2026_02 PARTITION OF orders
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE orders_2026_03 PARTITION OF orders
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- 查询自动路由到对应分区
SELECT * FROM orders WHERE created_at >= '2026-02-15'
AND created_at < '2026-03-01';
-- 只扫描 orders_2026_02 分区

2.2 分区边界的确定#

范围分区的关键决策是分区边界怎么定。边界决定了每个分区的数据量与查询路由效率:

边界策略适用场景优点缺点
固定区间时间序列、有序 ID简单直观,查询局部性好数据分布不均时产生热点
预设分桶键值范围已知分区数量固定,易于管理需要提前了解数据分布
动态分裂数据分布未知自适应数据分布分裂瞬间有性能抖动

动态分裂是 HBase 和 Bigtable 采用的策略:每个分区初始较大,当数据量超过阈值时自动一分为二。这种方式不需要预设边界,但分裂过程需要协调,可能短暂影响性能。

2.3 范围分区的优势与陷阱#

优势:高效的范围查询

范围分区最大的优势是查询局部性。当查询条件命中分区键的范围时,数据库只需扫描相关分区,跳过无关数据:

-- 范围查询:只涉及 1 个分区
SELECT * FROM orders
WHERE created_at BETWEEN '2026-02-01' AND '2026-02-28';
-- 扫描分区:orders_2026_02
-- 范围查询:跨分区但只涉及 2 个分区
SELECT * FROM orders
WHERE created_at BETWEEN '2026-01-20' AND '2026-02-15';
-- 扫描分区:orders_2026_01, orders_2026_02

陷阱:热点问题

范围分区最大的风险是写入热点。如果分区键是时间戳,所有新写入都路由到最后一个分区,导致该分区所在节点负载远高于其他节点:

graph LR subgraph 写入热点 W["写入请求<br/>全部指向最新分区"] --> P3["分区 3<br/>2026-03<br/> 热点"] P1["分区 1<br/>2026-01"] P2["分区 2<br/>2026-02"] end W -.->|"几乎无写入"| P1 W -.->|"几乎无写入"| P2 W ===>"全部写入" ===> P3 style W fill:#ffcdd2,stroke:#c62828 style P3 fill:#ffcdd2,stroke:#c62828 style P1 fill:#e8f5e9,stroke:#2e7d32 style P2 fill:#e8f5e9,stroke:#2e7d32
Warning

以自增 ID 或时间戳作为范围分区键,是生产环境中最常见的分区反模式。写入热点不仅影响写入性能,还会导致该分区的复制延迟更大、备份更慢、故障恢复更久——所有压力集中在单点。

2.4 范围分区的典型应用#

数据库分区方式分区键选择特点
PostgreSQL声明式范围分区时间、数值范围原生支持,分区裁剪高效
MySQLRANGE 分区时间、数值范围需要主键包含分区键
HBase自动范围分裂RowKey动态分裂,Region 管理
Bigtable自动范围分裂RowKey类似 HBase,Google 内部
Cassandra组合分区键分区键 + 聚簇列分区键哈希 + 聚簇列排序

三、哈希分区#

3.1 基本原理#

哈希分区(Hash Partitioning)对分区键施加哈希函数,将哈希值映射到分区编号。由于哈希函数的伪随机性,数据倾向于均匀分布到各分区,有效缓解范围分区的热点问题。

-- PostgreSQL 哈希分区
CREATE TABLE users (
id BIGINT,
name VARCHAR(100),
email VARCHAR(255),
city VARCHAR(50)
) PARTITION BY HASH (id);
CREATE TABLE users_p0 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE users_p1 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE users_p2 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE users_p3 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 3);
-- 数据路由:hash(id) % 4 → 分区编号
-- id = 42 → hash(42) % 4 = 2 → users_p2

哈希分区的路由公式:

partition_number = hash(key) % num_partitions

3.2 哈希分区的取舍#

维度范围分区哈希分区
数据分布取决于键的分布,可能不均哈希函数保证近似均匀
范围查询高效,只扫描相关分区必须扫描所有分区
点查询需要定位区间再查找直接计算分区编号
写入热点容易产生(时间键、自增 ID)有效缓解
再平衡添加分区需调整边界添加分区需重新哈希(迁移量大)
典型场景时间序列、有序数据用户数据、随机访问数据

3.3 一致性哈希#

传统哈希分区(hash(key) % N)在节点增减时有致命缺陷:当 N 变为 N+1 时,几乎所有数据都需要重新映射。假设从 4 个节点扩展到 5 个节点,约 80% 的键需要迁移——这几乎等同于重建整个数据集。

一致性哈希(Consistent Hashing)解决了这个问题。它将哈希值空间组织为一个环(0 ~ 2³²-1),每个节点在环上占据一个位置,每个键顺时针找到最近的节点:

graph TB subgraph 一致性哈希环 direction TB R["哈希环 0 ~ 2^32-1"] N1["节点 A<br/>hash=A → 位置 1000"] N2["节点 B<br/>hash=B → 位置 3000"] N3["节点 C<br/>hash=C → 位置 6000"] N4["节点 D<br/>hash=D → 位置 9000"] end K1["key=user_42<br/>hash=2500"] -->|"顺时针"| N2 K2["key=order_99<br/>hash=5500"] -->|"顺时针"| N3 K3["key=log_777<br/>hash=8000"] -->|"顺时针"| N4 style R fill:#f5f5f5,stroke:#616161 style N1 fill:#e3f2fd,stroke:#1565c0 style N2 fill:#e8f5e9,stroke:#2e7d32 style N3 fill:#fff3e0,stroke:#e65100 style N4 fill:#fce4ec,stroke:#c62828 style K1 fill:#bbdefb,stroke:#1565c0 style K2 fill:#c8e6c9,stroke:#2e7d32 style K3 fill:#ffe0b2,stroke:#e65100

一致性哈希的核心优势:当添加新节点时,只需从顺时针方向的下一个节点接管一部分数据;当移除节点时,其数据由顺时针方向的下一个节点接管。平均只需迁移 K/N 的数据(K 为总键数,N 为节点数)。

3.4 虚拟节点#

基本一致性哈希仍有问题:节点少时,数据分布可能严重不均。解决方案是引入虚拟节点(Virtual Nodes, VNodes)——每个物理节点在环上占据多个位置:

# 一致性哈希 + 虚拟节点的实现
import hashlib
class ConsistentHash:
def __init__(self, nodes, num_vnodes=150):
self.ring = {} # 哈希值 → 节点
self.sorted_keys = [] # 排序后的哈希值
self.num_vnodes = num_vnodes
for node in nodes:
self.add_node(node)
def _hash(self, key: str) -> int:
return int(hashlib.md5(key.encode()).hexdigest(), 16)
def add_node(self, node: str):
"""添加节点:在环上放置 num_vnodes 个虚拟节点"""
for i in range(self.num_vnodes):
vnode_key = f"{node}:{i}"
h = self._hash(vnode_key)
self.ring[h] = node
self.sorted_keys = sorted(self.ring.keys())
def remove_node(self, node: str):
"""移除节点:删除所有虚拟节点"""
for i in range(self.num_vnodes):
vnode_key = f"{node}:{i}"
h = self._hash(vnode_key)
del self.ring[h]
self.sorted_keys = sorted(self.ring.keys())
def get_node(self, key: str) -> str:
"""查找键对应的节点:顺时针查找"""
h = self._hash(key)
# 二分查找第一个 >= h 的虚拟节点
idx = self._bisect(h)
return self.ring[self.sorted_keys[idx]]
def _bisect(self, h: int) -> int:
lo, hi = 0, len(self.sorted_keys)
while lo < hi:
mid = (lo + hi) // 2
if self.sorted_keys[mid] < h:
lo = mid + 1
else:
hi = mid
return lo % len(self.sorted_keys)

虚拟节点带来的好处:

问题无虚拟节点有虚拟节点
少量节点时分布不均严重,偏差可达 30%+改善,偏差 < 10%
异构节点(性能不同)无法处理权重高的节点放置更多虚拟节点
节点增减时的数据迁移迁移量波动大迁移量更均匀
Note

Cassandra 和 Riak 使用虚拟节点(Cassandra 称为 Token),DynamoDB 在内部也采用类似机制。虚拟节点数量通常为每个物理节点 100~200 个,在分布均匀性和管理开销之间取得平衡。

3.5 一致性哈希的演进#

一致性哈希并非终点。在实际系统中,它面临若干工程挑战:

flowchart LR CH["一致性哈希<br/>基本版"] --> VNODE["+ 虚拟节点<br/>解决分布不均"] VNODE --> RING["+ 固定分区环<br/>解耦分区与节点"] RING --> AUTO["+ 自动再平衡<br/>节点增减时自动迁移"] style CH fill:#e3f2fd,stroke:#1565c0 style VNODE fill:#e8f5e9,stroke:#2e7d32 style RING fill:#fff3e0,stroke:#e65100 style AUTO fill:#fce4ec,stroke:#c62828

固定分区环是重要的演进方向:不再让节点直接占据哈希环上的位置,而是将环划分为固定数量的分区(如 2¹⁴ = 16384 个),再将分区分配给节点。这样,分区数量在集群生命周期内不变,再平衡只是将分区从一个节点移动到另一个节点,而不需要重新计算哈希。Redis Cluster 正是采用这一方案——16384 个哈希槽,每个节点负责一部分槽。

四、二级索引分区#

分区键决定了数据如何分布,但表上通常还有其他索引。二级索引的分区方式是一个容易被忽视但影响深远的设计决策。

4.1 本地索引(分区级索引)#

本地索引(Local Index)将二级索引与数据存储在同一个分区内——每个分区维护自己的索引,索引条目只指向本分区的数据。

graph TB subgraph 分区1["分区 1(user_id: A-M)"] D1["数据行<br/>(Alice, 北京)<br/>(Bob, 上海)"] I1["本地索引<br/>city → Alice<br/>city → Bob"] end subgraph 分区2["分区 2(user_id: N-Z)"] D2["数据行<br/>(张三, 北京)<br/>(李四, 广州)"] I2["本地索引<br/>city → 张三<br/>city → 李四"] end Q["查询:WHERE city='北京'"] -->|"扫描分区1"| I1 Q -->|"扫描分区2"| I2 style Q fill:#ffcdd2,stroke:#c62828 style 分区1 fill:#e3f2fd,stroke:#1565c0 style 分区2 fill:#e8f5e9,stroke:#2e7d32

优点:写入时只需更新本分区的索引,无需跨节点协调;再平衡时索引随数据一起移动。

缺点:查询二级索引时必须向所有分区发送请求(Scatter/Gather),然后合并结果。如果分区数量多,查询延迟会显著增加。

4.2 全局索引(跨分区索引)#

全局索引(Global Index)将二级索引独立于数据分区——索引本身按索引键分区,索引条目可能指向任何分区的数据。

graph TB subgraph 索引分区["全局索引分区(按 city)"] GI1["索引分区 1<br/>city=北京 → 分区1:Alice, 分区2:张三"] GI2["索引分区 2<br/>city=上海 → 分区1:Bob<br/>city=广州 → 分区2:李四"] end subgraph 数据分区 DP1["数据分区 1<br/>Alice, Bob"] DP2["数据分区 2<br/>张三, 李四"] end Q2["查询:WHERE city='北京'"] -->|"只查索引分区1"| GI1 GI1 -->|"回表"| DP1 GI1 -->|"回表"| DP2 style Q2 fill:#c8e6c9,stroke:#2e7d32 style 索引分区 fill:#fff3e0,stroke:#e65100 style 数据分区 fill:#e3f2fd,stroke:#1565c0

优点:查询二级索引时只需访问对应的索引分区,无需 Scatter/Gather,查询效率高。

缺点:写入时可能需要跨节点更新索引(数据在分区 A,索引在分区 B),引入分布式事务的开销;再平衡时索引与数据的移动需要协调。

4.3 本地索引 vs 全局索引#

维度本地索引全局索引
写入开销低,只写本地高,可能需要分布式事务
二级索引查询Scatter/Gather,延迟高直接定位,延迟低
再平衡简单,索引随数据移动复杂,索引与数据需协调
一致性天然一致需要额外保证(最终一致或分布式事务)
典型实现MongoDB, CassandraMySQL Sharding, OceanBase
适用场景写多读少,分区数少读多写少,二级索引查询频繁
Warning

全局索引的写入延迟是一个容易被低估的问题。如果数据写入与索引更新不在同一分区,就需要跨节点事务保证一致性。许多系统(如 Cassandra)选择最终一致性——索引更新异步完成,查询可能短暂读到旧数据。如果你的业务对二级索引的一致性有严格要求,务必确认数据库的全局索引一致性保证级别。

4.4 覆盖索引与分区#

当二级索引包含查询所需的所有列时,可以避免回表操作——这就是覆盖索引。在分区场景下,覆盖索引的价值更加突出:

-- 本地索引场景下的覆盖索引
-- 分区键:user_id,二级索引:(city, name)
SELECT name, city FROM users WHERE city = '北京';
-- 覆盖索引:不需要回表,但仍然需要 Scatter/Gather
-- 全局索引场景下的覆盖索引
-- 全局索引键:city,包含列:name, user_id
SELECT name FROM users WHERE city = '北京';
-- 覆盖索引:不需要回表,也不需要 Scatter/Gather
-- 直接从全局索引分区获取结果

全局覆盖索引消除了 Scatter/Gather 和回表两次开销,是分区表二级索引查询的最佳方案——但代价是写入时的跨分区协调。

五、再平衡策略#

再平衡(Rebalancing)是指在集群拓扑变化(节点增减、负载不均)时,将数据在节点间重新分配的过程。再平衡的目标是:移动最少的数据,达到最均匀的分布

5.1 为什么需要再平衡#

触发再平衡的常见场景:

触发场景说明再平衡目标
增加节点扩容,新节点需要承担一部分负载从现有节点迁移数据到新节点
移除节点缩容或节点故障将故障节点的数据迁移到其他节点
负载不均某些分区数据量或流量远高于其他重新分配数据以均衡负载
分区分裂某个分区过大将大分区一分为二

5.2 再平衡策略对比#

flowchart TB RB["再平衡策略"] RB --> S1["策略1:哈希取模变更<br/>hash(key) % N → hash(key) % (N+1)"] RB --> S2["策略2:固定分区数<br/>分区数不变,重新分配分区归属"] RB --> S3["策略3:动态分区<br/>按需分裂/合并"] S1 --> R1["几乎所有数据需迁移<br/>生产环境不可用"] S2 --> R2["只移动被重新分配的分区<br/>Redis Cluster, Cassandra"] S3 --> R3["只移动分裂/合并的分区<br/>HBase, Bigtable"] style RB fill:#e3f2fd,stroke:#1565c0 style S1 fill:#ffcdd2,stroke:#c62828 style S2 fill:#c8e6c9,stroke:#2e7d32 style S3 fill:#c8e6c9,stroke:#2e7d32 style R1 fill:#ffcdd2,stroke:#c62828 style R2 fill:#e8f5e9,stroke:#2e7d32 style R3 fill:#e8f5e9,stroke:#2e7d32

策略 1:哈希取模变更——最差策略

# 反面教材:直接改变取模数
# 从 4 个节点扩展到 5 个节点
# hash(key) % 4 → hash(key) % 5
# 迁移量分析
# 对于任意 key,hash(key) % 4 ≠ hash(key) % 5 的概率 ≈ 1 - 1/5 = 80%
# 即 80% 的数据需要迁移!

策略 2:固定分区数——推荐策略

预先创建远多于节点数的固定分区,将分区分配给节点。再平衡时只改变分区的归属,不改变分区内的数据:

# 固定分区再平衡示例(Redis Cluster 风格)
# 16384 个哈希槽,初始分配给 4 个节点
slot_assignment_before = {
"node_A": range(0, 4096), # 槽 0-4095
"node_B": range(4096, 8192), # 槽 4096-8191
"node_C": range(8192, 12288), # 槽 8192-12287
"node_D": range(12288, 16384), # 槽 12288-16383
}
# 添加 node_E,从每个节点各迁移一部分槽
slot_assignment_after = {
"node_A": range(0, 3277), # 迁出 819 个槽
"node_B": range(4096, 7373), # 迁出 819 个槽
"node_C": range(8192, 11469), # 迁出 819 个槽
"node_D": range(12288, 15565), # 迁出 819 个槽
"node_E": range(3277, 4096) + range(7373, 8192)
+ range(11469, 12288) + range(15565, 16384),
# 共接收 3276 个槽 ≈ 16384 / 5
}

策略 3:动态分区——自适应策略

不预设分区数量,而是根据数据量动态分裂与合并。HBase 的 Region 分裂是典型实现:

// HBase Region 分裂触发条件(简化)
// 当 Region 大小超过 hbase.hregion.max.filesize(默认 10GB)
// 自动触发分裂
// 分裂过程:
// 1. Region Server 选择分裂点(通常为中间行键)
// 2. 创建两个新 Region,原 Region 下线
// 3. 将数据按分裂点分配到新 Region
// 4. 新 Region 上线,更新 Meta 表
// 5. 可选:将新 Region 迁移到其他 Region Server 以均衡负载

5.3 再平衡的工程挑战#

再平衡不是简单的数据搬运,它需要处理一系列工程问题:

挑战说明常见解决方案
在线服务影响迁移数据占用 I/O 和网络带宽限流迁移,控制并发度
一致性保证迁移期间读写如何路由双写或路由表原子切换
迁移失败回滚迁移中断后如何恢复记录迁移进度,支持断点续传
自动 vs 手动自动再平衡可能雪崩设置冷却期,人工审批大规模迁移
Note

再平衡期间的路由问题是核心难点。在数据从节点 A 迁移到节点 B 的过程中,客户端可能仍然向节点 A 发送请求。常见的解决方案是:迁移期间,节点 A 仍然负责处理请求,但写入会同时写入 A 和 B(双写);迁移完成后,路由表原子切换到 B。这类似于数据复制中主从切换时的流量路由问题。

5.4 再平衡操作的最佳实践#

# Cassandra nodetool move 操作示例
# 将 token 重新分配给节点
# 1. 查看当前集群状态
nodetool status
# 2. 触发再平衡(自动计算新 token 分配)
nodetool rebalance
# 3. 手动移动节点到新 token(谨慎使用)
nodetool move <new_token>
# 4. 清理不再属于本节点的数据
nodetool cleanup
# 5. 监控再平衡进度
nodetool netstats
nodetool compactionstats

最佳实践总结:

  1. 避免频繁再平衡:设置合理的触发阈值,不要因为轻微不均就触发
  2. 限流迁移:控制迁移带宽,避免影响在线流量
  3. 选择低峰期:大规模再平衡安排在业务低峰期
  4. 监控进度:实时监控迁移速度、剩余数据量、集群负载
  5. 验证数据完整性:迁移完成后校验数据一致性

六、热点缓解#

即使选择了合理的分区策略,热点仍可能出现。本节讨论热点的识别与缓解方法。

6.1 热点的类型与识别#

热点类型典型场景识别方法
写入热点时间序列数据追加、自增 ID监控各节点写入 QPS
读取热点热门用户、爆款商品监控各分区读取 QPS
数据量热点大客户数据集中监控各分区数据量

6.2 热点缓解策略#

策略 1:组合分区键

将热点维度与分散维度组合,打破数据倾斜:

-- 反面:仅按时间分区,写入全部集中
-- PARTITION BY RANGE (created_at)
-- 正面:先按用户 ID 哈希,再按时间范围
-- Cassandra 风格的组合分区键
CREATE TABLE events (
user_id BIGINT,
event_time TIMESTAMP,
event_type VARCHAR(50),
payload TEXT,
PRIMARY KEY ((user_id), event_time)
-- ^^^^^^^^ 分区键(哈希)
-- ^^^^^^^^^^ 聚簇列(范围排序)
);
-- 同一用户的事件按时间排序存储在同一分区
-- 不同用户的事件分散到不同分区
-- 查询某用户某时间段的事件只需访问一个分区

策略 2:键前缀加盐

对于已知的倾斜键,添加随机前缀将数据打散:

# 热点键缓解:加盐打散
# 假设 user_id=1 是超级用户,数据量远超其他用户
# 原始分区键
def original_key(user_id):
return f"user:{user_id}"
# 加盐分区键:为热点用户添加随机前缀
def salted_key(user_id, num_buckets=10):
if is_hot_user(user_id):
bucket = random.randint(0, num_buckets - 1)
return f"user:{user_id}:bucket:{bucket}"
return f"user:{user_id}"
# 查询时需要扫描所有桶
def query_hot_user(user_id, num_buckets=10):
results = []
for bucket in range(num_buckets):
key = f"user:{user_id}:bucket:{bucket}"
results.extend(query_partition(key))
return results

策略 3:读写分离 + 缓存

对于读取热点,在分区层之上增加缓存层:

graph LR CLIENT["客户端"] --> CACHE["缓存层<br/>Redis / CDN"] CACHE -->|"缓存未命中"| PROXY["分区代理"] PROXY --> P1["分区 1"] PROXY --> P2["分区 2"] PROXY --> P3["分区 3<br/> 热点"] P3 -->|"读取副本"| REPLICA["分区 3 只读副本"] style CLIENT fill:#e3f2fd,stroke:#1565c0 style CACHE fill:#e8f5e9,stroke:#2e7d32 style P3 fill:#ffcdd2,stroke:#c62828 style REPLICA fill:#fff3e0,stroke:#e65100

6.3 热点缓解策略对比#

策略适用场景优点缺点
组合分区键预防热点从根源解决,查询局部性好需要提前设计,修改成本高
键前缀加盐已有热点实施简单,无需改分区策略查询需合并多个桶,增加复杂度
读写分离 + 缓存读取热点不改分区结构,见效快不解决写入热点,增加架构复杂度
自动分裂数据量热点自适应,无需人工干预分裂过程有性能抖动
请求队列 + 限流突发热点保护系统不崩溃降低吞吐量,治标不治本
Warning

热点缓解策略的选择需要区分”临时热点”和”结构性热点”。临时热点(如促销活动)适合用缓存和限流应对;结构性热点(如大客户数据集中)需要从分区策略层面解决——组合分区键或键前缀加盐。混淆两者会导致”头痛医头”——缓存挡住了读热点,但写入热点依然存在。

6.4 实战:电商订单表的分区设计#

以电商订单表为例,综合运用上述策略:

-- 电商订单表分区设计
-- 挑战:按卖家查询(热点卖家)、按时间查询(写入热点)
-- 方案:组合分区键 + 二级索引
-- 主表:按 (seller_id % 100, created_at) 分区
-- seller_id 哈希打散写入,created_at 保证时间局部性
CREATE TABLE orders (
order_id BIGINT, -- 分布式 ID(Snowflake)
seller_id BIGINT, -- 卖家 ID
buyer_id BIGINT, -- 买家 ID
status VARCHAR(20),
amount DECIMAL(10,2),
created_at TIMESTAMP,
PRIMARY KEY (order_id)
) PARTITION BY HASH (seller_id);
-- 买家维度的查询通过全局二级索引支持
CREATE INDEX idx_buyer ON orders (buyer_id, created_at)
GLOBAL; -- 全局索引,按 buyer_id 分区
-- 时间维度的查询通过本地二级索引支持
-- 同一卖家的订单按时间排序,范围查询高效
CREATE INDEX idx_seller_time ON orders (seller_id, created_at)
LOCAL; -- 本地索引,与主表同分区

这个设计的关键权衡:

  • 主表按 seller_id 哈希分区:打散写入,避免时间维度的写入热点
  • 全局索引按 buyer_id 分区:买家查询直接定位,无需 Scatter/Gather
  • 本地索引按 seller_id + created_at:卖家查询利用分区局部性,高效范围扫描

七、总结#

7.1 分区策略选择决策树#

flowchart TB START["选择分区策略"] --> Q1{"查询模式?"} Q1 -->|"大量范围查询"| RANGE["范围分区"] Q1 -->|"主要点查询"| Q2{"数据分布?"} Q1 -->|"混合模式"| HYBRID["组合分区"] Q2 -->|"均匀"| HASH["哈希分区"] Q2 -->|"倾斜"| Q3{"节点是否频繁增减?"} Q3 -->|"是"| CHASH["一致性哈希<br/>+ 虚拟节点"] Q3 -->|"否"| FIXED["固定分区哈希"] RANGE --> R_IDX["二级索引:本地索引"] HASH --> H_IDX["二级索引:全局索引"] HYBRID --> HY_IDX["二级索引:混合"] style START fill:#e3f2fd,stroke:#1565c0 style RANGE fill:#e8f5e9,stroke:#2e7d32 style HASH fill:#fff3e0,stroke:#e65100 style CHASH fill:#fce4ec,stroke:#c62828 style HYBRID fill:#f3e5f5,stroke:#6a1b9a

7.2 核心要点回顾#

主题核心要点
范围分区查询局部性好,但容易产生写入热点;适合时间序列和有序数据
哈希分区数据分布均匀,但破坏范围局部性;适合随机访问和点查询
一致性哈希节点增减时迁移量小;虚拟节点解决分布不均;固定分区环进一步解耦分区与节点
二级索引分区本地索引写入简单但查询需 Scatter/Gather;全局索引查询高效但写入需跨节点事务
再平衡固定分区数策略最优;限流迁移、低峰操作、监控进度是工程关键
热点缓解区分临时热点与结构性热点;组合分区键从根源预防,缓存和限流应急处理

7.3 与后续章节的联系#

数据分区是分布式数据库的基石,但它只是扩展故事的一部分:

  • 数据复制:分区解决”数据放哪”,复制解决”数据如何冗余”——两者正交组合,构成分布式存储的基本架构
  • 分库分表与 NewSQL:本章讨论的是分区的概念与策略;分库分表是分区在工程层面的具体实践,NewSQL 则试图将分区、复制、分布式事务等机制封装为对应用透明的分布式数据库

分区策略的选择没有银弹——它取决于数据特征、查询模式、集群规模和运维能力。理解范围分区与哈希分区的取舍、本地索引与全局索引的权衡、再平衡的工程挑战,才能在面对具体场景时做出合理的设计决策。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

数据分区:范围、哈希与再平衡
https://blog.souloss.com/posts/database/data-partitioning/
作者
Souloss
发布于
2024-09-25
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时