182 lines
7.8 KiB
Markdown
182 lines
7.8 KiB
Markdown
---
|
||
tags: [分布式, 一致性哈希, 哈希环, 数据分片]
|
||
create time: 2026-05-27 10:00
|
||
---
|
||
|
||
# 一致性哈希
|
||
|
||
## 概述
|
||
|
||
一致性哈希(Consistent Hashing)是分布式系统中解决**数据如何均匀分配到多个节点**的经典算法。它在 1997 年由 Karger 等人提出,核心目标是:**当节点增减时,尽量少地迁移数据**。
|
||
|
||
Redis Cluster 最终没有采用一致性哈希,而是选择了更简单的哈希槽方案,但理解一致性哈希是理解分布式分片的基础。
|
||
|
||
## 正文
|
||
|
||
## 一、传统取模方案的问题
|
||
|
||
最直观的分片方式是对 key 取模:`hash(key) % N`(N 是节点数)。
|
||
|
||
> [!QUESTION] 这个方案有什么问题?
|
||
> 假设有 3 个节点,突然要加到 4 个节点——N 从 3 变成 4,几乎所有 key 的取模结果都变了,意味着**几乎所有数据都需要重新分配**。对于百万级数据来说,这是一场灾难。
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
K["key: hash=7"] --> M["7 % 3 = 1<br/>Node 1"]
|
||
K2["key: hash=7"] --> M2["7 % 4 = 3<br/>Node 3"]
|
||
|
||
style M fill:#e8f5e9,stroke:#4caf50
|
||
style M2 fill:#fce4ec,stroke:#e91e63
|
||
```
|
||
|
||
同一个 key,只因为节点数变了,就被分配到了完全不同的节点。一致性哈希正是为了解决这个问题。
|
||
|
||
## 二、哈希环——一致性哈希的核心结构
|
||
|
||
一致性哈希将整个哈希值空间组织成一个**虚拟的环**,范围是 `[0, 2^32)`。
|
||
|
||
**步骤:**
|
||
1. **节点映射**:对每个节点的标识(如 IP:Port)做 hash,映射到环上的某个位置
|
||
2. **数据映射**:对每个 key 做 hash,同样映射到环上
|
||
3. **顺时针查找**:从 key 在环上的位置出发,沿顺时针方向找到的**第一个节点**,就是这个 key 应该存储的位置
|
||
|
||
> [!TIP] 哈希函数的选择
|
||
> 一致性哈希要求 hash 函数的输出尽可能均匀分布,实践中常用 **MurmurHash** 或 **xxHash** 这类非加密哈希函数。它们速度快、分布性好,远优于简单的取模或 CRC32。Java 中的 `TreeMap`、Go 中的 `sort.Search` 都可以用来高效实现顺时针查找。
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
N1["Node A: hash=100"] --> K1["Key 1: hash=200"]
|
||
K1 -->|"顺时针归属"| N2["Node B: hash=300"]
|
||
N2 --> K2["Key 2: hash=350"]
|
||
K2 -->|"顺时针归属"| N3["Node C: hash=500"]
|
||
N3 --> K3["Key 3: hash=80"]
|
||
K3 -->|"顺时针归属"| N1
|
||
|
||
style N1 fill:#e8eaf6,stroke:#3f51b5
|
||
style N2 fill:#e8eaf6,stroke:#3f51b5
|
||
style N3 fill:#e8eaf6,stroke:#3f51b5
|
||
style K1 fill:#fff3e0,stroke:#ff9800
|
||
style K2 fill:#fff3e0,stroke:#ff9800
|
||
style K3 fill:#fff3e0,stroke:#ff9800
|
||
```
|
||
|
||
> 上图将环形结构展开为线性示意。在实际的哈希环上,Key 3(hash=80)从 80 位置沿顺时针方向,经过 100 处的 Node A,所以它归属于 Node A。
|
||
|
||
**核心查找逻辑**:用一个排序数组维护环上所有节点位置,然后二分查找"第一个 >= key hash 值的位置",取模回到环头即为顺时针下一节点:
|
||
|
||
```go
|
||
// ring 是所有节点 hash 值的升序排列
|
||
func getTargetNode(ring []uint32, keyHash uint32) uint32 {
|
||
// 二分查找:第一个 >= keyHash 的位置
|
||
idx := sort.Search(len(ring), func(i int) bool {
|
||
return ring[i] >= keyHash
|
||
})
|
||
// 如果没找到,说明 key 落在环的尾部,回到环头(顺时针绕回)
|
||
if idx == len(ring) {
|
||
idx = 0
|
||
}
|
||
return ring[idx]
|
||
}
|
||
```
|
||
|
||
> [!TIP] 关键优势
|
||
> 当新增或删除一个节点时,**只有该节点到逆时针方向的前一个节点之间的 key 需要迁移**,其他 key 的归属完全不变。节点变动时,迁移数据量从 O(N) 降到 O(K/N)(K 是总 key 数,N 是节点数)。
|
||
|
||
## 三、虚拟节点——解决数据倾斜
|
||
|
||
基本的一致性哈希有一个致命问题:如果节点在环上分布不均匀,某些节点会承担远超平均值的数据量。
|
||
|
||
> [!QUESTION] 极端情况
|
||
> 如果 3 个节点的 hash 值恰好都挤在环的某 1/4 区域,那剩余 3/4 的数据全部落到一个节点上——这个节点瞬间成为热点。
|
||
|
||
**解决方案:虚拟节点(Virtual Nodes)**。每个物理节点对应环上的多个虚拟节点:
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
subgraph Ring["哈希环上的虚拟节点分布"]
|
||
VA1["Node A-1"]
|
||
VB1["Node B-1"]
|
||
VA2["Node A-2"]
|
||
VC1["Node C-1"]
|
||
VB2["Node B-2"]
|
||
VA3["Node A-3"]
|
||
VC2["Node C-2"]
|
||
VB3["Node B-3"]
|
||
VC3["Node C-3"]
|
||
end
|
||
|
||
VA1 -.->|映射| PA["物理 Node A"]
|
||
VA2 -.->|映射| PA
|
||
VA3 -.->|映射| PA
|
||
VB1 -.->|映射| PB["物理 Node B"]
|
||
VB2 -.->|映射| PB
|
||
VB3 -.->|映射| PB
|
||
VC1 -.->|映射| PC["物理 Node C"]
|
||
VC2 -.->|映射| PC
|
||
VC3 -.->|映射| PC
|
||
```
|
||
|
||
每个物理节点生成 100~200 个虚拟节点,分布在环的不同位置。这样即使物理节点数量很少,数据也能近似均匀分布。
|
||
|
||
```go
|
||
type ConsistentHash struct {
|
||
replicas int // 每个物理节点的虚拟节点数
|
||
ring []uint32 // 虚拟节点 hash 排序数组
|
||
vnodeMap map[uint32]string // 虚拟节点 hash → 物理节点名
|
||
}
|
||
|
||
func (ch *ConsistentHash) Add(node string) {
|
||
for i := 0; i < ch.replicas; i++ {
|
||
// 虚拟节点 key = 物理节点名 + 编号
|
||
vkey := fmt.Sprintf("%s#%d", node, i)
|
||
h := hash(vkey)
|
||
ch.ring = append(ch.ring, h)
|
||
ch.vnodeMap[h] = node
|
||
}
|
||
sort.Slice(ch.ring, func(i, j int) bool { return ch.ring[i] < ch.ring[j] })
|
||
}
|
||
|
||
func (ch *ConsistentHash) Get(key string) string {
|
||
h := hash(key)
|
||
idx := sort.Search(len(ch.ring), func(i int) bool { return ch.ring[i] >= h })
|
||
if idx == len(ch.ring) { idx = 0 }
|
||
return ch.vnodeMap[ch.ring[idx]] // 虚拟节点 → 物理节点
|
||
}
|
||
```
|
||
|
||
> [!NOTE] 虚拟节点数量的权衡
|
||
> 虚拟节点越多,数据分布越均匀,但也会增加内存开销和查找时间。经验值 100~200 个是工程上的平衡点——在 3~10 个物理节点的场景下已经足够均匀。当节点数增多时,虚拟节点数可以适当减少。
|
||
|
||
## 四、一致性哈希 vs 哈希槽
|
||
|
||
Redis Cluster 最终选择了**哈希槽**而非一致性哈希,两者的核心区别在于:
|
||
|
||
| 对比维度 | 一致性哈希 | 哈希槽(Redis Cluster) |
|
||
|---------|-----------|----------------------|
|
||
| 分片单位 | 连续的 hash 区间 | 固定的 16384 个离散槽 |
|
||
| 节点增减 | 邻居区间数据迁移 | 指定槽号迁移,粒度更可控 |
|
||
| 数据分布 | 依赖虚拟节点平衡 | 天然均匀(槽均分即可) |
|
||
| 元数据 | 只需记录节点位置 | 需要维护 slot → node 映射表 |
|
||
| 实现复杂度 | 较低 | 略高(需要槽位管理) |
|
||
|
||
> [!NOTE] 为什么 Redis 选择了哈希槽?
|
||
> 哈希槽方案下,数据迁移的粒度是**整个槽**而非连续区间,运维可以精确控制"迁哪些 slot",配合 `CLUSTER SETSLOT` 命令可以做到在线平滑迁移。而一致性哈希的迁移区间是隐式的,运维不够直观。
|
||
|
||
### 一致性哈希的实际应用
|
||
|
||
虽然 Redis 没有采用,但一致性哈希在分布式系统中被广泛使用:
|
||
|
||
| 系统 | 应用方式 |
|
||
|------|---------|
|
||
| **Amazon DynamoDB** | 使用一致性哈希(带虚拟节点)管理数据分区 |
|
||
| **Apache Cassandra** | 早期版本使用一致性哈希,后来改为更可控的分区策略 |
|
||
| **Memcached 客户端** | 客户端侧使用一致性哈希决定缓存路由 |
|
||
| **Nginx + upstream** | `hash` 负载均衡策略基于一致性哈希 |
|
||
|
||
> [!QUESTION] 思考:一致性哈希适合什么场景?
|
||
> 当节点数量**动态变化频繁**、且对迁移的数据量有严格要求时(如缓存集群、CDN 节点路由),一致性哈希是更好的选择。而当节点数量相对稳定、运维需要精确控制分片时(如 Redis Cluster),哈希槽方案更为实用。
|
||
|
||
## 关联笔记
|
||
|
||
- [[hhs/Redis/07-集群方案]] — Redis Cluster 集群方案(哈希槽机制)
|