3.4 缓存层:Redis 热帖缓存

场景:缓存穿透导致数据库雪崩

一个周末下午,我们经历了一次小规模事故。运维群报警 MySQL CPU 百分之百,登上机器一看,全部是同一条 SQL:查一个不存在的帖子。有人在社区里传播了一组错误链接,海量请求打向不存在的 post_id。我们的缓存层只缓存存在的帖子,查不到就不缓存,每个请求都落到数据库,典型的缓存穿透。那一晚我们给所有读接口都加上了空对象缓存和布隆过滤,数据库才慢慢恢复平静。

这不是 Redis 第一次救场,但它提醒我们:缓存不是性能装饰,而是数据库的保险丝。我们这套架构里 Redis 承担三块职责,热榜 zset、会话与计数,外加详情缓存。一旦穿透或雪崩,所有职责会同时受损,所以缓存层必须按最坏情况设计。从那次事故以后,团队形成了一个条件反射:任何读接口设计评审,第一个问题必然问缓存怎么挡,而不是问 SQL 怎么写。

事故复盘时我们算过一笔账:穿透持续的四十五分钟里,数据库响应的接口只有正常水平的一半,等于整个社区降速运行了将近一个小时。如果当时热帖恰好在被刷新,连锁反应会直接让首页空白。缓存层的强弱,决定了系统在异常流量面前是降级还是宕机。

事情过去一周后,我们又把这次事故的复盘做成了应急手册:穿透、击穿、雪崩三种场景各给一段确认步骤与止血动作,值班同学照单操作即可。手册第一次真正被用上是在三个月后的一次爬虫事件,那次从报警到止血只用了八分钟。事故的价值不在吃一堑,在于把吃一堑变成可复用的流程。

方案:空对象加互斥加多级兜底

缓存设计有四个必须回答的问题:穿透怎么挡、击穿怎么挡、雪崩怎么挡、一致性怎么管。我们的答案分别是:空对象缓存加布隆过滤、热点 key 互斥重建、过期时间加随机抖动、写后异步失效。所有缓存键按业务前缀分片管理,统一走一个封装模块,禁止业务代码裸写命令:

故障形态 原因 对策 生效位置
穿透 查不存在的数据 空对象缓存 读接口
击穿 热点 key 失效 互斥重建 读接口
雪崩 大量同时失效 过期随机抖动 写入时间
一致性 数据库与缓存不同步 写后异步失效 写接口
预热 重启缓存空 启动预热任务 部署阶段
监控 命中率下降 告警与巡检 运行期

除上表之外,我们还把缓存命中率也当作一个监控指标:低于百分之七十即告警,因为命中率长期不达标往往意味着键策略本身出了问题,而不是流量问题。

缓存模块还内置了两条纪律违反即告警:业务代码直接拼键名、缓存值超过一兆字节。前者为了避免键名冲突,后者因为 Redis 里放大对象会拖垮整个实例。上线第一个月这两条纪律各自抓到过一次违规,都在代码评审前被拦下。

键名注册表是纪律落地的工具。每个业务前缀必须在注册表里声明归属模块、过期策略与最大值体积,封装模块读取注册表生成最终键名,业务代码拿不到拼键名的入口。这份注册表同时充当排障文档:值班同学看到告警里的键名,一查就知道找哪个模块的同学:

const KEY_REGISTRY = {
  'feed:section':  { ttl: 900,  owner: 'feed',  max: '2M' },
  'post:detail':   { ttl: 300,  owner: 'post',  max: '1M' },
  'session:access':{ ttl: 7200, owner: 'auth',  max: '1M' },
  'likecnt:post':  { ttl: 0,    owner: 'like',  max: '100B' }
};
function makeKey(prefix, id, val) {
  const cfg = KEY_REGISTRY[prefix];
  if (!cfg) throw new Error('未注册的键名前缀 ' + prefix);
  if (JSON.stringify(val).length > cfg.max.length) throw new Error('值超限');
  return { key: prefix + ':' + id, ttl: cfg.ttl };
}

