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 改动必须走变更评审。实例选型与规格的更多细节,可以对照 腾讯云综合实践。
延伸阅读
缓存层是本书里代码量和话题量最大的一个模块,它把前面几节攒下来的矛盾一次性收纳:计数、排序、详情、会话全部在此处汇合,因此它的稳定就是全站的稳定。每次事故复盘最后一条动作项,无一例外都是给缓存层加一道防护或一条监控,这个模块的厚度就是这么一点点攒出来的。
