
1. 先搞清楚“哈希雪崩”到底怎么发生的1.1 一次扩容为什么反而把服务搞崩很多团队第一次听到“哈希雪崩”都是在一个特别尴尬的场景里凌晨扩容往缓存集群里加了一台节点本以为能扛住流量结果几十秒后数据库 CPU 直接打满缓存命中率掉到历史最低。等把新节点撤掉服务又恢复正常了。整个过程看起来就像“扩容反而把系统搞崩了”。问题不在新节点本身而在于路由方式。大多数早期分布式缓存系统用的是最简单的取模策略node_id hash(key) % node_count。key 的 hash 值对节点数求模得到的余数决定了这个 key 落在哪台机器上。这个方案在节点数固定的时候非常简单高效但一旦节点数发生变化几乎所有 key 的余数都会变。举个例子原来有 3 个节点某个 key 算出来余数是 2它落在节点 2 上。你扩容到 4 个节点后还是同一个 key还是同一个 hash 值但现在要对 4 求模余数很可能变成 1 或 3。于是这个 key 就被路由到了别的节点。此时新节点上没有缓存数据只能回源数据库。如果大量 key 同时发生这种情况数据库就会收到一波远超平时的查询流量缓存系统也就跟着“雪崩”了。1.2 取模路由的执行细节取模哈希的逻辑非常简单写成公式就是node_id hash(key) % node_count这个公式里有两个关键点需要注意。第一hash(key)必须是稳定且统一的。同一个 key 在每一台服务器上、每一次计算中都应该得到相同的 hash 值否则整个系统会立刻错乱。实际项目中经常踩的坑是不同语言的内置 hash 实现不同比如 Python 的hash()字符串哈希和 Java 的hashCode()完全不是一回事。跨服务调用时必须显式指定统一的哈希算法比如 MD5、SHA-256或者专门为路由设计的 MurmurHash。第二node_count一旦变了整个映射关系就整体失效。比如从 3 变成 4这个变化不是“新增 1/4 的数据量”而是“原本 3 份数据中大约有 2.25 份被重新映射”。你想想如果有一万个 key扩容后可能只有近两千五百个 key 还留在原地剩下七千多个都要重新迁移、重新回源。这就是标题里说的“导致大量数据需要重新迁移”的实质。我建议你在动手设计路由方案前先做一个小的模拟程序把当前生产环境里的 key 样本拿出来跑一遍取模逻辑看看节点数变化后到底有多少 key 会搬家。这个比例往往远超直觉也正是哈希雪崩的根源。1.3 雪崩、穿透、击穿三个容易混淆的概念分布式缓存领域有三个现象经常被人混着说排查方向完全不同这里先理清楚。缓存雪崩指的是大量缓存 key 在同一时间失效或大量 miss导致请求集中打到数据库。节点变化引发的 rehash 是常见诱因另一个常见诱因是大量 key 设置了相同过期时间。缓存穿透指的是查询一个根本不存在的 key缓存里没有数据库里也没有导致每次查询都穿透到数据库缓存始终无法生效。缓存击穿指的是某个热点 key 刚好过期突发流量瞬间集中到这个 key 上下游数据库被打垮。哈希雪崩属于“缓存雪崩”的一种触发方式但它的突发性更强通常在扩容、缩容、机器宕机后瞬间发生。分析问题时不要一上来就怪缓存过期策略先看是不是路由映射出现了大范围变化。2. 为什么取模哈希一遇到节点变化就崩2.1 算一算迁移比例到底有多大要判断取模哈希到底有多脆弱最直接的办法是把迁移比例算出来。假设原来有 3 个节点扩容到 4 个。对于随机 key旧的余数范围是 0、1、2新的余数范围是 0、1、2、3。新旧余数完全相等的概率大约是1 / 4。也就是说大约 75% 的 key 会迁移到别的节点。推广到一般情况下从 N 个节点扩容到 M 个节点取模哈希的迁移比例大约为(M - 1) / M其中 M 是扩容后的节点数。这个结果很反直觉迁移比例几乎只和“新节点数”有关和“旧节点数”关系不大。从 3 台扩到 4 台要迁移 75%从 100 台扩到 101 台甚至要迁移约 99%。对照来看一致性哈希在同样场景下的迁移比例大约是(M - N) / M。从 3 台扩到 4 台只迁移约 25%从 100 台扩到 101 台只迁移约 1%。这差距决定了系统扩容时能不能平稳度过。扩容场景取模哈希迁移比例一致性哈希迁移比例3 节点 - 4 节点约 75%约 25%3 节点 - 5 节点约 80%约 40%100 节点 - 101 节点约 99%约 1%这些数字不是理论摆设。迁移比例越高意味着缓存回源请求越多数据库承受的压力越大。很多系统不是承受不了新增一台机器的成本而是承受不了 99% 的 key 同时回源。2.2 取模哈希的三个致命短板取模哈希在我心里属于“能跑但很难运维”的方案。它有三个绕不开的短板。第一单调性差。单调性是指在节点变化后原本已经映射到某些节点的 key最好还能继续映射到原来的节点。取模哈希做不到节点数一变全局映射直接重排。第二数据分布不可控。取模哈希假设 key 足够随机、hash 足够均匀但热点 key 一旦集中在某个余数区间就会导致某台机器压力特别大。你无法通过路由层面做任何调整。第三扩缩容成本极高。无论扩容还是缩容都要面对大规模数据迁移。而迁移过程中旧节点上的数据还不能立刻删需要双写或回源过渡运维复杂度直线上升。这三个短板叠加在一起就形成了“哈希雪崩”的完整链条节点数变化 - 映射大范围失效 - 大量 key 需要重新迁移 - 缓存 miss 暴增 - 回源数据库 - 流量瞬间冲垮下游。2.3 两种“哈希雪崩”千万别搞混网上搜“哈希雪崩”可能会搜到两种完全不同的定义。一种是密码学里的 avalanche effect也就是雪崩效应。它描述的是哈希函数的一种优秀性质输入数据哪怕只改一个比特输出也应该有大约一半的比特发生变化。比如 SHA-256 就能很好地体现这种效果。这是好事代表算法足够“敏感”不容易被碰撞攻击。另一种是分布式系统里因节点数变化导致大量 key 重新映射的现象也就是我们这篇讨论的问题。它实际上是“rehash 引发的缓存失效雪崩”在一些技术讨论里被直接简称为“哈希雪崩”。做技术交流时如果听到对方说哈希雪崩先确认他指的是哪一种。否则你讲了一整套一致性哈希他以为你在讲 MD5 是不是不够“雪崩”那就完全岔开了。3. 行业里是怎么解决哈希雪崩的3.1 一致性哈希把环形结构引入路由一致性哈希是目前解决“节点变化导致大规模迁移”最经典的思路。它的核心思想是不直接把 key 映射到节点而是把哈希值的取值范围看成一个首尾相接的环。具体做法是把节点本身也做一次哈希放到这个环上。每个 key 计算 hash 后从环上的位置顺时针查找遇到的第一个节点就是这个 key 归属的节点。增加或删除一个节点时只有和这个节点相邻的那一段环上的 key 会受到影响其他 key 的路由完全不变。这其实就是利用了“取模节点数”和“取模环空间”的本质区别。环空间是固定的比如 0 到 2^32-1节点数变化不影响环本身的大小。而取模哈希的模数是节点数节点一变模数就变整个余数分布自然重排。在工程落地时最常见的实现是每个真实节点在环上放多个虚拟节点而不是只放一个。虚拟节点不仅能让数据分布更均匀还能在节点扩容时把影响范围尽量打散避免某一小段环上的 key 集中迁移。3.2 虚拟节点是必要条件不是可选项很多人初学一致性哈希时会觉得“把节点 hash 后放到环上”就够了。实际跑一轮数据就会发现节点只有 3 个环上只有 3 个点很难保证每个节点负责的环段是均匀的。如果节点 hash 值刚好都挤在一小段区域某些节点会负责几乎整个环另一些节点几乎是空转。解决办法是给每个真实节点生成一批逻辑节点。比如节点cache-a可以生成cache-a#0、cache-a#1、……一直到cache-a#149把这些逻辑节点的 hash 值全部放到环上。key 顺时针找到的虽然是一个逻辑节点但通过后缀能反查到真实节点。虚拟节点的数量需要做权衡。太少分布不均的问题还会存在太多环上要维护几万甚至几十万个点内存和计算开销都会上升。我在实践中通常先从每个节点 150 个虚拟节点起步再根据线上 key 分布情况调整。如果你的节点数量特别多比如上百个每个节点 50 到 100 个虚拟点通常也够如果只有两三个节点虚拟节点数量至少要到 200 才比较稳。3.3 Redis Cluster 的哈希槽方案Redis Cluster 没有直接用一致性哈希而是用了另一种思路固定槽位。整个哈希空间被分成固定数量的槽Redis Cluster 是 16384 个。计算关系是slot crc16(key) % 16384这个16384是固定值不管集群里有多少个节点都不会变。节点只负责一部分槽位。扩容时不需要重新计算所有 key 的所属只需要把一部分槽位从旧节点迁移到新节点每个槽位对应的 key 再跟着迁移。哈希槽方案和一致性哈希本质上都是在“节点数”和“key 的 hash 值”之间插入了一个固定大小的逻辑层。一致性哈希的逻辑层是环上的虚拟节点Redis Cluster 的逻辑层是 16384 个数字槽。这个中间层固定不变节点变化只影响中间层的归属关系。实际运维 Redis Cluster 时加节点通常用redis-cli --cluster add-node把新节点加入集群再用redis-cli --cluster reshard从旧节点迁移一部分槽到新节点。迁移过程中Redis 会逐个处理槽内的 key对客户端透明。尽管如此迁移期间部分 key 的访问仍可能触发ASK重定向客户端需要做好处理。3.4 扩容不只是加一台机器的事无论用一致性哈希还是哈希槽新节点加入后真正承受压力的时间点是“数据被迁移过来之前”。新节点一加入就可能开始承接流量但此刻它本地是空的所有落到它身上的 key 都会 miss然后回源数据库。如果回源量太大整个系统一样会崩。所以生产环境扩容不能只是改配置至少要配合一套预热流程。比较稳妥的做法是让新节点先以从节点的身份接入集群通过主从复制把数据同步到本地。同步完成后再把它提升为主节点或者手动把部分数据迁移过去。另一种方式是提前开启双写业务写入时同时写老节点和新节点但双写只覆盖新产生的数据历史热点数据还是需要通过 scan 或按路由区间扫描回填。另外扩缩容时一定要关注“渐进式”。在 Redis Cluster 里可以通过调整每次迁移的槽数量来控制节奏让流量逐步增加。千万别一次性把几千个槽全部迁移完否则迁移期间的网络带宽、CPU 和客户端重定向都可能成为新瓶颈。4. 实操复现用代码看取模哈希是怎么崩的4.1 准备一个确定性 hash 函数先做环境准备。我这里用 Python 来模拟核心是要有一个确定性的 hash 函数。注意不要用 Python 内置的hash()因为字符串 hash 在每次进程启动时会加入随机种子同样的 key 在两次运行之间结果可能完全不同这样没法做前后对比。我习惯用 MD5 截取整数方便演示路由import hashlib def stable_hash(key: str) - int: return int(hashlib.md5(key.encode(utf-8)).hexdigest(), 16)这个函数保证同一个 key 每次计算结果一致。虽然 MD5 在安全场景下已经不建议使用但在路由计算领域它依然够用。如果你要做更高性能的路由哈希可以替换成 MurmurHash3 或 xxHash思路完全一样。4.2 复现取模哈希的迁移比例接下来生成一万个模拟业务 key先按 3 个节点计算路由再把节点数改成 4统计有多少 key 换到了别的节点。keys [forder:{i} for i in range(1, 10001)] def mod_node(key: str, node_count: int) - int: return stable_hash(key) % node_count old_routes [mod_node(k, 3) for k in keys] new_routes [mod_node(k, 4) for k in keys] moved sum(1 for old, new in zip(old_routes, new_routes) if old ! new) print(f取模哈希迁移比例: {moved / len(keys):.2%})我实际跑下来的结果基本在 74% 到 76% 之间浮动。也就是说一万个 key 里有大约七千五百个需要重新迁移。落到线上这七千五百个 key 都可能在扩容后的一小段时间内 miss 并回源数据库。数据库平时的 QPS 如果已经比较饱和这波流量就是压垮服务的最后一根稻草。4.3 实现一个一致性哈希并验证迁移比例下面用一致性哈希替换取模逻辑实现包含虚拟节点的版本import bisect class ConsistentHash: def __init__(self, nodesNone, virtual150): self.virtual virtual self.ring [] self.hash_to_node {} if nodes: for node in nodes: self.add_node(node) def add_node(self, node: str): for i in range(self.virtual): h stable_hash(f{node}#{i}) pos bisect.bisect_left(self.ring, h) self.ring.insert(pos, h) self.hash_to_node[h] node def remove_node(self, node: str): for i in range(self.virtual): h stable_hash(f{node}#{i}) pos bisect.bisect_left(self.ring, h) if pos len(self.ring) and self.ring[pos] h: self.ring.pop(pos) self.hash_to_node.pop(h, None) def get_node(self, key: str): if not self.ring: return None h stable_hash(key) pos bisect.bisect_left(self.ring, h) if pos len(self.ring): pos 0 return self.hash_to_node[self.ring[pos]]然后模拟同样的扩容ch ConsistentHash([node-a, node-b, node-c], virtual150) old_routes [ch.get_node(k) for k in keys] ch.add_node(node-d) new_routes [ch.get_node(k) for k in keys] moved sum(1 for old, new in zip(old_routes, new_routes) if old ! new) print(f一致性哈希迁移比例: {moved / len(keys):.2%})对比很明显同样的 3 节点扩到 4 节点取模哈希要迁移约 75%一致性哈希通常只需要迁移约 25%。随着节点规模变大这个差距会更加悬殊。这组实验是我每次给团队讲分布式缓存路由时必放的示例比任何理论都直观。4.4 顺手用 Windows PowerShell 查看文件 hash说一个和“哈希雪崩”关系不大但很实用的技能。日常排查时如果你怀疑数据迁移过程中文件被改写要验证迁移前后的数据是否完全一致最简单的方法就是对比 hash 值。在 Windows 下查看单个文件的 hashGet-FileHash .\order_export.csv -Algorithm SHA256查看当前文件夹下所有文件的 hashGet-ChildItem -Path .\data -File | Get-FileHash -Algorithm SHA256 | Format-Table Path, Hash -AutoSize迁移前把结果保存到文件迁移后再执行一次对比两次的 hash 列表就能快速定位哪些文件发生了变化。这属于文件完整性校验的常规操作和缓存路由里的 hash 计算不是一回事但遇到“哈希雪崩”相关的数据迁移问题时它能帮你判断数据有没有在搬家过程中被损坏。5. 线上排查与避坑记录5.1 怎么判断线上正在发生哈希雪崩哈希雪崩发生时线上表现通常非常典型。最明显的是缓存命中率骤降随后数据库 QPS 和 CPU 使用率同步飙升接口延迟变大甚至超时。通过缓存实例自身指标可以更快定位。以 Redis 为例INFO stats里面有两个字段值得关注keyspace_hits和keyspace_misses。正常运行时命中率一般稳定在某个区间。扩容后如果 miss 数量突然大幅上涨而且上涨幅度和节点数变化后的预期迁移比例基本吻合那基本可以断定是路由重排引起的雪崩。再往下排查要区分是“取模哈希扩容导致的雪崩”还是“新节点刚加入还没预热导致的瞬时 miss”。前者通常会持续到迁移完成并回源完毕后者通常持续的时间更短而且和流量波动强相关。我的经验是提前把迁移比例算出来比如扩 4 节点预期 miss 比例约 25%如果实际 miss 暴涨到 70%那大概率不是正常的 rehash而是路由配置出了问题。5.2 一致性哈希踩过的坑一致性哈希能解决取模哈希的迁移问题但落地时依然有一堆细节。第一个坑是哈希算法不统一。比如有的服务用 MD5 生成环点有的服务用 SHA-1 生成环点两边环结构完全不一样key 自然路由不到同一节点。这个问题在微服务架构里特别常见排查起来也很隐蔽因为单看每个服务的代码都没错。第二个坑是虚拟节点数量设置不当。虚拟节点太少数据分布会明显倾斜。我曾见过一个只用 16 个虚拟节点的线上系统三台机器内存使用率分别是 51%、28%、21%这就是分布不均的典型表现。把虚拟节点提到 150 之后三台机器的内存占比回归到 34%、33%、33% 左右。第三个坑是删除节点时没有同步清理环上数据。某些实现为了省事删除节点时只从 map 里删了映射但环数组里残留了旧点。新 key 可能路由到已经不存在的节点最终访问超时。正确的删除流程是先从环数组中移除所有属于该节点的虚拟点再清理映射关系。第四个坑是过度依赖一致性哈希忽略了热点 key。一致性哈希只能保证数据分布大致均匀但它不会改变“某些 key 访问频率特别高”的事实。热点 key 依然需要本地缓存、限流、或者在 key 层面做拆分。5.3 上线前的一页纸演练清单经历过几次扩容事故之后我给自己定了一套流程每次改节点数之前必须走完从线上采样一批真实 key用新旧两种路由分别计算得出精确迁移比例。根据迁移比例估算扩容后可能多出多少回源流量判断数据库是否能扛住。提前预热新节点。能开主从就先主从同步不能开主从就用旧节点 scan 回填。修改路由配置后先灰度切流观察命中率和数据库 QPS确认稳定后再全量。准备回滚方案。一旦出现异常可以快速把路由切回旧节点或者把新节点从集群摘除。缩容前确认要摘除节点上的数据已经在其他节点上有副本或者已经完成迁移。这套流程不绑定具体技术栈无论是自研的一致性哈希还是 Redis Cluster 都适用。关键是让每次扩缩容都有数据支撑而不是拍脑袋改一个node_count就上线。5.4 一点个人体会我用了很长时间才真正理解取模哈希其实没有错错的是让节点数直接暴露在路由公式里。你只要在 key 和节点之间加一个固定层问题就缓解了。这个固定层可以是一致性哈希的环也可以是 Redis Cluster 的 16384 个槽位。如果团队规模不大我个人更推荐直接使用 Redis Cluster 这类成熟的哈希槽方案省去自己维护一致性哈希的复杂度。如果业务场景是自研网关、自研缓存中间件或者需要非常精细地控制热点分布再自己实现一致性哈希。最后想多说一句不管算法选得多好上线前一定要做迁移比例预估和回源流量评估。哈希雪崩不是算法没选对才会发生而是节点变化的那一刻必然会有一批 key 需要重新迁移。你能做的只是让这批 key 尽量少并且保证它们回源时数据库还扛得住。