本文目录导读:

在PHP项目中,缓存雪崩是一个非常典型的性能问题。缓存雪崩是指大量的缓存数据在同一时间段过期,导致所有请求直接打到数据库,从而压垮数据库。
实现防护的核心思想是避免集中过期和启用降级机制,以下是几种在PHP项目中可行的具体方案,按推荐程度排序:
设置不同的过期时间(防止集中失效)
这是最基础也最常用的方法,不要让所有缓存都在同一时刻过期。
-
原理: 在设置缓存的有效期时,加入一个随机值(通常是几秒到几分钟)。
-
PHP 示例(使用 Redis 或 Memcached):
<?php class CacheService { private $redis; private $baseTTL; // 基础过期时间,3600 秒 public function __construct($redis, $baseTTL = 3600) { $this->redis = $redis; $this->baseTTL = $baseTTL; } public function getWithRandomTLL($key, $callback) { $data = $this->redis->get($key); if ($data !== false) { return json_decode($data, true); } // 缓存不存在,执行回调获取数据 $data = $callback(); // 关键点:在基础时间上增加一个随机偏移(0-300 秒) $ttl = $this->baseTTL + rand(0, 300); $this->redis->setex($key, $ttl, json_encode($data)); return $data; } } // 使用示例 $cacheService = new CacheService($redis, 3600); $userList = $cacheService->getWithRandomTLL('user_list', function() { // 从数据库查询 return DB::table('users')->get()->toArray(); });
使用互斥锁(Mutex Lock)—— 单机防击穿
当缓存失效时,只允许一个请求去查询数据库并重建缓存,其他请求等待。
-
原理: 利用 Redis 的
set nx命令实现分布式锁。 -
注意: 这会降低并发时的响应速度,但保护了数据库。
<?php // 伪代码:基于 Redis 的互斥锁实现 function getDataWithMutex($key, $callback, $ttl = 3600) { $data = $redis->get($key); if ($data !== false) { return $data; } // 尝试获取锁(set nx ex) $lockKey = $key . ':lock'; $lockTTL = 10; // 锁的超时时间,防止死锁 if ($redis->setnx($lockKey, 1, ['ex' => $lockTTL, 'nx' => true])) { // 成功获取锁 try { // 再次检查缓存(双重检查),防止在获取锁的过程中被别人重建了 $data = $redis->get($key); if ($data !== false) { return $data; } // 从数据库加载数据 $data = $callback(); // 写入缓存 $redis->setex($key, $ttl, $data); return $data; } finally { // 释放锁(使用 Lua 脚本确保原子性删除) $redis->eval("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", [$lockKey, $lockValue], 1); } } else { // 没有获取到锁,说明另一个进程正在重建缓存 // 可以等待一小段时间后重试,或者直接返回一个“稍后再试”的降级结果 usleep(50000); // 等待 50 毫秒 return getDataWithMutex($key, $callback, $ttl); // 递归重试 } }
缓存二级策略(逻辑过期)
这是目前大中型项目中比较推荐的方案,它不设置物理过期时间,而是让缓存永远不过期,但在数据中存储一个逻辑过期时间。
-
原理: 优点是查询永远是直接命中缓存(除非被LRU淘汰),通过后台异步线程去更新缓存,避免雪崩。
-
PHP 实现思路:
<?php // 存储结构:['data' => ... , 'expire_at' => 时间戳] function getWithLogicExpire($key, $callback, $ttl = 3600) { $cache = $redis->get($key); if (!$cache) { // 缓存完全不存在(可能是第一次或者被淘汰了) // 需要同步重建(这里可以加互斥锁,或者直接重建) $data = $callback(); $cache = [ 'data' => $data, 'expire_at' => time() + $ttl ]; $redis->set($key, json_encode($cache)); // 注意:这里的 redis 不设 TTL return $data; } $cache = json_decode($cache, true); if (time() < $cache['expire_at']) { // 逻辑过期时间未到,直接返回 return $cache['data']; } // 逻辑过期时间已到,返回旧数据的同时,尝试异步更新 // PHP 实现异步更新常用手段: // 1. 使用消息队列(如 Redis Stream / RabbitMQ)投递一个更新任务 // 2. 使用 register_shutdown_function 在响应结束后发起请求 // 3. 简单的方案:加一个互斥锁,只让一个进程去更新 $lockKey = $key . ':renew_lock'; if ($redis->setnx($lockKey, 1, ['ex' => 10, 'nx' => true])) { // 当前进程负责更新缓存 try { $newData = $callback(); $redis->set($key, json_encode([ 'data' => $newData, 'expire_at' => time() + $ttl ])); } finally { $redis->del($lockKey); } } // 返回旧数据(即使过期了,也比让数据库死掉强) return $cache['data']; }
限流与降级(保底措施)
当缓存雪崩真的发生时(Redis 宕机),必须有保底策略。
-
降级:
- 如果查询缓存失败(如 Redis 连接超时),直接抛出一个友好的提示,或者返回静态的默认数据,而不是去查数据库。
- 可以设置一个“本地内存缓存”作为二级缓存(
apcu),当 Redis 不可用时,读取 APCu 中的过期数据。
<?php // 多级缓存示例 function getDataWithFallback($key, $callback) { // 1. 查 APCu (本地内存) $data = apcu_fetch($key); if ($data !== false) { return $data; } // 2. 查 Redis try { $data = $redis->get($key); if ($data !== false) { apcu_store($key, $data, 60); // 本地缓存 60 秒,作为 Redis 的备份 return $data; } } catch (Exception $e) { // Redis 挂了,记录日志,不要继续尝试 // 这里可以返回一个默认值或者 null } // 3. Redis 失效 + APCu 没有,降级:如果是非核心数据,直接返回空 // 如果是核心数据,尝试加锁查库(但风险较高) return $callback(); }
更彻底的方案:提前“预热”与“错峰”
- 预热: 项目上线前,写一个脚本将热点数据提前加载到缓存,而不是等用户请求来触发,这些缓存的有效期可以设成24小时 + 随机数。
- 错峰更新: 如果是定时任务刷新缓存(例如每天凌晨2点刷新1000个商品),可以在这个定时任务里,
sleep(rand(1, 300))秒再更新下一个,人工打散。
总结建议
- 首选方案 1(随机过期时间):代码改动最小,效果最明显,建议所有新项目都默认加上。
- 对于高并发热点数据:强烈推荐方案 3(逻辑过期),先用着老数据,后台慢慢更新。
- 对于关键业务(如支付、下单):必须加方案 4(降级),直接返回“服务器繁忙”也比让数据库雪崩要好。
- 尽量避免使用方案 2(互斥锁)作为唯一方案:因为它会降低吞吐量,更适合用于“缓存穿透”或辅助逻辑过期使用。