Redis 缓存穿透、击穿、雪崩:一套代码三个防护方案

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-16 02:19 ·1 浏览 ·0 回复

三个问题的根因完全不同:缓存穿透是"查的数据根本不存在",缓存击穿是"一个热点 key 刚好过期",缓存雪崩是"一大批 key 同时失效或 Redis 挂了"。所以防护方案也不该是一套模板硬套,正确做法是三招分开配——空值缓存+布隆过滤器防空查、互斥锁+逻辑过期防热点过期、TTL 随机化+多级缓存+降级兜底防集体失效。

先分清三者,别用错药

结论:判断依据是"失效的 key 有几个、数据在不在库里"——零个不存在的 key 反复查是穿透,一个热点 key 过期是击穿,成片 key 同时过期是雪崩。

  • 穿透:请求 `user:999999` 这种库里压根没有的 ID,缓存永远不命中,每次都落到数据库。典型来源是爬虫扫 ID 或恶意构造参数。
  • 击穿:某个爆款帖、首页配置这类热点 key 到了过期时间,恰好有成百上千个并发同时未命中,一起冲库。
  • 雪崩:凌晨批量预热时给几千个 key 写了同样的 3600 秒 TTL,一小时后集体到期;或者 Redis 实例宕机,缓存层整体失效。

搞错类型的代价很直接:给雪崩加布隆过滤器毫无意义,给穿透上互斥锁只会把一次查询变成排队。

穿透:不存在的也要"缓存起来"

结论:穿透用两招组合——短 TTL 的空值缓存挡住重复请求,布隆过滤器挡在缓存之前做第一道拦截。

空值缓存写法(PHP 示例,逻辑与语言无关):

function getPost($id) {
    $key = "post:$id";
    $val = $redis->get($key);
    if ($val !== false) {
        return $val === '__NULL__' ? null : json_decode($val, true);
    }
    // 第一道闸:布隆过滤器说"肯定不存在"就直接返回
    if (!$bloom->mightExist('post', $id)) return null;

    $row = $db->query("SELECT * FROM posts WHERE id = ?", [$id]);
    if (!$row) {
        // 空值缓存,TTL 必须短:60~300 秒
        $redis->setex($key, 180, '__NULL__');
        return null;
    }
    $redis->setex($key, 1800 + mt_rand(0, 300), json_encode($row));
    return $row;
}

两个注意点:一是空值 TTL 一定要短(建议 60~300 秒),否则数据后来真的被创建了,用户还要等几分钟才能看到;二是布隆过滤器只保证"说不存在就一定不存在",说存在可能误判,所以它只能放在缓存前面做粗筛,不能替代查库。

击穿:只让一个请求去重建缓存

结论:击穿用互斥重建(单飞)保证同一时刻只有一个请求查库,其余请求短暂等待或直接返回旧值。

$lock = "lock:post:$id";
if ($redis->set($lock, $token = uniqid('', true), ['nx', 'ex' => 10])) {
    try {
        $row = $db->query(...);              // 只有拿到锁的请求查库
        $redis->setex($key, 1800 + mt_rand(0,300), json_encode($row));
    } finally {
        // 比对 token 再删,避免删掉别人的锁
        $redis->eval("if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) end",
                     [$lock, $token], 1);
    }
} else {
    usleep(50000);
    return getPost($id);   // 重试一次,或直接返回上一版旧值
}

锁必须带过期时间(`ex`),否则持有者进程挂掉就是死锁。对延迟极度敏感的热点,改用逻辑过期:value 里塞一个 `expire_at` 字段,读到已过期也先把旧数据返回给用户,另起一个异步任务重建缓存——用户永远不等待,代价是短时间读到稍旧的数据。

雪崩:让 key 不同时死,也别把命交给一个 Redis

结论:雪崩防护是三件事——TTL 打散、本地缓存兜一层、Redis 出问题时服务能降级而不是直接 500。

TTL 打散只要一行:`$ttl = $base + mt_rand(0, (int)($base * 0.2));`,让同一批写入的 key 在 20% 的区间内错峰过期。同时避免在整点批量预热,改成随机时间点分批加载。

在此之上加多级缓存:进程内本地缓存(APCu、Swoole Table 或简单数组)挡掉大量只读热点,Redis 作为第二层,数据库最后兜底。很多轻量 PHP 系统本身就提供了封装好的缓存方法,比如 Clara BBS 公共设施里的 `Cache::remember`,用法就是"给 key 和回调,命中直接返回、未命中执行回调并写缓存",能顺手把空值处理和 TTL 抖动收在一个入口里,不必每处手写。

最后一定要有降级路径:缓存不可用时返回兜底数据(默认配置、上一版快照、静态页),并配合熔断,宁可少几个非核心模块,也别让数据库连接池被打满。

落地顺序和三个常见坑

结论:优先做成本最低、收益最大的——先 TTL 随机化,再加互斥锁,最后上布隆过滤器。

坑一:锁里做重活。拿到锁之后如果执行的是全表扫描或者跨服务调用,等待的请求会成片超时,锁内逻辑必须足够快。坑二:缓存空值不区分 key 前缀,跟正常数据混在一起,清理时容易误删。坑三:只加缓存不加监控,缓存命中率、Redis 连接数、锁等待时长这三个指标不看,出问题时只能靠猜。

把这三层防护按穿透、击穿、雪崩分别配好,再配合监控,缓存层才算真正能扛住高并发,而不是把数据库的压力推迟到下一个整点。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-389.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~