Skip to content

缓存穿透-击穿-雪崩 ​

看在前面 ​

缓存穿透、缓存击穿、缓存雪崩,这三个词是 Redis 面试的"开门三件事"。面试官问它们,不是想听你背三个名词解释。面试官想看你有没有想过:缓存本质上是用"一小份可能过期的数据"去挡住"一大份真实流量"。以及这份挡箭牌失效的时候系统会发生什么。

这三者就是挡箭牌失效的三种形态:

  • 穿透:挡箭牌压根不存在(key 在缓存和数据库里都没有);
  • 击穿:挡箭牌上最坚固的那一块碎了一个点(单个热点 key 过期);
  • 雪崩:整面挡箭牌同时塌了(大面积 key 同时失效,或 Redis 本身倒了)。

这篇文章把三者的原理、方案与代码一次讲透,延伸出的一致性与热 key 话题独立成篇(见文末)。建议先通读理解脉络,面试前再刷最后的面试官视角小结。文中代码都是关键片段(不是完整工程),重在时序和细节。

先给一张总图:三者到底差在哪 ​

面试官:先用一分钟说清楚,穿透、击穿、雪崩分别是什么?

一句话版本:

  • 缓存穿透(penetration):查询一个缓存和数据库都不存在的数据。因为不存在,所以永远不会有"查到后写回缓存"这一步,缓存永远挡不住,每次请求都打到数据库。
  • 缓存击穿(hotspot invalid / breakdown):某个热点 key 在过期的瞬间,恰逢高并发流量。成千上万条请求同时发现缓存 miss,全部回源数据库去重建缓存。DB 瞬间承受了本该只发生一次的查询的 N 倍流量。
  • 缓存雪崩(avalanche):大面积 key 同时失效(比如批量导入时设了相同 TTL)。或者 Redis 实例/集群整体不可用(宕机、主从切换)。此时流量整体涌向数据库,DB 被打死,进而拖垮整个依赖链,形成级联故障。

三者对比表(这张表本身就是面试的满分答案):

维度缓存穿透缓存击穿缓存雪崩
触发条件查询的数据在缓存和 DB 中都不存在某个热点 key 恰好过期大量 key 同时失效,或 Redis 整体不可用
key 是否存在不存在(DB 里也没有)存在,且访问量极大存在,数量巨大
缓存为什么挡不住无数据可缓存,miss 是"永久性"的miss 是"瞬时性"的(重建后即恢复)miss 是"风暴式"的(同时发生)
典型诱因恶意攻击(随机 id 爬接口)、脏数据、非法参数秒杀商品缓存到期、热门微博过期批量预加载设了同一 TTL;Redis 宕机/主从切换/网络分区
影响面恶意构造时理论上无上限(每个请求都穿透)单个 key 的并发量(但热 key 往往就是最大流量)最广:全量业务流量,可引发级联雪崩
本质"不存在"无法被缓存这件事被滥用并发重建没有做互斥失效在时间维度上没有做打散 + 缓存服务没有高可用
核心解法参数校验 + 空值缓存 + 布隆过滤器互斥锁重建 / 逻辑过期异步刷新 / 热 key 永不过期TTL 随机抖动 + 多级缓存 + 集群高可用 + 熔断降级限流

记忆锚点:穿透问"不存在",击穿问"一个点",雪崩问"一大片"。防御思路分别是:穿透靠"把不存在也缓存下来/挡在门外"。击穿靠"重建互斥化"。雪崩靠"失效打散 + 兜底降级"。

下面逐个深挖。

缓存穿透:查一个"根本不存在"的 key ​

面试官:讲讲穿透的完整链路,为什么它危险?

正常读路径是:查缓存 → miss → 查 DB → 写回缓存 → 下次命中。注意最后一步:只有 DB 里查得到的数据才会被写回缓存。

如果请求的是 id = -1 这种 DB 里根本不存在的数据,链路变成:查缓存 → miss → 查 DB → DB 也是空 → 无数据可写回。下一次请求来,还是这个链路。缓存对这种 key 永远是透明的。

单看一条请求没什么。但攻击者的视角是:用脚本对 id = -1, -2, -3 ... 或者随机 UUID 发起每秒几万次请求,每一条都直达 DB。DB 的连接池、CPU、磁盘 IO 被这种"查不到任何东西"的查询占满,正常业务跟着挂。这就是穿透——攻击成本极低(不需要知道任何真实数据),杀伤力却很大。

防御要设三道门,从外到里:接口层参数校验 → 空值缓存 → 布隆过滤器。

