缓存穿透、击穿、雪崩区别与防护

解读

国内高并发业务(电商大促、秒杀、直播带货)中,Redis+MySQL 是 PHP 项目的标配。穿透、击穿、雪崩是 Redis 缓存层最常见的三大“黑天鹅”,面试官想确认:

  1. 能否精准区分三种场景;
  2. 能否给出 PHP 可落地的代码级防护,而不仅是“加锁”两个字;
  3. 能否结合业务成本(QPS、内存、运维复杂度)做权衡。

知识点

  1. 穿透:key 在 Redis 和 MySQL 都不存在,被高并发恶意或异常请求反复查询,流量直达 DB。
  2. 击穿:热点 key 突然过期,大量同一 key 的并发线程同时回源 MySQL,造成 DB 瞬时 spike。
  3. 雪崩:大量 key 因同一批过期时间或 Redis 宕机,集体失效,DB 被打垮,引发级联服务不可用。
  4. PHP 常用武器:Redis 扩展、Lua 脚本、Setnx 分布式锁、Composer 包(predis/phpredis)、本地 APCu 二级缓存、消息队列(RocketMQ/RabbitMQ)异步重建。
  5. 国内大厂经验:本地布隆过滤器 + 异步刷新 + 随机 jitter + 哨兵集群 + 降级开关,兼顾成本与 SLA。

答案

一、三场景一句话区分

穿透:Redis 无,MySQL 也无,请求像“穿过”一样。
击穿:Redis 本来有,但过期瞬间被高并发“击穿”。
雪崩:Redis 大面积失效,像“雪崩”压垮 DB。

二、PHP 级防护方案(可直接写进简历)

  1. 穿透

    • 布隆过滤器:PHP 使用 bloom-filter 包,初始化时把全表 ID 同步到 Bloom,误判率 0.1%,内存 50 MB 可扛 5000 万 key。
    • 空值缓存:查询 MySQL 返回空时,Redis 写 SET key "NULL" EX 60,防止同一恶意 key 反复打 DB。
  2. 击穿

    • 单飞锁(互斥锁):使用 Redis Lua 脚本保证原子性
      local ok = redis.call('setnx',KEYS[1],ARGV[1]); if ok==1 then redis.call('expire',KEYS[1],ARGV[2]) end return ok
      锁超时 3 s,防止 PHP-FPM 进程挂掉造成死锁。
    • 逻辑过期:value 存 JSON {data:xxx, expireAt:1672531200},后台异步线程(PHP-cli + Supervisor)提前 30 s 重建,前端永不过期,实现“平滑滚动”。
  3. 雪崩

    • 随机 jitter:PHP 在写入时 EXPIRE = base + mt_rand(0,300),打散过期时间。
    • 二级缓存:APCu 本地缓存 3 s,Redis 缓存 10 min,形成“漏斗”。
    • 熔断降级:基于阿里 Sentinel-PHP 扩展,监控 Redis 失败率 >50% 时直接返回“排队中”静态页,MQ 异步下单,保证核心链路可用。

三、代码片段(面试手写示范)

// 穿透+空值缓存
function getProduct(int $id) {
    $key = "p:$id";
    $val = redis()->get($key);
    if ($val !== null) {
        return $val === 'NULL' ? null : json_decode($val, true);
    }
    $row = db()->query('SELECT * FROM product WHERE id=?', [$id]);
    if (!$row) {
        redis()->set($key, 'NULL', 60);   // 防穿透
        return null;
    }
    redis()->set($key, json_encode($row), 3600 + mt_rand(0,300));
    return $row;
}

// 击穿+互斥锁
function getHotProduct(int $id) {
    $key = "p:$id";
    $val = redis()->get($key);
    if ($val) return json_decode($val, true);

    $lockKey = "lock:p:$id";
    $identifier = uniqid('', true);
    // 原子抢锁
    $ok = redis()->eval(
        "return redis.call('setnx',KEYS[1],ARGV[1])==1 and redis.call('expire',KEYS[1],ARGV[2]) and 1 or 0",
        [$lockKey, $identifier, 3], 1
    );
    if ($ok) {
        $row = db()->query('SELECT * FROM product WHERE id=?', [$id]);
        if ($row) {
            redis()->set($key, json_encode($row), 3600);
        }
        redis()->del($lockKey); // 及时释放
        return $row;
    }
    usleep(50000);            // 自旋 50 ms
    return getHotProduct($id); // 重试
}

四、落地成本

  • 布隆过滤器全量同步放在凌晨低峰期,PHP-cli 脚本 5 min 跑完,内存增加 50 MB,QPS 提升 3 倍。
  • 互斥锁超时 3 s,极端情况下最多 1 个请求回源,DB QPS 从 2 万降到 1。
  • 随机 jitter 后,过期峰值打散,Redis 瞬时失效 key 从 20 万降到 1 千,DB 连接池未再报警。

拓展思考

  1. 多级缓存一致性:如何防止 APCu 与 Redis 数据不一致?可引入版本号机制,APCu 存 ver=12345,Redis 存 ver=12346 时强制回源。
  2. 大 Key 热 Key 问题:直播商品库存 key 成为热 key,可采用 Redis 分片 + 本地缓存 + 消息队列异步扣减,实现“读热写热”分离。
  3. 容灾演练:国内云厂商 Redis 主从切换 3 s 内完成,PHP 侧需捕获 ConnectionException 快速降级,避免用户看到 502。
  4. 成本优化:布隆过滤器误判导致少量空命中,可接受;若业务对误判 0 容忍,可用 RoaringBitmap 或 RedisBloom 模块,但需评估内存与运维复杂度。
  5. 合规角度:用户隐私数据(如手机号)不能缓存到 Redis,需走内存加密或 Token 化,面试可主动提及,体现安全意识。