PHP的缓存雪崩与穿透防御:布隆过滤器(Bloom Filter)与缓存预热策略

PHP的缓存雪崩与穿透防御:布隆过滤器与缓存预热策略

大家好,今天我们来聊聊PHP应用中常见的缓存问题:缓存雪崩和缓存穿透,以及如何利用布隆过滤器和缓存预热策略来有效防御。

缓存的重要性

在构建高并发、高性能的PHP应用时,缓存是不可或缺的一环。 缓存可以显著减少数据库的压力,提高响应速度,改善用户体验。 常见的缓存方案包括:

  • 页面静态化: 将动态生成的页面保存为静态HTML文件,直接返回给用户。
  • OPcache: PHP自带的字节码缓存,缓存编译后的PHP代码,减少重复编译开销。
  • 数据缓存: 将数据库查询结果、API响应等数据存储在内存中,如Redis、Memcached。
  • CDN: 内容分发网络,将静态资源缓存到全球各地的节点,加速用户访问。

虽然缓存能带来诸多好处,但如果使用不当,也可能引发问题。

缓存雪崩:突如其来的崩溃

什么是缓存雪崩?

缓存雪崩是指在某一时刻,大量的缓存key同时过期失效,导致所有请求直接落到数据库上,数据库无法承受巨大的压力而崩溃,进而导致整个系统崩溃。

原因:

  • 大量缓存key设置了相同的过期时间。
  • 缓存服务器宕机。

举例:

假设一个电商网站,商品信息缓存在Redis中,并设置了统一的过期时间为1小时。 在每天的某个时刻,大量用户同时访问,由于缓存过期,所有请求都直接访问数据库,导致数据库压力过大,最终宕机。

防御策略:

  1. 设置不同的过期时间(避免同时失效):

    给不同的缓存key设置不同的过期时间,尽量让key的过期时间分散开来。 可以在原有的过期时间基础上,加上一个随机值。

    <?php
    // 原始过期时间,单位秒
    $base_expire = 3600; // 1小时
    // 随机过期时间范围,单位秒
    $random_expire_range = 300; // 5分钟
    
    // 生成随机过期时间
    $expire_time = $base_expire + rand(0, $random_expire_range);
    
    // 将数据缓存到Redis,并设置过期时间
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    $redis->set('product:123', json_encode($productData), $expire_time);
    ?>
  2. 互斥锁(避免并发重建缓存):

    当缓存失效时,只允许一个请求去重建缓存,其他请求等待重建完成后,直接从缓存中获取数据。

    <?php
    function getProductInfo($productId) {
        $redis = new Redis();
        $redis->connect('127.0.0.1', 6379);
    
        $cacheKey = 'product:' . $productId;
        $productData = $redis->get($cacheKey);
    
        if ($productData) {
            return json_decode($productData, true);
        }
    
        // 尝试获取锁,设置过期时间,防止死锁
        $lockKey = 'lock:product:' . $productId;
        $lockAcquired = $redis->setnx($lockKey, 1);
    
        if ($lockAcquired) {
            $redis->expire($lockKey, 10); // 设置锁的过期时间为10秒
    
            // 从数据库获取数据
            $productData = getProductFromDatabase($productId);
    
            if ($productData) {
                $redis->set($cacheKey, json_encode($productData), 3600); // 缓存1小时
            }
    
            // 释放锁
            $redis->del($lockKey);
    
            return $productData;
        } else {
            // 获取锁失败,等待缓存重建
            sleep(1); // 短暂休眠
            return getProductInfo($productId); // 递归调用,重新尝试获取
        }
    }
    
    function getProductFromDatabase($productId) {
        // 模拟从数据库获取数据
        // ...数据库查询逻辑...
        return ['id' => $productId, 'name' => 'Product ' . $productId, 'price' => 99.99];
    }
    ?>
  3. 缓存预热(提前加载缓存):

    在系统上线或重启后,提前将热点数据加载到缓存中,避免缓存失效时大量请求直接访问数据库。

  4. 熔断降级(保护数据库):

    当数据库压力过大时,可以暂时关闭部分功能,或者返回默认值,保护数据库不被压垮。

  5. 构建多级缓存架构:

    使用多层缓存,例如本地缓存(如OPcache、APCu) + 分布式缓存(如Redis、Memcached),即使Redis宕机,本地缓存仍然可以提供服务。

缓存穿透:查无此物的困境