第一道门:接口层参数校验 ​

绝大多数穿透请求的参数本身就是非法的(负数 id、乱码 sku、不存在的枚举)。这类请求在 Controller 层就该拦掉,根本不给它碰缓存和 DB 的机会。

java
@GetMapping("/product")
public Result<Product> getProduct(@RequestParam long id) {
    if (id <= 0) {                       // 1. 合法性:明显非法的参数直接拒绝
        return Result.invalidParam("id 必须为正整数");
    }
    if (!idPattern.matches(skuCode)) {   // 2. 格式:正则/长度/字符集校验
        return Result.invalidParam("skuCode 格式非法");
    }
    if (!categoryEnum.contains(cid)) {   // 3. 白名单:枚举值、状态机校验
        return Result.invalidParam("分类不存在");
    }
    return productService.getProduct(id);
}

生产上配合 JSR-303(@Valid + @NotNull/@Min/@Pattern)把校验声明在 DTO 上。再加上针对单 IP / 单用户的限流(如 Sentinel 的流控规则),恶意流量在这道门就被消化掉大半。

但参数校验有盲区。攻击者如果拿到过合法 id 的分布规律(比如自增主键),用"格式合法但已删除/不存在"的 id 也能穿透。所以还需要第二道门。

第二道门:空值缓存——最简单,但藏着 TTL 陷阱 ​

思路很直接:DB 查不到时,把这个 key 对应的"空结果"本身也缓存起来。下次同样的请求直接命中空值返回,不再回源。

java
public Product getProduct(long id) {
    String key = "product:" + id;
    String cache = redis.get(key);
    if (cache != null) {
        return NULL_MARKER.equals(cache) ? null : parse(cache);  // 命中空值,直接返回"不存在"
    }
    Product p = db.queryById(id);                                 // 回源
    if (p == null) {
        redis.setex(key, 60, NULL_MARKER);   // 空值也要缓存,但 TTL 必须短
    } else {
        redis.setex(key, 3600, toJson(p));   // 真实值可以缓存久一些
    }
    return p;
}

这个方案有四个经典陷阱,面试官很爱追问:

陷阱一:TTL 过长 → 不一致窗口被拉大。 假设空值缓存了 1 小时。期间这条数据在 DB 里被创建了(运营后台新上架了这个商品),那么最长 1 小时内用户都查不到它。所以空值的 TTL 要明显短于真实值的 TTL,一般 30 秒 ~ 2 分钟。

陷阱二:TTL 过短 → 防不住持续攻击。 TTL 到期后攻击依旧穿透。所以说空值缓存只能"抬高攻击成本",不能根治,通常和布隆过滤器配合使用。

陷阱三:内存攻击(反向穿透)。 攻击者用海量随机 key 打你,每个 key 都会在 Redis 里留下一条空值缓存。这等于攻击者用 0 成本让你往 Redis 里灌垃圾,把 Redis 内存打爆。这也是空值 TTL 必须短的第二个原因;极端场景下甚至要对"高频 miss 的 key 模式"做限流封禁。

陷阱四:序列化歧义。 直接存 JSON 的 null 或空串,可能和业务的合法空值(如空字符串昵称)冲突。要用哨兵值(如上面的 NULL_MARKER = "\_\_NULL\_\_")或包装对象({"exist":false})显式区分"缓存了的不存在"和"没缓存"。

第三道门:布隆过滤器——用"绝不漏判"换空间 ​

面试官:布隆过滤器的原理?误判率怎么来的、怎么推导?

这是穿透部分的高潮,必须讲到源码/数学级。

数据结构:一个 m 位的位数组(初始全 0)+ k 个互相独立的哈希函数。

  • 写入:把元素依次过 k 个哈希,得到 k 个下标,把位数组中这 k 个位置成 1;
  • 查询:同样算 k 个下标。只要有任何一位是 0,该元素一定不存在(因为存在过就必然置过 1)。只有 k 位全是 1,才说"可能存在"(因为这些位可能是别的元素置的)。

关键特性是单向误判:

  • 假阴性(漏判):不存在。判"不存在"就是真不存在,可以放心拦截;
  • 假阳性(误判):存在。判"存在"有概率是误判,所以判"存在"的元素还要走一遍缓存/DB 确认。

对防穿透来说这个方向刚好是安全的。绝大多数不存在的 key 会被"一定不存在"直接拦掉。少数误判的放过一次回源,代价可控。

误判率推导(面试白板级,一步步来):

