☰
系统设计必考!一致性哈希:为什么加一台机器,75%的缓存瞬间失效?
2026/10/1 18:32:51 网站建设 项目流程

💥 加一台机器,缓存几乎全挂——这是很多线上事故的第一页

设想你有一个缓存集群,N台机器,最自然的写法是hash(key) % N。

简单、均匀、易懂。

然后业务涨了,你加一台机器:N变成N+1。这时几乎全部key的目标位置都变了。

缓存全部miss,请求像洪水一样毫无遮挡地冲向下游数据库——这就是缓存雪崩,也是很多线上事故的剧本第一页。

今天要解决的就是:能不能让节点数量变化时,只有尽可能少的key需要迁移?

答案就是1997年Karger等人提出的一致性哈希——把节点和key都映射到一个虚拟的哈希环上,加一台机器只影响环上相邻的一小段key。

我们还会处理它自带的两个副作用:数据倾斜(用虚拟节点解决)和如何高效找后继(用有序结构 + 二分)。


📦 问题描述(系统设计题)

设计一个分布式缓存的路由方案,满足:

  1. 均衡性:key尽可能均匀分布在N个节点上
  2. 单调性:新增/下线节点时,已有key 映射尽可能少地改变
  3. 分散性:客户端能自己算路由,不需要中心节点
  4. 容错性:一台宕机,流量分摊给剩下的机器,而不是砸向某一台

接口合约:

add_node("10.0.0.1") → 加入集群 remove_node("10.0.0.1") → 摘掉节点 get_node("user:10086") → 返回路由到的节点

隐藏约束:集群可能几百上千台,key量级百亿。get_node必须 O(logN),且路由计算要在客户端本地完成。


🧠 核心思路:把key和节点映射到同一个环

第一步:传统取模到底有多糟?

hash(key) % N:分布很均匀,但N从3变4时,%3和%4的结果几乎对不上——理论迁移率N/(N+1),3→4时约75%。

实测数据(10000个key):

方案3节点 → 4节点时迁移比例
hash(key) % N74.86%
一致性哈希(带虚拟节点)27.54%

75% 的迁移意味着:缓存命中率瞬间从95%掉到接近0,数据库QPS放大几十倍。

第二步:核心洞察——映射到同一个哈希环

  • 定义值域[0, 2³²),首尾相接成一个圆环
  • 用同一个哈希函数把每个节点映射到环上的一个点
  • 用同一个哈希函数把每个key也映射到环上的一个点
  • 规则:key顺时针往前走,遇到的第一个节点就是它的归属
0 / 2³² ● ┌───────────────┐ │ │ NodeA ● │ ● key9 │ │ │ ● NodeB │ ↑ ● key5 │ NodeC “顺时针第一个就是你”

为什么迁移量小?

新节点NodeD落到某个位置。只有落在“NodeD的前驱”到“NodeD”这段弧上的key会变——它们原本顺时针遇到的是NodeD的后继,现在先撞上NodeD。

  • 迁移量 ≈1/(N+1),而不是N/(N+1)
  • 单调性天然成立:新增节点只“抢走”别人的一部分
  • 宕机同理:只影响挂掉节点那段弧,不会波及全局

第三步:副作用一——数据倾斜与虚拟节点

节点少时,它们的哈希位置可能挤在一起。实测3个节点:

NodeA = 3669998473 NodeC = 3687093430 NodeB = 3783660063 ↑ 三者全挤在3.67~3.79e9这个窄区间

后果:剩下那条长达37亿的大弧全归NodeA管,NodeA扛了97%的流量。

解法:虚拟节点(Virtual Node)——不要把一个物理节点映射成一个点,而是映射成K个:

NodeA → hash("NodeA#0"), hash("NodeA#1"), ..., hash("NodeA#149")

实测(5 节点 / 50000 key):

虚拟节点数 K最大偏离(理想 20%)
K = 1±24.4个百分点
K = 20±6.4个百分点
K = 100±3.0个百分点
K = 300±1.7个百分点

工程上常用K = 100~300。虚拟节点还有一个隐藏好处:给性能好的机器分配更多虚拟节点,天然实现加权一致性哈希。

第四步:副作用二——O(logN)找后继

环上的节点位置是有序的。“顺时针遇到的第一个节点”=找 ≥ h的最小值(lower bound),没有就回绕到环首。

这就是二分查找的直接应用:

语言容器 + API复杂度
JavaTreeMap<Long, String>+ceilingEntry(h)O(log(KN))
Pythonsorted_keys+bisect_leftO(log(KN))
C++std::map<uint32_t, Node>+lower_boundO(log(KN))

🖼️ 图解算法(手把手走一遍)

环上先有3个节点,按升序:NodeA → NodeC → NodeB →(回绕)→ NodeA。

各段弧归属:

弧(顺时针)归属节点
(0 … NodeA]NodeA
(NodeA … NodeC]NodeC
(NodeC … NodeB]NodeB
(NodeB … 2³²) ∪ (0 … ]回绕NodeA

放入10个key:

keyhash值归属
key90.216e9NodeA
key30.909e9NodeA
key51.035e9NodeA
key81.565e9NodeA
key61.685e9NodeA
key22.030e9NodeA
key13.266e9NodeA
key43.405e9NodeA
key104.046e9NodeA(回绕)
key74.187e9NodeA(回绕)

注意这就是数据倾斜的真实模样——10个key全给NodeA!这就是必须有虚拟节点的原因。

现在加入NodeD = 1,485,806,211。

新环顺序:NodeD → NodeA → NodeC → NodeB → 回绕 → NodeD

谁要搬家?

keyhash加NodeD前加NodeD后是否搬家
key90.216e9NodeANodeD✅
key30.909e9NodeANodeD✅
key51.035e9NodeANodeD✅
key81.565e9NodeANodeA—
key61.685e9NodeANodeA—
key22.030e9NodeANodeA—
key13.266e9NodeANodeA—
key43.405e9NodeANodeA—
key104.046e9NodeANodeD✅
key74.187e9NodeANodeD✅

搬家的5个 key,正好是落在NodeD那段弧里的那些;其余5个一字未改。

这就是一致性哈希的全部价值——迁移量是“必须迁移的量”,没有任何冤枉扩大。


💻 代码实现(Python + Java)

Python版

importhashlibfrombisectimportbisect_leftclassConsistentHash:"""一致性哈希:哈希环 + 虚拟节点 + 二分找后继"""def__init__(self,nodes=None,virtual_nodes=150):self.vnodes=virtual_nodes self.ring={}# 虚拟节点hash -> 物理节点名self.sorted_hashes=[]# 排好序的hash列表self.nodes=set()fornin(nodesor[]):self.add_node(n)@staticmethoddef_hash(s:str)->int:"""把任意字符串映射到 [0, 2^32) 上的一个点"""returnint(hashlib.md5(s.encode('utf-8')).hexdigest()[:8],16)defadd_node(self,node:str)->None:ifnodeinself.nodes:returnself.nodes.add(node)foriinrange(self.vnodes):h=self._hash(f"{node}#{i}")# 虚拟节点:一个物理节点造K个分身self.ring[h]=node self.sorted_hashes=sorted(self.ring)defremove_node(self,node:str)->None:ifnodenotinself.nodes:returnself.nodes.discard(node)forhin[kfork,vinself.ring.items()ifv==node]:delself.ring[h]self.sorted_hashes=sorted(self.ring)defget_node(self,key:str)->str:"""key 顺时针遇到的第一个节点"""ifnotself.sorted_hashes:returnNoneh=self._hash(key)idx=bisect_left(self.sorted_hashes,h)# 第一个 >= h的位置ifidx==len(self.sorted_hashes):idx=0# 越过环尾 → 回绕到环首returnself.ring[self.sorted_hashes[idx]]

Java 版

