PHP防止缓存击穿方案

wen PHP项目 2

本文目录导读:

PHP防止缓存击穿方案

  1. 方案一:互斥锁(Mutex Lock)—— 最常用
  2. 方案二:逻辑过期(主动标记过期)—— 高并发最优选
  3. 方案三:分布式锁 + 单一飞地(Single Flight)
  4. 方案四:永远不过期(物理不过期 + 定时刷新)
  5. 方案五:MySQL 读半衰期(简化版)
  6. 性能对比与选型建议
  7. 总结(最佳实践)

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/常驻进程)定期清理或更新。

实现

  1. 缓存永远不删,只 SET
  2. 增加一个更新队列,每隔一段时间主动刷新缓存。
  3. 或者:在缓存更新失败时,记录日志并告警。

MySQL 读半衰期(简化版)

原理:如果查询数据库发现没有数据(空值穿透),也缓存空值,并设置较短的过期时间。 这主要用于防穿透(穿透与击穿不同),但结合锁使用效果最佳。

// 查库
$dbData = findByDb($id);
if (empty($dbData)) {
    // 缓存空值,过期时间 60s
    $redis->setex($cacheKey, 60, json_encode([])); 
    return null;
}

性能对比与选型建议

方案 并发性 数据一致性 实现复杂度 推荐场景
互斥锁 低(会阻塞等待) 单体应用、对一致性要求高
逻辑过期 (不阻塞) 弱(短暂返回旧值) 电商首页、热点数据(如爆款商品)
物理不过期 数据不敏感,可容忍短暂旧值

最佳实践)

无论使用哪种方案,都是核心。

  • 如果是 PHP-FPM(传统 Web 服务器),由于进程间不共享内存,推荐使用 Redis 分布式锁(Lua 脚本) + 双重检查
  • 如果是 Swoole 常驻内存,可以使用 Swoole\LockAtomic,性能更高。

最后提醒:如果使用 Redis 做锁,务必保证 Redis SET 命令带 NXEX 参数(PHP 的 $redis->set($key, $value, ['NX', 'EX'=>10])),避免死锁,并且释放锁要用 Lua 脚本比较 value,防止误删。

抱歉,评论功能暂时关闭!