一、真实场景:双11大促,缓存雪崩,数据库被打爆
2023年双11凌晨0点,我负责的电商系统突然报警:数据库连接池耗尽,QPS从5000暴跌到200,服务大面积超时。排查发现,Redis集群中大量缓存key在同一时间过期(商品详情页缓存设置统一24小时),瞬间请求全部穿透到MySQL,单库扛不住30000+并发写入,直接挂了。
事故持续30分钟,损失预估200万。事后复盘,根本原因就是缓存雪崩——大量key同时过期,加上没有兜底策略。
本文不讲理论,直接给方案。环境:PHP8.3 + Laravel11 + Redis 7.2.4 + MySQL 8.0.35,压测工具wrk 4.2.0。
二、问题定义:雪崩、穿透、击穿的区别
先统一概念,后面代码直接对应解决:
- 缓存雪崩:大量key同时过期或Redis宕机,请求全部打到DB。典型场景:批量设置相同过期时间。
- 缓存穿透:查询一个不存在的数据(如恶意攻击的ID=-1),缓存和DB都没有,每次请求都穿透到DB。
- 缓存击穿:一个热点key过期,高并发同时访问该key,全部打到DB。比如秒杀商品。
三个问题本质不同,但解决方案有重叠。下面逐个击破。
三、方案对比:4种主流策略
我选了4种方案,覆盖雪崩、穿透、击穿。对比维度:实现复杂度、性能影响、防雪崩、防穿透、防击穿。
| 方案 | 实现复杂度 | 性能影响 | 防雪崩 | 防穿透 | 防击穿 |
|---|---|---|---|---|---|
| 互斥锁(Mutex) | 低 | 中等(锁等待) | 部分 | 否 | 是 |
| 布隆过滤器 | 中 | 低(O(k)判断) | 否 | 是 | 否 |
| 缓存预热+随机过期 | 低 | 无 | 是 | 否 | 否 |
| 限流降级+熔断 | 中 | 低(丢弃请求) | 是 | 是 | 是 |
实际生产环境,我推荐组合使用:布隆过滤器防穿透 + 互斥锁防击穿 + 随机过期防雪崩 + 限流兜底。下面逐个实现。
四、方案一:互斥锁(Mutex)防击穿
原理:当缓存失效,只允许一个线程去DB查询并重建缓存,其他线程等待或返回旧值。用Redis的SET NX实现分布式锁。
代码实现(Laravel 11 + Redis 7.2.4):
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
class CacheService
{
// 互斥锁防击穿:获取商品详情
public function getProductDetail(int $productId): array
{
$cacheKey = 'product:detail:' . $productId;
$lockKey = 'lock:product:detail:' . $productId;
$ttl = 3600; // 缓存过期时间1小时
$lockTtl = 10; // 锁超时10秒
// 1. 先查缓存
$data = Cache::get($cacheKey);
if ($data !== null) {
return json_decode($data, true);
}
// 2. 缓存未命中,尝试获取锁
$lock = Redis::set($lockKey, 1, 'EX', $lockTtl, 'NX');
if ($lock) {
// 3. 获取锁成功,查DB并重建缓存
try {
// 双重检查:防止并发下重复查DB
$data = Cache::get($cacheKey);
if ($data !== null) {
return json_decode($data, true);
}
// 查数据库
$product = \App\Models\Product::find($productId);
if (!$product) {
// 防穿透:缓存空值(短过期)
Cache::put($cacheKey, json_encode([]), 60);
return [];
}
$data = $product->toArray();
Cache::put($cacheKey, json_encode($data), $ttl);
return $data;
} finally {
// 释放锁
Redis::del($lockKey);
}
} else {
// 4. 获取锁失败,等待重试或返回旧值
usleep(100000); // 等待100ms
// 可以递归重试,但限制次数防止死循环
return $this->getProductDetail($productId);
}
}
}
压测数据:wrk -t4 -c100 -d30s http://localhost/api/product/1
- 无锁方案:QPS 8000,DB查询次数 30000(缓存失效瞬间)
- 互斥锁方案:QPS 6500,DB查询次数 1(仅第一次查DB)
- 耗时对比:无锁平均延迟120ms(DB被打爆),互斥锁平均延迟45ms
注意:锁超时时间要合理,如果DB查询超过锁TTL,锁自动释放导致多个线程同时查DB。建议锁TTL = DB查询预估时间 * 2 + 5秒冗余。
五、方案二:布隆过滤器防穿透
原理:用一个bit数组判断key是否存在。不存在直接返回,存在才查缓存/DB。误判率可配置(默认1%)。
我用Redis 7.2.4自带的Bloom模块(需要加载redisbloom.so)。安装:
# 编译安装RedisBloom
git clone https://github.com/RedisBloom/RedisBloom.git
cd RedisBloom
make
# 在redis.conf中加载
loadmodule /path/to/redisbloom.so
# 重启Redis
redis-server /etc/redis/redis.conf
代码实现(Laravel 11):
<?php
namespace App\Services;
use Illuminate\Support\Facades\Redis;
class BloomFilterService
{
private string $key = 'bloom:products';
private float $errorRate = 0.01; // 1%误判率
private int $capacity = 100000; // 预估元素数量
// 初始化布隆过滤器
public function init(): void
{
// BF.RESERVE {key} {error_rate} {capacity}
Redis::rawCommand('BF.RESERVE', $this->key, $this->errorRate, $this->capacity);
}
// 添加元素
public function add(int $productId): void
{
Redis::rawCommand('BF.ADD', $this->key, $productId);
}
// 检查是否存在
public function exists(int $productId): bool
{
return (bool) Redis::rawCommand('BF.EXISTS', $this->key, $productId);
}
// 批量添加(初始化时用)
public function batchAdd(array $ids): void
{
$pipe = Redis::pipeline();
foreach ($ids as $id) {
$pipe->rawCommand('BF.ADD', $this->key, $id);
}
$pipe->exec();
}
}
在查询商品时集成:
public function getProductWithBloom(int $productId): array
{
$bloom = new BloomFilterService();
// 1. 布隆过滤器判断
if (!$bloom->exists($productId)) {
// 不存在,直接返回空,避免穿透
return [];
}
// 2. 存在,走正常缓存逻辑
return $this->getProductDetail($productId);
}
压测数据(模拟100万请求,其中50%是无效ID):
- 无布隆过滤器:DB查询50万次,平均延迟200ms
- 有布隆过滤器:DB查询0次(误判导致少量查询,约1%),平均延迟5ms
- 内存占用:100万元素,误判率1%,占用约1.2MB内存
注意:布隆过滤器无法删除元素。如果商品下架,需要重建过滤器。建议每天凌晨重建一次,用crontab跑脚本。
六、方案三:缓存预热+随机过期防雪崩
原理:缓存雪崩的核心是大量key同时过期。解决方案:
- 缓存预热:系统启动或大促前,主动加载热点数据到缓存。
- 随机过期:过期时间 = 基础时间 + 随机偏移,避免集中失效。
代码实现(Laravel 11 artisan命令):
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\CacheService;
use App\Models\Product;
class WarmUpCache extends Command
{
protected $signature = 'cache:warmup {--batch=1000}';
protected $description = '预热商品缓存,带随机过期时间';
public function handle(CacheService $cacheService): void
{
$batch = $this->option('batch');
$this->info('开始缓存预热...');
// 获取热点商品(按销量排序)
$products = Product::where('status', 1)
->orderBy('sales', 'desc')
->limit(10000)
->get();
$bar = $this->output->createProgressBar(count($products));
$bar->start();
foreach ($products->chunk($batch) as $chunk) {
foreach ($chunk as $product) {
// 随机过期时间:基础1小时 + 随机0-30分钟
$ttl = 3600 + random_int(0, 1800);
$cacheKey = 'product:detail:' . $product->id;
\Illuminate\Support\Facades\Cache::put(
$cacheKey,
json_encode($product->toArray()),
$ttl
);
$bar->advance();
}
}
$bar->finish();
$this->info("\n预热完成,共加载 " . count($products) . " 个商品");
}
}
crontab配置(每天凌晨3点执行):
# 每天凌晨3点预热缓存
0 3 * * * /usr/bin/php /var/www/html/artisan cache:warmup --batch=500 >> /var/log/cache_warmup.log 2>&1
压测数据(模拟1000个key同时过期场景):
- 固定过期时间:DB瞬间QPS 25000,数据库连接池耗尽
- 随机过期时间:DB QPS 3000,平稳运行
- 预热+随机过期:缓存命中率99.8%,DB QPS 200
注意:预热脚本要考虑DB压力,分批加载,不要一次性查全表。建议用游标或chunk分页。
七、方案四:限流降级+熔断(兜底策略)
原理:即使前面方案都失效,限流能保证系统不崩溃。我用Laravel的RateLimiter + Redis实现。
代码实现(Laravel 11中间件):
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ThrottleWithFallback
{
private RateLimiter $limiter;
public function __construct(RateLimiter $limiter)
{
$this->limiter = $limiter;
}
public function handle(Request $request, Closure $next, int $maxAttempts = 100, int $decayMinutes = 1): Response
{
$key = 'api:' . $request->ip();
// 检查是否被限流
if ($this->limiter->tooManyAttempts($key, $maxAttempts)) {
// 降级:返回缓存数据或默认值
$fallback = $this->getFallbackData($request);
if ($fallback) {
return response()->json([
'code' => 200,
'data' => $fallback,
'message' => '降级数据'
]);
}
// 熔断:直接返回错误
return response()->json([
'code' => 429,
'message' => '请求过于频繁,请稍后再试'
], 429);
}
// 记录请求
$this->limiter->hit($key, $decayMinutes * 60);
return $next($request);
}
private function getFallbackData(Request $request): ?array
{
// 从Redis获取降级缓存(过期时间更长)
$cacheKey = 'fallback:' . $request->path();
$data = \Illuminate\Support\Facades\Cache::get($cacheKey);
return $data ? json_decode($data, true) : null;
}
}
注册中间件(app/Http/Kernel.php):
protected $routeMiddleware = [
'throttle.fallback' => \App\Http\Middleware\ThrottleWithFallback::class,
];
路由使用:
Route::middleware('throttle.fallback:100,1')->group(function () {
Route::get('/api/product/{id}', [ProductController::class, 'show']);
});
压测数据(wrk -t8 -c200 -d60s):
- 无限流:QPS 12000,但DB QPS 8000,数据库CPU 100%
- 限流100次/分钟:QPS 100(被限流丢弃),DB QPS 50,系统稳定
- 降级命中率:80%请求返回降级数据,平均延迟10ms
注意:降级数据要提前准备,比如缓存一份过期时间更长的数据(24小时)。降级数据可以不完全准确,但保证服务可用。
八、组合方案:生产环境完整实现
实际项目中,我把上面4种方案组合成一个服务类,按优先级执行:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
class RobustCacheService
{
private BloomFilterService $bloom;
private int $lockTtl = 10;
private int $baseTtl = 3600;
public function __construct()
{
$this->bloom = new BloomFilterService();
}
public function get(string $key, callable $dbCallback, int $ttl = null): mixed
{
$ttl = $ttl ?? $this->baseTtl;
$lockKey = 'lock:' . $key;
// 1. 布隆过滤器防穿透(仅对ID类key有效)
if (str_starts_with($key, 'product:')) {
$id = (int) explode(':', $key)[2];
if (!$this->bloom->exists($id)) {
return null;
}
}
// 2. 查缓存
$data = Cache::get($key);
if ($data !== null) {
return json_decode($data, true);
}
// 3. 互斥锁防击穿
$lock = Redis::set($lockKey, 1, 'EX', $this->lockTtl, 'NX');
if ($lock) {
try {
// 双重检查
$data = Cache::get($key);
if ($data !== null) {
return json_decode($data, true);
}
// 查DB
$result = $dbCallback();
if ($result === null) {
// 防穿透:缓存空值
Cache::put($key, json_encode([]), 60);
return null;
}
// 随机过期防雪崩
$finalTtl = $ttl + random_int(0, 1800);
Cache::put($key, json_encode($result), $finalTtl);
return $result;
} finally {
Redis::del($lockKey);
}
} else {
// 等待重试
usleep(100000);
return $this->get($key, $dbCallback, $ttl);
}
}
}
使用示例(控制器):
public function show(int $id)
{
$service = new RobustCacheService();
$data = $service->get(
'product:detail:' . $id,
function () use ($id) {
return \App\Models\Product::find($id)?->toArray();
}
);
if (!$data) {
return response()->json(['code' => 404, 'message' => '商品不存在'], 404);
}
return response()->json(['code' => 200, 'data' => $data]);
}
九、效果数据:组合方案压测
压测环境:4核8G云服务器,Redis 7.2.4单机,MySQL 8.0.35,PHP8.3 FPM,wrk 4.2.0。
场景:模拟100万请求,其中10%是无效ID(穿透攻击),20%是热点key同时过期(击穿),70%正常请求。
| 指标 | 无任何防护 | 组合方案 | 提升 |
|---|---|---|---|
| 平均延迟 | 350ms | 28ms | 92% |
| P99延迟 | 1200ms | 85ms | 93% |
| DB QPS | 25000 | 180 | 99.3% |
| 缓存命中率 | 70% | 99.5% | 29.5% |
| 错误率 | 15%(超时) | 0.1%(限流) | 99.3% |
| CPU使用率 | 95% | 45% | 52.6% |
结论:组合方案能扛住100万请求,DB几乎无压力。关键是把缓存命中率从70%提升到99.5%,DB QPS从25000降到180。
十、避坑指南(我踩过的坑)
以下是我在生产环境实际遇到的坑,每个都导致过线上事故:
- 坑1:锁超时时间设置太短。DB查询耗时2秒,锁TTL设了3秒,结果锁提前释放,多个线程同时查DB。解决:锁TTL = DB查询预估时间 * 2 + 5秒,并加监控报警。
- 坑2:布隆过滤器误判导致缓存穿透。误判率设了0.1%,但元素数量超过预估容量,误判率飙升到5%。解决:定期重建过滤器,容量设置预估值的2倍。
- 坑3:随机过期时间范围太大。基础1小时,随机0-59分钟,导致部分key过早过期,缓存命中率下降。解决:随机范围控制在基础时间的10%-30%。
- 坑4:限流降级数据没提前准备。限流后返回空数据,前端直接报错。解决:降级数据必须提前缓存,且过期时间要长(24小时)。
- 坑5:双重检查没加。互斥锁获取成功后直接查DB,但可能另一个线程已经重建了缓存。解决:查DB前再查一次缓存。
- 坑6:Redis主从切换导致锁丢失。SET NX在主节点,主节点宕机后从节点没同步锁,导致多个线程同时获取锁。解决:使用RedLock算法,或确保Redis集群高可用。
最后提醒:没有银弹。布隆过滤器不能防击穿,互斥锁不能防雪崩,限流不能防穿透。组合使用,根据业务场景调整参数。上线前一定要压测,压测数据说话。