图 1:本文封面,点明缓存三大经典风险。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。缓存穿透、击穿、雪崩这组题,是缓存面试的「保留曲目」——三个词长得像,成因和药方完全不同,混着答基本等于没答。
想象面试现场,面试官的问题很直接:说说缓存穿透、击穿、雪崩,各自是什么原因,怎么防?
第一步,先抓共同本质。
三个问题听起来各异,本质是同一件事:本该被缓存拦住的请求,绕过缓存直接打到了数据库。区别只在「为什么拦不住」——拦不住不存在的数据,是穿透;拦不住热点数据过期的瞬间,是击穿;拦不住成片数据同时失效,是雪崩。抓住这条主线,三个名词就再也混不了。
第二步,穿透:查一个根本不存在的数据。
成因:请求查的 key 在缓存里没有,在数据库里也没有——比如恶意构造的不存在的商品 ID。缓存里查不到,只能去数据库查;数据库也查不到,没法写入缓存;于是每个这样的请求都完整地穿透到数据库,量一大就能把库打垮。
防护有两招。一是缓存空值:数据库查出「不存在」,也把这个空结果写进缓存,配上较短的过期时间,后续同样的请求直接被缓存拦下。二是布隆过滤器:在缓存前面加一层概率结构,预先登记所有合法 key。Redis 官方文档对布隆过滤器的定位说得很准——它能保证「一个元素肯定不在集合里」,但对「在」的回答存在小概率误判。用它把肯定不存在的请求直接拦在门外,穿透流量就进不了数据库。
第三步,击穿:一个热点 key 在过期瞬间被围攻。
成因:某个超级热点的 key——比如一场秒杀的商品详情——在它过期的那一瞬间,成百上千个并发请求同时发现缓存未命中,同时涌向数据库回源。一个 key 的过期,制造了一次微型流量风暴。
防护也有两招。一是互斥锁:回源前先用锁抢一个「重建资格」,只放第一个请求去查库重建缓存,其余请求等锁释放后读新缓存——数据库最多被打一次。二是逻辑过期:key 不设置物理过期,把过期时间写进 value 里,发现逻辑过期后由后台线程异步刷新,请求全程不堵。
第四步,雪崩:成片 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 放缓存里,这套检查项同样一条不能少。
三个坑里你实际踩过哪一个?当时的流量曲线长什么样?欢迎分享你的事故复盘。