数据放哪儿:从 HashMap 到一致性哈希

全文只有一条线

上一篇 ( Hash 全景:从 HashMap 到一致性哈希,一文吃透哈希核心)讲哈希个人感觉有点乱,啥都有,但又没深入讲解,这一篇专门来说哈希存储。

其实所有哈希分桶方案,都在回答同一个问题:

给定一个 key,它应该放在哪个位置?

区别只在于:位置的集合会不会变。

  • 不会变 → HashMap 的取模。
  • 会变 → 普通取模崩溃,一致性哈希出场。

下面沿着这一条线走。


第一站:HashMap 的答案------取模

HashMap 底层是一个数组,数组每个位置叫一个桶。

给定 key,怎么决定它去哪个桶?

java 复制代码
index = hash(key) % n;

n 是桶的数量,也就是数组长度。

这是最直觉的答案:用 key 的哈希值对桶数量取模。

但取模有两个问题

问题一:除法慢。

取模本质是除法,CPU 做除法比做位运算慢很多。

问题二:扩容时,几乎全部元素要重新分配。

容量从 n 变成 2n,hash % n 的结果和 hash % 2n 的结果大面积不同,大部分元素都要搬家。

HashMap 的两个对策

对策一:容量固定为 2 的幂,把取模变成位运算。

java 复制代码
n = 32;
index = hash & (n - 1);   // 等价于 hash % 32

因为 n 是 2 的幂,n - 1 的低位全是 1,hash & (n - 1) 正好保留 hash 的低几位,等价于对 n 取模。

对策二:容量翻倍时,只多考察一位。

容量从 n 变成 2n,索引计算从 hash & (n - 1) 变成 hash & (2n - 1),本质上只是多看了 hash 的某一个二进制位:

  • 这一位是 0 → 留在原下标;
  • 这一位是 1 → 移到"原下标 + 旧容量"。

所以 JDK 8 的 resize() 不需要重新计算 hash,把链表拆成低位链和高位链即可。

还有一个小优化:扰动函数

java 复制代码
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

因为数组长度通常很小,(n - 1) 只保留 hash 的低几位,高位信息会被丢弃。

高 16 位异或低 16 位,让高位"下沉"参与索引计算,成本极低,但散列效果明显改善。

回到 new HashMap<>(17)

HashMap 要求容量必须是 2 的幂。

传入 17,会被 tableSizeFor 向上取到 32。

项目 值
传入初始容量 17
实际数组长度 32
索引计算 hash & 31
等价取模 hash % 32
扩容阈值 24

到这里,单机场景下取模被优化到了极致。

只要桶数量不变,取模就是最简单、最均匀的方案。


第二站:分布式场景------桶数量会变,取模崩了

把同样的思路搬到分布式缓存。

假设有 3 台缓存服务器,决定 key 放哪台:

java 复制代码
nodeIndex = hash(key) % 3;

3 台机器,6 个 key,假设分布如下:

key hash % 3 归属
key1 10 1 B
key2 21 0 A
key3 32 2 C
key4 43 1 B
key5 54 0 A
key6 65 2 C

均匀、简单、没有额外元数据。只要节点数不变,它工作得很好。

现在加一台机器,节点数从 3 变成 4:

key % 3 % 4 是否迁移
key1 1 2 迁移
key2 0 1 迁移
key3 2 0 迁移
key4 1 3 迁移
key5 0 2 迁移
key6 2 1 迁移

6 个 key 全部迁移。

从 3 台扩到 4 台,迁移比例约 75%。

从 10 台扩到 11 台,迁移比例约 90.9%。

这意味着:加一台机器,几乎全部缓存失效,请求穿透到数据库。

问题出在哪?

映射规则 hash % N 依赖了 N。N 一变,规则就变了。

HashMap 用"容量总是 2 的幂"把影响压缩到"只多查一位",但分布式节点数不可能永远是 2 的幂。

所以需要换一个不依赖节点数量的规则。


第三站:一致性哈希------把规则和节点数量解耦

一致性哈希的规则是:

从 hash(key) 的位置顺时针找第一个节点。

这个规则里没有 N。

节点数量变了,只是环上多了或少了几个点,规则本身不变。

怎么建这个环?

把 0 ~ 2^32 - 1 首尾相接成一个环。

节点用 IP 或 IP:端口 做哈希,放到环上:

text 复制代码
hash("节点A") = 100
hash("节点B") = 300
hash("节点C") = 600

环上的顺序:

text 复制代码
0 -> A(100) -> B(300) -> C(600) -> 回到 0

数据也用同样的哈希函数映射到环上,顺时针找第一个节点:

key 的 hash 顺时针第一个节点 归属
50 A(100) A
150 B(300) B
400 C(600) C
700 绕回 A(100) A

加节点时发生了什么?

加入节点 D,hash("节点D") = 200。

环上的顺序变成:

text 复制代码
0 -> A(100) -> D(200) -> B(300) -> C(600) -> 回到 0

原来 hash 在 100 到 300 之间的数据归 B。

现在 100 到 200 之间的数据归 D。

