深蓝黑背景品牌封面:左侧大标题「穿透 · 击穿 · 雪崩」,副标题「缓存三个坑,一次分清」;右侧原创装饰——一面盾牌挡住三支分别标注「穿透」「击穿」「雪崩」的来袭箭头,盾牌后是受保护的数据库圆柱;左上标注「经典题解析 · 缓存」,右上标注「Day 01 · 09-29」 图 1:本文封面,点明缓存三大经典风险。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。缓存穿透、击穿、雪崩这组题,是缓存面试的「保留曲目」——三个词长得像,成因和药方完全不同,混着答基本等于没答。

想象面试现场,面试官的问题很直接:说说缓存穿透、击穿、雪崩,各自是什么原因,怎么防?

第一步,先抓共同本质。

三个问题听起来各异,本质是同一件事:本该被缓存拦住的请求,绕过缓存直接打到了数据库。区别只在「为什么拦不住」——拦不住不存在的数据,是穿透;拦不住热点数据过期的瞬间,是击穿;拦不住成片数据同时失效,是雪崩。抓住这条主线,三个名词就再也混不了。

第二步,穿透:查一个根本不存在的数据。

成因:请求查的 key 在缓存里没有,在数据库里也没有——比如恶意构造的不存在的商品 ID。缓存里查不到,只能去数据库查;数据库也查不到,没法写入缓存;于是每个这样的请求都完整地穿透到数据库,量一大就能把库打垮。

防护有两招。一是缓存空值:数据库查出「不存在」,也把这个空结果写进缓存,配上较短的过期时间,后续同样的请求直接被缓存拦下。二是布隆过滤器:在缓存前面加一层概率结构,预先登记所有合法 key。Redis 官方文档对布隆过滤器的定位说得很准——它能保证「一个元素肯定不在集合里」,但对「在」的回答存在小概率误判。用它把肯定不存在的请求直接拦在门外,穿透流量就进不了数据库。

第三步,击穿:一个热点 key 在过期瞬间被围攻。

成因:某个超级热点的 key——比如一场秒杀的商品详情——在它过期的那一瞬间,成百上千个并发请求同时发现缓存未命中,同时涌向数据库回源。一个 key 的过期,制造了一次微型流量风暴。

防护也有两招。一是互斥锁:回源前先用锁抢一个「重建资格」,只放第一个请求去查库重建缓存,其余请求等锁释放后读新缓存——数据库最多被打一次。二是逻辑过期:key 不设置物理过期,把过期时间写进 value 里,发现逻辑过期后由后台线程异步刷新,请求全程不堵。

第四步,雪崩:成片 key 同时失效,或缓存整体宕机。

成因有两种:大量 key 在同一时刻集中过期(比如凌晨统一批量预热,过期时间又设成一样),流量齐刷刷转向数据库;或者缓存服务本身宕机,所有请求瞬间失去屏障。

防护对症下药:过期时间加随机抖动,比如基础一小时再加零到几分钟的随机量,把集中过期打散;多级缓存,本地缓存接住第一层冲击;缓存层高可用部署,主从加哨兵或集群,避免单点宕机引发全面雪崩。

三栏对比图:缓存穿透是查询一个缓存与数据库都不存在的 key,每次请求都直达数据库,防护靠缓存空值(短过期)与布隆过滤器拦截;缓存击穿是某个热点 key 在过期瞬间被大量并发请求同时回源,防护靠互斥锁只放一个请求重建缓存,或采用逻辑过期异步刷新;缓存雪崩是大量 key 在同一时刻集中过期或缓存整体宕机,流量全部压到数据库,防护靠过期时间加随机抖动、多级缓存与高可用部署。底部总结:三者的共同本质都是「请求绕过缓存直击数据库」,上线前检查项是空值缓存、互斥重建、过期抖动是否就位 图 2:三害的成因对照与各自防护手段。

第五步,用可运行代码把「互斥锁防击穿」坐实。

下面这份代码模拟热点 key 过期后 100 个并发请求同时回源,对比「无锁」与「互斥锁(singleflight)」两种写法下数据库被打几次,用 Node.js 22.15.0 直接运行(node cache-stampede.js)即可:

'use strict';

// 模拟慢数据库:一次查询 50ms,并统计被打了几次
let dbHits = 0;
function queryDB(key) {
  dbHits++;
  return new Promise((resolve) => setTimeout(() => resolve(`value-of-${key}`), 50));
}

// 无防护:缓存未命中时,所有并发请求各自回源
async function getWithoutLock(key, cache) {
  if (cache.has(key)) return cache.get(key);
  const value = await queryDB(key);
  cache.set(key, value);
  return value;
}

// 互斥锁(singleflight):同一个 key 的并发回源合并成一次
const inflight = new Map();
async function getWithLock(key, cache) {
  if (cache.has(key)) return cache.get(key);
  if (inflight.has(key)) return inflight.get(key); // 等待第一个请求的结果
  const p = queryDB(key).then((value) => {
    cache.set(key, value);
    inflight.delete(key);
    return value;
  });
  inflight.set(key, p);
  return p;
}

// 过期时间加随机抖动,打散集中过期(防雪崩)
function jitterTTL(baseSeconds, jitterSeconds) {
  return baseSeconds + Math.floor(Math.random() * jitterSeconds);
}

const CONCURRENT = 100;
for (const mode of ['无锁', '互斥锁']) {
  const cache = new Map(); // 热点 key 已过期,缓存为空
  dbHits = 0;
  const getter = mode === '无锁' ? getWithoutLock : getWithLock;
  const results = await Promise.all(
    Array.from({ length: CONCURRENT }, () => getter('hot:product:42', cache))
  );
  console.log(`${mode}:${CONCURRENT} 个并发请求,数据库被回源 ${dbHits} 次,结果一致=${new Set(results).size === 1}`);
}
const ttls = Array.from({ length: 5 }, () => jitterTTL(3600, 600));
console.log(`过期时间抖动示例(基础 3600s + 随机 0~600s):${ttls.join(', ')}`);

实测输出如下(抖动秒数随机,其余确定):

无锁:100 个并发请求,数据库被回源 100 次,结果一致=true
互斥锁:100 个并发请求,数据库被回源 1 次,结果一致=true
过期时间抖动示例(基础 3600s + 随机 0~600s):3992, 3975, 4027, 3923, 3747

同样的 100 个并发请求,无锁时数据库结结实实挨了 100 次;互斥锁把回源合并成 1 次——这就是「击穿」与「防击穿」的量化差别。

易错点清单。

一,三个名词张冠李戴——记住穿透是「不存在」、击穿是「单点热点过期」、雪崩是「成片失效」。二,缓存空值不设短过期,导致新数据上线后长时间读到旧空值。三,以为布隆过滤器零误判,它只保证「不在」是确定的。四,互斥锁忘记兜底——拿到锁的线程挂了,要有锁超时或看门狗,否则全员死等。

回到面试现场,建议的答题节奏:先一句话点破共同本质,再按「成因一句、防护两条」的结构逐层展开三者,最后补一句上线检查项——空值缓存、互斥重建、过期抖动是否就位。AI 服务把热点问答和 embedding 放缓存里,这套检查项同样一条不能少。

三个坑里你实际踩过哪一个?当时的流量曲线长什么样?欢迎分享你的事故复盘。