ThinkPHP 面试精讲:缓存穿透、击穿与雪崩解决方案

在高并发场景下,缓存是提升系统性能的利器,但使用不当也会带来一系列棘手问题。面试中,缓存穿透、缓存击穿、缓存雪崩堪称 PHP 工程师的“必考题”,尤其结合 ThinkPHP 框架时,面试官更希望听到你如何在实际项目中落地解决方案。本文将从概念、区别到 ThinkPHP 中的代码实现,帮你彻底理清思路。

一、三兄弟到底有什么区别?

很多开发者容易混淆这三个概念,先用一句话总结:

  • 缓存穿透:查询一个数据库和缓存都不存在的数据,每次请求都打到数据库。
  • 缓存击穿:某个热点 Key 突然过期,大量并发请求同时打到数据库。
  • 缓存雪崩:大量 Key 同时过期,或缓存服务宕机,导致数据库瞬间被压垮。

它们的共同点是都会让数据库压力骤增,但触发条件和应对策略截然不同。

二、缓存穿透:布隆过滤器 + 空值缓存

场景复现

恶意用户频繁请求 id = -1 的商品详情。缓存中没有,数据库中也没有,于是每次请求都穿透到 MySQL。

解决方案

方案一:缓存空值

即使数据库查询结果为空,也将一个特殊标记(如 NULL 或 __EMPTY__)写入缓存,并设置较短的过期时间(如 60 秒)。

public function getProduct($id)
{
    $cacheKey = 'product:' . $id;
    $data = Cache::get($cacheKey);

    if ($data === '__EMPTY__') {
        return null; // 直接返回空,不再查库
    }

    if ($data !== null) {
        return $data;
    }

    $product = Db::name('product')->where('id', $id)->find();

    if (empty($product)) {
        Cache::set($cacheKey, '__EMPTY__', 60);
        return null;
    }

    Cache::set($cacheKey, $product, 3600);
    return $product;
}

方案二:布隆过滤器

在缓存层之前加一层布隆过滤器,将所有合法 ID 预先加载进去。请求先经过布隆过滤器,不存在的 ID 直接拦截。

ThinkPHP 中可借助 predis 扩展使用 Redis 的 Bloom Filter 模块,或使用 php-bloom-filter 库自行实现。布隆过滤器存在误判率,但不会漏判,适合对准确性要求不极端的场景。

面试加分点:空值缓存可能被大量随机 ID 攻击导致 Redis 内存暴涨,可配合限流或 IP 黑名单使用。

三、缓存击穿:互斥锁 + 逻辑过期

场景复现

秒杀活动中,某个爆款商品的缓存 Key 过期瞬间,1 万个请求同时发现缓存失效,全部涌向数据库。

解决方案

方案一:互斥锁(推荐)

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

public function getHotProduct($id)
{
    $cacheKey = 'hot_product:' . $id;
    $data = Cache::get($cacheKey);

    if ($data !== null) {
        return $data;
    }

    // 尝试获取锁,锁的过期时间要大于业务执行时间
    $lockKey = 'lock:' . $cacheKey;
    $lock = Cache::store('redis')->handler()->set(
        $lockKey, 1, ['nx', 'ex' => 10]
    );

    if ($lock) {
        try {
            $product = Db::name('product')->where('id', $id)->find();
            Cache::set($cacheKey, $product, 3600);
            return $product;
        } finally {
            Cache::store('redis')->handler()->del($lockKey);
        }
    }

    // 未获取到锁,短暂休眠后重试
    usleep(100000);
    return $this->getHotProduct($id);
}

方案二:逻辑过期

缓存中不设置物理过期时间,而是存储一个 expire_at 字段。发现逻辑过期后,异步更新缓存,当前请求返回旧数据。

$data = Cache::get($cacheKey);
if ($data && $data['expire_at'] > time()) {
    return $data['value'];
}

// 逻辑过期,异步刷新
if ($data) {
    // 投递队列任务更新缓存
    Queue::push(RefreshCacheJob::class, ['id' => $id]);
    return $data['value']; // 先返回旧数据
}

这种方式牺牲了短暂的数据一致性,换取高可用性,适合商品详情等对实时性要求不高的场景。

四、缓存雪崩:过期时间随机化 + 多级缓存

场景复现

凌晨 0 点,运维批量预热了 10 万个 Key,统一设置 1 小时过期。1 点整,所有 Key 同时失效,数据库瞬间 QPS 飙升到 5 万,直接宕机。

解决方案

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

这是最简单也最有效的手段。

$baseTtl = 3600;
$randomTtl = $baseTtl + mt_rand(0, 600); // 在 1 小时基础上加 0~10 分钟随机
Cache::set($cacheKey, $data, $randomTtl);

方案二:多级缓存

使用“本地缓存 + Redis 缓存”双层结构。本地缓存用 ThinkPHP 的 File 驱动或 Swoole 的 Table,Redis 作为第二层。即使 Redis 全部失效,本地缓存仍能抵挡一部分流量。

// 第一层:本地缓存(进程内)
$localKey = 'local_product:' . $id;
$data = $this->localCache->get($localKey);

if ($data !== null) {
    return $data;
}

// 第二层:Redis
$data = Cache::get('product:' . $id);
if ($data !== null) {
    $this->localCache->set($localKey, $data, 60);
    return $data;
}

// 第三层:数据库
$data = Db::name('product')->where('id', $id)->find();
Cache::set('product:' . $id, $data, 3600 + mt_rand(0, 600));
$this->localCache->set($localKey, $data, 60);
return $data;

方案三:Redis 高可用

使用 Redis 哨兵或集群模式,避免单点故障。同时开启持久化,宕机后能快速恢复数据。

方案四:熔断降级

在应用层接入熔断器(如 Hystrix 思想),当数据库请求失败率达到阈值时,直接返回兜底数据或友好提示,保护数据库不被拖垮。

五、面试答题框架

当面试官问到“ThinkPHP 中如何处理缓存雪崩”时,建议按以下结构回答:

  1. 先定义:一句话解释问题本质。
  2. 再区分:与穿透、击穿的区别,体现知识体系完整。
  3. 给方案:从简单到复杂,过期随机化 → 互斥锁 → 多级缓存 → 熔断降级。
  4. 结合框架:提到 ThinkPHP 的 Cache 门面、Redis 驱动配置、队列异步刷新等。
  5. 谈权衡:一致性与可用性的取舍,业务场景决定方案。

六、总结

问题 核心原因 首选方案
缓存穿透 查不存在的数据 布隆过滤器 + 空值缓存
缓存击穿 热点 Key 过期 互斥锁 + 逻辑过期
缓存雪崩 大量 Key 同时过期 过期随机化 + 多级缓存

在 ThinkPHP 项目中,合理利用 Cache 门面、Redis 原子操作和队列组件,就能构建一套健壮的缓存防护体系。面试时不仅要答出方案,更要讲清为什么选它以及什么场景下不适用,这才是高级工程师的思维体现。

未经允许不得转载:任鹏个人博客 » ThinkPHP 面试精讲:缓存穿透、击穿与雪崩解决方案

赞 (0) 打赏

评论 0

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

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

支付宝扫一扫打赏

微信扫一扫打赏