一、传统哈希的痛点:为什么我们需要一致性哈希?
先说说我自己的经历吧。几年前我负责一个订单分库分表系统,用的是最简单的 hash(userId) % 16 这种取模方式。一开始 16 个库跑得挺欢,但后来业务量上来了,需要扩容到 32 个库。结果呢?
几乎所有数据都要重新分布。线上直接炸了,迁移数据花了整整一个周末。你想想看,如果这是双十一期间,谁敢动?
传统哈希的三个致命问题
- 扩容/缩容成本极高:节点数一变,映射关系全乱。大部分 key 需要重新分配,数据迁移量巨大。
- 热点无法隔离:某个节点负载高了,你没法单独给它减压。只能整体扩容,浪费资源。
- 节点故障影响面大:一台机器挂了,它上面的所有请求都会被打到其他节点。搞不好就是雪崩。
核心矛盾:传统哈希的映射关系是「硬编码」的,节点数变了就得全盘推倒重来。而分布式系统最怕的就是节点变化。
二、一致性哈希原理:环形空间与顺时针寻址
一致性哈希怎么解决这个问题的?说白了,它把哈希值空间组织成一个环。
我习惯这么理解:想象一个钟表,0 点在 12 点方向,然后顺时针依次是 1、2、3...一直到 2^32-1。所有节点和数据都映射到这个环上。
核心步骤
- 构建哈希环:把 0 到 2^32-1 的哈希值空间首尾相连,形成一个环。
- 节点映射:对每个物理节点(比如服务器 IP)计算哈希值,放到环上。
- 数据映射:对每个 key 计算哈希值,也放到环上。
- 顺时针寻址:从 key 的位置出发,顺时针找到第一个节点,就是它归属的节点。
我的经验:哈希函数的选择很关键。我一般用 MD5 或 MurmurHash,后者性能更好。千万别用简单的 hashCode(),分布不均匀。
为什么它能解决扩容问题?
假设现在有 3 个节点 A、B、C 分布在环上。新增一个节点 D,它只会影响 D 到顺时针下一个节点之间的数据。其他数据纹丝不动。
举个例子:D 插入在 A 和 B 之间,那么原来属于 B 的一部分 key(从 D 到 B 之间的)现在归 D 管。A 和 C 的数据完全不受影响。
这就是一致性哈希的精髓——只影响局部,不动全局。
三、虚拟节点技术:解决数据倾斜的利器
但是,光有基本的一致性哈希还不够。我在项目中遇到过一个问题:节点少的时候,数据分布很不均匀。
比如只有 3 个节点,它们在环上的位置可能扎堆。结果 80% 的 key 都落到了一个节点上。这跟没做负载均衡有啥区别?
虚拟节点怎么玩?
思路很简单:每个物理节点虚拟出 N 个虚拟节点,分散到环上。
- 物理节点 A → 虚拟节点 A-1, A-2, A-3, ..., A-N
- 物理节点 B → 虚拟节点 B-1, B-2, B-3, ..., B-N
- 物理节点 C → 虚拟节点 C-1, C-2, C-3, ..., C-N
每个虚拟节点都独立计算哈希值,均匀分布在环上。数据先找到虚拟节点,再映射到物理节点。
效果:虚拟节点越多,分布越均匀。我一般建议每个物理节点配置 150-200 个虚拟节点,效果就很好了。
四、Java 实现:从零手写一致性哈希
光说不练假把式。下面是我在实际项目中用过的实现,去掉了一些业务细节,保留了核心逻辑。
public class ConsistentHash<T> {
// 哈希环,用 TreeMap 实现有序性
private final SortedMap<Integer, T> circle = new TreeMap<>();
// 虚拟节点数
private final int virtualNodes;
// 哈希函数
private final HashFunction hashFunction;
public ConsistentHash(int virtualNodes, Collection<T> nodes) {
this.virtualNodes = virtualNodes;
this.hashFunction = new MD5Hash();
for (T node : nodes) {
addNode(node);
}
}
// 添加物理节点,同时创建虚拟节点
public void addNode(T node) {
for (int i = 0; i < virtualNodes; i++) {
String virtualKey = node.toString() + "#" + i;
int hash = hashFunction.hash(virtualKey);
circle.put(hash, node);
}
}
// 移除物理节点,同时移除所有虚拟节点
public void removeNode(T node) {
for (int i = 0; i < virtualNodes; i++) {
String virtualKey = node.toString() + "#" + i;
int hash = hashFunction.hash(virtualKey);
circle.remove(hash);
}
}
// 顺时针查找最近的节点
public T getNode(Object key) {
if (circle.isEmpty()) {
return null;
}
int hash = hashFunction.hash(key.toString());
// 找到第一个大于等于 hash 的节点
SortedMap<Integer, T> tailMap = circle.tailMap(hash);
Integer nodeHash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
return circle.get(nodeHash);
}
}
避坑指南:我曾经在 removeNode 方法里忘了移除虚拟节点,结果节点明明下线了,数据还在往它身上打。排查了半天才发现是虚拟节点没清理干净。
五、数据倾斜处理:实战中的那些坑
虚拟节点能解决大部分问题,但有些场景还是会出现倾斜。我遇到过几种典型情况:
情况一:热点 key 集中
某个大客户的订单量是普通客户的 1000 倍。即使节点分布均匀,这个 key 所在的节点也会被打爆。
解法:对热点 key 做二次散列。比如在 key 后面加随机后缀,把流量分散到多个节点。
情况二:节点权重不均
有的机器是 32 核 128G,有的是 8 核 16G。如果一视同仁,高性能机器就浪费了。
解法:给高性能节点分配更多的虚拟节点。比如 32 核机器配 300 个虚拟节点,8 核机器配 100 个。
情况三:节点频繁上下线
容器化部署后,节点经常扩缩容。每次变化都会触发数据迁移,虽然比传统哈希好很多,但频繁操作还是有影响。
解法:引入「预热」机制。新节点上线后,先不立即承接流量,慢慢把数据迁移过来。我一般设置 5 分钟的预热窗口。
注意:一致性哈希不是银弹。如果你的节点数很少(比如 2-3 个),或者数据量极小,直接用取模可能更简单。别为了用技术而用技术。
六、知识体系总览
下面这张图把一致性哈希的核心逻辑串起来了。我画图时习惯把「数据分布」和「节点管理」分开看,这样思路更清晰。
从图上可以看得很清楚:数据 key 在环上顺时针找节点,虚拟节点让分布更均匀。每个物理节点对应多个虚拟节点,这样即使某个物理节点挂了,它的负载也能被其他节点平滑承接。
总结一句话:一致性哈希的核心就三个词——环形空间、顺时针寻址、虚拟节点。搞懂了这三个,你就能应对 90% 的分布式数据分布场景。
公众号:蓝海资料掘金营,微信deep3321