Redis缓存雪崩穿透击穿:实战方案
发布日期: 2026/07/24 阅读总量: 0

一、真实场景:双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%正常请求。

指标无任何防护组合方案提升
平均延迟350ms28ms92%
P99延迟1200ms85ms93%
DB QPS2500018099.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集群高可用。

最后提醒:没有银弹。布隆过滤器不能防击穿,互斥锁不能防雪崩,限流不能防穿透。组合使用,根据业务场景调整参数。上线前一定要压测,压测数据说话。