缓存雪崩成因分析与高可用应对策略实战
缓存雪崩?别慌,我用这几招稳住了
上周上线一个新功能,凌晨三点被告警电话吵醒——数据库 CPU 100%,接口全部超时。查了一圈,发现是缓存集体过期,瞬间几千个请求打穿 Redis,直接把 DB 干趴了。这不就是传说中的“缓存雪崩”嘛。折腾到天亮,最后靠几个简单策略稳住了。今天就把我亲测有效的方案分享出来,少走点弯路。
核心代码就这几行:加随机过期时间
最直接的解法,就是在设置缓存时,给过期时间加个随机偏移。比如你原本打算缓存 300 秒,那就改成 300 + 随机(0~60) 秒。这样就算一批数据同时写入,也不会在同一毫秒集体失效。
这里注意下,我踩过坑:别用太小的随机范围,否则意义不大;也别太大,否则缓存命中率会掉。我一般用基础时间的 10%~20% 作为浮动区间。
// 设置缓存时加随机过期
function setCacheWithRandomTTL(key, data, baseTTL = 300) {
const randomOffset = Math.floor(Math.random() * 60); // 0~59秒
const finalTTL = baseTTL + randomOffset;
redis.setex(key, finalTTL, JSON.stringify(data));
}
这个方法简单粗暴,但对大多数场景够用了。我们线上用这个策略后,再没出现过大规模雪崩。
高并发场景?试试互斥锁重建缓存
但有些场景不行。比如某个热门商品详情页,缓存刚失效,瞬间涌进 1 万个请求。这时候就算你加了随机过期,第一个请求去查 DB 的时候,后面 9999 个还在等——DB 一样扛不住。
这时候就得上“互斥锁”:只让一个线程去查数据库,其他线程等它把缓存写好后再读。Node.js 里可以用 Redis 的 SET key value NX EX 命令实现分布式锁。
async function getOrCreate(key, fetchFromDB, ttl = 300) {
// 先尝试读缓存
let data = await redis.get(key);
if (data) return JSON.parse(data);
// 尝试获取锁(key:lock)
const lockKey = ${key}:lock;
const lockAcquired = await redis.set(lockKey, '1', 'EX', 10, 'NX');
if (lockAcquired) {
try {
// 锁获取成功,查数据库
data = await fetchFromDB();
// 写回缓存(带随机 TTL)
const finalTTL = ttl + Math.floor(Math.random() * 60);
await redis.setex(key, finalTTL, JSON.stringify(data));
return data;
} finally {
// 释放锁
await redis.del(lockKey);
}
} else {
// 没拿到锁,短暂等待后重试(避免死循环)
await new Promise(resolve => setTimeout(resolve, 50));
return getOrCreate(key, fetchFromDB, ttl);
}
}
亲测有效,但要注意两点:锁的超时时间不能太长(我设 10 秒),否则 DB 慢的时候会卡住;重试次数要限制,不然可能无限递归。我们加了最多重试 3 次,超过就直接返回兜底数据。
踩坑提醒:这三点一定注意
- 别在锁里做耗时操作:我之前把日志上报、埋点统计都塞进锁里,结果锁持有时间变长,反而加剧了排队。现在只留 DB 查询和缓存写入。
- 兜底数据不能少:即使缓存和 DB 都挂了,也要返回默认结构(比如空列表、默认配置),避免前端白屏。我们有个全局 fallback 函数,所有接口都包一层。
- 监控要跟上:光有策略不够,得知道什么时候触发了。我们在 Redis 缓存 miss 时打日志,配合 Grafana 看板,一旦 miss 率突增就告警。
有一次就是因为没监控,缓存策略改错了,导致每天凌晨 2 点 DB 被打爆,三天后才被发现。血泪教训啊。
这个场景最好用:永不过期 + 后台刷新
对于某些核心数据(比如系统配置、首页 Banner),我干脆不用 TTL,而是“永不过期”。那怎么更新?用后台定时任务主动刷新。
// 启动时加载一次
async function loadConfig() {
const config = await fetchFromDB();
await redis.set('global_config', JSON.stringify(config));
}
// 每 5 分钟刷新一次
setInterval(async () => {
try {
await loadConfig();
console.log('Config refreshed');
} catch (err) {
console.error('Config refresh failed:', err);
}
}, 5 * 60 * 1000);
// 接口直接读缓存,永不穿透
async function getConfig() {
const data = await redis.get('global_config');
return data ? JSON.parse(data) : DEFAULT_CONFIG;
}
这种方式彻底规避了雪崩,但只适合更新频率低、数据量小的场景。别用在用户订单这种高频变化的数据上,否则数据会严重滞后。
不是最优解,但最省事:Nginx 层缓存
如果改动代码成本太高,还可以在 Nginx 层加一层缓存。用 proxy_cache 模块,把 API 响应缓存几秒。这样即使后端缓存失效,Nginx 也能扛住一波流量。
proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=my_cache:10m max_size=1g inactive=60m;
server {
location /api/data {
proxy_cache my_cache;
proxy_cache_valid 200 5s; # 成功响应缓存5秒
proxy_pass https://jztheme.com/api/data;
}
}
虽然有点“治标不治本”,但紧急情况下能救命。我们有次大促前临时加了这个,顶住了流量峰值。
最后说两句
缓存雪崩没有银弹,得根据业务场景选策略。我现在的组合拳是:普通数据用随机 TTL + 互斥锁,核心配置用永不过期 + 定时刷新,再加一层 Nginx 缓存兜底。上线半年,再没出过雪崩事故。
以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多(比如结合布隆过滤器防穿透),后续会继续分享这类博客。

暂无评论