PHP如何解决高并发场景下Redis缓存击穿与雪崩问题

各位程序员朋友,大家好!

欢迎来到今天的技术“急救室”。我是你们的老朋友,那个因为半夜两点被报警短信吵醒过十次的资深架构师。

今天咱们不聊Hello World,也不聊CRUD(增删改查),咱们来聊聊那个让PHP开发者头皮发麻、让DBA(数据库管理员)血压飙升的终极BOSS——高并发下的Redis缓存问题

尤其是缓存雪崩缓存击穿。这两个词听起来很高大上,其实就是两个“坑”。今天我就化身你们的“防坑导师”,带你像拆弹专家一样,一步步把这两个炸弹拆了。

准备好了吗?把咖啡喝好,坐直了,咱们开始。


第一部分:雪崩——一场毫无准备的“大跳水”

首先,咱们得定义一下什么是“缓存雪崩”。字面意思很好理解,就是“雪崩”嘛,就是大规模的崩塌。

想象一下,你是一家大型电商平台的负责人。双十一晚上0点0分,一亿用户同时涌入。为了减轻数据库压力,你用了Redis缓存。你把所有商品的缓存数据设置了一个过期时间,比如都是“1小时”。

然后,神奇的事情发生了。时间到了1小时,所有商品的缓存同时失效了。

这时候会发生什么?Redis里空空如也,数据库面前涌进了一亿个请求。数据库CPU瞬间爆表,磁盘读写达到峰值,最后——就像多米诺骨牌一样,数据库崩溃,网站挂了,老板开始找你谈话。

这就是缓存雪崩。它不是某一个点的问题,而是面上的崩溃。

1. 根源分析:为什么大家都死在一个坑里?

在代码里,这通常是因为我们太懒,或者太追求“整齐划一”。比如:

// ❌ 危险!这是在写自杀代码
$cacheKey = 'product:1001';
$redis->set($cacheKey, $productData, ['ex' => 3600]); // 1小时后全部过期

你想想,所有的Key都设置成了3600秒。一旦到达整点,这一秒内,Redis就像个死去的老人,没有任何数据。后续的请求统统打到MySQL上。

2. 解决方案一:给过期时间加一点“随机性”(保险策略)

既然大家都用1小时,那我们就不要大家都用1小时。我们给过期时间加个随机偏移量。这就像是给每个人都有一个不同的放学时间,总不能全校一起冲出校门吧?

// ✅ 安全!稍微有点人性化的代码
function getCacheData($key, $dataCallback, $baseExpire = 3600) {
    $data = $redis->get($key);

    if ($data === false) {
        $data = $dataCallback();

        // 关键点:计算过期时间 = 基础时间 + 随机 0-600秒
        // 这样就不会在同一秒有大量失效,避免雪崩
        $expireTime = $baseExpire + rand(0, 600);

        $redis->set($key, $data, ['ex' => $expireTime]);
        return $data;
    }

    return json_decode($data, true);
}

这样,有的Key在3小时后过期,有的在1小时10分过期。虽然依然有人会失效,但就像下雨一样,不是倾盆大雨,而是断断续续的毛毛雨,数据库就能扛住了。

3. 解决方案二:互斥锁(堵住缺口)

如果不幸,你的所有Key确实在同一时间失效了(比如你被老板逼着统一设置的),那咋办?

这时候就需要引入互斥锁。当第一个请求发现缓存没了,他去查数据库,更新缓存。其他的请求来了,发现缓存没了,不能去查库,必须排队,等着第一个查完,拿结果就走。

PHP处理这个锁有点意思,因为PHP是脚本语言,脚本结束锁就释放了。我们可以利用Redis的SETNX命令(SET if Not eXists)。

// ❌ 错误示范:非原子操作,会死锁
if (!$redis->exists("lock:product:1001")) {
    $redis->set("lock:product:1001", 1); // 这里的代码还没执行,另一个请求进来了...
    // ... 查库 ...
    $redis->del("lock:product:1001");    // 这里的代码还没执行,脚本崩溃了...
}

