缓存雪崩与穿透:空值缓存、TTL打散与回源单飞

事故:一次批量发布把缓存刷成集体过期

去年冬天的一个晚上,我在后台一次改了两百多篇旧文的标题。改完之后习惯性地清了整站缓存,然后回家吃饭。半小时后手机开始响:首页 TTFB 从 90ms 涨到 6 秒,MySQL 的 CPU 打到 100%,PHP-FPM 的 listen queue 一路涨到 max,Nginx 开始大量返回 502。监控上最刺眼的一条是 MySQL 的 Threads_running 从个位数直接冲到 200 多——数据库连接数上限是 300,离拒绝服务只差一点点。

事后复盘很清楚:我把整站缓存清空的瞬间,所有页面的缓存同时消失,几万个 URL 在接下来的一两分钟里一起回源。这不是"某个页面变慢",而是一个被自己亲手制造出来的压力脉冲。它和另一种更常见的、由时间引起的同类问题有一个共同名字:缓存雪崩。

这篇不讲概念,只讲怎么在个人站的规模上把这类问题压住。个人站通常只有一台机器、一个 Redis 或 APCu、一个 MySQL,没有冗余没有限流网关,一次雪崩就是真停站。所以下面三个方向的措施都刻意挑"改配置就能落地、不需要额外组件"的做法:空值缓存与回源互斥挡穿透,TTL 打散与随机偏移防雪崩,单飞与预热解决重建期的并发。

三种失效形态先分清,处置完全不同

很多人把穿透、击穿、雪崩混着说,导致排查时方向跑偏。先做一个严格区分,后文的措施才有落点。

穿透(penetration):请求的数据在缓存里没有,在数据库里也没有。缓存永远不会被填上,所以同一个不存在的键会次次打到数据库。典型来源是扫描器在拼 URL、爬虫在枚举文章 ID、有人手工改地址栏试 /999999.html。这类请求的特点是"数据库查了也白查",返回空。

击穿(breakdown/热点失效):某一个被高频访问的热点键过期了,在它重建完成之前,所有请求都打向数据库。特点是"只有一两个键,但并发极高"。首页、置顶文章、热门榜这几类最容易出。用互斥锁或逻辑过期就能治。

雪崩(avalanche):大批键在同一时间失效,或者缓存整体不可用。触发原因通常是三件:批量发布/批量更新后统一清缓存、TTL 设成同一个固定值导致同一批数据在写入后整齐过期、缓存进程重启或 Redis 被 OOM killer 干掉重启后空库。

第一层:空值缓存把穿透挡在数据库前面

穿透最省事的处理是"把空结果也当结果缓存起来",但这里有几个必须提前想清楚的边界,否则会从"少打数据库"变成"整站看不见新内容"。

关键点一:空值缓存必须短 TTL,且写入路径要主动删

空值缓存的 TTL 一般取 30 到 120 秒,绝不能用正常缓存的几小时。原因很直接:如果用户在文章发布后立刻访问,而这一刻空值还在缓存里,他看到的就是 404。发布流程里必须显式删除该 URL 的空值键,不能只依赖过期。

// 伪代码:读缓存的统一入口
$key = 'post:' . $id;
$val = apcu_fetch($key, $hit);
if ($hit) {
    if ($val === '__NULL__') {
        // 空值命中:直接返回 404,不查库
        return null;
    }
    return $val;
}

$row = $db->querySingle('SELECT * FROM posts WHERE id = ?', $id);
if ($row === null) {
    apcu_store($key, '__NULL__', 60);   // 空值只压 60 秒
    return null;
}
apcu_store($key, $row, 1800 + mt_rand(0, 300));  // 正常值 30 分钟 + 随机 5 分钟
return $row;

注意这里用了 __NULL__ 这样的哨兵字符串而不是 false 或 null,因为在 PHP 里 apcu_fetch 存 false 时和"未命中"难以区分(尤其是第三个参数引用传值的写法),用哨兵最不容易出错。用 Redis 的话同理,但要额外留意 PHP 客户端把 null 序列化成什么,有的版本会把 null 读回来变成 false。

关键点二:别用"随机数兜底"代替真正的白名单

