一、传统哈希的痛点:为什么我们需要一致性哈希?

先说说我自己的经历吧。几年前我负责一个订单分库分表系统,用的是最简单的 hash(userId) % 16 这种取模方式。一开始 16 个库跑得挺欢,但后来业务量上来了,需要扩容到 32 个库。结果呢?

几乎所有数据都要重新分布。线上直接炸了,迁移数据花了整整一个周末。你想想看,如果这是双十一期间,谁敢动?

传统哈希的三个致命问题

  • 扩容/缩容成本极高:节点数一变,映射关系全乱。大部分 key 需要重新分配,数据迁移量巨大。
  • 热点无法隔离:某个节点负载高了,你没法单独给它减压。只能整体扩容,浪费资源。
  • 节点故障影响面大:一台机器挂了,它上面的所有请求都会被打到其他节点。搞不好就是雪崩。

核心矛盾:传统哈希的映射关系是「硬编码」的,节点数变了就得全盘推倒重来。而分布式系统最怕的就是节点变化。

二、一致性哈希原理:环形空间与顺时针寻址

一致性哈希怎么解决这个问题的?说白了,它把哈希值空间组织成一个环。

我习惯这么理解:想象一个钟表,0 点在 12 点方向,然后顺时针依次是 1、2、3...一直到 2^32-1。所有节点和数据都映射到这个环上。

核心步骤

  1. 构建哈希环:把 0 到 2^32-1 的哈希值空间首尾相连,形成一个环。
  2. 节点映射:对每个物理节点(比如服务器 IP)计算哈希值,放到环上。
  3. 数据映射:对每个 key 计算哈希值,也放到环上。
  4. 顺时针寻址:从 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 个),或者数据量极小,直接用取模可能更简单。别为了用技术而用技术。

六、知识体系总览

下面这张图把一致性哈希的核心逻辑串起来了。我画图时习惯把「数据分布」和「节点管理」分开看,这样思路更清晰。

一致性哈希核心知识体系 A 物理节点 B 物理节点 C 物理节点 A-1 A-2 B-1 B-2 C-1 C-2 key-1 key-2 顺时针 数据 key 顺时针找到第一个虚拟节点 → 映射到物理节点

从图上可以看得很清楚:数据 key 在环上顺时针找节点,虚拟节点让分布更均匀。每个物理节点对应多个虚拟节点,这样即使某个物理节点挂了,它的负载也能被其他节点平滑承接。

总结一句话:一致性哈希的核心就三个词——环形空间、顺时针寻址、虚拟节点。搞懂了这三个,你就能应对 90% 的分布式数据分布场景。


公众号:蓝海资料掘金营,微信deep3321