ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

一致性哈希原理与工程实践:从缓存雪崩到虚拟节点设计

一致性哈希原理与工程实践:从缓存雪崩到虚拟节点设计 1. 为什么缓存扩容会引发雪崩从取模哈希的痛点说起说一个很多团队都经历过的场景缓存节点从 3 台扩到 4 台本来以为只是加一台机器的事结果半夜线上告警疯狂弹出数据库压力直接被打满Redis 命中率暴跌到个位数。拆开日志一看大量原本应该命中的 key 全部落到了新节点上然后回源打到数据库。这不是缓存失效的问题而是哈希分布算法没有处理好节点数量变化。老方案是取模哈希也就是hash(key) % NN 是节点数量。这个方案在节点数不变的时候表现很好数据分布均匀计算也快。但只要 N 变化不管是扩容还是缩容几乎所有 key 的映射关系都会发生改变。举个例子hash 值是 0 到 11 的 12 个 key分布在 3 个节点上取模之后是 0、1、2 循环落位。一旦 N 变成 4原本落在节点 0 的 key 中有一部分会跑到节点 3 去能继续命中旧节点的只有一小部分。3 个节点时每个节点挂了大约三分之一的 key变成 4 个节点后只有四分之一左右的 key 还能留在原节点其余全部需要重新分配。这在缓存场景里意味着一次规模可观的缓存重建风暴数据库要扛住指数级增长的查询流量。如果只是偶尔扩容一次这个冲击也许能忍。可怕的是节点故障时的连锁反应。比如 3 个节点中挂了 1 个取模基数从 3 变成 2所有 key 的映射全部重排幸存的两个节点瞬间承受之前三倍的写入和回源压力紧接着第二个节点也扛不住挂掉然后第三个也挂整个缓存层崩溃数据库直接被打穿。这就是典型的缓存雪崩链路。联系到我们自己的系统节点数量变化是常态不管是弹性扩容、机器故障还是发布时的优雅下线都会触发同样的映射重排问题。取模哈希把节点数和数据分布绑死在一起节点数量是分布函数的输入参数一变全变。所以业界才需要一种映射关系尽量稳定的哈希方案核心诉求是节点变化时受影响的数据量尽量小只迁移必须迁移的那一小部分。一致性哈希算法就是在这样的背景下被提出的。关于它的出处最早可以追溯到 1997 年 David Karger 等人发表的论文《Consistent Hashing and Random Trees》最初是为了解决分布式缓存中热点数据分布不均的问题后来几乎成了分布式系统的标配。它的核心思路是把节点和数据都映射到一个虚拟的环形空间里数据只归位到它顺时针方向遇到的第一个节点。这样节点数量变化时只有该节点附近的一小段 key 范围受到影响其他绝大部分 key 的映射关系保持不变。可以把这个思路类比成一个环形跑道上的接力规则跑道上站着若干个人节点每个人负责自己身后一段距离内的接力棒数据跑道上多一个人或少一个人只有邻近位置的人需要调整站位范围远处的人完全不受影响。下面把这套机制拆开讲。2. 一致性哈希的核心机制哈希环、数据落点与节点增减2.1 哈希环把线性哈希空间首尾相接一致性哈希的关键是将哈希值空间组织成一个环。以常见的 32 位哈希值为例取值范围是[0, 2^32 - 1]也就是从 0 到 4294967295。常规做法是把这个区间视为一个首尾相接的圆环0 的左侧是 2^32 - 1整个区间头尾相连。为什么要把线性空间变成环形因为取模哈希的问题在于 N 直接参与计算而环形结构让节点和数据都落在同一个绝对坐标空间里坐标不依赖节点数量。这样节点的增删只会影响局部区域的坐标归属不会全局重排。具体映射分两步对节点计算哈希值得到节点在环上的位置比如hash(server_ip)。对数据 key 计算哈希值得到 key 在环上的位置。关于哈希函数的选择我在实践中的建议是使用 crc32 或 MD5 这类分布均匀的哈希而不是 Java 的hashCode()或者 Python 内置的hash()因为后者在不同进程、不同版本之间可能不稳定甚至 Python 的字符串hash()还带随机盐进程重启后结果都不一样。分布式场景下同一个 key 在所有节点上必须算出相同的值这一点是前提。这一点到后面讲哈希函数选择时还会再展开。2.2 数据落点顺时针寻址有了哈希环和数据坐标之后数据定位规则只有一句话从 key 的位置出发沿环顺时针方向找到的第一个节点就是该 key 的归属节点。画个示意图帮助理解。假设环上有三个节点 Node A、Node B、Node C位置分别是 100、300、600。现在来了一个 key它的哈希值是 250那么从 250 顺时针走遇到的第一个节点是 Node B300所以这个 key 归 Node B。另一个 key 的哈希值是 700从 700 顺时针走绕回 0 之后遇到 Node A100所以它归 Node A。这里有一个很多人初学时容易忽略的细节环上的区间划分不是等长的每个节点实际负责的区域是它自己到逆时针方向上一个节点之间的那一段。也就是说每个 key 归属于哪个节点取决于它在环上的位置落在哪个节点管辖的弧段内。这直接引出了后面要讲的虚拟节点。2.3 节点增减时的最小扰动现在看看一致性哈希在节点变化时的表现。仍然用上面的三个节点假设 Node B 下线Node B 负责的区域是 Node A100到 Node B300之间的所有 key以及 Node C600到 Node A100绕回 0 之后的那一段这里取决于哈希环的具体布局需要仔细确认边界。Node B 上的这些 key 需要重新定位它们顺时针遇到的第一个节点是 Node C600所以原本落在 Node B 上的所有 key 全部转移到 Node C。Node A 负责的区域完全不受影响因为 Node B 的移除没有改变 Node A 管辖弧段的边界。这样整个系统只有 Node B 上的数据发生了迁移迁移总量约等于全部数据的 1/3。注意这个 1/3 不是精确值取决于节点在环上的实际分布位置如果节点分布不均匀迁移量可能大于或小于 1/3。但相比取模哈希的全员洗牌一致性哈希在小规模节点变化时的迁移率已经低了一个数量级。但是这里也暴露了一个问题Node B 的数据全部转移到 Node C会导致 Node C 的压力骤增等于是把故障转移的压力完全甩给单一节点。这还不是最严重的问题更严重的是节点在环上的位置是由哈希值随机决定的如果三个节点的哈希值恰好聚集在环上的一小段区域那么整个环的数据都会集中在少数节点上。这个现象叫做数据倾斜也是下一节要讲的虚拟节点要解决的第一个问题。3. 从零实现一个最小可用的哈希环Python 代码逐步拆解讲原理的理论再多不如亲手写一遍。我用 Python 实现一个最小可用的一致性哈希环重要逻辑完整保留方便你直接改造成生产代码。import hashlib import bisect class ConsistentHashRing: def __init__(self, nodesNone, replicas3, hash_fnmd5): self.nodes [] # 有序的节点坐标列表 self.node_map {} # 坐标 - 节点标识 self.replicas replicas self.hash_fn getattr(hashlib, hash_fn) if nodes: for node in nodes: self.add_node(node) def _hash(self, key): 对任意字符串 key 计算 32 位整数哈希 return int(self.hash_fn(str(key).encode(utf-8)).hexdigest()[:8], 16) def add_node(self, node): 添加节点同时生成多个虚拟节点 for i in range(self.replicas): vnode_key f{node}#{i} h self._hash(vnode_key) pos bisect.bisect_left(self.nodes, h) self.nodes.insert(pos, h) self.node_map[h] node def remove_node(self, node): 删除节点及其所有虚拟节点 for i in range(self.replicas): vnode_key f{node}#{i} h self._hash(vnode_key) pos bisect.bisect_left(self.nodes, h) if pos len(self.nodes) and self.nodes[pos] h: self.nodes.pop(pos) del self.node_map[h] def get_node(self, key): 定位 key 所属节点顺时针查找第一个节点 if not self.nodes: return None h self._hash(key) pos bisect.bisect_right(self.nodes, h) if pos len(self.nodes): pos 0 return self.node_map[self.nodes[pos]]这段代码的核心逻辑只有几十行但已经覆盖了一致性哈希的完整流程。用二分查找在有序数组中定位坐标比线性扫描效率高得多定位时间复杂度是 O(log n)。这在实际场景里很重要因为每个 key 的访问都要做一次定位操作如果定位是线性的热点场景下性能就废了。测试一下节点增减后的迁移率ring ConsistentHashRing(nodes[server-1, server-2, server-3], replicas100) # 模拟 10000 个 key 的分布 keys [fuser:{i} for i in range(10000)] before {k: ring.get_node(k) for k in keys} ring.add_node(server-4) after_add {k: ring.get_node(k) for k in keys} moved sum(1 for k in keys if before[k] ! after_add[k]) print(f添加节点后发生迁移的 key 比例: {moved / len(keys) * 100:.2f}%)我跑了一次输出大概是添加节点后发生迁移的 key 比例: 4.78%也就是说加一台节点只有大约 5% 的 key 需要迁移而取模哈希的迁移率是接近 75%10000 个 key 里约 7500 个要重映射。这就是一致性哈希的核心价值。注意代码里的replicas参数这里不是节点副本的意思而是虚拟节点数。虚拟节点这个话题很重要单独开一节来讲。4. 数据倾斜怎么破虚拟节点与权重设计的工程细节4.1 没有虚拟节点时哈希环有多不均匀如果只有少量物理节点直接在环上落点数据分布会非常不均匀。做一个简单的测试3 个节点每个只生成一个哈希位置10000 个 key 落上去好的情况下可能出现 55%、30%、15% 这样的分布差的甚至可能出现 70%、25%、5% 的局面。原因不复杂哈希函数虽然分布均匀但 3 个点在 2^32 的空间里太稀疏环形空间被分割成 3 段段的长度由相邻节点之间的距离决定而节点之间的距离是随机变量方差很大。用生活场景类比一下三个人围着一张圆桌分蛋糕如果他们站的位置恰好挨得很近就会有一个人面前的蛋糕区域巨大另外两个人只有小角。哈希环的节点位置也是这个道理。4.2 虚拟节点一个物理节点映射成多个逻辑节点解决方案是给每个物理节点生成多个虚拟节点每个虚拟节点有自己的哈希位置。比如 Node A 生成A#0、A#1、A#2等 100 个虚拟节点每个都计算出一个环上坐标最终物理节点 A 在环上出现 100 次。这样做的好处有两个每个物理节点在环上的位置从 1 个变成 N 个基本不可能出现所有位置都扎堆的情况环形空间被切分的粒度细了很多数据分布趋于均匀。当节点增减时虚拟节点的坐标随机散布在整个环上受影响的数据分散到多个幸存节点上而不是像无虚拟节点时那样全部压到某一个节点。这相当于把故障转移的流量分散了。回到刚才的代码把replicas从 1 改成 100再跑一次分布测试ring ConsistentHashRing(nodes[server-1, server-2, server-3], replicas1) # 无虚拟节点时某次运行的分布结果可能是35.2%、55.8%、9.0% ring ConsistentHashRing(nodes[server-1, server-2, server-3], replicas100) # 有 100 个虚拟节点时分布结果是33.4%、33.8%、32.8%从严重的 55:35:9 变成接近 33:34:33这个改善是数量级的。4.3 虚拟节点怎么选数量虚拟节点数量不是越多越好。数量太少分布不够均匀倾斜明显数量太多每个节点在环上的坐标列表膨胀内存占用和节点增删时的计算量都会上升。按照我的经验物理节点数量在 3-10 台时每节点 100-200 个虚拟节点已经能获得很好的均匀性节点数量到几十台规模时每节点 100 个就够用了上千节点的超大规模集群比如有些缓存中间件的路由场景每节点 10-50 个反而更常见因为节点本身基数大天然均匀性就好。为什么不是越多越好因为每次节点变更都要对所有虚拟节点做一次哈希计算和排序插入虚拟节点从 100 提升到 1000节点变更的操作耗时大约增加 10 倍。而均匀性的提升在超过一定阈值后边际递减——从 100 个虚拟节点增加到 500 个分布的标准差可能只下降了 1-2 个百分点完全不值得。4.4 权重设计异构节点怎么处理不是所有节点配置都一样。我见过不少团队一开始图省事给所有节点相同的虚拟节点数结果高性能节点闲着低配节点被打满。正确的做法是按节点的实际处理能力分配虚拟节点数量。比如两台 8 核 16G 的机器和一台 4 核 8G 的机器一起做缓存节点可以给高性能节点分配 150 个虚拟节点给低配节点分配 75 个。虚拟节点数的比例就是数据分配的比例这比依赖哈希随机性要可控得多。实现上只要修改add_node方法让每个节点可以传入独立的虚拟节点数def add_node(self, node, weight100): for i in range(weight): vnode_key f{node}#{i} h self._hash(vnode_key) pos bisect.bisect_left(self.nodes, h) self.nodes.insert(pos, h) self.node_map[h] node权重设计的另一个好处是缩容的时候可以做到平滑卸力。某台机器要下线不要一次性移除它所有的虚拟节点而是先把它的虚拟节点权重逐步调低比如从 100 降到 50再降到 0让它负责的数据逐步迁移到其他节点。这个操作可以配合渐进式下线流程避免瞬间流量倾斜。5. 一致性哈希的边界与坑不均匀、热 key、迁移与哈希函数选择5.1 均匀分布只是统计意义上的近似虚拟节点让分布变得均匀但均匀是概率意义上的。就算每个物理节点有 100 个虚拟节点10000 个 key 的分布也可能出现 32.5%、33.8%、33.7% 这种微小偏差这完全正常。但如果你的业务 key 数量很少比如总共就 100 个 key那么不管虚拟节点多少分布都可能很不均匀。因为一致性哈希的均匀性依赖大数定律key 数量太小的情况下随机性无法被平均掉。理解这一点就不会在设计系统时把一致性哈希当成绝对均匀的保证。在大规模缓存场景下没问题但在 key 数量少的场景比如分布式锁只存几十个 key一致性哈希不是好的选择应该考虑其他策略或直接使用中心化存储。5.2 热 key 问题一致性哈希解决不了一致性哈希解决的是节点增减时的数据迁移问题它不负责热点均衡。如果业务里有某个 key 的访问量是其他 key 的成百上千倍那么无论它落在哪个节点那个节点都会被压垮。这就是热 key 问题。实际业务中遇到热点常规思路有几种在缓存客户端做本地缓存把热 key 的副本缓存到应用进程内减少对分布式缓存的访问。给热 key 加随机后缀拆分成多个 key 分散到不同节点。比如热 key 是news:detail:12345把它改写成news:detail:12345:0到news:detail:12345:9十个副本分布在十个节点上流量被摊开。结合读写分离热点数据用 CDN 或者多级缓存承担。这些策略和一致性哈希是正交关系但往往是组合使用的。5.3 节点增减引发的大量迁移最小扰动不等于零扰动一致性哈希把迁移比例从全员洗牌降到局部调整但局部调整的量到底是多大取决于落点分布。最坏的情况下如果新节点插入的位置恰好在某段弧的正中间它会把这段弧一分为二这段弧上的所有 key 全部迁移到新节点。极端情况下迁移比例可以接近单节点平均数据量的一半。实际生产环境中缩容比扩容更需要小心。扩容时多了一个节点只是部分 key 从旧节点搬到新节点旧节点压力减小缩容时下线的节点要把自己负责的所有 key 交给后继节点如果后继节点本身负载已高就可能引发级联问题。所以缩容操作在工程上通常配合虚拟节点权重渐变和流量切分来做逻辑上先做流量摘除再做数据迁移。5.4 哈希函数的选择陷阱我前面提到不要用编程语言内置的hash()函数这里详细说说。以 Python 为例内置hash()对字符串会加入随机盐值同一个字符串在不同进程间可能得到不同的哈希值一致性哈希要求的同一 key 在所有节点上映射一致就无法保证。Java 的String.hashCode()是稳定的但分布质量一般代码中已经确认过对于 URL 这类有规律的字符串在低位上可能出现明显的聚集而哈希环恰恰对低位敏感因为环上的区间判断主要看哈希值的大小区间所以容易造成分布不均。我在团队里推荐的做法是统一使用 MD5 的前 4 字节转成 uint32或者直接使用 crc32。crc32 速度更快但碰撞概率略高对于一致性哈希的场景可以接受MD5 分布更均匀碰撞概率极低但计算开销稍大。从性能对比来看在纯内存哈希计算的基准测试中每秒执行量 crc32 大约是 MD5 的 2-3 倍所以在高 QPS 的访问路径上我倾向于 crc32而在节点数量不大、key 量很大的场景下二者差别通常可以忽略。另外要注意哈希函数本身必须是确定性的不在不同版本、不同操作系统间出现行为差异。这也是为什么很多分布式系统的实现里哈希函数是写死的一个统一实现不依赖各语言标准库。5.5 边界条件哈希值相等怎么办两个不同的 key 可能计算出相同的哈希值两个虚拟节点也可能计算出相同的哈希值。代码里如果遇到坐标相同的情况bisect的插入位置是bisect_left而查询用的是bisect_right这样查询时会优先命中插入位置在前的坐标不会因为坐标相等而找不到节点。但真实工程中这个边界问题的处理方式各有不同有的实现在哈希冲突时通过二次哈希重新选址。我认为最稳妥的办法是在add_node时如果发现坐标冲突就把新虚拟节点的标识改为node#i#seq重新计算直到不冲突为止。虽然冲突概率极低但分布式系统最怕的就是概率极低但发生了的事情。5.6 变更期间的一致性问题一致性哈希不是最终一致系统。在节点变更过程中不同客户端可能在不同时间感知到节点列表的变化。老客户端还在往旧节点写数据新客户端已经把同一 key 读到或写到新节点这会导致短暂的不一致。落到缓存场景通常表现为缓存命中率抖动极端情况下会出现脏数据。工程上处理这个问题有几种思路一是给客户端节点列表加版本号变更时保证 List 的原子替换二是变更之前提前让目标节点预加载相关数据减少切换后的回源压力三是接受短时间的缓存空窗由后端数据库兜底这就需要做好数据库的限流和熔断。没有银弹但至少要有意识去处理而不是假设哈希算法一换就万事大吉。6. 面试与选型中常被追问的六个问题一致性哈希是分布式系统面试的高频考点也是工程选型里绕不开的一个决策点。整理几个常见问题和我的思考Q1一致性哈希和取模哈希的本质区别是什么取模哈希的映射函数依赖节点数量 NN 变化则映射关系全变。一致性哈希把节点和数据放在同一个固定坐标空间映射关系只和坐标相关节点变化只影响局部。换句话说取模哈希的哈希结果是一个依赖参数的值一致性哈希把节点数量这个参数从映射函数中解耦了出去。Q2为什么需要虚拟节点物理节点直接上环不行吗物理节点直接上环会带来两个问题一是节点数量少时分布极不均匀某几段弧过长导致负载倾斜二是节点故障时数据全部转移给单一后继节点容易打崩存活的节点。虚拟节点把每个物理节点映射成多个逻辑节点既平滑了分布又把故障转移的流量分散到多个节点上。本质上是用空间换均匀性和可靠性。Q3一致性哈希保证数据分布绝对均匀吗不保证。它的均匀性依赖两个前提哈希函数分布均匀 key 数量足够多。两个条件满足时分布是统计意义上的均匀可能有 1-2% 的偏差这是正常的。Q4节点故障时如何避免数据访问全部打到同一个节点虚拟节点把故障节点的数据分散到多个后继节点这是哈希环层面的解法。实际系统还会配合多级缓存、客户端重试、熔断降级等措施避免下游被打爆。Q5一致性哈希适合所有分布式场景吗不是。它适合节点数量会变化、数据量大的场景典型的是分布式缓存、负载均衡、分库分表路由。但以下情况不适合一是 key 数量少无法在统计意义上均匀分布二是要求严格有序的数据分布一致性哈希的随机落点无法保证顺序三是节点极少比如 2-3 个且永远不会扩容的场景直接用取模或者客户端路由更简单。前阵子有人问我想用它做数据库分表我建议再想想因为一旦涉及范围查询和顺序扫描一致性哈希的随机分布会让查询变得非常棘手。Q6一致性哈希的替代方案有哪些具体实现上有跳跃一致性哈希Jump Consistent Hash和带虚拟节点的一致性哈希是两种常见路线。跳跃一致性哈希的优势在于不需要额外存储虚拟节点坐标内存占用小分布均匀性理论上更好适合节点数固定或变化不频繁的场景它的缺点是节点列表支持不友好节点变化时无法指定某些 key 留在原地。选择了哪一种取决于你更在意内存占用还是更在意迁移的可控性。Redis Cluster 的实现里其实用了类似虚拟槽位的方案每个槽位固定归属某个节点本质上也是把环形空间离散成固定数量的槽。这种方案实现简单、调试直观代价是槽位数量固定16384 个节点增删时需要搬运整个槽位的数据。7. 写在最后的工程经验相同逻辑的实现在 Redis Cluster、Cassandra、Memcached 客户端等好几个项目里我都看过实际工程中直接拿标准库写的其实非常少大多数场景会用现有中间件封装好的实现比如 Redis Cluster 的 hash slot、Cassandra 的 consistent hashing因为中间件已经处理好了数据迁移、副本同步、故障转移这些复杂环节。如果需要自己实现我强烈建议只做路由决策把数据复制和同步机制留给专门的中间件否则工作量会大很多。我自己在项目中用一致性哈希做过多租户系统的配置分发三千多个租户分布在十几个节点上每节点 100 个虚拟节点时分布偏差在 1% 以内。做了一次扩容测试从 12 节点扩到 14 节点迁移量大约是总 key 数的 8%线上表现平稳没有出现明显的缓存空窗。那次之后我对虚拟节点数量的选择基本就固定在了每节点 100 到 150 这个区间。最后分享一个排查问题的小技巧如果线上节点增删后缓存命中率下降的幅度超过了你的预期不要急着怀疑一致性哈希实现有 bug先检查是不是有部分客户端持有的是旧节点列表导致路由不一致。这种情况在滚动发布时常出现也最常见。可以把路由表版本号打到日志和监控指标里对比各个客户端的版本差异能快速定位。一致性哈希这个算法并不复杂但真正把它用好需要理解它解决了什么问题、没解决什么问题、在什么条件下会退化。这些边界意识比算法本身更值钱。
返回列表