缓存策略:缓存穿透、缓存击穿、缓存雪崩的PHP解决方案

各位同学,今天我们来聊聊缓存世界里最让人头疼的三个问题。说实话,我刚入行那会儿,这三个概念经常搞混。直到有一次线上事故,缓存雪崩直接把数据库打挂了,我才真正体会到它们的威力。

嗯,咱们一个一个来拆解。说白了,这三个问题都是缓存没命中时引发的连锁反应,但各自的场景和解决方案完全不同。

一、缓存穿透:打穿缓存层的"刺客"

什么是缓存穿透?

缓存穿透,就是请求的数据在缓存和数据库中都不存在。每次请求都直接打到数据库上。你想想看,如果有人恶意构造大量不存在的key来请求,数据库瞬间就扛不住了。

我在项目中遇到过这种情况:有个用户查询接口,前端传了个不存在的ID,结果每次请求都穿透到MySQL。那天数据库CPU直接飙到100%,差点把整个服务搞崩。

核心问题:缓存和数据库都没有该数据,请求直接穿透缓存打到数据库。

解决方案一:缓存空对象

这是最直接的办法。既然数据不存在,那就把"不存在"这个结果也缓存起来。

<?php
class CachePenetrationResolver {
    private $redis;
    private $db;
    private $emptyTtl = 60; // 空值缓存60秒
    
    public function getData($key) {
        // 先查缓存
        $data = $this->redis->get($key);
        if ($data !== false) {
            // 判断是否是空值标记
            if ($data === 'NULL_VALUE') {
                return null;
            }
            return json_decode($data, true);
        }
        
        // 缓存未命中,查数据库
        $data = $this->db->query("SELECT * FROM users WHERE id = ?", [$key]);
        
        if (empty($data)) {
            // 缓存空对象,防止穿透
            $this->redis->setex($key, $this->emptyTtl, 'NULL_VALUE');
            return null;
        }
        
        // 正常数据缓存
        $this->redis->setex($key, 3600, json_encode($data));
        return $data;
    }
}

我的建议:空值缓存的过期时间不要太长,一般30-60秒就够了。太长会导致正常数据写入后,用户还看到空结果。

解决方案二:布隆过滤器

说白了,布隆过滤器就是一个很省内存的"存在性判断"工具。它告诉你"这个key一定不存在"或者"可能存在"。

<?php
class BloomFilterCache {
    private $redis;
    private $bloomKey = 'user_bloom_filter';
    
    public function initBloomFilter($capacity = 100000, $errorRate = 0.001) {
        // 使用Redis的布隆过滤器模块
        $this->redis->rawCommand('BF.RESERVE', $this->bloomKey, $errorRate, $capacity);
    }
    
    public function addToFilter($userId) {
        $this->redis->rawCommand('BF.ADD', $this->bloomKey, $userId);
    }
    
    public function getData($userId) {
        // 先过布隆过滤器
        $exists = $this->redis->rawCommand('BF.EXISTS', $this->bloomKey, $userId);
        
        if (!$exists) {
            // 一定不存在,直接返回
            return null;
        }
        
        // 可能存在,继续查缓存和数据库
        // ... 后续逻辑同上
    }
}

注意:布隆过滤器有误判率,它会说"可能存在"但实际上不存在。不过它说"不存在"那就一定不存在。这个特性正好适合做穿透防护。

二、缓存击穿:热点key的"突然死亡"

什么是缓存击穿?

缓存击穿和穿透不一样。击穿是指一个热点key在缓存过期的一瞬间,大量并发请求同时打到数据库上。说白了,就是缓存失效的那一瞬间,流量把数据库冲垮了。

我记得有一次双十一活动,某个爆款商品的详情页缓存刚好在高峰期过期了。结果几千个请求同时去查数据库,MySQL直接报连接数超限。嗯,那次教训挺深刻的。

核心问题:热点key过期瞬间,大量并发请求同时访问数据库。

解决方案一:互斥锁(Mutex)

让第一个请求去查数据库并重建缓存,其他请求等待。说白了就是"排队"。

<?php
class CacheBreakdownResolver {
    private $redis;
    private $db;
    private $lockTimeout = 5; // 锁超时时间
    
    public function getHotData($key) {
        // 先查缓存
        $data = $this->redis->get($key);
        if ($data !== false) {
            return json_decode($data, true);
        }
        
        // 尝试获取锁
        $lockKey = $key . ':lock';
        $lockValue = uniqid('', true);
        
        // 使用setnx实现分布式锁
        $locked = $this->redis->set($lockKey, $lockValue, ['nx', 'ex' => $this->lockTimeout]);
        
        if ($locked) {
            try {
                // 双重检查,防止重复查询
                $data = $this->redis->get($key);
                if ($data !== false) {
                    return json_decode($data, true);
                }
                
                // 查数据库
                $data = $this->db->query("SELECT * FROM hot_products WHERE id = ?", [$key]);
                
                // 重建缓存
                $this->redis->setex($key, 3600, json_encode($data));
                
                return $data;
            } finally {
                // 释放锁,使用Lua脚本保证原子性
                $script = <<<'LUA'
                    if redis.call('get', KEYS[1]) == ARGV[1] then
                        return redis.call('del', KEYS[1])
                    else
                        return 0
                    end
                LUA;
                $this->redis->eval($script, [$lockKey, $lockValue], 1);
            }
        }
        
        // 没拿到锁,等待后重试
        usleep(10000); // 等待10毫秒
        return $this->getHotData($key);
    }
}

