Files
cs-note/hhs/Redis/07-集群方案/一致性哈希.md
T
2026-05-27 11:24:47 +08:00

182 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 集群方案(哈希槽机制)