缓存策略:缓存穿透、缓存击穿、缓存雪崩的PHP解决方案
各位同学,今天我们来聊聊缓存世界里最让人头疼的三个问题。说实话,我刚入行那会儿,这三个概念经常搞混。直到有一次线上事故,缓存雪崩直接把数据库打挂了,我才真正体会到它们的威力。
嗯,咱们一个一个来拆解。说白了,这三个问题都是缓存没命中时引发的连锁反应,但各自的场景和解决方案完全不同。
一、缓存穿透:打穿缓存层的"刺客"
什么是缓存穿透?
缓存穿透,就是请求的数据在缓存和数据库中都不存在。每次请求都直接打到数据库上。你想想看,如果有人恶意构造大量不存在的key来请求,数据库瞬间就扛不住了。
我在项目中遇到过这种情况:有个用户查询接口,前端传了个不存在的ID,结果每次请求都穿透到MySQL。那天数据库CPU直接飙到100%,差点把整个服务搞崩。
核心问题:缓存和数据库都没有该数据,请求直接穿透缓存打到数据库。
解决方案一:缓存空对象
这是最直接的办法。既然数据不存在,那就把"不存在"这个结果也缓存起来。
<?php
class CachePenetrationResolver {
private $redis;
private $db;
private $emptyTtl = 60; // 空值缓存60秒
public function getData($key) {
// 先查缓存
$data = $this->redis->get($key);
if ($data !== false) {
// 判断是否是空值标记
if ($data === 'NULL_VALUE') {
return null;
}
return json_decode($data, true);
}
// 缓存未命中,查数据库
$data = $this->db->query("SELECT * FROM users WHERE id = ?", [$key]);
if (empty($data)) {
// 缓存空对象,防止穿透
$this->redis->setex($key, $this->emptyTtl, 'NULL_VALUE');
return null;
}
// 正常数据缓存
$this->redis->setex($key, 3600, json_encode($data));
return $data;
}
}
我的建议:空值缓存的过期时间不要太长,一般30-60秒就够了。太长会导致正常数据写入后,用户还看到空结果。
解决方案二:布隆过滤器
说白了,布隆过滤器就是一个很省内存的"存在性判断"工具。它告诉你"这个key一定不存在"或者"可能存在"。
<?php
class BloomFilterCache {
private $redis;
private $bloomKey = 'user_bloom_filter';
public function initBloomFilter($capacity = 100000, $errorRate = 0.001) {
// 使用Redis的布隆过滤器模块
$this->redis->rawCommand('BF.RESERVE', $this->bloomKey, $errorRate, $capacity);
}
public function addToFilter($userId) {
$this->redis->rawCommand('BF.ADD', $this->bloomKey, $userId);
}
public function getData($userId) {
// 先过布隆过滤器
$exists = $this->redis->rawCommand('BF.EXISTS', $this->bloomKey, $userId);
if (!$exists) {
// 一定不存在,直接返回
return null;
}
// 可能存在,继续查缓存和数据库
// ... 后续逻辑同上
}
}
注意:布隆过滤器有误判率,它会说"可能存在"但实际上不存在。不过它说"不存在"那就一定不存在。这个特性正好适合做穿透防护。
二、缓存击穿:热点key的"突然死亡"
什么是缓存击穿?
缓存击穿和穿透不一样。击穿是指一个热点key在缓存过期的一瞬间,大量并发请求同时打到数据库上。说白了,就是缓存失效的那一瞬间,流量把数据库冲垮了。
我记得有一次双十一活动,某个爆款商品的详情页缓存刚好在高峰期过期了。结果几千个请求同时去查数据库,MySQL直接报连接数超限。嗯,那次教训挺深刻的。
核心问题:热点key过期瞬间,大量并发请求同时访问数据库。
解决方案一:互斥锁(Mutex)
让第一个请求去查数据库并重建缓存,其他请求等待。说白了就是"排队"。
<?php
class CacheBreakdownResolver {
private $redis;
private $db;
private $lockTimeout = 5; // 锁超时时间
public function getHotData($key) {
// 先查缓存
$data = $this->redis->get($key);
if ($data !== false) {
return json_decode($data, true);
}
// 尝试获取锁
$lockKey = $key . ':lock';
$lockValue = uniqid('', true);
// 使用setnx实现分布式锁
$locked = $this->redis->set($lockKey, $lockValue, ['nx', 'ex' => $this->lockTimeout]);
if ($locked) {
try {
// 双重检查,防止重复查询
$data = $this->redis->get($key);
if ($data !== false) {
return json_decode($data, true);
}
// 查数据库
$data = $this->db->query("SELECT * FROM hot_products WHERE id = ?", [$key]);
// 重建缓存
$this->redis->setex($key, 3600, json_encode($data));
return $data;
} finally {
// 释放锁,使用Lua脚本保证原子性
$script = <<<'LUA'
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
LUA;
$this->redis->eval($script, [$lockKey, $lockValue], 1);
}
}
// 没拿到锁,等待后重试
usleep(10000); // 等待10毫秒
return $this->getHotData($key);
}
}
避坑指南:我曾经在释放锁时直接用del命令,结果发现如果锁超时自动释放了,当前线程再去del就会误删其他线程的锁。所以一定要用Lua脚本判断锁的持有者。
解决方案二:逻辑过期
不给缓存设置物理过期时间,而是在数据里带一个逻辑过期时间。后台起一个线程去检查并更新缓存。
<?php
class LogicalExpireCache {
private $redis;
private $db;
public function getHotData($key) {
$data = $this->redis->get($key);
if (!$data) {
return null; // 缓存没了,直接返回
}
$cacheData = json_decode($data, true);
// 检查逻辑过期时间
if ($cacheData['expire_time'] < time()) {
// 异步更新缓存
$this->asyncRefreshCache($key);
}
// 返回旧数据,保证响应速度
return $cacheData['data'];
}
private function asyncRefreshCache($key) {
// 使用Redis队列或消息队列异步处理
$this->redis->lPush('cache_refresh_queue', $key);
}
}
三、缓存雪崩:大面积缓存同时失效
什么是缓存雪崩?
缓存雪崩是指大量缓存key在同一时间过期,或者缓存服务宕机,导致所有请求都打到数据库上。这比击穿更可怕,因为它是大面积、全方位的冲击。
我经历过一次最惨的事故:那天凌晨我们统一给所有缓存设置了凌晨3点过期,结果3点一到,数据库瞬间被几万个请求打爆。整个系统瘫痪了20分钟,老板差点把我叫去喝茶。
核心问题:大量缓存同时失效,数据库瞬间承受巨大压力。
解决方案一:过期时间加随机值
这是最简单有效的办法。不要让所有key在同一时间过期,给过期时间加个随机偏移量。
<?php
class CacheAvalanchePreventer {
private $redis;
public function setCache($key, $data, $baseTtl = 3600) {
// 基础过期时间加上随机偏移,范围0-300秒
$randomOffset = mt_rand(0, 300);
$ttl = $baseTtl + $randomOffset;
$this->redis->setex($key, $ttl, json_encode($data));
}
// 批量设置缓存时尤其重要
public function batchSetCache(array $items, $baseTtl = 3600) {
$pipe = $this->redis->pipeline();
foreach ($items as $key => $data) {
$randomOffset = mt_rand(0, 300);
$ttl = $baseTtl + $randomOffset;
$pipe->setex($key, $ttl, json_encode($data));
}
$pipe->exec();
}
}
解决方案二:多级缓存架构
不要把所有鸡蛋放在一个篮子里。用本地缓存+Redis缓存+数据库,形成三级防护。
<?php
class MultiLevelCache {
private $localCache = []; // 本地缓存
private $redis;
private $db;
private $localTtl = 60; // 本地缓存60秒
private $redisTtl = 3600; // Redis缓存1小时
public function getData($key) {
// 第一级:本地缓存
if (isset($this->localCache[$key])) {
$cache = $this->localCache[$key];
if ($cache['expire_time'] > time()) {
return $cache['data'];
}
}
// 第二级:Redis缓存
$data = $this->redis->get($key);
if ($data !== false) {
// 回填本地缓存
$this->localCache[$key] = [
'data' => json_decode($data, true),
'expire_time' => time() + $this->localTtl
];
return $this->localCache[$key]['data'];
}
// 第三级:数据库
$data = $this->db->query("SELECT * FROM products WHERE id = ?", [$key]);
// 回填各级缓存
$this->redis->setex($key, $this->redisTtl, json_encode($data));
$this->localCache[$key] = [
'data' => $data,
'expire_time' => time() + $this->localTtl
];
return $data;
}
}
解决方案三:限流降级
当缓存大面积失效时,主动限流保护数据库。说白了就是"丢车保帅"。
<?php
class RateLimiter {
private $redis;
private $maxRequests = 100; // 每秒最多100个请求
private $windowSize = 1; // 时间窗口1秒
public function allowRequest($key = 'global') {
$current = $this->redis->get($key . ':count');
if ($current !== false && $current >= $this->maxRequests) {
return false; // 限流
}
$this->redis->multi();
$this->redis->incr($key . ':count');
$this->redis->expire($key . ':count', $this->windowSize);
$this->redis->exec();
return true;
}
}
四、三种问题的对比总结
| 问题类型 | 触发场景 | 影响范围 | 推荐方案 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 单个key | 布隆过滤器 + 空值缓存 |
| 缓存击穿 | 热点key过期瞬间 | 单个热点key | 互斥锁 + 逻辑过期 |
| 缓存雪崩 | 大量key同时过期 | 大面积 | 随机过期时间 + 多级缓存 + 限流 |
我的经验总结:实际项目中,这三种问题往往同时存在。我建议你做一个统一的缓存中间件,把布隆过滤器、互斥锁、随机过期时间都集成进去。这样上层业务代码只需要调用一个get方法,底层自动处理各种异常情况。
好了,今天的内容就到这里。这三种缓存问题,说白了就是"缓存没命中"的三种不同表现形式。理解了它们的本质区别,你就能针对性地选择解决方案。下次遇到线上缓存问题,别慌,先判断是穿透、击穿还是雪崩,然后对症下药。