避坑指南:我曾经在释放锁时直接用del命令,结果发现如果锁超时自动释放了,当前线程再去del就会误删其他线程的锁。所以一定要用Lua脚本判断锁的持有者。

解决方案二:逻辑过期

不给缓存设置物理过期时间,而是在数据里带一个逻辑过期时间。后台起一个线程去检查并更新缓存。

<?php
class LogicalExpireCache {
    private $redis;
    private $db;
    
    public function getHotData($key) {
        $data = $this->redis->get($key);
        if (!$data) {
            return null; // 缓存没了,直接返回
        }
        
        $cacheData = json_decode($data, true);
        
        // 检查逻辑过期时间
        if ($cacheData['expire_time'] < time()) {
            // 异步更新缓存
            $this->asyncRefreshCache($key);
        }
        
        // 返回旧数据,保证响应速度
        return $cacheData['data'];
    }
    
    private function asyncRefreshCache($key) {
        // 使用Redis队列或消息队列异步处理
        $this->redis->lPush('cache_refresh_queue', $key);
    }
}

三、缓存雪崩:大面积缓存同时失效

什么是缓存雪崩?

缓存雪崩是指大量缓存key在同一时间过期,或者缓存服务宕机,导致所有请求都打到数据库上。这比击穿更可怕,因为它是大面积、全方位的冲击。

我经历过一次最惨的事故:那天凌晨我们统一给所有缓存设置了凌晨3点过期,结果3点一到,数据库瞬间被几万个请求打爆。整个系统瘫痪了20分钟,老板差点把我叫去喝茶。

核心问题:大量缓存同时失效,数据库瞬间承受巨大压力。

解决方案一:过期时间加随机值

这是最简单有效的办法。不要让所有key在同一时间过期,给过期时间加个随机偏移量。

<?php
class CacheAvalanchePreventer {
    private $redis;
    
    public function setCache($key, $data, $baseTtl = 3600) {
        // 基础过期时间加上随机偏移,范围0-300秒
        $randomOffset = mt_rand(0, 300);
        $ttl = $baseTtl + $randomOffset;
        
        $this->redis->setex($key, $ttl, json_encode($data));
    }
    
    // 批量设置缓存时尤其重要
    public function batchSetCache(array $items, $baseTtl = 3600) {
        $pipe = $this->redis->pipeline();
        
        foreach ($items as $key => $data) {
            $randomOffset = mt_rand(0, 300);
            $ttl = $baseTtl + $randomOffset;
            $pipe->setex($key, $ttl, json_encode($data));
        }
        
        $pipe->exec();
    }
}

解决方案二:多级缓存架构

不要把所有鸡蛋放在一个篮子里。用本地缓存+Redis缓存+数据库,形成三级防护。

<?php
class MultiLevelCache {
    private $localCache = []; // 本地缓存
    private $redis;
    private $db;
    private $localTtl = 60; // 本地缓存60秒
    private $redisTtl = 3600; // Redis缓存1小时
    
    public function getData($key) {
        // 第一级:本地缓存
        if (isset($this->localCache[$key])) {
            $cache = $this->localCache[$key];
            if ($cache['expire_time'] > time()) {
                return $cache['data'];
            }
        }
        
        // 第二级:Redis缓存
        $data = $this->redis->get($key);
        if ($data !== false) {
            // 回填本地缓存
            $this->localCache[$key] = [
                'data' => json_decode($data, true),
                'expire_time' => time() + $this->localTtl
            ];
            return $this->localCache[$key]['data'];
        }
        
        // 第三级:数据库
        $data = $this->db->query("SELECT * FROM products WHERE id = ?", [$key]);
        
        // 回填各级缓存
        $this->redis->setex($key, $this->redisTtl, json_encode($data));
        $this->localCache[$key] = [
            'data' => $data,
            'expire_time' => time() + $this->localTtl
        ];
        
        return $data;
    }
}

解决方案三:限流降级

当缓存大面积失效时,主动限流保护数据库。说白了就是"丢车保帅"。

<?php
class RateLimiter {
    private $redis;
    private $maxRequests = 100; // 每秒最多100个请求
    private $windowSize = 1; // 时间窗口1秒
    
    public function allowRequest($key = 'global') {
        $current = $this->redis->get($key . ':count');
        
        if ($current !== false && $current >= $this->maxRequests) {
            return false; // 限流
        }
        
        $this->redis->multi();
        $this->redis->incr($key . ':count');
        $this->redis->expire($key . ':count', $this->windowSize);
        $this->redis->exec();
        
        return true;
    }
}

四、三种问题的对比总结

问题类型 触发场景 影响范围 推荐方案
缓存穿透 查询不存在的数据 单个key 布隆过滤器 + 空值缓存
缓存击穿 热点key过期瞬间 单个热点key 互斥锁 + 逻辑过期
缓存雪崩 大量key同时过期 大面积 随机过期时间 + 多级缓存 + 限流

我的经验总结:实际项目中,这三种问题往往同时存在。我建议你做一个统一的缓存中间件,把布隆过滤器、互斥锁、随机过期时间都集成进去。这样上层业务代码只需要调用一个get方法,底层自动处理各种异常情况。

好了,今天的内容就到这里。这三种缓存问题,说白了就是"缓存没命中"的三种不同表现形式。理解了它们的本质区别,你就能针对性地选择解决方案。下次遇到线上缓存问题,别慌,先判断是穿透、击穿还是雪崩,然后对症下药。