在高并发场景下,缓存是提升系统性能的利器,但使用不当也会带来一系列棘手问题。面试中,缓存穿透、缓存击穿、缓存雪崩堪称 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 中如何处理缓存雪崩”时,建议按以下结构回答:
- 先定义:一句话解释问题本质。
- 再区分:与穿透、击穿的区别,体现知识体系完整。
- 给方案:从简单到复杂,过期随机化 → 互斥锁 → 多级缓存 → 熔断降级。
- 结合框架:提到 ThinkPHP 的
Cache门面、Redis 驱动配置、队列异步刷新等。 - 谈权衡:一致性与可用性的取舍,业务场景决定方案。
六、总结
| 问题 | 核心原因 | 首选方案 |
|---|---|---|
| 缓存穿透 | 查不存在的数据 | 布隆过滤器 + 空值缓存 |
| 缓存击穿 | 热点 Key 过期 | 互斥锁 + 逻辑过期 |
| 缓存雪崩 | 大量 Key 同时过期 | 过期随机化 + 多级缓存 |
在 ThinkPHP 项目中,合理利用 Cache 门面、Redis 原子操作和队列组件,就能构建一套健壮的缓存防护体系。面试时不仅要答出方案,更要讲清为什么选它以及什么场景下不适用,这才是高级工程师的思维体现。
未经允许不得转载:任鹏个人博客 » ThinkPHP 面试精讲:缓存穿透、击穿与雪崩解决方案