配置与代码

读缓存的标准封装,空对象与正常对象分开放,过期时间加随机量避免集中失效:

async function getPost(post_id) {
  const key = 'post:detail:' + post_id;
  let val = await redis.get(key);
  if (val === 'NULL') return null;              // 空对象缓存
  if (!val) {
    const lock = await redis.set('lock:post:' + post_id, 1, 'NX', 'EX', 5);
    if (!lock) return null;                     // 互斥:别人在重建
    val = await mysql.query('SELECT * FROM t_post WHERE post_id = ?', [post_id]);
    if (!val) { await redis.setex(key, 60, 'NULL'); return null; }
    await redis.setex(key, 300 + Math.random() * 60, JSON.stringify(val));
  }
  return JSON.parse(val);
}

写操作落库后再异步删缓存,不放在同一事务里,避免事务回滚反而留下脏缓存:

async function updatePost(post_id, data) {
  await mysql.query('UPDATE t_post SET ? WHERE post_id = ?', [data, post_id]);
  setImmediate(() => redis.del('post:detail:' + post_id));
}

启动预热是部署流程里的一环,新版本开放流量前先把热榜与置顶帖刷进缓存:

async function warmup() {
  const hot = await mysql.query('SELECT post_id FROM t_post ORDER BY view_cnt DESC LIMIT 200');
  for (const p of hot) await getPost(p.post_id);
}

预热任务跑完会向 CLS 写一条汇总日志,记录刷入 key 的数量与耗时,部署流水线把它当作放行条件:预热未完成,Nginx 就不开放外网流量。这条卡口避免了多次冷启动打穿事故。

踩坑

第一个坑就是开头的穿透事故。加空对象之后,恶意请求全部被挡在 Redis,但空对象过期时间不能太长,误删的真实帖子会短暂不可见,我们定了六十秒上限。第二个坑是互斥锁超时,重建热点帖要两秒而锁五秒,慢请求堆积时所有读请求都拿不到锁返回空,改成锁两秒加最长等待八百毫秒,失败的读直接降级查库。第三个坑是集中失效,某次大促把一批热帖缓存同时写入了相同过期时间,十分钟后同时失效,接口耗时曲线像跳水,加随机抖动后曲线平滑。第四个坑是缓存与计数混用,早期用同一个 Redis 实例跑计数和详情缓存,计数高峰时的内存抖动偶尔把冷缓存 key 挤出去,后来热榜和会话换独立实例、详情缓存留在共享实例。第五个坑是启动冷启动,新部署的机器缓存全空、流量直接打穿,上线流程加了一分钟预热任务再开放流量。第六个坑是删除缓存的时序,先删缓存后更新数据库的方案在高并发下会让脏数据被写回,最终采用先更新再异步删,删除失败进重试队列。第七个坑是布隆过滤器的误判,过滤器说存在的 key 大概率真存在,说不存在的一定不存在,上线时把预期误判率配到千分之一,反向漏过的少量穿透由空对象兜底,两层防护各司其职。

小结

缓存层的价值不在加速而在兜底。把穿透、击穿、雪崩、一致性这四个问题各给一个明确对策,缓存层才算设计完成。键名前缀统一管理、互斥重建加随机过期、实例按职责隔离,这三条是我们用事故换来的经验。再补一条执行层面的经验:缓存策略的任何变更都要先在压测环境演练,直接上线试错一次的代价比演练十天还高,我们这条规矩也是用一次生产事故换来的,当时一次键前缀调整让整层缓存集体失效,首页空窗十五分钟,那以后所有 key 改动必须走变更评审。实例选型与规格的更多细节,可以对照 腾讯云综合实践。

延伸阅读

缓存层是本书里代码量和话题量最大的一个模块,它把前面几节攒下来的矛盾一次性收纳:计数、排序、详情、会话全部在此处汇合,因此它的稳定就是全站的稳定。每次事故复盘最后一条动作项,无一例外都是给缓存层加一道防护或一条监控,这个模块的厚度就是这么一点点攒出来的。