设位数组 m 位,已插入 n 个元素,k 个哈希,且哈希近似均匀。

  1. 插入一个元素时,位数组某特定位置 1 的概率是 1/m,保持 0 的概率是 1 - 1/m;

  2. 插入 n 个元素共 k*n 次置位(各次独立),某位仍为 0 的概率:

    p0 = (1 - 1/m)^(k*n),当 m 较大时,利用极限 (1 - 1/m)^m ≈ e^(-1) 化简为 p0 ≈ e^(-k*n/m);

  3. 查询一个不存在的元素时发生误判。误判要求它的 k 个哈希位全是 1,每位为 1 的概率是 1 - p0。所以:

    P_fp ≈ (1 - e^(-k*n/m))^k

  4. 求最优 k:固定 m、n,对 k 求导取极值,得 k* = (m/n) * ln2 ≈ 0.693 * (m/n)。代入后发现此时 e^(-k*n/m) = 1/2,即位数组中为 1 的位恰好占 1/2,误判率简化为:

    P_fp ≈ (1/2)^k* ≈ (0.6185)^(m/n)

  5. 反推内存:给定目标误判率 p,m = -n * ln p / (ln2)^2,即每个元素占 1.44 * log2(1/p) 位。

算一笔账(面试时能报出这组数是加分项):1 亿个商品 id,目标误判率 1%:

  • 每元素 1.44 * log2(100) ≈ 9.6 bit,总位数组约 9.6 * 10^8 bit ≈ 114 MB——相比把 1 亿个 id 放 HashSet(至少几个 GB)省了两个数量级;
  • 最优哈希数 k ≈ 0.693 * 9.6 ≈ 7 个;
  • 误判率 1% 意味着:一万个不存在的 key 里平均只有 100 个会漏到缓存/DB 层,配合空值缓存,几乎无伤。

实现技巧——双重哈希(Kirsch–Mitzenmacher):实际不需要真的准备 k 个独立哈希。两个哈希 h1、h2 组合 h_i(x) = h1(x) + i * h2(x) 即可扩展出 k 个,效果渐近等价,Guava 就是这么干的。

Guava / Redisson 用法:

java
// 单机版:JVM 内存的布隆过滤器
BloomFilter<Long> bf = BloomFilter.create(
        Funnels.longFunnel(),
        1_000_000,   // 预期元素数 n(必须提前估准,见下面缺点)
        0.01);       // 目标误判率 1%

// 写入侧:商品上架时(或定时全量/增量同步)加入
bf.put(productId);

// 读取侧:先问布隆
if (!bf.mightContain(productId)) {
    return null;     // 一定不存在,直接返回,连 Redis 都不用查
}
// 可能存在,才继续走缓存 → DB
java
// 分布式版:Redisson 基于 Redis 位图实现,多实例共享
RBloomFilter<Long> bf = redisson.getBloomFilter("bf:product");
bf.tryInit(1_000_000L, 0.01);      // 底层即按上面公式算好 m 和 k

布隆的三个天生缺点(面试官必追问):

  1. 不能删除。一个位可能被多个元素共享。删除一个元素清了位,会把别的元素"误伤"成不存在。而"判不存在"是布隆唯一可信的输出,伤不起。变通是计数布隆过滤器:每位换成 4 bit 计数器,删除时减一。但空间膨胀 4 倍,且有计数回绕风险。
  2. 容量要预估。实际元素数远超预设的 n 时,误判率沿 (0.6185)^(m/n) 曲线急剧恶化。此时位数组趋近全 1,人人误判;
  3. 只能全量重建。数据删除后过滤器感知不到,只能定期重建(重建期间新旧双过滤器切换)。

布谷鸟过滤器:能删除的"布隆" ​

面试官:那想支持删除怎么办?听说过布谷鸟过滤器吗?

布谷鸟过滤器(Cuckoo Filter,2014 年论文)换了一副骨架:

  • 不存"哪些位是 1",而是给每个元素算一个指纹(fingerprint)——如 8 bit 的哈希摘要。指纹存进两个候选桶之一(桶内多个槽位)。桶位置由两个部分哈希决定:i1 = hash(x),i2 = i1 XOR hash(fingerprint(x))。异或的性质使得知道任一桶和指纹就能推出另一个桶;
  • 插入:两个桶有空槽就放。都满了,随机踢掉一个旧指纹给它腾位("鸠占鹊巢",cuckoo 之名)。被踢的再去它的另一个候选桶找位置,如此连锁搬迁。超过上限(如 500 次)则判定过滤器已满、插入失败;
  • 查询:看两个桶里有没有这个指纹;
  • 删除:找到指纹直接删除一个槽即可——因为删的是"指纹副本"而不是共享的位,不会误伤别的元素。