importjava.nio.charset.StandardCharsets;importjava.security.MessageDigest;importjava.util.*;publicclassConsistentHash{privatefinalTreeMap<Long,String>ring=newTreeMap<>();privatefinalSet<String>nodes=newHashSet<>();privatefinalintvirtualNodes;publicConsistentHash(List<String>nodes,intvirtualNodes){this.virtualNodes=virtualNodes;for(Stringn:nodes)addNode(n);}privatelonghash(Strings){try{MessageDigestmd=MessageDigest.getInstance("MD5");byte[]d=md.digest(s.getBytes(StandardCharsets.UTF_8));return((long)(d[0]&0xFF)<<24)|((long)(d[1]&0xFF)<<16)|((long)(d[2]&0xFF)<<8)|((long)(d[3]&0xFF));}catch(Exceptione){thrownewRuntimeException(e);}}publicsynchronizedvoidaddNode(Stringnode){if(!nodes.add(node))return;for(inti=0;i<virtualNodes;i++){ring.put(hash(node+"#"+i),node);}}publicsynchronizedvoidremoveNode(Stringnode){if(!nodes.remove(node))return;for(inti=0;i<virtualNodes;i++){ring.remove(hash(node+"#"+i));}}publicStringgetNode(Stringkey){if(ring.isEmpty())returnnull;longh=hash(key);Map.Entry<Long,String>entry=ring.ceilingEntry(h);// >= h的最小键if(entry==null)entry=ring.firstEntry();// 回绕到环首returnentry.getValue();}}

⚠️防坑提醒(必看):

  • 虚拟节点的 key 必须带不同后缀(node#0…node#K-1),否则全重叠成一个点。
  • Java用long存哈希值——用int负数会打乱环顺序,回绕逻辑直接失效。
  • ceilingEntry返回 null 就取firstEntry()——这一步就是“环”的具象化。
  • 哈希函数选MD5/SHA取前若干位,别用String.hashCode()(分布质量差)。

⏱️ 复杂度分析(面试必问)

操作时间空间
get_nodeO(log(KN)) ≈ O(logN)—
add_node/remove_nodeO(K·log(KN))—
总空间—O(K·N)

N=1000、K=200时,20万个条目,几MB内存,放在每个客户端本地毫无压力。

核心收益:节点数从N变到N±1 时,迁移比例约1/(N+1),远优于取模的N/(N+1)。


🚀 方案对比:一致性哈希 vs 其他

方案节点变化迁移量中心路由典型使用者
hash % N≈ N/(N+1)否早期/小规模
一致性哈希 + 虚拟节点≈ 1/(N+1)否Memcached、Dynamo、Cassandra
Redis Cluster 哈希槽精确可控轻量Redis Cluster
Rendezvous Hashing≈ 1/(N+1)否GitHub GLB、Envoy

💬 面试追问模拟(提前准备,惊艳全场)

Q1:传统取模为什么不行?

hash(key) % N的分母N直接参与映射计算,N一变几乎所有key全变:3→4时迁移率75%(实测74.86%)。
直接后果是缓存大面积失效,请求穿透到数据库形成缓存雪崩。它也不满足单调性——想只挪 1% 流量都做不到。

Q2:虚拟节点解决了什么问题?

解决数据倾斜。节点少时位置随机,可能扎堆:实测 3 个节点全挤在一个窄区间,导致NodeA独占大弧、承接97%的key。
虚拟节点让每个物理节点在环上有K个散布的段,大数定律生效后负载趋于均匀(偏离度从±24pp降到±1.7pp)。副作用是天然支持加权分配。

Q3:如何高效找后继?

环上的点有序,这是标准的“找 ≥ h 的最小值”问题,二分:JavaTreeMap.ceilingEntry,Pythonbisect_left,C++std::map::lower_bound。返回空/越界时记得回绕到环首——忘了这一步,环就退化成数轴。

Q4:Redis Cluster用的是一致性哈希吗?

不是,它用哈希槽。
固定16384个槽,路由是CRC16(key) % 16384,集群元数据维护“哪个槽归哪个主节点”。扩容时手动migrate槽,迁移量精确可控。
Redis追求的是可控性:运维能精确指定搬哪些槽,还能用ASK/MOVED做平滑过渡。

Q5:一致性哈希还有什么工程补丁?

①有界负载(Google 2017 SOSP):目标节点负载超平均(1+ε)倍就跳过;
②虚拟节点按机器规格加权;
③故障转移:虚拟节点按机架/可用区分组,避免同机架同时宕机。


🧩 实战小技巧(刷题党必备)

  • 口诀:节点key上同环,顺时针找第一个;虚拟节点治倾斜,二分查找O(logN)。
  • 模板:哈希环 = TreeMap/sorted + ceiling/lower_bound + 回绕。
  • 防坑:虚拟节点带后缀;哈希值用long;回绕别忘。

📈 实际应用场景(不止是刷题)

  • Memcached 客户端路由:libketama经典实现
  • Amazon Dynamo / Cassandra / Riak:分区
  • Nginx/Envoy 负载均衡:hash $request_uri consistent
  • CDN 边缘节点调度:就近路由
  • RPC 服务治理:连接池选址
  • 分库分表:路由规则

🎁 今日思考题

K(每台机器的虚拟节点数)取多少合适?
提示:工程常用100~300,再往上收益递减。

如果集群里有一台机器性能是别的两倍,你会怎么改代码让它多扛一倍流量?
提示:加权虚拟节点——给它分配2K个虚拟节点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询