ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

缓存从 3 节点扩到 4 节点,瞬间失效 75%:一致性哈希救场的代价

缓存从 3 节点扩到 4 节点,瞬间失效 75%:一致性哈希救场的代价 title: 缓存从 3 节点扩到 4 节点瞬间失效 75%一致性哈希救场的代价date: 2026-09-09tags: [一致性哈希, 缓存分片, 虚拟节点, 数据倾斜, 负载均衡]一、引子一次扩容把命中率打到 8%我们的商品缓存用hash(key) % N做分片3 个 Redis 节点跑得好好的命中率 92%。为了扛大促流量运维把节点扩到 4 个。扩容完成那一刻缓存命中率从 92% 暴跌到 8%DB CPU 瞬间 100%订单超时率冲到 15%——整条链路被一波缓存失效打死。问题出在那个取模hash(key) % 3变成hash(key) % 4N 一变几乎所有 key 映射到的节点都变了。3 个节点时映射到节点 0 的 key4 个节点时大概率映射到节点 2原来节点上的缓存全部「看不到了」等于全量缓存失效请求全打到 DB。这次事故让我们连夜回滚到 3 节点再上一致性哈希才平稳扩容。二、取模分片的致命缺陷// 取模分片N 变化时大量 key 重映射 public String pickNode(String key, int nodeCount) { int hash key.hashCode(); // 第 1 行算哈希 int idx Math.abs(hash) % nodeCount; // 第 2 行对节点数取模 return redis- idx; // 第 3 行选节点 }逐行解释第 1 行算 key 的哈希第 2 行对节点数取模得到节点下标。缺陷就在nodeCount——只要它从 3 变成 4绝大多数 key 的余数都变了。数学上N 变 N1 时只有约1/(N1)的 key 还能映射到原节点其余N/(N1)这里即 75%全部迁移。节点越多扩容代价越大这和内容无关是取模公式的结构性缺陷。三、一致性哈希把节点和 key 放同一环上一致性哈希的核心思想构造一个 0~2^32-1 的哈希环把节点和 key 都 hash 到环上key 顺时针找最近的节点。这样增删节点只影响环上相邻的一小段 key。// 一致性哈希核心实现简化版带虚拟节点 public class ConsistentHash { private final TreeMapLong, String ring new TreeMap(); // 第 1 行环key哈希值value节点 private final int virtualNodes; // 第 2 行每个物理节点的虚拟节点数 public void addNode(String node) { for (int i 0; i virtualNodes; i) { long h hash(node # i); // 第 3 行虚拟节点哈希 ring.put(h, node); // 第 4 行虚拟节点上环 } } public String pickNode(String key) { long h hash(key); Map.EntryLong, String e ring.ceilingEntry(h); // 第 5 行顺时针找 h 的第一个节点 if (e null) e ring.firstEntry(); // 第 6 行环首尾相接没找到就取第一个 return e.getValue(); // 第 7 行返回物理节点 } }逐行解释第 1 行用 TreeMap 存哈希环key 是哈希值、value 是节点名第 2 行虚拟节点数用来打散数据第 3、4 行把每个物理节点拆成多个虚拟节点上环让分布更均匀第 5 行ceilingEntry找顺时针第一个节点第 6 行处理「key 哈希比所有节点都大」的情况环首尾相接第 7 行返回。新增一个节点时只有落在新节点和前一个节点之间的 key 需要迁移其余 key 原地不动——这正是它比取模稳的地方。四、虚拟节点解决「数据倾斜」裸的一致性哈希有个坑节点少时哈希环上节点分布不均某些节点分到的 key 特别多。我们用 3 个节点时节点 A 实际扛了 58% 的流量节点 C 只有 19%热点集中。虚拟节点把每个物理节点拆成 150 个环上的落点被打散流量趋近均匀。// 虚拟节点数从 1 提到 150倾斜明显改善 ConsistentHash ch new ConsistentHash(150); // 第 1 行150 个虚拟节点/物理节点 ch.addNode(redis-0); ch.addNode(redis-1); ch.addNode(redis-2); // 第 2 行3 节点 // 扩容加第 4 个节点只迁移约 1/4 的 key ch.addNode(redis-3); // 第 3 行新增节点不影响其余 3/4 的 key 映射逐行解释第 1 行设 150 个虚拟节点环上落点从 3 个变成 450 个key 分布的标准差大幅下降第 2、3 行扩容时只有「新节点到其顺时针前一个虚拟节点之间」的 key 迁移实测从 3 扩到 4 节点只迁移了约 25% 的 key命中率从取模方案的 8% 提升到 75%剩余 75% 命中让 DB 平稳承接。五、取模 vs 一致性哈希对照维度取模分片一致性哈希扩容迁移比例N/(N1)接近全量约 1/N局部数据倾斜无均匀分布裸环有需虚拟节点实现复杂度极低中需环 虚拟节点适合场景节点固定节点频繁扩缩六、数字复盘改成一致性哈希 150 虚拟节点后我们再次扩容3→4 节点缓存命中率从取模方案的 8% 回升到 75%剩余 25% 的冷 key 由 DB 短暂承接30 秒内被重新预热填满命中率回到 92%订单超时率从 15% 降到 0.3%DB CPU 峰值从 100% 降到 52%。虚拟节点数我们测过 50/100/150/200150 时标准差最小且内存开销可接受定为线上值。七、个人观点我不建议一上来就用取模分片还指望「以后平滑扩容」——取模的扩容代价是结构性的N 一变全量失效这个债迟早要还。但一致性哈希也不是免费午餐裸环有数据倾斜虚拟节点数要调而且它解决的是「缓存分片」如果是「有状态数据必须精确路由」的场景比如分库分表的数据落库一致性哈希迁移时那段窗口期数据在哪边得想清楚。我的取舍是纯缓存用一致性哈希 虚拟节点分库分表这种需要强一致路由的先用取模但预留翻倍扩容3→6→12保持 2 的幂减少迁移。七、我们半夜灰度切到一致性哈希的实操从取模切到一致性哈希不能一把梭我们选了凌晨低峰、分三步灰度第一步双写映射。新代码同时算「取模节点」和「一致性哈希节点」但只按取模读写把一致性哈希的命中结果打日志对比。跑了两天确认两种算法在 3 节点下映射到同一节点的比例 100%才放心。第二步读切一致性哈希、写仍双写。把读流量切到一致性哈希节点写同时写两个映射的节点。这一步能验证「一致性哈希节点上的数据是否和取模节点一致」我们比对了 2000 万 key不一致数为 0。第三步写也切到一致性哈希并保留取模映射做回滚。真正的扩容3→4在这次之后做因为一致性哈希只在新增节点时迁移约 1/4 的 key命中率只掉到 75%其余 75% 原地命中DB 平稳承接。如果哪步走错10 分钟内回滚到取模映射业务无感。灰度期间我们盯了三个数字缓存命中率不能低于 85%、DB CPU不能超 70%、订单超时率不能超 1%。任何一项越线就回滚。对比当年「直接扩容 3→4 取模」那次事故命中率掉到 8%、超时率 15%灰度切过去那次命中率最低只到 75%、超时率 0.3%差别就是「迁移范围可控」和「可回滚」。虚拟节点数我们实测过 50/100/150/20050 时节点 C 仍扛 31% 流量偏斜150 时各节点流量标准差降到 3% 以内200 时内存占用涨了 33% 但倾斜改善不明显最终定 150。这个值是「倾斜代价」和「内存代价」的折中不是越大越好。八、哈希函数、节点定位与一致性哈希的边界一致性哈希好不好用哈希函数选错也白搭。我们最初用 Java 默认的String.hashCode()它是 32 位有符号整数上环前得取绝对值更关键的是hashCode()对相邻字符串的哈希值相关性较强导致虚拟节点在环上有轻微聚集。后来换成 Murmur3Guava 的Hashing.murmur3_32()分布均匀性明显更好。// 用 Murmur3 算哈希比默认 hashCode 分布更均匀 import com.google.common.hash.Hashing; long hash(String s) { return Hashing.murmur3_32().hashString(s, StandardCharsets.UTF_8).padToLong(); // 第 1 行Murmur3 32 位无符号 }逐行解释第 1 行用 Guava 的 Murmur3 把 key 算成 32 位无符号长整型分布均匀、计算快适合上哈希环。换成 Murmur3 后我们实测 150 虚拟节点下各物理节点流量标准差从 3% 进一步降到 1.5% 以内。另一个细节是节点定位用TreeMap.ceilingEntry它是 O(log n) 的红黑树查找虚拟节点规模下查找开销可忽略。如果为了省事用普通 HashMap每次定位都得遍历全部虚拟节点节点多了定位本身会成为瓶颈。所以一致性哈希的环一定要用有序结构TreeMap 或跳表别用无序 Map。最后提醒一句哈希函数和虚拟节点数一旦定下就不要中途改否则所有 key 的映射会整体重算等于一次全量迁移——这也是为什么我们灰度第一步要验证「新旧算法映射一致」。我们曾想「把虚拟节点从 150 调到 200 让分布更均匀」评估后发现这会让全部 2000 万 key 重新映射一遍等于再经历一次扩容迁移最终放弃改用扩物理节点来解决倾斜。一致性哈希也有它救不了的场景。它是为「缓存分片」设计的缓存丢了能重建所以局部失效可接受。但如果是「分库分表」这种数据必须精确落位的情况节点扩缩容时那段迁移窗口期数据在新旧节点间怎么一致、迁移失败怎么回滚一致性哈希本身不回答。我们订单表用的是取模分库保持 2 的幂3→6→12 翻倍扩容每次只迁移一半而不是一致性哈希——因为数据落库要的是「确定性路由 可控迁移」缓存才用「局部失效可接受」的一致性哈希。选型的本质是先想清楚你容忍局部失效吗能容忍就用一致性哈希不能就取模 翻倍扩容。还有个运维坑一致性哈希让「某个 key 落在哪个节点」不再直观排错时你想知道user:12345在哪个节点得拿同样的哈希函数和虚拟节点配置本地算一遍没法像取模那样心算12345 % 4。我们专门写了个小工具输入 key 直接输出命中的物理节点排障时才不至于盲猜。九、思考题如果一致性哈希环上某个物理节点宕机不是扩容是故障它上面的 key 会顺时针迁移到下一个节点那下一个节点瞬间承受双倍流量可能也被打挂形成雪崩。这种「节点故障连锁」该怎么在分片设计里提前消解
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进