两者对比:

维度布隆过滤器布谷鸟过滤器
支持删除不支持(计数布隆可删,空间 ×4)原生支持(删指纹)
同空间下的误判率基线更低(相同误判率下空间约省 10%~40%)
插入复杂度O(k),永不失败摊还 O(1),桶满触发连锁搬迁,可能失败(需扩容)
查询复杂度O(k) 次位探测只看 2 个桶
重复元素上限无限制受桶容量约束(同一指纹副本数有限,删多了会误删)
实现复杂度简单,Guava 开箱即用较复杂,需处理踢出/扩容

选型口径:只判存在、数据只增不减 → 布隆(更简单)。数据有删除、要动态维护 → 布谷鸟。Redis 里的 RedisBloom 模块同时提供两者。

三道门怎么排兵布阵 ​

层次手段拦截对象局限
接入层参数校验 + 限流(Sentinel/IP 限流/WAF)格式非法、高频恶意流量拦不住"格式合法但不存在"的 key
缓存层空值缓存(短 TTL + 哨兵值)已知"不存在"的 key内存可被反向攻击、TTL 到期复穿
过滤层布隆/布谷鸟前置过滤全部"不存在"的 key(漏网率 = 误判率)要维护同步链路、容量预估

生产标准姿势是三层叠加:请求先过校验和限流,再问布隆过滤器(拦截 99% 的不存在)。少数漏网或误判的走缓存,空值兜底。

缓存击穿:一个热 key 过期,万箭齐发 ​

面试官:击穿和穿透一字之差,本质区别是什么?

本质区别是数据存在,且极热。

某个秒杀商品 key 的 QPS 是 5 万,TTL 到期的那一毫秒,恰好有 500 个并发请求同时抵达。每个请求都发现缓存 miss,每个请求都去查 DB、都去重建缓存。重建 500 次"只需要 1 次"的数据——DB 瞬间多了 500 倍的重复压力。如果这个 query 本身就重(多表 join),DB 直接被打挂。DB 挂了之后缓存重建不出来,请求继续堆在 DB 上,恶性循环。

穿透是"没有数据可缓存",击穿是"并发重建没有互斥"。所以解法的核心思想只有一个:把 N 次重建合并成 1 次。三种植被策略,按一致性要求选型。

方案一:互斥锁重建(SET NX + 双检 + 谁重建谁负责) ​

加锁的对象不是数据,是"重建这个 key 的资格"。缓存 miss 时,先抢一把与 key 绑定的分布式锁,抢到的线程去查库回填,其他线程等待或稍后重试。

先看原生 Redis 怎么写才正确,这里全是面试细节:

java
public String getWithMutex(String key) {
    String value = redis.get(key);
    if (value != null) return value;                    // ① 一检:大多数请求在这里就返回了

    String token = UUID.randomUUID().toString();        // 锁的持有者标识
    // ② 加锁:NX 和 EX 必须是同一条命令!
    String ok = redis.set("lock:" + key, token, "NX", "EX", 10);
    if ("OK".equals(ok)) {
        try {
            value = redis.get(key);                     // ③ 双检:排队期间可能已被别人重建
            if (value != null) return value;
            value = db.query(key);                      // 只有持锁者执行重建
            redis.setex(key, ttlWithJitter(), value);
            return value;
        } finally {
            unlock("lock:" + key, token);               // ④ 只删自己的锁(Lua)
        }
    }
    Thread.sleep(50);                                   // ⑤ 没抢到:短暂等待后重试,或降级
    return getWithMutex(key);
}

// 解锁必须用 Lua 保证"校验持有者 + 删除"原子执行
private void unlock(String lockKey, String token) {
    redis.eval("if redis.call('get', KEYS[1]) == ARGV[1] " +
               "then return redis.call('del', KEYS[1]) " +
               "else return 0 end", List.of(lockKey), List.of(token));
}

四个必考细节:

细节一:SET key value NX EX 10 必须一条命令。 老写法 SETNX + EXPIRE 是两条命令,进程在两条之间挂掉就留下一把永不过期的死锁。Redis 2.6.12 之后 SET 支持 NX/EX 原子组合,这是标准答案。

