ARTICLE DETAIL

资讯详情

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

缓存扩容必知:哈希雪崩原理与一致性哈希、Redis Cluster 解决方案

缓存扩容必知:哈希雪崩原理与一致性哈希、Redis Cluster 解决方案 先问一个分布式场景的经典问题缓存集群原来有 3 个节点为了扛流量扩到 4 个结果扩容之后缓存命中率不升反降数据库 CPU 瞬间被打满告警响成一片。这不是故事很多团队在从固定节点数“求模”转发切到动态扩缩容时都会撞上同一个坑——哈希雪崩。这里的哈希雪崩和密码学里讲的那个 avalanche effect 不完全是一回事。密码学里的雪崩是指输入稍微变一点输出完全不认分布式里这个雪崩是指节点拓扑一变key 和节点的映射关系大面积失效导致缓存数据集体作废流量瞬间穿透到后端存储。两者的共同点是都强调“小变化引发大范围结果变动”但一个偏加密安全一个偏系统稳定性。这篇文章适合谁正在用 Redis/Memcached 做缓存、想把节点从 3 个扩到 4 个的后端同学被“扩容后缓存全部失效”折磨过的运维和架构师以及准备面试分布式基础题目的候选人。读完你至少能回答三个问题节点数变化为什么会导致数据迁移如何避免迁移引发雪崩工程上常用的方案和踩坑点是什么1. 哈希雪崩到底是怎么发生的1.1 从求模节点分配说起最朴素的多节点数据分布方式就是拿 key 的哈希值对节点数求模。公式是node_id hash(key) % N。这个方案简单、直接不需要额外服务很多早期系统都是这么干的。比如用户 ID 为 10001算出来的 hash 是 xx % 3 2就落到编号 2 的节点上。问题出在“节点数 N”不是永远不变的。业务涨了要扩容机器坏了要摘除N 从 3 变成 4 的那一刻公式变成了hash(key) % 4。由于取模的底数变了同一个 key 算出来的目标节点大概率就不再是原来的节点。也就是说扩容前你把 key 放在节点 2扩容后它可能被算到节点 0 或节点 1原来节点 2 上的缓存数据就成了没人访问的“孤儿”而新节点上又没有这些数据所有请求只能去数据库重新拉。我写过一段特别简单的模拟代码用来感受这种迁移量。你不用管哈希函数选什么MD5、SHA 甚至自研 hash 都行核心逻辑就是对比同一批 key 在旧节点数和新节点数下的映射变化import hashlib def old_route(key: str, n: int) - int: return int(hashlib.md5(key.encode()).hexdigest(), 16) % n keys [fuser:{i} for i in range(1000)] old_map {k: old_route(k, 3) for k in keys} new_map {k: old_route(k, 4) for k in keys} moved sum(1 for k in keys if old_map[k] ! new_map[k]) print(f三个节点扩容到四个迁移比例{moved / len(keys):.1%})跑一下这段代码迁移比例通常会在 75% 上下。这不是 bug是数学规律。很多人的第一反应是“扩容 3 到 4多了一个节点最多迁移三分之一吧”但实际上取模方案远比这个直觉残酷。1.2 一个扩容案例看迁移比例如何计算为什么不是只迁移一小部分因为旧节点数和新节点数分别是两个不同的模数。对任意一个均匀分布的 hash 值旧映射等于h % 3新映射等于h % 4两个结果相等必须满足h % 3 h % 4而这只对少数 h 成立。均匀哈希下满足这个条件的概率约为1/(N1)所以迁移比例就是N/(N1)。从 3 到 4迁移 75%从 9 到 10迁移 90%从 99 到 100甚至要迁移 99%。这个趋势我放在一张表里原节点数 N扩容到 N1 后的数据迁移比例266.7%375%990%9999%所以“少量节点迁移”的直觉是错的。取模方案下扩容不是搬走一小部分数据而是把绝大多数数据的位置全部打乱重排。更麻烦的是缩容缩容时节点数变小所有 key 都要重新对新的 N 取模迁移范围同样是全局的。知道了这个公式你再去看线上扩容时的缓存命中率就能理解了命中率下降的幅度和迁移比例基本是同一个量级。数据被迁移过去后老节点上的缓存 TTL 还在但已经没有请求认识它们了新节点上又没有缓存冷启动必然穿透到下游。1.3 雪崩的连锁反应数据位置变了但应用并不知道。大量请求还是按照旧路由去原来的节点找数据找不到就去数据库查。最夸张的时候刚扩容完的几分钟内缓存命中率从 95% 直接掉到百分之二三十数据库的读 QPS 翻好几倍。数据库一旦扛不住慢查询变多、连接池满、超时堆积继而是应用线程阻塞最终整个服务雪崩。更隐蔽的是这种雪崩常常被误判为“网络抖动”或“数据库故障”。排查半小时后才发现根因只是配置里N从 3 改成了 4。我见过不止一次扩容操作刚执行完告警就刷屏最后只能先把节点回滚等业务低峰期再做更平滑的方案。2. 取模哈希的三个硬伤与一致性哈希的解法2.1 取模方案为什么扛不住节点变化取模方案有三个硬伤任何一个都会在节点变更时放大问题。第一是全局重排。只要节点数 N 变化所有 key 都要重新计算目标节点没有任何“局部不变”的概念。这直接导致迁移范围最大雪崩概率最高。第二是无法局部摘除。如果某台节点故障不只是它自己上的数据不可用其他节点也会因为 key 重新映射而受到冲击。原本只挂一台机器最后可能拖垮整个缓存集群。第三是没有数据倾斜修正机制。取模只能保证统计上的均匀当某些 key 是热点时它们还是会集中到同一台节点取模方案没有任何虚拟节点或权重手段来打散访问热点。这三个问题本质上是同一个根源key 到节点的映射关系直接依赖节点数量节点数量一变整个映射表就要重算。要解决哈希雪崩核心思路是“把节点数从映射关系里解耦出去”。2.2 一致性哈希把求模改为环形顺时针查找一致性哈希就是为解决这个问题提出的。它把整个 hash 值空间组织成一个首尾相接的环范围通常是 0 到 2^32 - 1。每个物理节点根据节点标识比如 IP:端口的 hash 值放在环上每个 key 根据自身的 hash 值放在环上然后沿顺时针方向找到的第一个节点就是目标节点。举例来说环上有节点 A、B、C它们在环上的位置把圆分成三段。key1 哈希后落在 A 和 B 之间的弧段上顺时针遇到的第一个节点是 B所以 key1 归 B。key2 落在 B 和 C 之间顺时针遇到 C归 C。当新节点 D 插入 A 和 B 之间时原来从 A 到 B 那段弧上的 key本来要顺时针走到 B现在走到 D 就会停下所以这些 key 被迁移到 D而环上的其他弧段完全不受影响。扩容时迁移范围不再是全局 75%而是大约“D 覆盖的环段长度 / 环总长度”。如果虚拟节点足够密、分布足够均匀迁移比例就接近新增容量占总容量的比例这对在线业务来说是可以接受的。我自己早期实现一致性哈希时最大的误区是拿节点直接 hash三五个节点很容易把环切成几段极不均匀的弧。比如 A、B、C 三个节点扎堆在环的一侧那么另一侧的一大段弧都会指向 CC 的负载会高得离谱。所以一致性哈希必须搭配虚拟节点使用。2.3 虚拟节点让分布变均匀虚拟节点的做法是每个物理节点在环上不只有一个点而是生成多个副本标识比如node1-0、node1-1、node1-2……这些副本都参与 hash 落点但最终路由时仍归属到同一个物理节点。这样环上的点变多了区间切得更碎统计数据分布也更均匀。网上流传的说法是每台物理节点创建 150 到 200 个虚拟节点效果不错实际需要根据节点规模和 key 规模做微调。虚拟节点太少分布可能还是倾斜虚拟节点太多ring 的排序和查找内存开销会变大。一个简单的实现思路我放在下面仅作参考import hashlib import bisect class ConsistentHash: def __init__(self, nodesNone, virtuals150): self.virtuals virtuals self.ring [] self.node_map {} for node in nodes or []: self.add_node(node) def _hash(self, key: str) - int: return int(hashlib.md5(key.encode()).hexdigest(), 16) def add_node(self, node: str): for i in range(self.virtuals): vhash self._hash(f{node}:{i}) bisect.insort(self.ring, (vhash, node)) self.node_map[(vhash, node)] node def get_node(self, key: str) - str: kh self._hash(key) idx bisect.bisect_left(self.ring, (kh, )) if idx len(self.ring): idx 0 return self.ring[idx][1]这个实现里get_node的复杂度是O(log m)m 是虚拟节点总数。真实生产环境还会处理节点下线、ring 重建、数据搬运等逻辑但核心思想就是“通过虚拟节点扩大样本数把不均匀的环切成均匀的小区间”。一致性哈希并不是银弹它也有实现复杂度删除节点时需要把虚拟节点从环上摘除同时把迁移任务交给后继节点为了保证平滑很多实现还会做跳跃一致性哈希等优化。但思路永远是控制变更范围而不是放任全局重算。3. 工业界的答案哈希槽与平滑迁移3.1 Redis Cluster 的 16384 个槽一致性哈希解决的是“如何让映射关系受节点变化影响最小”但工程上还有另一种更稳的方案哈希槽。Redis Cluster 就把整个 hash 空间固定切成 16384 个槽key 先算CRC16(key) % 16384得到槽号槽再被分配到不同节点。注意这里的“求模”不是对节点数求模而是对固定槽数求模。槽数永远不变变的只是“槽到节点”的映射关系。扩容时只需要把一部分槽从旧节点迁移到新节点其他槽的数据完全不动。和一致性哈希相比哈希槽的思路更偏“先把数据分到固定桶里再把桶分配给机器”。为什么是 16384这是一个工程折中。每个节点在集群握手和心跳时需要携带自己负责的槽位 bitmap16384 bit 就是 2KB网络包开销可控如果太小节点数量多时分布不均太大则 bitmap 传输浪费。我们不需要死记这个数字但理解这个 trade-off能帮你明白哈希槽是“固定模数 动态映射”的组合。3.2 扩容时槽迁移的完整流程如果你直接使用 Redis Cluster扩容操作不需要改客户端代码。客户端只跟槽号打交道槽号到节点的映射由集群内部维护。新节点加入后典型操作是这样的# 将新节点加入集群existing_host 可以是任意一个老节点 redis-cli --cluster add-node 10.0.0.5:6379 10.0.0.1:6379 # 查看集群节点和槽位分布 redis-cli --cluster info 10.0.0.1:6379 # 执行重新分片按提示输入要迁移的槽数量、目标节点ID、源节点ID redis-cli --cluster reshard 10.0.0.1:6379 # 如果需要整体平衡 redis-cli --cluster rebalance 10.0.0.1:6379reshard默认是按槽粒度迁移每个槽内可能有多个 key。如果某个槽里存在大 key迁移时会触发阻塞式MIGRATE有几率造成短暂的命令延迟。所以生产环境建议先扫描大 key拆槽或者分批迁移。我习惯的流程是先 add-node 把新节点加进来但不分配槽观察一段时间确认节点稳定再使用 reshard 每次迁几百个槽边迁边观察命中率和主从复制延迟最后确认新节点上的槽数和其他节点接近再正式给客户端压力。整个过程要避开业务高峰最好提前一小时开始预热。3.3 一致性哈希和哈希槽怎么选很多同学会纠结到底用一致性哈希还是哈希槽我的答案是看你在用什么系统。如果直接用 Redis Cluster用官方哈希槽不要自己再造轮子如果自研缓存中间件或做 DNS、负载均衡等场景一致性哈希代码更轻配合虚拟节点也够用。维度一致性哈希哈希槽数据位置计算key 直接在环上找节点key 对固定槽数取模槽映射到节点迁移范围只影响环上相邻区间只影响被迁移的槽客户端要求客户端实现环路由需要支持集群协议如 Redis Cluster灵活度虚拟节点数量、权重可调槽数固定但槽分配可动态调整典型场景自研缓存、负载均衡、DNSRedis Cluster、Codis 等集群中间件一致性哈希胜在实现简单、可定制哈希槽胜在运维路径成熟、官方工具链完善。二者并不是互斥的很多分布式系统内部会同时用两种思路处理不同层次的数据分布。4. 哈希雪崩的常见问题排查与避坑清单4.1 扩容后缓存命中率暴跌的排查路径如果你已经扩容完成发现命中率正在跳水别慌按顺序排查。先看缓存侧指标。Redis 的INFO stats里有keyspace_hits和keyspace_misses能直接算出命中率同时看每台节点内存使用量确认是否出现某节点内存暴涨、某节点内存明显偏低。再看数据库侧 QPS如果 DB 读 QPS 和慢查询同时上升基本可以判定缓存穿透。这时候回到路由代码找有没有写死% 节点数的逻辑如果有那么扩容造成雪崩的概率极高。如果用的是 Redis Cluster还要检查集群状态。执行redis-cli --cluster info看cluster_state:ok并确认槽分布是否均匀。有时候客户端连的是旧拓扑槽迁移完成后客户端还没刷新路由表也会出现短暂的大量 miss。这种情况重启客户端或等待路由刷新即可。处理方案上先用限流保护数据库然后写预热脚本把热 key 重新灌到新节点。不要一边雪崩一边再继续扩容先把节点回滚或恢复原有容量等低峰期用平滑方案重新操作。4.2 数据倾斜与热Key的场景化处理哈希算法即使均匀热点 key 还是会出现因为用户访问天然是二八法则。取模、一致性哈希、哈希槽都只能解决数据层面的均匀解决不了访问层面的倾斜。比如一个“爆款商品”的 key 被大量用户同时访问无论它落在哪个节点那个节点都很可能被打爆。实践中的处理办法有三种。第一对超热 key 做本地缓存或二级缓存让大部分读请求在应用进程内结束根本不打到缓存集群。第二将热 key 加随机后缀拆分成多个子 key比如product:123拆成product:123:0、product:123:1等分散到不同节点然后再通过一个小索引或固定规则找到子 key。第三如果只是物理节点负责的区间不均衡调大虚拟节点数量或者执行集群 rebalance 重新分配槽。注意拆分热 key 有个副作用数据一致性维护变得更麻烦写时要多写多个子 key。所以这个方案只用于极端热点不要把所有 key 都做拆分。4.3 线上兜底与预防策略雪崩发生后再救火总归是被动的提前预防更有价值。我的经验是至少保留四道防线。缓存 TTL 加随机值避免大量 key 在同一秒过期。多级缓存兜底本地缓存挡住一部分穿透流量。扩容前预迁移和预热新节点先分到部分槽提前把热 key 写入再切换流量。最后是监控告警把“命中率低于阈值”和“数据库读 QPS 突增”绑定为高优告警确保你能第一时间发现。这些策略都不能消除哈希雪崩但能把雪崩的爆炸半径控制在可接受范围内。真正的根治还是要把路由算法从“对节点数求模”替换成一致性哈希或哈希槽让节点变化只影响局部数据。5. 延伸别把“文件哈希”和“分布式哈希”搞混5.1 Windows下批量查看文件哈希聊完集群顺便说一个日常容易被误解的场景。有时候你在 Windows 下想核对一批文件有没有被改过会在当前文件夹里批量跑Get-FileHash。有人会问文件哈希会不会也发生“雪崩”答案是不会因为文件 SHA256 属于完整性哈希输入内容变输出 hash 变但每个文件之间互不影响没有一个“节点数”参与映射。命令很简单在当前目录下执行Get-ChildItem -Path .\data -File | Get-FileHash -Algorithm SHA256 | Select-Object Path, Hash | Format-Table这条命令会遍历当前文件夹下所有文件逐个计算 SHA256 并输出路径和哈希值。它只适合做内容校验、发布包核对、日志完整性审计和分布式缓存路由完全是两码事。如果你在处理数据去重或扫描海量文件还可以用 GPU 批量计算哈希但那关心的是计算吞吐和碰撞率也不是节点分布问题。5.2 哈希在不同场景的核心矛盾同样叫“哈希”实际关心点完全不同。文件哈希关注的是内容完整性和碰撞概率GPU 批量哈希扫描关注的是每秒能算多少条数据分布式缓存关注的是节点拓扑变化下多少数据需要重新映射、多少请求会穿透到后端。它们其实有一个共同矛盾当输入空间或映射关系发生变化时如何减少重算和迁移的代价。文件哈希里输入文件内容变了你只能重新计算这个文件的校验值但不需要动其他文件分布式集群里节点数变了如果路由算法设计得不好就得重算所有 key这就是雪崩的根源。理解了这层语义差别再看一致性哈希和哈希槽会更容易抓住核心。5.3 一点实际施工经验我这些年做集群扩容最大的体会是扩容本身不难难的是让存量数据相信扩容。无论是把求模改成一致性哈希还是用 Redis Cluster 的槽迁移核心都是先控制迁移范围再保证数据可平滑过渡。另外别等到流量高峰才扩容提前几小时预迁移、预热比事后救火幸福太多。最后随手分享一个检查习惯每次改动节点数之前先看一眼路由代码里有没有写死的% N有的话先补一个平滑迁移方案再动手。这个习惯救过我很多次。
返回列表