为什么要小心? 在高并发下,existsset之间是有时间差的。如果第一个脚本执行到一半挂了,锁永远在,其他请求就被堵死了。

✅ 正确示范:使用Redis的原子操作 SET key value NX EX

function getProductWithMutex($productId) {
    $lockKey = "lock:product:" . $productId;
    $lockValue = uniqid(); // 生成唯一ID,防止误删别人的锁

    // 尝试获取锁,设置5秒过期时间(防止死锁)
    $isLocked = $redis->set($lockKey, $lockValue, ['nx', 'ex' => 5]);

    if ($isLocked) {
        try {
            // 获取锁成功!开始干活
            echo "我是老大,我来查库。";
            $product = $this->queryDatabase($productId); // 查询MySQL

            // 更新缓存,设置一个比锁长的过期时间,比如1小时
            $redis->set("product:" . $productId, json_encode($product), ['ex' => 3600]);

            return $product;

        } finally {
            // 必须释放锁!
            // 这里有个坑:要保证释放的是自己加的锁
            // 生产环境通常用 Lua 脚本保证原子性,这里为了演示简化
            $redis->del($lockKey);
        }
    } else {
        // 获取锁失败,说明有别人在查库,我等一会儿再试
        echo "我是小弟,老板在查,我歇会儿。";
        usleep(100000); // 休眠0.1秒
        return $this->getProductWithMutex($productId); // 递归重试
    }
}

这就是互斥锁,简单粗暴,但有效。它强制让并发请求变成串行请求,保住了数据库。

4. 解决方案三:逻辑过期(大招)

前面的方案虽然好,但总得去查库吧?查库还是慢。有没有一种办法,缓存永远不过期

有,这就是逻辑过期

你的Redis里,永远存着数据。只不过,数据里有个字段叫“逻辑过期时间”。当请求来的时候,我们检查这个字段。如果还没过期,直接返回;如果过期了,后台悄悄把新数据查出来,更新Redis,然后返回旧数据(或者更新中的数据)。

这听起来像是在开挂,其实原理很简单。

class ProductCache {
    private $redis;
    // 定义一个很长的逻辑过期时间
    private $LOGIC_EXPIRE_TIME = 86400; // 24小时

    public function getProduct($key) {
        // 1. 从Redis获取数据(假设数据里包含过期时间字段)
        $redisData = $redis->get($key);
        if (!$redisData) {
            return null;
        }

        $data = json_decode($redisData, true);
        $now = time();

        // 2. 检查逻辑过期时间
        if ($now < $data['expire_time']) {
            // 还没过期,直接返回
            return $data['content'];
        } else {
            // 3. 过期了!触发异步更新
            $this->asyncRefreshCache($key, $data['content']);

            // 这里你可以选择返回空,或者返回旧数据(部分一致性)
            return $data['content']; 
        }
    }

    private function asyncRefreshCache($key, $oldContent) {
        // 使用Swoole或者WorkerMan,或者简单的Crontab
        // 后台线程去查库,更新Redis,不需要等响应回来
        go(function() use ($key, $oldContent) {
            $newData = $this->queryDatabase(); // 慢操作
            $newData['expire_time'] = time() + $this->LOGIC_EXPIRE_TIME;
            $redis->set($key, json_encode($newData), ['ex' => $this->LOGIC_EXPIRE_TIME]);
        });
    }
}

这种方案的好处是永远不会有缓存击穿,因为缓存永远存在。坏处是可能读到脏数据(刚过期那几秒的数据)。但高并发场景下,为了保住数据库,这种“稍微有点旧”的数据是可以接受的。


第二部分:击穿——独木桥上的“生死时速”

如果说“雪崩”是大水漫灌,把堤坝冲垮了,那“击穿”就是一条独木桥,突然断了

缓存击穿指的是:一个极其热门的Key(比如“王宝强前妻是谁”这种热搜,或者秒杀活动中的商品),在某个时间点突然失效

这时候会发生什么?Redis里没有这个Key,但是成千上万的并发请求瞬间全部到达,像饿狼一样扑向MySQL。

