PHP 面试题:Redis 缓存穿透、缓存击穿、缓存雪崩在 PHP 项目中的解决方案

在高并发 PHP 项目中,Redis 通常作为 MySQL 前面的缓存层使用。缓存层一旦出现问题,数据库将直接暴露在流量洪峰之下,轻则响应变慢,重则宕机。缓存穿透、缓存击穿、缓存雪崩是面试中高频出现的三兄弟问题,很多候选人能背出概念,但被追问“在你的 PHP 项目里具体怎么写”时却答不上来。本文从实际代码出发,把这三个问题的成因和落地方案讲清楚。

一、缓存穿透

问题本质

缓存穿透是指查询一个数据库中也不存在的数据。由于缓存中没有,每次请求都会打到数据库,数据库也查不到,自然也不会回写缓存。如果有人恶意用不存在的 ID 频繁请求,数据库压力会持续升高。

典型场景:用户请求 GET /user/999999,而 users 表中根本没有 ID 为 999999 的记录。

解决方案

方案一:缓存空值

即使数据库查不到,也往 Redis 写一个空值或特殊标记,并设置较短的过期时间。

public function getUserById(int $id): ?array
{
    $cacheKey = "user:info:{$id}";
    $cache = $this->redis->get($cacheKey);

    if ($cache !== false) {
        // 命中空值标记,直接返回 null
        return $cache === '__NULL__' ? null : json_decode($cache, true);
    }

    $user = $this->userModel->find($id);

    if ($user === null) {
        // 缓存空值,过期时间不宜过长,防止数据后续被创建后长时间不一致
        $this->redis->setex($cacheKey, 60, '__NULL__');
        return null;
    }

    $this->redis->setex($cacheKey, 3600, json_encode($user));
    return $user;
}

方案二:布隆过滤器

在缓存之前加一层布隆过滤器,把所有可能存在的 key 预先加载进去。请求先经过布隆过滤器,不存在的 key 直接拦截。

// 使用 RedisBloom 扩展
public function mightExist(int $id): bool
{
    return (bool) $this->redis->rawCommand('BF.EXISTS', 'user:bloom', (string)$id);
}

// 新增用户时加入布隆过滤器
public function addToBloom(int $id): void
{
    $this->redis->rawCommand('BF.ADD', 'user:bloom', (string)$id);
}

布隆过滤器的优点是内存占用极小,缺点是有误判率(可能把不存在的判为存在),且删除元素困难。实际项目中,缓存空值 + 布隆过滤器组合使用效果最好。

二、缓存击穿

问题本质

缓存击穿是指某个热点 key 突然过期,此时大量并发请求同时涌入,全部打到数据库去查询同一条数据。注意它和穿透的区别:穿透查的是不存在的数据,击穿查的是存在的数据,只是缓存刚好失效了。

典型场景:首页某个热门商品的缓存设了 30 分钟过期,过期瞬间有上万请求同时进来。

解决方案

方案一:互斥锁(Mutex Lock)

只允许一个请求去数据库加载数据,其他请求等待或重试。

public function getHotProduct(int $productId): ?array
{
    $cacheKey = "product:hot:{$productId}";
    $lockKey  = "lock:product:{$productId}";

    $data = $this->redis->get($cacheKey);
    if ($data !== false) {
        return json_decode($data, true);
    }

    // 尝试获取锁,SET NX EX 保证原子性
    $locked = $this->redis->set($lockKey, '1', ['nx', 'ex' => 10]);

    if ($locked) {
        try {
            // 双重检查:拿到锁后再查一次缓存,防止重复加载
            $data = $this->redis->get($cacheKey);
            if ($data !== false) {
                return json_decode($data, true);
            }

            $product = $this->productModel->find($productId);
            if ($product) {
                $this->redis->setex($cacheKey, 3600, json_encode($product));
            }
            return $product;
        } finally {
            $this->redis->del($lockKey);
        }
    }

    // 没拿到锁,短暂等待后重试
    usleep(100000); // 100ms
    return $this->getHotProduct($productId);
}

