💥 加一台机器,缓存几乎全挂——这是很多线上事故的第一页
设想你有一个缓存集群,N台机器,最自然的写法是hash(key) % N。
简单、均匀、易懂。
然后业务涨了,你加一台机器:N变成N+1。这时几乎全部key的目标位置都变了。
缓存全部miss,请求像洪水一样毫无遮挡地冲向下游数据库——这就是缓存雪崩,也是很多线上事故的剧本第一页。
今天要解决的就是:能不能让节点数量变化时,只有尽可能少的key需要迁移?
答案就是1997年Karger等人提出的一致性哈希——把节点和key都映射到一个虚拟的哈希环上,加一台机器只影响环上相邻的一小段key。
我们还会处理它自带的两个副作用:数据倾斜(用虚拟节点解决)和如何高效找后继(用有序结构 + 二分)。
📦 问题描述(系统设计题)
设计一个分布式缓存的路由方案,满足:
- 均衡性:key尽可能均匀分布在N个节点上
- 单调性:新增/下线节点时,已有key 映射尽可能少地改变
- 分散性:客户端能自己算路由,不需要中心节点
- 容错性:一台宕机,流量分摊给剩下的机器,而不是砸向某一台
接口合约:
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) % N | 74.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 | 复杂度 |
|---|---|---|
| Java | TreeMap<Long, String>+ceilingEntry(h) | O(log(KN)) |
| Python | sorted_keys+bisect_left | O(log(KN)) |
| C++ | std::map<uint32_t, Node>+lower_bound | O(log(KN)) |
🖼️ 图解算法(手把手走一遍)
环上先有3个节点,按升序:NodeA → NodeC → NodeB →(回绕)→ NodeA。
各段弧归属:
| 弧(顺时针) | 归属节点 |
|---|---|
(0 … NodeA] | NodeA |
(NodeA … NodeC] | NodeC |
(NodeC … NodeB] | NodeB |
(NodeB … 2³²) ∪ (0 … ]回绕 | NodeA |
放入10个key:
| key | hash值 | 归属 |
|---|---|---|
| key9 | 0.216e9 | NodeA |
| key3 | 0.909e9 | NodeA |
| key5 | 1.035e9 | NodeA |
| key8 | 1.565e9 | NodeA |
| key6 | 1.685e9 | NodeA |
| key2 | 2.030e9 | NodeA |
| key1 | 3.266e9 | NodeA |
| key4 | 3.405e9 | NodeA |
| key10 | 4.046e9 | NodeA(回绕) |
| key7 | 4.187e9 | NodeA(回绕) |
注意这就是数据倾斜的真实模样——10个key全给NodeA!这就是必须有虚拟节点的原因。
现在加入NodeD = 1,485,806,211。
新环顺序:NodeD → NodeA → NodeC → NodeB → 回绕 → NodeD
谁要搬家?
| key | hash | 加NodeD前 | 加NodeD后 | 是否搬家 |
|---|---|---|---|---|
| key9 | 0.216e9 | NodeA | NodeD | ✅ |
| key3 | 0.909e9 | NodeA | NodeD | ✅ |
| key5 | 1.035e9 | NodeA | NodeD | ✅ |
| key8 | 1.565e9 | NodeA | NodeA | — |
| key6 | 1.685e9 | NodeA | NodeA | — |
| key2 | 2.030e9 | NodeA | NodeA | — |
| key1 | 3.266e9 | NodeA | NodeA | — |
| key4 | 3.405e9 | NodeA | NodeA | — |
| key10 | 4.046e9 | NodeA | NodeD | ✅ |
| key7 | 4.187e9 | NodeA | NodeD | ✅ |
搬家的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_node | O(log(KN)) ≈ O(logN) | — |
add_node/remove_node | O(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 的最小值”问题,二分:Java
TreeMap.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个虚拟节点。