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

7.8 KiB
Raw Blame History

tags, create time
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 的取模结果都变了,意味着几乎所有数据都需要重新分配。对于百万级数据来说,这是一场灾难。

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 都可以用来高效实现顺时针查找。

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 值的位置",取模回到环头即为顺时针下一节点:

// 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)。每个物理节点对应环上的多个虚拟节点:

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 个虚拟节点,分布在环的不同位置。这样即使物理节点数量很少,数据也能近似均匀分布。

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] 虚拟节点数量的权衡 虚拟节点越多,数据分布越均匀,但也会增加内存开销和查找时间。经验值 100200 个是工程上的平衡点——在 310 个物理节点的场景下已经足够均匀。当节点数增多时,虚拟节点数可以适当减少。

四、一致性哈希 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),哈希槽方案更为实用。

关联笔记