各位程序员朋友,大家好!
欢迎来到今天的技术“急救室”。我是你们的老朋友,那个因为半夜两点被报警短信吵醒过十次的资深架构师。
今天咱们不聊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"); // 这里的代码还没执行,脚本崩溃了...
}
为什么要小心? 在高并发下,exists和set之间是有时间差的。如果第一个脚本执行到一半挂了,锁永远在,其他请求就被堵死了。
✅ 正确示范:使用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,这就回到了我们前面讲的“逻辑过期”方案。
第四部分:总结与升华
好了,各位,今天的讲座接近尾声。
咱们今天干了什么?
- 面对雪崩,我们学会了给过期时间加“随机盐”,就像给每个人发不同的门禁卡,防止大家同时出门撞车。我们学会了用互斥锁,让排队的比先跑的更聪明。
- 面对击穿,我们明白了热点Key的脆弱。我们用互斥锁筑起了高墙,并用布隆过滤器挡住了那些不存在的垃圾请求。
在PHP的世界里,我们没有Java的ThreadLocal,也没有C++的锁机制,我们更多的是利用Redis的原子特性来“借力打力”。
记住,高并发不是目的,高可用才是王道。你的代码再快,如果数据库挂了,也就是个摆设。
所以,下次当你写代码时,如果看到那个简单的set命令,记得停下来想一想:“万一这一秒所有人都要用数据,怎么办?”
好了,下课!记得点赞,不然下次讲Redis集群分片时,我可就不这么详细了。
祝大家代码无Bug,奖金拿到手软,发际线……虽然难保,但至少心是稳的!