缓存雪崩成因分析与高可用应对策略实战

令狐俊凤 优化 阅读 875
赞 10 收藏
二维码
手机扫码查看
反馈

缓存雪崩?别慌,我用这几招稳住了

上周上线一个新功能,凌晨三点被告警电话吵醒——数据库 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 缓存兜底。上线半年,再没出过雪崩事故。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多(比如结合布隆过滤器防穿透),后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论