PHP项目怎么实现缓存雪崩防护?

wen java案例 1

本文目录导读:

PHP项目怎么实现缓存雪崩防护?

  1. 设置不同的过期时间(防止集中失效)
  2. 使用互斥锁(Mutex Lock)—— 单机防击穿
  3. 缓存二级策略(逻辑过期)
  4. 限流与降级(保底措施)
  5. 更彻底的方案:提前“预热”与“错峰”
  6. 总结建议

在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. 首选方案 1(随机过期时间):代码改动最小,效果最明显,建议所有新项目都默认加上。
  2. 对于高并发热点数据:强烈推荐方案 3(逻辑过期),先用着老数据,后台慢慢更新。
  3. 对于关键业务(如支付、下单):必须加方案 4(降级),直接返回“服务器繁忙”也比让数据库雪崩要好。
  4. 尽量避免使用方案 2(互斥锁)作为唯一方案:因为它会降低吞吐量,更适合用于“缓存穿透”或辅助逻辑过期使用。

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