有人对不存在的 ID 随机返回一篇旧文,想以此"安抚"爬虫。这会造成严重的 SEO 事故:同一个 URL 返回不同内容,搜索引擎判定为软 404 或垃圾内容,索引大面积掉。不存在的资源就该老老实实返回 404 状态码,页面上给一个带链接的提示页,别搞花活。

第二层:TTL 打散,把"同一时刻集体过期"变成一条平滑曲线

雪崩的根本原因是"过期时间对齐"。只要所有键在同一秒失效,无论缓存多大多快,回源压力都会在那一秒见顶。解决它的办法不复杂:让过期时间带随机抖动。

固定 TTL 的两种对齐方式

第一类是显式对齐:批量刷新时给一批键写了同一个 expire 值。第二类是隐式对齐,更容易被忽略——如果数据是定时任务在凌晨 3 点统一刷新的,那么这批数据的 TTL 就会全部从 3 点起算,第二天 3 点集体过期。你在白天测什么都很好,凌晨那一刻数据库必被踩一脚。

抖动该给多少

经验值是按基础 TTL 的 10% 到 20% 做正向抖动:基础 1800 秒就给 1800 到 2160 秒。抖动量太小(比如只随机 3 秒)不起作用;抖动量太大(随机到 2 倍)会让命中率明显下降。真正的判断标准是看回源曲线:打了抖动之后 redis 或 MySQL 的 QPS 曲线应该从"周期性尖峰"变成"轻微锯齿"。

$ttl = 1800 + mt_rand(0, 300);          // 30~35 分钟
redis->setex(key, ttl, value)

另一个办法是给每台机器/每个进程一个固定的偏移量,比如按进程 PID 对 300 取模,让同一进程写入的键始终错开一点,避免"同一时刻新写入的一批键"又在同一时刻一起过期。

批量更新后不要清整站缓存

这是本文开头事故的直接原因。批量改了文章之后,正确的动作是:只删除被改动的那批 URL 的键,其余照旧。如果改了公共模板(页头、侧栏、文章页布局),那确实整站都受影响,这时也不该直接清空,而是改用版本号或命名空间切换。

// 模板版本变了 → 提升命名空间版本号,老键自然淘汰
$ver = 'v' . (int)filemtime('/path/to/theme/version.txt');
$key = "page:$ver:" . md5($uri);

这样旧键还在缓存里慢慢自然过期,新请求全部走新命名空间,但这不是"瞬间全空"——如果新命名空间完全没预热,压力还是会在同一时刻到来。所以下一步必须配合单飞和预热。

第三层:回源单飞,让一百个并发只查一次库

即便 TTL 打散了,热点键过期那一刻仍然会有几十上百个并发同时发现自己没命中,然后一起去查库。这就是击穿。个人站上一个首页如果每 30 分钟过期一次,而这期间有 50 个并发,就意味着每 30 分钟有一次 50 倍的数据库小脉冲。数量级不大,但如果首页查询本身要 300ms、数据库连接只有 20 个,这 50 个并发足以把连接池占满,排队时间迅速叠加到所有其他请求上。

标准解法是"只在第一个请求上查库,其余请求等结果"。PHP 里可以用文件的 flock 或 APCu 的 apcu_add 做轻量互斥,不必引入分布式锁组件。

$lockKey = "lock:$key";
if (apcu_add($lockKey, 1, 10)) {        // 抢到锁:只有一个人去查库
    $row = $db->fetch($id);
    apcu_store($key, $row, 1800 + mt_rand(0, 300));
    apcu_delete($lockKey);
    return $row;
}
// 没抢到锁:短轮询等待,最多等 300ms
for ($i = 0; $i < 30; $i++) {
    usleep(10000);
    $val = apcu_fetch($key, $hit);
    if ($hit) return $val;
}
// 等不到就降级:用旧的 stale 值,或返回一个轻量页面
return $stale ?: $fallback;

这段代码有三个细节值得强调。一是锁的 TTL 必须大于最慢查询时间,否则锁会提前过期导致"羊群"重新聚起来;二是等待必须设上限并把上限设在 PHP 的 max_execution_time 之内,不然会把 worker 耗死;三是等待失败时一定要有降级路径,不能让请求变成 500。降级返回旧值(stale-while-revalidate 的思路)在内容站上往往是最好的选择,页面可能旧了 30 秒,但用户能打开。