细节二:为什么要双检(②③两次查缓存)。 你抢锁前 miss,不代表抢到锁时还 miss——排在你前面的持锁者可能刚刚重建完。不加双检,你会再做一次无意义的 DB 查询。更重要的是配合"谁重建谁负责",保证任意时刻每个 key 至多一个线程在查库。

细节三:解锁为什么要带 token 的 Lua。 场景:线程 A 持锁,但业务(慢 SQL)执行了 12 秒,锁 10 秒时自动过期,线程 B 拿到锁开始重建。第 12 秒 A 执行完直接 DEL lock:key——删除的是 B 的锁,于是 C 又能进来,互斥失效。解法就是 value 存唯一 token,删之前用 Lua 原子地校验"锁还是我的吗"。锁超时和业务超时的错配,是分布式锁一半的事故来源。

细节四:等锁策略。 没抢到锁的线程不能无限自旋(会打满 Tomcat 线程)。要么 tryLock 设短超时后走降级(返回旧值/默认值/友好提示),要么有限次数重试。击穿防护本身不能变成新的雪崩源。

Redisson 版(生产推荐,上面四坑全填好了):

java
public String getWithRedisson(String key) {
    String value = redis.get(key);
    if (value != null) return value;

    RLock lock = redisson.getLock("lock:" + key);
    // 最多排队 3 秒;不指定 leaseTime → 启动看门狗自动续期
    if (lock.tryLock(3, TimeUnit.SECONDS)) {
        try {
            value = redis.get(key);                 // 双检
            if (value == null) {
                value = db.query(key);
                redis.setex(key, ttlWithJitter(), value);
            }
            return value;
        } finally {
            lock.unlock();                          // 内部已做"可重入 + 属主校验"
        }
    }
    return degrade(key);                            // 排队超时 → 降级
}

Redisson 看门狗(watchdog)原理值得多说一句:tryLock 不传 leaseTime 时,锁默认 30 秒,且后台任务每 30/3 = 10 秒检查一次。持锁线程还活着就把过期时间续回 30 秒。线程挂了续不上,锁最多 30 秒后自然释放。这就根治了"业务没执行完锁先过期"的问题。一旦你显式传了 leaseTime,看门狗就关闭——又回到超时错配的原始风险,这是个高频追问点。

互斥锁的代价:重建期间其他请求在等待(吞吐下降、线程占用),DB 挂了锁住的是所有人的重试。它换来的是强一致——重建完成前,宁可让用户等,也不给旧数据。

方案二:逻辑过期(物理不过期 + 异步刷新) ​

互斥锁是"一致性优先",逻辑过期是"可用性优先"——秒杀、大促场景的标配。

思路:key 在 Redis 里物理上永不设置 TTL,把"过期时间"作为字段存进 value 里。读请求发现逻辑上过期了,不阻塞、不等库,先返回旧值兜底,同时异步触发一次重建。

java
// value 结构:{"data": {...}, "expireAt": 1729137600000}
public Object getWithLogicalExpire(String key) {
    String json = redis.get(key);                       // 物理 TTL = 永不过期
    if (json == null) return firstLoad(key);            // 冷启动:首次仍需同步加载

    CacheWrapper w = parse(json);
    if (w.expireAt > System.currentTimeMillis()) {
        return w.data;                                  // 逻辑上没过期,直接返回
    }
    // 逻辑过期:尝试抢"重建权"
    RLock lock = redisson.getLock("refresh:" + key);
    if (lock.tryLock(0, 10, TimeUnit.SECONDS)) {        // tryLock(0):抢不到立刻放弃
        try {
            REFRESH_POOL.submit(() -> {                 // 异步重建,当前请求绝不等待
                Object newest = db.query(key);
                redis.set(key, wrap(newest, now() + LOGICAL_TTL));
            });
        } finally { lock.unlock(); }
    }
    return w.data;                                      // 无论如何,先给用户旧值
}

要点拆解:

  • tryLock 的等待时间传 0:所有线程里只有抢到锁的那一个去触发异步刷新。其余线程连等都不等,直接拿旧值走人。用户侧感知是"数据晚几秒更新",而不是"页面卡住";
  • 重建丢给独立线程池。注意和 UI/请求线程隔离,且要设置队列上限与拒绝策略(重建风暴自身也可能压垮线程池);
  • 代价一:不一致窗口。从逻辑过期到异步重建完成之间,返回的都是旧值。窗口大小 = 重建耗时(可控制在秒级),业务上要能接受(商品详情页可以,账户余额不行);
  • 代价二:内存常驻。物理不过期意味着这批 key 永远占着内存。只适合数量可控的热点集合(如 Top 1000 秒杀商品),不能全量使用。