方案二:逻辑过期

不设置 Redis 的 TTL,而是在 value 中嵌入一个逻辑过期时间。发现逻辑过期后,异步更新缓存,当前请求先返回旧数据。这种方式保证请求永远不会等待,适合对可用性要求极高的场景。

$value = [
    'data'      => $product,
    'expire_at' => time() + 3600,
];
$this->redis->set($cacheKey, json_encode($value)); // 不设 TTL

三、缓存雪崩

问题本质

缓存雪崩是指大量 key 在同一时间集中过期,或者 Redis 服务本身宕机,导致所有请求同时涌向数据库。与击穿的区别在于:击穿是单个热点 key,雪崩是大面积 key 同时失效。

解决方案

方案一:过期时间加随机值

这是最简单也最有效的办法。给每个 key 的 TTL 加上一个随机偏移量,避免集中过期。

$baseTtl = 3600;
$randomTtl = $baseTtl + mt_rand(0, 600); // 在 1 小时基础上随机增加 0~10 分钟
$this->redis->setex($cacheKey, $randomTtl, json_encode($data));

方案二:多级缓存

在 Redis 前面再加一层本地缓存(如 APCu、Swoole Table),Redis 出问题时本地缓存还能顶一阵。

// 先查本地缓存
$local = apcu_fetch($cacheKey);
if ($local !== false) {
    return $local;
}

// 再查 Redis
$data = $this->redis->get($cacheKey);
if ($data !== false) {
    apcu_store($cacheKey, json_decode($data, true), 60);
    return json_decode($data, true);
}

// 最后查数据库
// ...

方案三:熔断与限流

当检测到数据库压力过大时,对非核心业务直接返回降级数据或默认值,保护数据库不被打垮。可以使用 Swoole 的协程限流器或 Redis 的令牌桶实现。

public function acquireToken(): bool
{
    $script = <<<LUA
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local token = redis.call('hget', key, 'token')
local last = redis.call('hget', key, 'last')
if token == false then
    token = capacity
    last = now
end
local delta = math.max(0, now - last) * rate
token = math.min(capacity, token + delta)
if token < 1 then
    return 0
end
redis.call('hmset', key, 'token', token - 1, 'last', now)
return 1
LUA;
    return (bool) $this->redis->eval($script, [$this->rateKey, 100, 200, microtime(true)], 1);
}

方案四:Redis 高可用

通过主从复制、哨兵或 Cluster 集群保证 Redis 本身的高可用,避免单点故障引发雪崩。

四、三者的对比总结

问题 触发条件 影响范围 核心对策
缓存穿透 查询不存在的数据 单条恶意请求反复打库 缓存空值、布隆过滤器
缓存击穿 单个热点 key 过期 同一条数据被并发查询 互斥锁、逻辑过期
缓存雪崩 大量 key 同时过期或 Redis 宕机 整个数据库被压垮 随机 TTL、多级缓存、熔断限流

五、面试答题建议

回答这类问题时,不要只背定义。建议按以下结构组织:

  1. 先说现象:用一句话描述问题是什么、什么场景下会发生。
  2. 再说区别:穿透查的是不存在的数据,击穿是单个热点 key 失效,雪崩是大面积失效。
  3. 重点讲方案:结合 PHP 代码说明具体实现,比如 setnx 做互斥锁、TTL 加随机值、布隆过滤器拦截。
  4. 补充工程实践:提到监控告警、降级预案、压测验证等,体现你不仅懂原理,还真正落地过。

把这三点讲透,面试官基本就能判断你对缓存体系有实战理解,而不是停留在八股文层面。

未经允许不得转载:任鹏个人博客 » PHP 面试题:Redis 缓存穿透、缓存击穿、缓存雪崩在 PHP 项目中的解决方案

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