什么是缓存穿透?

缓存穿透是指查询一个数据库中不存在的数据,由于缓存中没有该数据,每次请求都会直接访问数据库,导致数据库压力增大。

原因:

  • 恶意攻击,故意请求不存在的数据。
  • 程序bug,导致查询条件错误。

举例:

假设一个用户查询ID为-1的商品,由于数据库中不存在ID为-1的商品,缓存中也不会存在该数据。 每次请求都会直接访问数据库,导致数据库压力增大。

防御策略:

  1. 缓存空对象(避免穿透数据库):

    当数据库查询结果为空时,仍然将空对象(例如null)缓存到Redis中,并设置一个较短的过期时间。 这样可以避免每次请求都穿透到数据库。

    <?php
    function getProductInfo($productId) {
        $redis = new Redis();
        $redis->connect('127.0.0.1', 6379);
    
        $cacheKey = 'product:' . $productId;
        $productData = $redis->get($cacheKey);
    
        if ($productData) {
            if ($productData === 'null') {
                return null; // 返回null,表示数据不存在
            }
            return json_decode($productData, true);
        }
    
        // 从数据库获取数据
        $productData = getProductFromDatabase($productId);
    
        if ($productData) {
            $redis->set($cacheKey, json_encode($productData), 3600); // 缓存1小时
            return $productData;
        } else {
            // 将空对象缓存到Redis,设置较短的过期时间
            $redis->set($cacheKey, 'null', 60); // 缓存60秒
            return null;
        }
    }
    
    function getProductFromDatabase($productId) {
        // 模拟从数据库获取数据
        // ...数据库查询逻辑...
        // 如果数据库中不存在该商品,返回null
        return null;
    }
    ?>
  2. 布隆过滤器(快速判断是否存在):

    使用布隆过滤器,在缓存之前先判断该key是否存在于数据库中。 如果布隆过滤器判断不存在,则直接返回,避免访问数据库。

    什么是布隆过滤器?

    布隆过滤器是一种概率型数据结构,用于快速判断一个元素是否存在于一个集合中。 它具有以下特点:

    • 高效性: 插入和查询的时间复杂度都是O(k),k是哈希函数的个数。
    • 空间效率: 占用空间小。
    • 误判率: 存在一定的误判率,即可能将不存在的元素判断为存在,但不会将存在的元素判断为不存在。

    PHP中使用布隆过滤器:

    可以使用phpredis扩展提供的bfAddbfExists命令,或者使用第三方库,例如jmikola/jbloom

    使用phpredis扩展:

    <?php
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    
    // 初始化布隆过滤器
    $filterName = 'product_filter';
    // 创建布隆过滤器,设置容量和错误率 (可选)
    //$redis->command('BF.RESERVE', $filterName, 0.01, 1000000);
    
    function getProductInfo($productId) {
    
        global $redis, $filterName;
    
        // 先判断布隆过滤器中是否存在该商品ID
        $exists = $redis->command('BF.EXISTS', $filterName, $productId);
    
        if (!$exists) {
            // 布隆过滤器判断不存在,直接返回null
            return null;
        }
    
        $cacheKey = 'product:' . $productId;
        $productData = $redis->get($cacheKey);
    
        if ($productData) {
            if ($productData === 'null') {
                return null; // 返回null,表示数据不存在
            }
            return json_decode($productData, true);
        }
    
        // 从数据库获取数据
        $productData = getProductFromDatabase($productId);
    
        if ($productData) {
            // 将商品ID添加到布隆过滤器
            $redis->command('BF.ADD', $filterName, $productId);
            $redis->set($cacheKey, json_encode($productData), 3600); // 缓存1小时
            return $productData;
        } else {
            // 将空对象缓存到Redis,设置较短的过期时间
            $redis->set($cacheKey, 'null', 60); // 缓存60秒
            return null;
        }
    }
    
    function getProductFromDatabase($productId) {
        // 模拟从数据库获取数据
        // ...数据库查询逻辑...
        // 如果数据库中不存在该商品,返回null
        return null;
    }
    
    // 示例:将所有商品ID添加到布隆过滤器 (初始化)
    $allProductIds = [1, 2, 3, 4, 5]; // 假设有这些商品ID
    foreach ($allProductIds as $productId) {
        $redis->command('BF.ADD', $filterName, $productId);
    }
    ?>

    代码解释:

    • BF.ADD: 将元素添加到布隆过滤器。
    • BF.EXISTS: 判断元素是否存在于布隆过滤器中。
    • BF.RESERVE (可选): 创建布隆过滤器时,可以指定错误率和容量,以优化性能。

    注意:

    • 布隆过滤器需要预先加载数据,将所有存在的key添加到布隆过滤器中。
    • 布隆过滤器可能会存在误判,因此需要结合缓存空对象使用,避免将不存在的数据一直穿透到数据库。
  3. 参数校验:

    对请求参数进行严格的校验,避免非法参数导致缓存穿透。