方案三:热点永不过期 + 主动更新 ​

对极少数"皇冠级"热 key(首页推荐、超级大促的旗舰商品),干脆连逻辑过期都不要。key 永不过期,由定时任务或变更事件(binlog/MQ)驱动主动刷新。

  • 优点:读路径零 miss、零等待,抗住任何并发;
  • 缺点:数据新鲜度完全取决于刷新链路,链路一断就是永久脏数据(连 TTL 自愈都没有了)。所以必须配监控告警 + 手动刷 key 的运维工具;
  • 适用:key 数量少、可枚举、变更频率已知。本质是用"运维确定性"换"读取无限流量"。

击穿方案对比表 ​

方案一致性可用性复杂度适用场景
互斥锁(SET NX / Redisson)强(重建完成前阻塞等待)中(重建期间请求堆积)低一致性敏感的普通热点
逻辑过期 + 异步刷新弱(秒级旧值窗口)高(请求永不等 DB)中秒杀/大促,可用性优先
永不过期 + 主动刷新最终(依赖刷新链路)极高低(但配套运维高)数量可控的超级热点

一句话选型:能让用户等就互斥,不能等就逻辑过期,皇冠热点直接永不过期 + 主动刷。

缓存雪崩:不是一把刀,是一场崩塌 ​

面试官:雪崩和击穿不就是量变到质变吗?

方向对,但雪崩多了一个击穿完全没有的诱因。雪崩有两种成因,对策完全不同:

  • 成因 A:大面积 key 同时失效。 经典事故:凌晨批量刷新 50 万个 key,代码里写死 TTL = 24h——第二天凌晨同一毫秒,50 万个 key 集体过期。全部流量回源,DB 直接被拍死。这是人祸(失效时间没有打散);
  • 成因 B:Redis 本身不可用。 实例宕机、主从切换、网络分区、云厂商故障——缓存服务整体消失,所有业务的流量整体漏到 DB。这是架构问题(缓存层没有高可用和兜底)。

击穿是"一个点失效",成因 A 是"一个面同时失效",成因 B 是"缓存服务整体失效"。按成因逐一拆解。

成因 A 的解法:TTL 随机抖动 ​

让失效时间在时间轴上摊开,别让它们肩并肩过期。一行代码的事,但要坚持:

java
// 基础 TTL 30 分钟,叠加 [0, 10 分钟) 的随机抖动
public long ttlWithJitter() {
    long base = TimeUnit.MINUTES.toSeconds(30);
    long jitter = ThreadLocalRandom.current()
                     .nextLong(TimeUnit.MINUTES.toSeconds(10));
    return base + jitter;
}
redis.setex(key, ttlWithJitter(), value);

抖动幅度按业务容忍度设计:流量高峰越尖、DB 余量越小,抖动区间越大。大促场景甚至可以把基础 TTL 本身拉长 + 全量打散。另外注意批量预热/导入是重灾区——循环里写死同一个 TTL 等于亲手制造明天的雪崩。review 时专门盯这个。

成因 B 的解法一:集群高可用视角 ​

面试官听到这里通常会追一句:"Redis 挂了怎么办?"——这就从"缓存问题"升级到"存储高可用"视角了。

先理解 Redis 主从复制的异步本质:客户端写 master,master 写完自己的内存就回 OK,还没等 replica 同步完。这个设计换来了写性能,埋下了两个雷:

  1. failover 窗口:master 宕机 → 哨兵主观下线(SDOWN)→ quorum 仲裁客观下线(ODOWN)→ 选举 leader 哨兵。之后挑选新 master(replica-priority / 复制偏移量优先),其余节点改指向新 master,客户端感知重连。整个过程通常 10~30 秒。期间所有请求要么返回错误,要么超时。超时本身也算"缓存不可用",流量还是涌向 DB;
  2. 切换后的数据回退:复制是异步的。master 挂掉时,最后若干毫秒的写入可能还没同步出去,新 master 上没有。对缓存而言这不算灾难(丢的缓存回源重建即可)。但要有一个架构共识。**Redis 当缓存时,丢数据的后果是性能抖动而不是正确性问题。如果谁把 Redis 当权威存储用,这里就是正确性事故。**脑裂(旧 master 还在接收写)可以配 min-replicas-to-write / min-replicas-max-lag 拒绝写入来缓解。

雪崩视角下,高可用的完整清单:

层次手段说明
部署主从 + 哨兵自动故障转移;或 Cluster 分片(16384 slot)消除单点;Cluster 还横向分摊 CPU/带宽
客户端Lettuce 自动重连、命令超时、退避重试failover 期间快速失败,别把线程耗死在等待上
读写读走 replica 分摊读流量注意复制延迟带来的读旧值
数据启用 AOF(everysec)/ RDB 持久化重启后缓存快速回温,避免冷启动全量回源
预案缓存预热脚本 + 灰度放量重启/切换后先灌热数据再放真实流量

成因 B 的解法二:多级缓存(Caffeine + Redis) ​

Redis 不可用时的第一兜底:把最热的一层数据放在进程内。本地缓存的读取是纳秒级的数组访问,且不依赖任何网络。Redis 整个塌了,本地这层还能顶住核心热 key 的流量。

java
Cache<String, Product> l1 = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(10, TimeUnit.SECONDS)    // L1 只做短时兜底,容忍秒级不一致
        .recordStats()                              // 暴露命中率给监控
        .build();

public Product getProduct(long id) {
    String key = "product:" + id;
    Product p = l1.getIfPresent(key);
    if (p != null) return p;                        // ① 本地:纳秒级,不碰网络

    p = loadFromRedis(key);                         // ② Redis:毫秒级
    if (p == null) {
        p = getWithMutex(key);                      // ③ 回源(复用击穿的互斥重建)
    }
    if (p != null) l1.put(key, p);                  // 回填 L1
    return p;
}

// 写路径:更库 → 删 Redis → 广播所有实例失效本地缓存
public void update(Product np) {
    db.update(np);
    redis.del("product:" + np.getId());
    redisson.getTopic("cache:invalidate")           // Redis pub/sub 广播
            .publish("product:" + np.getId());
}
// 每个实例订阅:topic.addListener((ch, k) -> l1.invalidate(k));

多级缓存的难点从来不是读,是一致性:

  • 每个实例都有一份 L1,更新时删除自己的没用,要让所有实例都失效。方案按可靠性和成本排序:Redis pub/sub 广播(简单,但 pub/sub 不保证送达,掉消息的实例靠短 TTL 自愈)。MQ 广播(可靠,重一点)。cache invalidate 专用总线。
  • L1 的 TTL 必须短(秒级),这是兜底不是权威——即使广播全丢,不一致窗口也被 TTL 封顶。这个"短 TTL 自愈"的设计自觉,是面试的加分点;
  • Caffeine 的 W-TinyLFU 淘汰算法(频率 + 新近度,近似 TinyLFU)以极低的内存开销逼近理想 LFU 命中率。比传统 LRU 更抗偶发扫描流量,可作为知识点顺带提一句;
  • 如果热 key 数量少,还可以用一致性哈希路由让同一个 key 固定落在同一实例。天然减少 L1 副本数、缩小不一致面。

成因 B 的解法三:熔断降级限流(Sentinel / Resilience4j) ​

最后也是最重要的底线:当缓存层必然失效时,保住 DB。缓存雪崩的终极伤害不是"慢",是 DB 被打死后的全站级联故障。熔断限流就是给 DB 上保险丝。

Sentinel 版(国内生态最常用,且它的热点参数限流简直是为击穿/热 key 量身定做的):

java
@SentinelResource(value = "getProduct",
                  blockHandler = "getBlock",     // 被限流/熔断时走这里
                  fallback = "getFallback")      // 业务异常时走这里
public Product getProduct(long id) {
    return cacheAsideGet(id);                    // 正常:缓存 → DB
}

public Product getBlock(long id, BlockException ex) {
    return Product.DEFAULT_DETAIL;               // 降级:返回兜底模板/静态数据/友好提示
}

配套两条规则。一是对 getProduct 配 QPS 流控(阈值 ≈ DB 安全水位的 70%),超出的流量直接走 blockHandler 降级。二是启用熔断规则(异常比例 / 慢调用比例超阈值 → 熔断 N 秒),熔断期间请求不再穿透到 DB。热点参数限流(ParamFlowRule,如 id=123 单独限到 500 QPS)还能精准压制单个爆款 id,防的就是击穿。

Resilience4j 版(函数式、轻量,Spring Cloud CircuitBreaker 默认抽象之一):

