在高并发 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、多级缓存、熔断限流 |
五、面试答题建议
回答这类问题时,不要只背定义。建议按以下结构组织:
- 先说现象:用一句话描述问题是什么、什么场景下会发生。
- 再说区别:穿透查的是不存在的数据,击穿是单个热点 key 失效,雪崩是大面积失效。
- 重点讲方案:结合 PHP 代码说明具体实现,比如
setnx做互斥锁、TTL 加随机值、布隆过滤器拦截。 - 补充工程实践:提到监控告警、降级预案、压测验证等,体现你不仅懂原理,还真正落地过。
把这三点讲透,面试官基本就能判断你对缓存体系有实战理解,而不是停留在八股文层面。
未经允许不得转载:任鹏个人博客 » PHP 面试题:Redis 缓存穿透、缓存击穿、缓存雪崩在 PHP 项目中的解决方案

