分布式缓存一致性 Hash实现方法详解(详解算法原理与相比普通 Hash 的优势)

分布式缓存用一致性 Hash,把节点和键都映射到一个首尾相接的哈希环上,键沿顺时针找最近节点。相比普通取模 Hash,加减节点时只迁移约 1/N 的键,缓存命中率几乎不受影响。配合虚拟节点,负载还能均匀分布。下文给出原理、对比与可直接运行的实现。

一、普通 Hash 在扩容时崩了

最朴素的路由是 hash(key) % N,N 是节点数。4 个节点时一切正常;一旦加一个节点变成 5,取模基数变了,几乎每个键都映射到不同节点,缓存命中率瞬间跌到接近零,所有请求击穿到数据库。节点少时取模的代价最明显——从 4 扩到 5,约 80% 的键要挪窝。

二、一致性 Hash 解决什么

维度 普通取模 Hash 一致性 Hash
扩容迁移量 近乎全部重映射 约 1/N 的键
节点下线影响 全量失效 仅其负责的键
负载均衡 依赖 N 固定 需虚拟节点辅助
典型查找 O(1) O(log N)

一致性 Hash 把哈希空间看作一个 [0, 2^32) 的环。节点和键都经同一哈希函数落到环上,键从自己位置顺时针走,碰到的第一个节点就是归属。增减节点时,只有落在新增/移除节点与其上游之间的键需要迁移,其余原地不动。

三、为什么必须加虚拟节点

节点少时,几个物理节点在环上分布很不均匀,某节点可能独占 60% 的键空间。解决办法是给每个物理节点分配多个虚拟节点(vnode):一台机器在环上散落几十到几百个点,键被均摊到各机器。生产上每物理节点配 100~200 个虚拟节点即可把负载偏差压到 10% 以内;Apache Cassandra 默认 256 个,Amazon DynamoDB 也采用类似方案。没有虚拟节点时,3 台机器在环上可能分布成 10%/30%/60%,热点机器扛大部分请求。加虚拟节点后,每台机器的多个点分散在环各处,它负责的弧段被切成很多小段平均分给邻居;单点失效时负载也平摊到所有存活机器,不会”一棵树倒一片”。

四、Python 实现(含虚拟节点)

用 MD5 做哈希,靠 bisect 做二分查找。增删节点只动对应虚拟节点的环位置。get_node 里 bisect_right 找第一个大于等于键哈希的位置;走到环尾则回绕环首,保证任何键都有归属。

把缓存接入一致性 Hash,按四步推进:

  1. 选均匀哈希函数(MurmurHash3 或 MD5),确定环空间大小 2^32;
  2. 给每台物理节点生成 V 个虚拟节点位置,写入有序结构并排序;
  3. 查键时算哈希,二分找顺时针第一个虚拟节点,映射到物理节点;
  4. 增删节点只动其虚拟节点位置,并触发相邻节点的数据再平衡。
import hashlib
import bisect
from typing import List


class ConsistentHash:
    def __init__(self, nodes: List[str], vnodes: int = 150):
        self.vnodes = vnodes
        self.ring = {}                 # 哈希值 -> 物理节点
        self.sorted_keys = []
        for node in nodes:
            self.add_node(node)

    def _hash(self, key: str) -> int:
        return int(hashlib.md5(key.encode).hexdigest, 16)

    def add_node(self, node: str) -> None:
        for i in range(self.vnodes):
            pos = self._hash(f"{node}#{i}")
            self.ring[pos] = node
            self.sorted_keys.append(pos)
        self.sorted_keys.sort

    def remove_node(self, node: str) -> None:
        for i in range(self.vnodes):
            pos = self._hash(f"{node}#{i}")
            self.ring.pop(pos, None)
            self.sorted_keys.remove(pos)

    def get_node(self, key: str) -> str:
        if not self.ring:
            raise KeyError("ring is empty")
        pos = self._hash(key)
        idx = bisect.bisect_right(self.sorted_keys, pos)
        if idx == len(self.sorted_keys):
            idx = 0                    # 环尾回绕到环首
        return self.ring[self.sorted_keys[idx]]

4.1 验证迁移量

ring = ConsistentHash(["nodeA", "nodeB", "nodeC"], vnodes=150)
before = {k: ring.get_node(k) for k in map(str, range(10000))}
ring.add_node("nodeD")
moved = sum(1 for k in map(str, range(10000))
            if ring.get_node(k) != before[k])
print(f"迁移比例 ≈ {moved / 100:.1f}%")   # 约 25%,即 1/4

4.2 另两种思路

Jump Consistent Hashing(Google,2014)用确定性跳跃函数把键映射到桶,O(1) 查找、均衡极佳,但只支持整数桶编号、不便带权。Rendezvous(HRW)哈希对每个键算”键+节点”的最高分,无需建环,节点少时更简单。

五、落地要注意的坑

哈希函数要均匀,优先 MurmurHash3、xxHash,MD5/SHA 也可用但偏慢,别用简单字符串哈希。虚拟节点别少于 100,否则仍有热点。节点故障时靠成员协议(如 gossip)及时更新环,否则请求还会持续发到死节点;并向下游 N 个节点复制数据防丢失。虚拟节点数也不是越多越好——每多一个就多占一条环记录,内存与查找成本线性涨,150~200 是平衡点。Memcached 客户端 ketama、Redis Cluster、NGINX 上游模块都在用这套思路。

六、虚拟节点为什么能均摊负载

靠的是大数定律:每台物理节点有 150 个随机点散布在环上,它拥有的弧段总和趋近均等。虚拟节点少于 100 仍能看到明显倾斜,生产建议 150 起步。Cassandra 默认 256 个,Amazon DynamoDB 也采用类似虚拟节点方案。

七、真实系统的落地位置

Memcached 客户端 ketama、Redis Cluster、NGINX 上游一致性 Hash 模块都用这套思路。CDN 用它对 URL 选边缘节点,某边缘下线只重路由它负责的 URL,其余内容不受影响。负载均衡用它做会话保持,同一用户的请求稳定落到同一后端,增删节点也不丢亲和。

八、别忘了复制

一致性 Hash 只决定主副本归属。节点宕机时它的键若无副本就会丢失,应把键复制到顺时针后续 N 个节点,主节点失效由副本接管。这也解释了为什么移除节点后还要做数据再平衡,把迁出键同步到新 owner。

九、复杂度与内存开销

环用有序数组存虚拟节点,查找走二分 O(log N),N 为虚拟节点总数。200 虚拟节点 × 100 台机器约 2 万条目,二分约 15 次比较,开销可忽略;增删节点是 O(log N) 的插入删除。跳数一致性 Hash 把查找降到 O(1),但只支持整数桶编号,不便带权。

常见问题(FAQ)

Q1:虚拟节点设多少合适?

每物理节点 100~200 个,Cassandra 默认 256,偏差可压到 10% 内。

Q2:查找复杂度怎么算?

环上二分查找 O(log N),N 为虚拟节点总数。

Q3:节点宕机数据会丢吗?

会,需向下游 N 个节点复制副本,单点故障不丢数据。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部