java
CircuitBreaker cb = CircuitBreaker.of("productCache",
    CircuitBreakerConfig.custom()
        .failureRateThreshold(50)                       // 失败率 >50% → 打开熔断
        .slowCallRateThreshold(80)
        .slowCallDurationThreshold(Duration.ofSeconds(1))
        .waitDurationInOpenState(Duration.ofSeconds(10)) // 打开 10s 后进半开试探
        .slidingWindowSize(100)
        .build());

Supplier<Product> readCache = () -> loadFromRedis(key); // 可能抛超时/连接异常
Product p = Try.ofSupplier(CircuitBreaker.decorateSupplier(cb, readCache))
               .recover(e -> Product.DEFAULT_DETAIL)    // 熔断打开/调用失败 → 兜底
               .get();

框架选型一句话:Sentinel 功能全(流控/熔断/系统自适应/热点参数),有控制台,适合国内中大厂体系。Resilience4j 轻量无依赖、纯函数式风格,适合小服务和云原生。

雪崩方案全景表(事前 / 事中 / 事后) ​

阶段手段针对成因
事前预防TTL 随机抖动、批量预热时打散过期时间A(大面积同时失效)
事前预防主从/哨兵/Cluster 高可用部署、持久化、缓存预热B(实例不可用)
事前削峰多级缓存(Caffeine L1)、读写分离A + B
事中保命熔断(Sentinel/Resilience4j)、限流、降级返回兜底数据A + B(保 DB)
事后恢复灰度放量、监控告警、雪崩演练A + B

延伸两篇 ​

上面追问里引出的两个大话题各自独立成篇:缓存与数据库的一致性(Cache Aside 推演 / 延迟双删 / Canal 兜底 / 强一致的分层答案)见缓存一致性-CacheAside与延迟双删;热 key 的发现与处置(服务端 --hotkeys / JD hotkey 客户端埋点 / 三板斧)见热key治理-发现与处置三板斧。 面试官视角小结:30 秒版本 ​

面试时按下面五条说,每条控制在 30 秒内。先给结论再给一句原理/方案。面试官自然往深里追问,而你已经埋好了钩子:

  1. 穿透——查"缓存和 DB 都不存在"的数据,缓存永远挡不住。三道门如下。接口参数校验(拦非法)。空值缓存:短 TTL + 哨兵值,注意别被反向灌内存。布隆过滤器:"一定不存在"绝不漏判,"可能存在"有 1% 级误判。结构是 m 位 + k 个哈希,最优 k = (m/n)·ln2。1 亿元素 1% 误判只要 114 MB。要删除就换布谷鸟过滤器。

  2. 击穿——单个热 key 过期的瞬间,并发全部回源重复重建。核心是"把 N 次重建合并成 1 次"。互斥锁:SET NX EX 一条命令 + token + Lua 解锁 + 双检。Redisson 看门狗解决锁超时错配,一致性优先。逻辑过期:物理不过期,抢到锁的线程异步刷新,其余线程返回旧值。可用性优先。超级热点直接永不过期 + 主动刷新。

  3. 雪崩——两种成因分开答。大面积 key 同时失效,用 TTL 随机抖动打散(盯紧批量预热的代码)。Redis 整体不可用,靠三层手段保住 DB 这条命。一是主从/哨兵/Cluster 高可用。二是多级缓存:Caffeine L1 + 短 TTL 自愈 + pub/sub 广播失效。三是熔断降级限流:Sentinel 热点参数限流 / Resilience4j 熔断。

  4. 一致性——Cache Aside:先更库再删缓存的不一致窗口最小(四种组合推演过)。是删不是更新(懒加载 + 避免并发写乱序)。要求更高上延迟双删:延迟要覆盖读回填 + 主从同步窗口。或者 Canal 订阅 binlog 异步删:业务零侵入,靠位点保证不丢。不加锁就没有强一致,只有最终一致。

  5. 热 key——发现靠探测。服务端用 redis-cli --hotkeys(LFU 计数)。客户端埋点计数上报(JD hotkey 架构:agent 上报 → 中心计算 → 推回本地缓存)。处置靠本地缓存化、key 打散多副本、再叠上防击穿的逻辑过期。

最后送一个开场模板,面试官问"你了解缓存三兄弟吗"时的第一句话:

"这三个本质上都是缓存失效后流量漏到数据库的问题,区别在失效的形态。穿透是数据不存在导致缓存永久失效。击穿是单个热点 key 瞬时失效。雪崩是大面积同时失效或缓存服务整体不可用。所以防御思路分别是'把不存在也挡住'、'重建互斥化'、'失效打散加兜底降级'。"

这句话说完,主动权就在你手里了。