第四层:缓存重启后的"冷启动"要提前设计

Redis 或 PHP-FPM 重启之后,缓存是空的。这是最典型的雪崩场景:重启本身几十秒,但重启后第一波流量会全部打到数据库上。如果这次重启是夜里被 OOM killer 干掉的,那么它会发生在你完全不知情的时候。

三个动作能显著降低冷启动风险:

一是关键页面预热。重启脚本里加一个预热步骤,按访问日志里最近一个小时访问量最高的 50 到 200 个 URL 顺序请求一遍(必须走内网地址绕开 CDN,用 curl --resolve 或直接打 127.0.0.1)。顺序请求而不是并发,否则预热本身又是一次雪崩。

awk '{print $7}' /var/log/nginx/access.log \
  | sed 's/?.*//' | sort | uniq -c | sort -rn | head -100 \
  | awk '{print $2}' > /tmp/hot.txt

while read -r u; do
  curl -s -o /dev/null --max-time 5 "http://127.0.0.1$u"
  sleep 0.05
done < /tmp/hot.txt

二是限制回源并发。用 Nginx 的 limit_conn 或 FPM 的进程池上限,给"打数据库的那条路径"设一个比总容量小的并发上限。挡不住的请求排队,虽然慢但不会让数据库直接躺下。

三是给数据库留缓冲。MySQL 侧的 max_connections 不要按峰值配太高(每个连接都有内存开销),而是让连接数在一个可控范围内排队,配合 PHP 侧短超时快速失败,形成背压。这部分细节在连接池那篇里展开过,这里只留下一个原则:宁可让一部分请求快速失败,也不要让全部请求一起变慢。

监控:怎么提前看到雪崩的苗头

雪崩是"几秒钟内发生"的事,事后看日志只能复盘。所以在日常巡检里应该留三组数字:

命中率的分时曲线。正常情况下这个曲线很平稳,如果某个时刻出现"命中率突然跌到 20% 以下、QPS 同时翻倍",那就是批量失效或缓存重启的信号。APCu 用 apcu_cache_info(),Redis 用 INFO stats 里的 keyspace_hits 与 keyspace_misses 计算。

回源请求数。如果前面有 CDN,看它报的源站请求数;如果前面是 Nginx fastcgi_cache,看 $upstream_cache_status 的分布。MISS 比例突然升高是同一个信号。

MySQL 的并发与等待。Threads_running 持续高于 CPU 核数 2 到 3 倍,说明已经在排队;Innodb_row_lock_waits 或 Handler_read_rnd_next 的突增也能看出回源查询在放大。

把这三组数字按分钟落盘,比任何实时告警都更有用。因为你真正需要的是一个"基线",有了基线才谈得上判断"现在是不是异常"。

落地顺序:先做哪几件最划算

如果只有半天时间,按这个顺序做:

第一步(半小时):把所有固定 TTL 加上 10% 随机抖动,并且把批量发布后的"清整站缓存"改成"只删改动键"。这一步成本极低,能消掉绝大部分自造的雪崩。

第二步(一小时):给读缓存的统一入口加空值缓存,TTL 60 秒,发布流程里删对应键。这能挡住扫描器和爬虫枚举带来的无效查询——这类请求往往占日志的一半以上。

第三步(两小时):给热点键加单飞锁和降级路径。首页、列表页、热门榜这三个位置优先。

第四步:写一个预热脚本,挂在缓存服务重启之后,不要只写在文档里——写在文档里的东西在出事那天没人会想得起来。

最后提醒一个容易被忽略的边界:如果站点用了 fastcgi_cache 或 CDN 这类"整页缓存",那么本文说的 APCu/Redis 层可能根本不是瓶颈,真正被刷爆的是整页缓存。判断方法很简单:看 Nginx 日志里的 $upstream_cache_status,如果大量 HIT,说明请求根本没进 PHP,此时清 APCu 是无效动作,要处理的是 fastcgi_cache 的 key 和刷新策略。诊断永远在动手之前。

Last modification:October 23rd, 2026 at 08:28 pm

Leave a Comment