前言
Redis 作为缓存使用时,会遇到三大经典问题:缓存穿透、缓存击穿和缓存雪崩。这三个问题在实际生产中频繁出现,处理不当可能导致数据库被压垮甚至服务不可用。本文将深入分析每个问题的成因并给出完整解决方案。
一、缓存穿透
1.1 问题描述
用户请求的数据在缓存和数据库中都不存在,每次请求都直接打到数据库。常见于恶意攻击(用不存在的 ID 大量请求)或业务异常数据。
请求 → 缓存MISS → 数据库MISS → 返回空
↑ │
└──── 不断重复 ──────────────────────┘1.2 危害
- 数据库承受大量无效查询
- 如果是恶意攻击,可能打垮数据库
- 浪费服务器资源
1.3 解决方案
方案一:缓存空值
function getUser($id) {
$cacheKey = "user:{$id}";
$data = $redis->get($cacheKey);
if ($data !== false) {
// 命中缓存(包括空值缓存)
return $data === 'NULL' ? null : json_decode($data, true);
}
// 查数据库
$data = $db->query("SELECT * FROM users WHERE id = ?", [$id]);
if (empty($data)) {
// 缓存空值,设置较短过期时间(60秒)
$redis->setex($cacheKey, 60, 'NULL');
return null;
}
$redis->setex($cacheKey, 3600, json_encode($data));
return $data;
}优点:实现简单
缺点:可能缓存大量空值,占用内存;数据后来新增了也不会被感知(除非过期)
方案二:布隆过滤器(Bloom Filter)
// 系统初始化时,将所有存在的ID加入布隆过滤器
function initBloomFilter() {
$redis->del('bloom:user_ids');
$userIds = $db->query("SELECT id FROM users");
foreach ($userIds as $id) {
// 使用多个哈希函数
bloomAdd('bloom:user_ids', $id);
}
}
// 检查ID是否可能存在
function bloomAdd($key, $value) {
$hashes = multiHash($value);
foreach ($hashes as $h) {
$redis->setBit($key, $h, 1);
}
}
function bloomExists($key, $value) {
$hashes = multiHash($value);
foreach ($hashes as $h) {
if ($redis->getBit($key, $h) == 0) {
return false; // 一定不存在
}
}
return true; // 可能存在(有误判率)
}
// 请求处理
function getUser($id) {
// 先过布隆过滤器
if (!bloomExists('bloom:user_ids', $id)) {
return null; // 一定不存在,直接返回
}
// 再查缓存和数据库
// ...
}方案三:接口层参数校验
// 在入口处拦截非法参数
function getUser($id) {
// ID 必须是正整数
if (!is_numeric($id) || $id <= 0 || $id > PHP_INT_MAX) {
throw new InvalidArgumentException('Invalid user ID');
}
// 其他业务规则校验
if (strlen($id) > 10) {
throw new InvalidArgumentException('ID too long');
}
// 正常流程...
}二、缓存击穿
2.1 问题描述
某个热点 key 突然过期(或被删除),大量并发请求同时访问这个 key,全部穿透到数据库。
热点key过期
│
├──→ 请求1 → 缓存MISS → 查DB ──┐
├──→ 请求2 → 缓存MISS → 查DB ──┤
├──→ 请求3 → 缓存MISS → 查DB ──┤
├──→ 请求N → 缓存MISS → 查DB ──┤
│ │
└──────── 数据库被压垮 ◄────────┘2.2 解决方案
方案一:互斥锁(推荐)
function getHotData($key) {
$data = $redis->get($key);
if ($data !== false) {
return json_decode($data, true);
}
// 获取互斥锁
$lockKey = "lock:{$key}";
$lockValue = uniqid('', true);
// SET NX EX 尝试加锁
$locked = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 10]);
if ($locked) {
try {
// 再次检查缓存(双重检查)
$data = $redis->get($key);
if ($data !== false) {
return json_decode($data, true);
}
// 查数据库
$data = $db->query("SELECT * FROM hot_table WHERE key_name = ?", [$key]);
// 写入缓存
$redis->setex($key, 3600, json_encode($data));
return $data;
} finally {
// 释放锁(Lua脚本保证原子性)
$lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
$redis->eval($lua, [$lockKey, $lockValue], 1);
}
} else {
// 没拿到锁,短暂等待后重试
usleep(100000); // 100ms
return getHotData($key); // 递归重试
}
}方案二:逻辑过期
不给 key 设置 TTL,而是在 value 中存储逻辑过期时间。后台异步刷新:
function getWithLogicalExpire($key, $expireSeconds) {
$data = $redis->get($key);
if ($data === false) {
return null; // 首次加载,需要手动初始化
}
$cached = json_decode($data, true);
$now = time();
// 未逻辑过期,直接返回
if ($cached['expire_at'] > $now) {
return $cached['data'];
}
// 已逻辑过期,尝试获取锁刷新
$lockKey = "lock:refresh:{$key}";
$locked = $redis->set($lockKey, '1', ['NX', 'EX' => 10]);
if ($locked) {
// 后台异步刷新(非阻塞)
// 实际项目中可以用队列或协程
go(function() use ($key, $expireSeconds) {
$data = $db->query("SELECT * FROM table WHERE key_name = ?", [$key]);
$redis->set($key, json_encode([
'data' => $data,
'expire_at' => time() + $expireSeconds,
]));
$redis->del("lock:refresh:{$key}");
});
}
// 无论是否刷新,先返回旧数据(不阻塞用户)
return $cached['data'];
}方案三:永不过期 + 主动更新
// 热点数据不设置过期时间
$redis->set('hot:ranking', json_encode($data));
// 不设置 TTL
// 数据变更时主动更新缓存
function updateRanking() {
$data = $db->query("SELECT * FROM ranking ORDER BY score DESC LIMIT 100");
$redis->set('hot:ranking', json_encode($data));
// 仍然不设置 TTL
}
// 定时任务刷新
// crontab: */10 * * * * php artisan ranking:refresh三、缓存雪崩
3.1 问题描述
大量 key 在同一时间集中过期,或者 Redis 宕机,导致大量请求同时打到数据库。
大量key同时过期
│
├──→ 请求1 → MISS → 查DB ──┐
├──→ 请求2 → MISS → 查DB ──┤
├──→ 请求3 → MISS → 查DB ──┤
├──→ 请求N → MISS → 查DB ──┤
│ │
└──── 数据库被压垮 ◄────────┘3.2 与缓存击穿的区别
| 对比 | 缓存击穿 | 缓存雪崩 |
|---|---|---|
| 影响范围 | 单个热点 key | 大量 key |
| 原因 | 热点 key 过期 | 大量 key 同时过期 / Redis 宕机 |
| 量级 | 小 | 大 |
3.3 解决方案
方案一:随机过期时间
function cacheData($key, $data, $baseTTL = 3600) {
// 在基础TTL上加随机偏移,避免同时过期
$randomTTL = $baseTTL + mt_rand(0, 600); // 0~10分钟随机
$redis->setex($key, $randomTTL, json_encode($data));
}
// 批量缓存时尤其重要
function batchCache($items) {
foreach ($items as $key => $value) {
$ttl = 3600 + mt_rand(0, 1800); // 1~1.5小时随机
$redis->setex($key, $ttl, json_encode($value));
}
}方案二:多级缓存
function getWithMultiLevel($key) {
// L1: 本地缓存(进程内)
$data = $localCache->get($key);
if ($data !== null) {
return $data;
}
// L2: Redis 缓存
$data = $redis->get($key);
if ($data !== false) {
$localCache->set($key, $data, 60); // 本地缓存60秒
return json_decode($data, true);
}
// L3: 数据库
$data = $db->query("SELECT * FROM table WHERE key_name = ?", [$key]);
if ($data) {
$redis->setex($key, 3600 + mt_rand(0, 600), json_encode($data));
$localCache->set($key, $data, 60);
}
return $data;
}方案三:熔断降级
function getProductList($categoryId) {
try {
$data = $redis->get("products:{$categoryId}");
if ($data !== false) {
return json_decode($data, true);
}
// 数据库查询带超时和限流
$data = $db->query("SELECT * FROM products WHERE category_id = ?",
[$categoryId], ['timeout' => 1]);
$redis->setex("products:{$categoryId}",
3600 + mt_rand(0, 600),
json_encode($data));
return $data;
} catch (Exception $e) {
// 降级:返回默认数据或静态数据
return getDefaultProductList();
}
}方案四:Redis 高可用
# Redis Sentinel 哨兵模式自动故障转移
sentinel monitor mymaster 192.168.1.10 6379 2
sentinel down-after-milliseconds mymaster 30000
sentinel failover-timeout mymaster 180000
# Redis Cluster 集群模式
# 数据分散在多个节点,单节点宕机不影响整体四、综合对比
| 问题 | 本质 | 核心方案 | 推荐组合 |
|---|---|---|---|
| 缓存穿透 | 查询不存在数据 | 布隆过滤器 + 空值缓存 | 布隆过滤器 + 短TTL空值 |
| 缓存击穿 | 热点key过期 | 互斥锁 + 双重检查 | 互斥锁 + 逻辑过期 |
| 缓存雪崩 | 大量key同时过期 | 随机TTL + 多级缓存 | 随机TTL + 熔断降级 |
五、完整防护代码示例
class CacheService {
private $redis;
private $db;
private $localCache = [];
/**
* 安全缓存读取(防穿透+击穿+雪崩)
*/
public function get($key, $dbQuery, $ttl = 3600) {
// 1. 本地缓存
if (isset($this->localCache[$key])) {
return $this->localCache[$key];
}
// 2. Redis 缓存
$data = $this->redis->get($key);
if ($data !== false) {
if ($data === 'NULL') return null;
$this->localCache[$key] = json_decode($data, true);
return $this->localCache[$key];
}
// 3. 互斥锁防击穿
$lockKey = "lock:{$key}";
$locked = $this->redis->set($lockKey, '1', ['NX', 'EX' => 10]);
if (!$locked) {
usleep(100000); // 等待100ms
return $this->get($key, $dbQuery, $ttl); // 重试
}
try {
// 双重检查
$data = $this->redis->get($key);
if ($data !== false) {
return $data === 'NULL' ? null : json_decode($data, true);
}
// 4. 查数据库
$data = $dbQuery();
// 5. 缓存(随机TTL防雪崩)
$randomTTL = $ttl + mt_rand(0, 300);
if ($data === null) {
// 空值缓存(短TTL防穿透)
$this->redis->setex($key, 60, 'NULL');
} else {
$this->redis->setex($key, $randomTTL, json_encode($data));
$this->localCache[$key] = $data;
}
return $data;
} finally {
$this->redis->del($lockKey);
}
}
}
// 使用
$cache = new CacheService();
$user = $cache->get("user:1001", function() use ($db) {
return $db->query("SELECT * FROM users WHERE id = 1001");
}, 3600);总结
缓存三大问题的核心防护策略:
- 穿透:布隆过滤器拦截 + 空值缓存兜底
- 击穿:互斥锁保证只有一个请求查DB + 双重检查
- 雪崩:随机TTL打散过期时间 + 多级缓存 + 熔断降级
生产环境建议三者组合使用,形成完整的缓存防护体系。
评论 (0)