全文只有一条线
上一篇 ( 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 个虚拟节点。
第五站:代码实现
核心只有三步:
- 用
TreeMap或ConcurrentSkipListMap存环:hash -> 节点; - 节点(和虚拟节点)通过哈希放到环上;
- 查找时用
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 的桶到分布式系统的哈希环,都是同一个问题的不同答案:
数据放哪儿,以及当位置集合变化时,怎么让迁移最少。