新节点只接管它前一个节点到它之间的那一段数据,其他数据完全不动。

3 台扩到 4 台,新节点期望接管约 1/4 = 25% 的数据。

对比:

方案 3 台扩到 4 台 10 台扩到 11 台
普通取模 约 75% 约 90.9%
一致性哈希 约 25% 约 9.1%

为什么有效?

因为规则和节点数量解耦了。

  • 普通取模:hash(key) % N,规则里有 N;
  • 一致性哈希:从 hash(key) 顺时针找第一个节点,规则里没有节点数量。

节点数量只影响环上有哪些点,不影响"顺时针找第一个"这个规则。


第四站:一致性哈希自己的问题------分布不均

只有 3 个物理节点时,它们在环上可能挤在一起:

text 复制代码
A 在 100
B 在 120
C 在 600

从 120 到 600 这一大段数据全归 C,C 压力远大于 A 和 B。

这是节点在环上分布不均导致的。

解决办法:虚拟节点。

给每个物理节点生成多个虚拟身份:

text 复制代码
节点A -> A#1, A#2, A#3 ...
节点B -> B#1, B#2, B#3 ...
节点C -> C#1, C#2, C#3 ...

这些虚拟节点分别哈希到环上,数据落到哪个虚拟节点,就路由到对应的物理节点。

虚拟节点把每个物理节点"打散"成很多小段,整体分布就均匀了。

经验值:每个物理节点配 100 ~ 200 个虚拟节点。


第五站:代码实现

核心只有三步:

  1. 用 TreeMap 或 ConcurrentSkipListMap 存环:hash -> 节点;
  2. 节点(和虚拟节点)通过哈希放到环上;
  3. 查找时用 ceilingEntry 找第一个 >= keyHash 的节点,找不到就绕回 firstEntry。
java 复制代码
public class ConsistentHash {

    private final TreeMap<Long, String> ring = new TreeMap<>();
    private final int replicaCount;

    public ConsistentHash(int replicaCount) {
        this.replicaCount = replicaCount;
    }

    private long hash(String key) {
        return MurmurHash3.hash64(key);
    }

    public void addNode(String node) {
        for (int i = 0; i < replicaCount; i++) {
            ring.put(hash(node + "#" + i), node);
        }
    }

    public void removeNode(String node) {
        for (int i = 0; i < replicaCount; i++) {
            ring.remove(hash(node + "#" + i));
        }
    }

    public String getNode(String key) {
        if (ring.isEmpty()) return null;
        Map.Entry<Long, String> entry = ring.ceilingEntry(hash(key));
        if (entry == null) {
            entry = ring.firstEntry();  // 绕回环起点
        }
        return entry.getValue();
    }
}

几个细节:

  • ceilingEntry 返回 null 说明 keyHash 大于环上所有节点,要绕回 firstEntry;
  • 删除节点时要删掉它对应的所有虚拟节点;
  • TreeMap 不是线程安全的,生产环境用 ConcurrentSkipListMap;
  • String.hashCode() 分布不够好,生产环境用 MurmurHash 或 xxHash。

终点:回头看,三件事是一件事

场景 规则 规则依赖什么 基数变化时
HashMap hash & (n - 1) 桶数量 n 用 2 的幂把影响压到"只多查一位"
分布式取模 hash % N 节点数量 N 几乎全部迁移
一致性哈希 顺时针找第一个节点 不依赖节点数量 只迁移一小段

核心洞察:

取模本身没有错,错的是让映射规则依赖于一个会变的数。

HashMap 把"会变的数"限制成 2 的幂,让变化的影响最小。

一致性哈希把"会变的数"从规则里彻底拿掉,让变化只影响环上的一小段。

从 HashMap 的桶到分布式系统的哈希环,都是同一个问题的不同答案:

数据放哪儿,以及当位置集合变化时,怎么让迁移最少。

相关推荐
Flynt40 分钟前
Java 27 悄悄改了 3 个默认值,我在 1 核小机器上逐个验证了一遍
java·jvm·性能优化
二月龙40 分钟前
大模型 RAG 检索增强是什么?简单讲清原理和实用价值
后端
1360967572340 分钟前
Jev 决策模型原理:为什么 Agent 循环里 80% 的判断不该交给 LLM
后端
天天被压力40 分钟前
【Python 量化取数指南 #09】Python 拿港股通数据:港股通成交与港股财报实测
java·人工智能·python
旺仔不是程序员40 分钟前
pg_trgm GIN 索引:PostgreSQL 正则、模糊与近似度查询的三合一加速器
数据库·后端·sql
一粒麦仔40 分钟前
SafeTensors vs GGUF:大模型权重格式的硬核拆解
人工智能·后端·架构
量化分析码农41 分钟前
【Python量化数据工程实战 #09】数据 Pipeline 跑了一个月才发现缺数?用质量评分卡 5 分钟定位问题
后端
1360967572341 分钟前
报错总在"跑完之后"
后端
爱勇宝41 分钟前
程序员该不该自己掏钱买Token:这笔账该怎么算
前端·后端·程序员