2017 年春天给一个内容站的首页加 Redis。首页每次要查 5 张表拼数据,访问一多 MySQL 就吃紧。想法很直接:把拼好的结果序列化存进 Redis,key 用 homepage:v1,过期 300 秒,命中就不查库。
上线第一天挺好。第二天早上出问题,记录一下过程。
现象
早八点前后,MySQL 的 CPU 从 20% 跳到 90% 多,慢查询日志里全是同一条首页聚合 SQL。Redis 的命中率看板掉到 60% 以下。按理说过期的 key 只会有一个请求回源,不该打成这样。
1. 缓存过期那一刻,几十个请求一起回源
300 秒一到,key 失效,恰好赶上一个流量小高峰,几十个并发请求同时发现没缓存,全去查库,又都去写缓存。当时没有互斥。
改法:回源前先 setnx 一个短期的锁 key,比如 10 秒,抢到锁的那个去查库并回填,其余的睡 50 毫秒再重试读缓存。代码加了不到 20 行。
2. 有人请求不存在的栏目 id
真正把库打慢的是另一个:日志里有一批 /list?id=999999 这样的请求,栏目根本不存在,缓存里当然也没有,每次都穿透到数据库查一遍再返回空。是某个采集脚本在扫。
改法两层。一,查不到数据时也往 Redis 写个空值,过期设短一点,60 秒;二,入口对 id 做范围校验,超出当前最大栏目 id 直接返回 404,不进缓存逻辑。
3. 序列化用了 serialize,跨版本出了警告
缓存里存的是 PHP 数组 serialize 后的字符串。后来一台机器升了 PHP 版本,读出来的对象结构对不上,报了一堆 notice。换成 json_encode 和 json_decode,跨语言也方便,虽然多一点点体积。
4. key 命名没带版本,改版时全站空白
第一版 key 就叫 homepage,首页模板改版后,新代码读旧缓存,页面上少了一块,排查了半小时才想起来。后来统一改成 homepage:v2 这种,改结构就升版本号,旧 key 让它自己过期。
现在这套
首页缓存 300 秒,空值 60 秒,锁 10 秒,key 带版本,入口先验 id。上线前我们会拿 ab 压一下过期那一刻,看回源请求是不是只有一个。
Redis 本身没出过问题,出问题的是「以为加了缓存就不用管数据库了」这个念头。

