VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

缓存三件套:穿透查没有,击穿热一个,雪崩倒一片——Java 工兵连第 16 篇

「Java 工兵连」第 16 篇,进入最贴业务的缓存篇。面试爱问「缓存穿透、击穿、雪崩的区别」,生产里它们是三种不同的急诊——共同点都是缓存缺席时流量直砸 DB,区别全在缺席的原因。背定义容易混,记一句辨析口诀就再也不混:

穿透查没有,击穿热一个,雪崩倒一片——个别(不存在)、个别(太热)、全体(一起没)。

一、三件套逐个拆

三联场景与各自解药

① 穿透:查「没有的东西」

请求的 key 在缓存和 DB 都不存在——典型是恶意攻击(id=-1、遍历不存在的 id)或爬虫脏请求。流程死循环:miss → 查 DB → DB 也没有 → 返回空 → 缓存里没法放(放什么?)→ 下一个同样请求继续打 DB。防御为零,成本全在 DB。

解药一:缓存空值。 DB 查不到就往 Redis 放一个「空标记」(如 "NULL"),短 TTL(30~60 秒)——短,是为了不占内存,也为了这个 id 将来真被创建时不至于被空值挡住。

解药二:布隆过滤器。 前置一层「存在性门卫」:把全量合法 id 预先注册进去,请求先问门卫。布隆的脾气你要记住:说「不在」就一定不在(直接拒,不碰 DB);说「在」只是可能(有误判率,放行去查)。误判率由位数组大小决定,工程上 1% 足够;普通布隆不支持删除(要删得用计数布隆/布谷鸟过滤器),全量重建走定时任务。

② 击穿:热「一个东西」

秒杀商品、爆款词条——单个超热 key 到期的那一毫秒,几万并发同时 miss,全部涌向 DB 重建同一份数据。关键词:热点集中、单个 key 的塌方。

解药一:互斥重建。 miss 后先抢一把锁(SET rebuild:sku:1001 nx ex 10——第 7 篇分布式锁的正用武之地),只有一个线程去查 DB 回填,其他人短暂等待或先返回兜底数据。锁的粒度是「这一个 key 的重建权」,代价是等待。

解药二:逻辑过期。 key 物理上永不过期,过期时间写进 value 里;读到「逻辑过期」的值时不等——先返回旧数据,同时异步触发重建。用短暂的不一致换永远可用的读。对「旧一秒无所谓」的热点数据(榜单、首页推荐),这是最优雅的答案。

③ 雪崩:倒「一片」

两种形态:批量 key 同时到期(凌晨批量导入时统一设了 EXPIRE 3600,一小时后集体阵亡)或 Redis 整体倒下(宕机、网络分区)。洪水整体灌进 DB,DB 倒下后依赖它的服务连环倒——「雪崩」名副其实。

解药是一组组合拳:TTL 加随机抖动(基础 3600s + random(0, 300s),把到期时间打散);多级缓存(本地 Caffeine → Redis → DB,Redis 倒下时本地层还能顶一阵——注意本地缓存容量要小而精);高可用架构(第 15 篇的哨兵/集群,防「整体倒下」这半边);DB 前挂限流熔断(第 20 篇预告)——雪崩已经发生时,限流是保住 DB 的最后一道闸。

二、集大成:一条带防护的读路径

三件解药合到一条代码路径里(hero 图下半的流程),Java 骨架:

public Product getProduct(long id) {
    // ① 本地缓存(防雪崩的缓冲层)
    Product p = caffeine.getIfPresent(id);
    if (p != null) return p;

    // ② Redis
    String raw = redis.get("sku:" + id);
    if (raw != null) {
        return "NULL".equals(raw) ? null : parse(raw);   // 空值标记也是答案
    }

    // ③ 布隆门卫:说不在就不在(防穿透)
    if (!bloom.mightContain(id)) return null;

    // ④ 互斥重建:一个名额查 DB(防击穿)
    String lockKey = "rebuild:sku:" + id;
    if (redis.set(lockKey, "1", "NX", "EX", 10)) {
        try {
            p = productDao.findById(id);
            // ⑤ 回填:TTL = 基础 + 抖动(防雪崩);DB 没有就缓存空值(防穿透)
            if (p == null) redis.setex("sku:" + id, 60, "NULL");
            else redis.setex("sku:" + id, 3600 + ThreadLocalRandom.current().nextInt(300), toJson(p));
            return p;
        } finally { redis.del(lockKey); }
    }
    Thread.sleep(50);                       // 没抢到的稍等重试(或返回兜底值)
    return getProduct(id);
}

四层防护各管一段:本地顶整体、空值拦假的、锁管重建、抖动打散到期——每个解药都长在读路径上,写路径一条没动。写路径的一致性问题(先更 DB 还是先动缓存)是下一篇的事——读管「不出事」,写管「不脏数据」,两半分开想。

老兵点评

  1. 三件套的病根不同,药就不能开混。见过「防雪崩的 TTL 抖动」被拿来治穿透(无效),也见过「缓存空值」一把梭所有 miss(新数据的可见性被空值挡住,客诉上门)。先问「缺席的原因是什么」,再翻药方——这篇口诀就是分诊台。
  2. 热 key 是击穿的近亲,单列一档。击穿是「过期那一下」,热 key 是「一直热到单节点打满」(第 15 篇 Cluster 也救不了——同一个 key 只会在一个槽)。解法是新那套:本地缓存扛读、读写分离副本、key 打散成 sku:1001:{1..N} 随机读一份。热点治理值得单独立项,别等大促当天现学。
  3. 面试段位差:初级背三定义;中级讲三套解药并知道布隆的误判方向(只假阳不假阴);高级讲逻辑过期的一致性代价、热 key 与击穿的边界、以及「限流是雪崩最后的闸」的纵深防御——聊到这层是在聊容量设计。

带走三句话

  1. 辨析口诀:穿透查没有(空值缓存 / 布隆门卫),击穿热一个(互斥重建 / 逻辑过期),雪崩倒一片(TTL 抖动 / 多级缓存 / 高可用 / 限流兜底)。
  2. 布隆的脾气:说「不在」必真,说「在」可能误判——只拦「一定不存在」,别指望它当字典。
  3. 读路径四层防护:本地顶整体、空值拦假的、锁管重建、抖动打散到期;写路径的一致性下一篇见。

本篇是「Java 工兵连」第 16 篇。→ 上一篇:第 15 篇 · Redis 持久化与高可用选型 | 继续阅读:第 17 篇 · 缓存一致性:写库删缓存,删优于更 | 浏览整个专栏 →。

❦ 终 ❦

← 回到首页全部归档 →