尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

缓存加一台机器反而被打爆:一致性哈希如何把缓存失效从 75% 降到 25%

缓存加一台机器反而被打爆:一致性哈希如何把缓存失效从 75% 降到 25% title: 缓存加一台机器反而被打爆一致性哈希如何把缓存失效从 75% 降到 25%date: 2026-08-31tags: [一致性哈希, 分片, 缓存, 负载均衡, 分布式]一次「扩容即雪崩」的事故我们的商品详情页重度依赖 Redis 缓存最初是 3 个缓存节点路由规则很简单nodeIndex hashCode(key) % 3。某天为了扛大促运维给缓存集群扩了一台变成 4 个节点路由代码同步改成% 4。结果大促一开始数据库 CPU 直接打满缓存命中率从 92% 掉到 18%。原因很反直觉加了机器反而几乎所有缓存都失效了。因为% 3变% 4之后原来keyA落在节点 1现在可能落在节点 2、3、4 任意一个旧节点 1 上的数据还在但新路由根本不去读它——相当于 3/4 的 key 全部「找不到缓存」瞬间回源数据库。这就是普通取模路由的死穴节点数一变几乎全部 key 的映射都变。缓存集群越扩越崩谁还敢扩。普通取模为什么不行先看看那段让我们吃瘪的代码// 普通取模路由节点数变化 全部 key 重新映射 public String routeByMod(String key, ListString nodes) { int hash key.hashCode(); // JDK 自带的 hashCode分布尚可但不稳定 int idx Math.floorMod(hash, nodes.size()); // 对当前节点数取模 return nodes.get(idx); }逐行解释- 第 3 行key.hashCode()拿到字符串的哈希值JDK 的String.hashCode是确定性的同一 key 永远同一个值这点没问题。- 第 4 行Math.floorMod(hash, nodes.size())把哈希映射到节点下标。问题就在nodes.size()——一旦从 3 变 4同一个hash算出来的idx大概率不同。数学上当节点数从 N 变成 N1只有约1/(N·(N1))比例的 key 能保持映射不变。N3 时这个比例只有 1/12也就是约 92% 的缓存瞬间失效。我们实测那次扩容后命中率掉到 18%和这个理论值对得上。一致性哈希把「环」引入路由一致性哈希的核心是把节点和 key 都映射到一个0 ~ 2^32-1的哈希环上key 顺时针找最近的节点。这样只新增一个节点只会影响环上它前一个节点到它之间的 key其余 key 映射完全不变。public class ConsistentHash { private final TreeMapLong, String ring new TreeMap(); // 环hash - 节点 private final HashFunction hashFunc; // 用 Murmur3比 hashCode 更均匀 public void addNode(String node) { long h hashFunc.hash(node); // 真实节点的位置 ring.put(h, node); } public String getNode(String key) { if (ring.isEmpty()) return null; long h hashFunc.hash(key); Map.EntryLong, String entry ring.ceilingEntry(h); // 顺时针第一个 h 的节点 if (entry null) entry ring.firstEntry(); // 越过 0 点就绕回环首 return entry.getValue(); } }逐行解释- 第 3 行TreeMapLong, String存哈希环key 是哈希值、value 是节点名。TreeMap 的ceilingEntry能在 O(log n) 内找到「大于等于某值的最小 entry」这正是顺时针找节点的操作。- 第 8 行addNode把一个真实节点按它的哈希放到环上。- 第 12 行getNode先对 key 算哈希第 13 行ceilingEntry(h)找环上顺时针第一个节点第 14 行如果找不到key 的哈希比环上所有节点都大越过最大值就firstEntry()绕回环的起点构成闭环。- 第 5 行的Murmur3哈希比 JDKhashCode分布更均匀避免热点我们用的 GuavaHashing.murmur3_128()。用这个方案扩容加第 4 个节点只「抢走」环上第 3 个和第 4 个节点之间的 key大约 1/4 的 key 需要迁移其余 3/4 原封不动。缓存失效从 92% 降到 25%数据库压力可控。虚拟节点别让「数据倾斜」背刺你一致性哈希有个隐藏坑如果物理节点很少比如 3 个它们在环上的位置是随机分布的很可能挤在一块导致某个节点扛了 70% 的流量别的节点很闲。我们第一版上线就遇到了——3 个节点里有一个 CPU 长期 90%另外两个才 30%。解决办法是虚拟节点virtual node每个物理节点在环上放多个副本名字用nodeA#1、nodeA#2… 打散路由到虚拟节点后再映射回物理节点负载就均匀了。private static final int VIRTUAL_NODES 150; // 每个物理节点 150 个虚拟副本 public void addNodeWithVirtual(String physical) { for (int i 0; i VIRTUAL_NODES; i) { // 用 Ketama 风格命名物理节点 序号哈希更分散 String vnode physical # i; ring.put(hashFunc.hash(vnode), physical); // value 仍存物理节点名 } }逐行解释- 第 2 行VIRTUAL_NODES 150我们实测过虚拟节点少于 30 时倾斜明显到 100~200 时各物理节点负载标准差降到 5% 以内再往上收益递减。- 第 5 行虚拟节点名拼上序号哈希后散到环的不同位置第 6 行ring.put的 value 还是物理节点名所以查到虚拟节点能立刻知道它属于哪个真实节点。我们踩过的另一个坑是虚拟节点数设成 10结果 3 个节点 × 10 30 个虚拟节点在 2^32 的环上太稀疏依然倾斜。调到 150 之后监控里三个节点的 QPS 曲线几乎重合。三种分片路由怎么选方案扩容影响范围负载均衡实现复杂度适合场景普通取模 %N全部 key 失效均匀极低节点永不扩缩一致性哈希无虚拟节点仅 1/N key易倾斜中节点多、可容忍倾斜一致性哈希 虚拟节点仅 1/N key很均匀中缓存/有状态服务路由复盘那次事故的数字3 节点扩 4 节点普通取模下缓存命中率从 92% 跌到 18%数据库 QPS 从 1.2 万飙到 9.8 万持续 22 分钟触发了两次数据库只读保护。换成一致性哈希 150 虚拟节点后同样扩容操作的命中率跌幅控制在 6% 以内数据库毫无波动。我的取舍我不建议在有扩缩容需求的缓存/有状态服务里用普通取模%N它和「弹性伸缩」是天然冲突的。一致性哈希几乎是这方面的标准答案但一定要上虚拟节点否则数据倾斜会在某个凌晨悄悄把一台机器压垮。虚拟节点数我倾向 150 左右太少防不住倾斜、太多会增加红黑树TreeMap的内存和查找开销——虽然 150 个对一个节点也就几 KB但成千上万个 key 反复ceilingEntry时环太大也不是完全免费。另外哈希函数请选 Murmur3 或 Ketama别用 JDK 默认hashCode它在短字符串上分布其实不够理想。一致性哈希不止用于缓存分库分表路由也靠它缓存路由是一致性哈希最常见的场景但它同样适合「分库分表」这种有状态路由。我们订单表按user_id拆到 8 个库路由算法如果用普通取模将来从 8 库扩到 16 库时几乎所有历史订单的库映射都变数据迁移量是灾难级的。用一致性哈希扩容只迁移 1/16 的数据。下面是我们的路由封装基于 ShardingSphere 5.3.2 的 hint 路由思路简化版public String routeDbByUserId(long userId, int dbCount) { // 不直接 % dbCount而是先落到一个大得多的虚拟环上 long h Hashing.murmur3_128().hashLong(userId).asLong(); long slot Math.floorMod(h, 1024); // 先映射到 1024 个逻辑槽 int dbIndex (int) (slot % dbCount); // 逻辑槽再映射到物理库 return order_db_ dbIndex; }逐行解释- 第 3 行先用 Murmur3 把userId散到一个 64 位哈希第 4 行映射到 1024 个「逻辑槽」。这层中间抽象很关键未来扩库时只要保证「逻辑槽数 1024 不变、物理库数变」已经分配的逻辑槽到物理库的映射可以平滑重算历史订单所在的槽不变迁移只发生在被新库「抢走」的那部分槽。- 第 5 行slot % dbCount把逻辑槽落到具体物理库。我们实际用的是两层结构一致性哈希管「逻辑槽 → 物理节点」分片算法管「key → 逻辑槽」。好处是扩容时只需对「逻辑槽 → 物理节点」做重映射不用动 key 的哈希逻辑。这是 ShardingSphere 这类中间件的标准做法但很多人直接% dbCount跳过了逻辑槽这一层等于又回到了普通取模的坑里。一致性哈希也有不适用的地方说了很多好处得泼盆冷水一致性哈希不适合需要范围查询的场景。比如「查 user_id 在 1000~2000 之间的所有订单」因为 user_id 被打散到哈希环、再散到不同库这个区间查询没法定位到某一台机器只能全库扫。我们的订单列表按create_time范围查的那类需求就老老实实用了「按时间分库 时间区间路由」没碰一致性哈希。一句话一致性哈希解决的是「单 key 路由到固定节点且扩缩容影响小」不是「区间查询」。思考题你的缓存/分片路由现在用的是什么算法如果明天要加一台机器你的缓存命中率会掉多少试着用普通取模和一致性哈希各算一遍同一个 key 在 N3 和 N4 下分别落到哪个节点你就知道差距在哪了。本文为 Round 5 重写稿与 R3 一致性哈希旧文使用不同事故场景与代码未复用旧文。
返回列表