本文目录导读:

- 方案一:互斥锁(Mutex Lock)—— 最常用
- 方案二:逻辑过期(主动标记过期)—— 高并发最优选
- 方案三:分布式锁 + 单一飞地(Single Flight)
- 方案四:永远不过期(物理不过期 + 定时刷新)
- 方案五:MySQL 读半衰期(简化版)
- 性能对比与选型建议
- 总结(最佳实践)
PHP 中防止缓存击穿(Cache Breakdown)的核心思路是确保当缓存失效时,只有一个请求去重建缓存,其他请求等待或获取旧值。
以下是 5 种主流的解决方案,从简单到复杂,你可以根据项目架构选择:
互斥锁(Mutex Lock)—— 最常用
原理:当缓存失效时,进程尝试获取锁,只有拿到锁的进程才能查询数据库并写缓存,其他进程等待锁释放后直接读取新缓存。
适用场景:单体应用、单机部署。
PHP 代码示例(使用 Redis 锁):
function getData($id) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$cacheKey = "data:{$id}";
$lockKey = "lock:{$id}";
// 1. 尝试读取缓存
$data = $redis->get($cacheKey);
if ($data !== false) {
return json_decode($data, true);
}
// 2. 缓存未命中,尝试获取锁(SET NX EX 原子操作)
$lockValue = uniqid('', true);
$isLocked = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 10]); // 10秒超时
if ($isLocked) {
try {
// 3. 双重检查:获取锁后再次检查缓存(防止等待过程中已有其他线程写入)
$data = $redis->get($cacheKey);
if ($data !== false) {
return json_decode($data, true);
}
// 4. 查询数据库(模拟耗时)
$dbData = ['id' => $id, 'name' => 'Product ' . $id];
sleep(2); // 模拟慢查询
// 5. 写入缓存(过期时间加随机数防止雪崩)
$expire = 3600 + rand(0, 300);
$redis->setex($cacheKey, $expire, json_encode($dbData));
return $dbData;
} finally {
// 6. 释放锁(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 {
// 7. 未获取到锁,等待重试(自旋等待)
usleep(100000); // 等待100ms
// 递归调用或重新读取缓存(建议最多重试3次)
return getData($id); // 重试
}
}
需要注意的细节:
- 锁必须设置过期时间(防止进程崩溃导致死锁)。
- 释放锁时要验证锁值(防止误删别人的锁)。
- 获取锁后要双重检查缓存。
逻辑过期(主动标记过期)—— 高并发最优选
原理:在缓存中存放数据时,同时存入一个逻辑过期时间,查询时,即使物理缓存存在,也判断逻辑时间是否过期;若过期,则异步或同步去更新缓存,但先返回旧数据。
优点:不会阻塞请求,返回的是旧数据(对用户体验友好)。
PHP 代码示例:
function getData($id) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$cacheKey = "data:{$id}";
// 假设缓存中结构为 ['data' => ..., 'logical_expire' => timestamp]
$cached = $redis->get($cacheKey);
if ($cached) {
$cachedArr = json_decode($cached, true);
// 逻辑未过期,直接返回
if ($cachedArr['logical_expire'] > time()) {
return $cachedArr['data'];
}
// 逻辑已过期,尝试获取锁去更新缓存(只允许一个线程更新)
$lockKey = "lock:update:{$id}";
$isLocked = $redis->set($lockKey, 1, ['NX', 'EX' => 5]);
if ($isLocked) {
// 开启异步线程去更新缓存(避免阻塞当前请求)
// 这里使用 swoole 或 消息队列
// 此处为了演示,直接同步更新(但实际应异步)
// 模拟异步更新
$newData = ['id' => $id, 'name' => 'Updated '.rand()];
$logicalExpire = time() + 3600;
$redis->setex($cacheKey, 7200, json_encode(['data' => $newData, 'logical_expire' => $logicalExpire]));
// 删除锁
$redis->del($lockKey);
return $newData; // 这一条请求返回新数据
}
// 未获取到锁,返回过期的旧数据(逻辑过期但物理未过期)
return $cachedArr['data'];
}
// 缓存完全不存在(例如冷启动),需要走互斥锁流程
// ... 省略,可参考方案一
}
核心点:即使缓存“过期”了,也返回旧数据,然后后台默默更新缓存。
分布式锁 + 单一飞地(Single Flight)
原理(Go/B 语言思维):保证同一个 key 在同一个时刻只有一个请求到达数据库,PHP 通常通过 Redis + 队列或 Redis + 锁实现,其实这本质是方案一的升级版,重点是防止多余的数据库查询。
常见实现:使用 Swoole 协程或 MQ 队列,将请求合并为一个去查库。
永远不过期(物理不过期 + 定时刷新)
原理:不给缓存设置物理过期时间(EXPIRE),而是设置一个逻辑过期时间。
优点:请求永远能命中缓存(物理层面),解决了击穿和雪崩。
缺点:需要后台任务(Cron/常驻进程)定期清理或更新。
实现:
- 缓存永远不删,只
SET。 - 增加一个更新队列,每隔一段时间主动刷新缓存。
- 或者:在缓存更新失败时,记录日志并告警。
MySQL 读半衰期(简化版)
原理:如果查询数据库发现没有数据(空值穿透),也缓存空值,并设置较短的过期时间。 这主要用于防穿透(穿透与击穿不同),但结合锁使用效果最佳。
// 查库
$dbData = findByDb($id);
if (empty($dbData)) {
// 缓存空值,过期时间 60s
$redis->setex($cacheKey, 60, json_encode([]));
return null;
}
性能对比与选型建议
| 方案 | 并发性 | 数据一致性 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|
| 互斥锁 | 低(会阻塞等待) | 强 | 低 | 单体应用、对一致性要求高 |
| 逻辑过期 | 高(不阻塞) | 弱(短暂返回旧值) | 中 | 电商首页、热点数据(如爆款商品) |
| 物理不过期 | 高 | 弱 | 高 | 数据不敏感,可容忍短暂旧值 |
最佳实践)
无论使用哪种方案,锁都是核心。
- 如果是 PHP-FPM(传统 Web 服务器),由于进程间不共享内存,推荐使用 Redis 分布式锁(Lua 脚本) + 双重检查。
- 如果是 Swoole 常驻内存,可以使用
Swoole\Lock或Atomic,性能更高。
最后提醒:如果使用 Redis 做锁,务必保证 Redis SET 命令带 NX 和 EX 参数(PHP 的 $redis->set($key, $value, ['NX', 'EX'=>10])),避免死锁,并且释放锁要用 Lua 脚本比较 value,防止误删。