1. 场景重现:秒杀活动

假设你卖一瓶“神水”,售价9999元。大家都想去抢。

你设置了缓存,结果运气不好,那个Key的TTL正好到期了。
第一万个请求进来,没缓存,查库。耗时10ms。
第二万个请求进来,没缓存,查库。耗时10ms。
第十万个请求进来,没缓存,查库……

这时候,你的MySQL数据库CPU直接起飞,物理爆炸。虽然你的Redis有抗住并发的能力,但你的数据库(MySQL)经不住这么造啊!

2. 核心痛点:并发穿透

缓存击穿最可怕的不是没数据,而是并发穿透。如果没有缓存,或者缓存过期,所有请求瞬间穿透Redis,直奔数据库。

3. 解决方案:分布式锁(再次登场)

虽然前面在雪崩场景下讲过锁,但在击穿场景下,锁的用法更典型。

对于热点Key,一旦缓存失效,必须只能有一个线程去查询数据库,更新缓存。其他的线程必须等待。

这就是经典的“互斥锁”场景。

public function getHotProduct($productId) {
    $cacheKey = "product:hot:" . $productId;

    // 尝试拿锁
    $lockKey = "lock:product:hot:" . $productId;
    $isLock = $redis->set($lockKey, 1, ['nx', 'ex' => 10]); // 10秒超时

    if ($isLock) {
        // === 持有锁的线程 ===

        // 双重检查!防止持有锁期间,其他线程已经更新了缓存
        $data = $redis->get($cacheKey);
        if ($data) {
            return json_decode($data, true);
        }

        // 查库
        $product = $this->db->query("SELECT * FROM product WHERE id = ?", [$productId]);

        // 更新缓存
        $redis->set($cacheKey, json_encode($product), ['ex' => 3600]);

        return $product;

    } else {
        // === 没拿到锁的线程 ===
        // 等待一小会儿,重试
        sleep(0.1); 
        return $this->getHotProduct($productId);
    }
}

4. 高级技巧:布隆过滤器(堵住错误请求)

有时候,击穿是因为请求本身就是错的

比如有人访问一个根本不存在的商品ID(product:99999999)。缓存里没有,数据库里也没有。结果就是,每次请求都去查库,查库,查库……这就叫“缓存穿透”。

这时候,布隆过滤器就是你的神器。

布隆过滤器是一个巨大的位数组。它不能存具体数据,但它能告诉你“这个数据可能存在”或者“这个数据肯定不存在”。

// 伪代码演示布隆过滤器使用
$bloomFilter = new BloomFilter(1000000); // 100万个位

// 1. 预加载:把所有合法的商品ID都丢进去
$products = $this->db->getAllProductIds();
foreach ($products as $id) {
    $bloomFilter->add($id);
}

// 2. 请求来了
function getProduct($id) {
    // 先问布隆过滤器
    if (!$bloomFilter->exists($id)) {
        return null; // 布隆过滤器说肯定没有,直接返回空,别查库了!
    }

    // 布隆过滤器说可能有,再查缓存
    $data = $redis->get("product:" . $id);
    if (!$data) {
        return null;
    }
    return json_decode($data, true);
}

这招非常狠,它能在请求到达Redis之前,就干掉90%不存在的请求。虽然布隆过滤器有极微小的误判率(可能把存在的说成不存在的),但只要配合缓存使用,几乎无伤大雅。


第三部分:综合实战与PHP的“小心机”

好了,理论讲完了。咱们来点实际的。在PHP里做这个,有几个细节特别容易踩雷。

1. PHP的“单线程”特性与锁

PHP是脚本语言,运行在Web服务器(Nginx/FPM)下。虽然它是单线程的,但在高并发下,可能有成百上千个PHP进程同时在跑。

Redis是单线程的,但它很快。我们可以利用Redis的原子性命令来搞事情。

千万不要自己写一个while(true)循环去抢锁,那样会把CPU跑满的。

2. 完美的“缓存服务类”代码示例