缓存预热:未雨绸缪的策略

什么是缓存预热?

缓存预热是指在系统上线或重启后,提前将热点数据加载到缓存中,避免缓存失效时大量请求直接访问数据库。

场景:

  • 系统上线
  • 系统重启
  • 缓存服务器重启
  • 定时刷新缓存

策略:

  1. 定时任务:

    定期执行一个脚本,将热点数据加载到缓存中。

    <?php
    // config.php  数据库配置
    $dbConfig = [
        'host' => 'localhost',
        'username' => 'root',
        'password' => 'password',
        'database' => 'mydb'
    ];
    
    // warm_cache.php  缓存预热脚本
    require_once 'config.php';
    
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    
    // 连接数据库
    $mysqli = new mysqli($dbConfig['host'], $dbConfig['username'], $dbConfig['password'], $dbConfig['database']);
    
    if ($mysqli->connect_error) {
        die('Connect Error (' . $mysqli->connect_errno . ') ' . $mysqli->connect_error);
    }
    
    // 查询热点数据
    $sql = "SELECT id, name, price FROM products WHERE is_hot = 1";
    $result = $mysqli->query($sql);
    
    if ($result->num_rows > 0) {
        while ($row = $result->fetch_assoc()) {
            $productId = $row['id'];
            $cacheKey = 'product:' . $productId;
            $productData = json_encode($row);
    
            // 缓存数据
            $redis->set($cacheKey, $productData, 3600); // 缓存1小时
            echo "Cached product: " . $productId . "n";
        }
    } else {
        echo "No hot products found.n";
    }
    
    $mysqli->close();
    $redis->close();
    ?>

    定时任务配置 (crontab):

    0 * * * * php /path/to/warm_cache.php > /dev/null 2>&1

    该配置表示每小时执行一次warm_cache.php脚本。

  2. 事件驱动:

    当数据发生变更时,立即更新缓存。

  3. 预计算:

    提前计算好一些复杂的结果,并缓存起来。

总结:保护你的PHP应用

防御策略 针对问题 描述 代码示例
设置不同过期时间 缓存雪崩 给不同的缓存key设置不同的过期时间,避免大量key同时失效。 rand(0, $random_expire_range)
互斥锁 缓存雪崩 当缓存失效时,只允许一个请求去重建缓存,其他请求等待重建完成后,直接从缓存中获取数据。 $redis->setnx($lockKey, 1)
缓存预热 缓存雪崩 在系统上线或重启后,提前将热点数据加载到缓存中,避免缓存失效时大量请求直接访问数据库。 定时任务、事件驱动、预计算
缓存空对象 缓存穿透 当数据库查询结果为空时,仍然将空对象缓存到Redis中,并设置一个较短的过期时间。 $redis->set($cacheKey, 'null', 60)
布隆过滤器 缓存穿透 使用布隆过滤器,在缓存之前先判断该key是否存在于数据库中。 如果布隆过滤器判断不存在,则直接返回,避免访问数据库。 $redis->command('BF.EXISTS', $filterName, $productId)
参数校验 缓存穿透 对请求参数进行严格的校验,避免非法参数导致缓存穿透。 使用正则表达式、类型检查等方式进行参数校验

缓存雪崩和缓存穿透是PHP应用中常见的缓存问题,会严重影响系统的稳定性和性能。 通过设置不同的过期时间、互斥锁、缓存预热、缓存空对象、布隆过滤器和参数校验等策略,可以有效防御这些问题,提高系统的可用性和性能。选择哪种策略需要根据具体场景和需求进行权衡。

平衡与优化:缓存策略的艺术

理解缓存雪崩和穿透的原理,并选择合适的防御策略是至关重要的。没有一劳永逸的解决方案,最佳实践在于持续监控、分析和优化缓存策略,以确保系统在各种负载下都能保持最佳性能和稳定性。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注