下面这段代码,把刚才讲的所有技巧(随机过期、互斥锁)都揉进去了。你可以直接拿去改造成你的项目。

class RedisService {
    private $redis;

    public function __construct() {
        $this->redis = new Redis();
        $this->redis->connect('127.0.0.1', 6379);
    }

    /**
     * 获取数据,自动处理雪崩和击穿
     * @param string $key 缓存Key
     * @param callable $callback 数据库查询回调函数
     * @param int $expireSeconds 缓存基础有效期
     */
    public function get($key, callable $callback, $expireSeconds = 3600) {
        // 1. 先查缓存
        $data = $this->redis->get($key);

        if ($data) {
            return json_decode($data, true);
        }

        // 2. 缓存没命中,尝试获取锁(防击穿)
        $lockKey = "lock:" . $key;
        $lockValue = uniqid(); // 防止误删

        // NX: 只在不存在时设置, EX: 设置过期时间(防止死锁)
        $isLocked = $this->redis->set($lockKey, $lockValue, ['nx', 'ex' => 5]);

        if ($isLocked) {
            try {
                // === 双重检查:可能别的线程已经更新了 ===
                $data = $this->redis->get($key);
                if ($data) {
                    return json_decode($data, true);
                }

                // === 查询数据库 ===
                $result = $callback();

                // === 写入缓存 ===
                // 随机过期时间防止雪崩
                $randomExpire = $expireSeconds + rand(0, 600);
                $this->redis->set($key, json_encode($result), ['ex' => $randomExpire]);

                return $result;

            } finally {
                // === 释放锁 ===
                // 简单粗暴删除,生产环境建议用 Lua 脚本确保只删自己的锁
                $this->redis->del($lockKey);
            }
        } else {
            // 3. 没拿到锁,说明有人在处理,休眠一下再重试(自旋)
            usleep(50000); // 50毫秒
            return $this->get($key, $callback, $expireSeconds);
        }
    }
}

// ==========================================
// 怎么用?看这里!
// ==========================================

$redisService = new RedisService();

// 场景:获取热门商品
$productId = 10086;

$result = $redisService->get("product:{$productId}", function() use ($productId) {
    // 模拟数据库查询,这里比较慢
    echo "正在查询数据库...<br>";
    sleep(1); 
    return [
        'id' => $productId,
        'name' => 'PHP程序员的内裤',
        'price' => 9999.00
    ];
}, 3600); // 基础缓存1小时

print_r($result);

3. 永远不要“永不过期”

有的同学可能会说:“那我把所有缓存都设为永不过期不就行了?雪崩、击穿全解决了!”

哎,朋友,你这是在偷懒,也是自掘坟墓。

如果数据在数据库里改了(比如商品价格变了),你还在返回旧数据,这就叫脏读。用户付了9999元,结果发来一箱石头。

所以,“永不过期”必须配合后台异步更新。数据变了,触发更新任务,后台慢慢更新Redis,这就回到了我们前面讲的“逻辑过期”方案。


第四部分:总结与升华

好了,各位,今天的讲座接近尾声。

咱们今天干了什么?

  1. 面对雪崩,我们学会了给过期时间加“随机盐”,就像给每个人发不同的门禁卡,防止大家同时出门撞车。我们学会了用互斥锁,让排队的比先跑的更聪明。
  2. 面对击穿,我们明白了热点Key的脆弱。我们用互斥锁筑起了高墙,并用布隆过滤器挡住了那些不存在的垃圾请求。

在PHP的世界里,我们没有Java的ThreadLocal,也没有C++的锁机制,我们更多的是利用Redis的原子特性来“借力打力”。

记住,高并发不是目的,高可用才是王道。你的代码再快,如果数据库挂了,也就是个摆设。

所以,下次当你写代码时,如果看到那个简单的set命令,记得停下来想一想:“万一这一秒所有人都要用数据,怎么办?”

好了,下课!记得点赞,不然下次讲Redis集群分片时,我可就不这么详细了。

祝大家代码无Bug,奖金拿到手软,发际线……虽然难保,但至少心是稳的!

发表回复

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