缓存穿透、击穿、雪崩的判定与各自对策

面试里问到 Redis 缓存问题,最常见的失分点不是不知道三个名词,而是把三者的触发条件讲混。很多候选人能把「布隆过滤器」「互斥锁」「随机过期时间」背出来,但面试官追问一句「一个不存在的 user_id 反复打进来,命中率没掉但 DB 的 QPS 涨了,这算哪种」,就答不上来了。本文按「怎么判定 → 为什么会发生 → 具体对策 → 代码怎么写 → 线上怎么验

面试里问到 Redis 缓存问题,最常见的失分点不是不知道三个名词,而是把三者的触发条件讲混。很多候选人能把「布隆过滤器」「互斥锁」「随机过期时间」背出来,但面试官追问一句「一个不存在的 user_id 反复打进来,命中率没掉但 DB 的 QPS 涨了,这算哪种」,就答不上来了。本文按「怎么判定 → 为什么会发生 → 具体对策 → 代码怎么写 → 线上怎么验证」的顺序拆一遍,每个对策都给出真实可运行的代码和参数,最后附常见追问。

先建立判定框架:看命中率、看 key 分布、看 DB 压力形态

三种问题在监控曲线上有明显区别,先把这张表记住,面试时能直接用来回答「你怎么判断是哪种」。

维度 缓存穿透 缓存击穿 缓存雪崩
请求的 key 是否存在于数据源 根本不存在 存在,但缓存刚好失效 大量 key 同时失效或 Redis 整体不可用
缓存命中率 明显下降(大量 miss) 短时间局部下降,热点 key 抖动 断崖式下跌
影响范围 单个/一类非法 key 单个热点 key 大面积 key
DB 压力形态 持续、低幅但异常的查询量,查询条件高度重复 瞬间尖峰,随后恢复 持续高压甚至打满连接池
典型场景 爬虫遍历 id、恶意攻击、业务逻辑误传 秒杀商品、首页配置、大 V 微博 批量预热时统一设置同一过期时间、Redis 主节点宕机

实操判定时看三个信号:一是 keyspace_misses 与 DB 查询量的比值,穿透场景下这个比值长期偏高且查询条件重复;二是热点 key 的 expired_keys 是否集中在某一秒;三是 DB 慢查询日志里是否出现大量完全相同(或高度相似)的 SQL。第三种情况最容易被忽略,很多人把「同一个 SQL 在 1 秒内打了 8000 次」当成雪崩,其实是击穿。

缓存穿透:不存在的 key 打穿了缓存层

为什么会发生

缓存的设计前提是「数据存在,缓存只是加速」。当请求查询一个 DB 里也不存在的记录时,流程是:查 Redis → miss → 查 DB → 查不到 → 不写缓存 → 返回空。下一次同样的请求依旧走完整链路。攻击者用脚本遍历 /user/1 到 /user/1000000,DB 就被打穿了。

对策一:空值缓存,最简单的兜底

查到 null 时也写一份缓存,值用特殊标记,过期时间设短一些(通常 30~120 秒),避免正常数据写入后长时间读到脏空值。缺点是如果攻击者每次用随机 key,空值缓存会撑爆内存,所以必须配合对策二或限流。

public User getUser(long userId) {
    String key = "user:info:" + userId;
    String cached = redis.get(key);
    // 用固定哨兵串表示"数据库确认不存在"
    if (cached != null) {
        if ("__NULL__".equals(cached)) {
            return null;
        }
        return JSON.parseObject(cached, User.class);
    }
    User user = userMapper.selectById(userId);
    if (user == null) {
        redis.setex(key, 60, "__NULL__");
        return null;
    }
    redis.setex(key, 1800 + ThreadLocalRandom.current().nextInt(300), JSON.toJSONString(user));
    return user;
}

对策二:布隆过滤器前置拦截

把所有合法 id 预先加载进 Bloom Filter,查询前先 mightContain,返回 false 直接拒掉,返回 true 才走缓存和 DB。注意 Bloom Filter 有误判率(false positive,会把不存在的判成存在),但不会漏判,所以能挡掉绝大部分非法请求。Redis 侧可用 BF.RESERVE / BF.ADD / BF.EXISTS(需加载 RedisBloom 模块),也可以用 Guava 的本地实现。

# RedisBloom 真实命令,容量 100 万,误判率 0.1%
redis-cli BF.RESERVE user:bloom 0.001 1000000
redis-cli BF.ADD user:bloom 10086
redis-cli BF.EXISTS user:bloom 10086
# 返回 1 表示可能存在,0 表示一定不存在
# 返回 0 的请求可直接短路,不打 DB

参数解释:BF.RESERVE key error_rate capacity,error_rate 越小内存占用越大,0.001 对 100 万容量约占用 1.8MB。业务上新增用户时要同步 BF.ADD,删除用户时不能 BF.DEL(Bloom Filter 不支持删除,会破坏其他位的判断),只能靠重建。

对策三:接口层参数校验与限流

id 必须是 18 位以内正整数、必须符合业务编码规则,这一层能过滤掉大量畸形请求。再配合网关按 IP / 按用户维度限流(如 Sentinel 的 QPS 流控规则),把攻击流量挡在业务逻辑之外。

缓存击穿:单个热点 key 失效瞬间的并发冲击

为什么会发生

一个 key 承载了极高并发(比如首页 banner 配置、秒杀商品详情),恰好过期的一瞬间,几百上千个线程同时 miss,同时去查 DB,DB 在几十毫秒内被打出尖峰。注意击穿的前提是数据存在,这和穿透有本质区别。

对策一:互斥锁重建缓存

只有拿到锁的线程去查 DB 并回写,其余线程短暂等待后重新读缓存。锁的过期时间要覆盖「查 DB + 写缓存」的最坏耗时,防止持锁线程崩溃导致死锁。用 SET key value NX PX 保证原子性,释放时用 Lua 校验 value,避免误删别人的锁。

public User getUserWithMutex(long userId) {
    String key = "user:info:" + userId;
    String cached = redis.get(key);
    if (cached != null) {
        return JSON.parseObject(cached, User.class);
    }
    String lockKey = "lock:user:info:" + userId;
    String token = UUID.randomUUID().toString();
    // NX: 不存在才设置; PX 5000: 5 秒自动过期
    Boolean locked = redis.set(lockKey, token, "NX", "PX", 5000);
    if (Boolean.TRUE.equals(locked)) {
        try {
            // 双重检查,防止等锁期间别人已经回写
            cached = redis.get(key);
            if (cached != null) {
                return JSON.parseObject(cached, User.class);
            }
            User user = userMapper.selectById(userId);
            if (user != null) {
                redis.setex(key, 1800, JSON.toJSONString(user));
            }
            return user;
        } finally {
            // Lua 保证"判断 value 相等再删除"的原子性
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                    "return redis.call('del', KEYS[1]) else return 0 end";
            redis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(token));
        }
    } else {
        // 没抢到锁,短暂等待后重试读缓存,最多重试 3 次
        for (int i = 0; i < 3; i++) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            cached = redis.get(key);
            if (cached != null) {
                return JSON.parseObject(cached, User.class);
            }
        }
        // 重试仍未拿到,降级为直接查库,务必配合限流避免再次打满
        return userMapper.selectById(userId);
    }
}

对策二:逻辑过期,永不物理过期

缓存里存一个 expireAt 字段,Redis 层面不设 TTL(或设很长),读到数据时判断逻辑时间是否过期:未过期直接返回;已过期则尝试获取锁,拿到锁的线程异步刷新缓存,其他线程直接返回旧值。这样能做到「读永远不阻塞」,代价是短时间内可能读到旧数据,适合对一致性要求不高的场景(如商品详情、榜单)。

对策三:热点 key 预热 + 提前续期

在过期前用定时任务或后台线程主动刷新 TTL(比如 TTL 剩余 20% 时续期),让热点 key 永不自然过期。同时把热点 key 打散到多个副本,用 key:1 ... key:N 的 suffix 分摊读压力,读时随机取一个。

缓存雪崩:大面积同时失效

为什么会发生

两种成因要分开讲。第一类是过期时间集中:批量预热时写成 redis.setex(key, 3600, value),一小时后整批 key 一起失效。第二类是Redis 本身不可用:主节点宕机、网络分区、被运维误 flush,此时所有请求直接打到 DB,属于最严重的情况。

对策一:过期时间加随机抖动

这是成本最低也最有效的一条,必须在预热脚本里做。抖动范围取基础 TTL 的 10%~20% 即可。

import random
import redis

r = redis.Redis(host='127.0.0.1', port=6379)

def warm_up(items):
    pipe = r.pipeline()
    for item in items:
        base_ttl = 3600
        # 抖动 10%,让失效时间散在 360 秒的窗口内
        ttl = base_ttl + random.randint(0, int(base_ttl * 0.1))
        pipe.setex(f"product:detail:{item['id']}", ttl, item['json'])
    pipe.execute()

对策二:多级缓存

在应用进程内挂一层本地缓存(Caffeine),形成 L1 本地 + L2 Redis + L3 DB。Redis 整体不可用时,本地缓存仍能顶住大部分读流量。Caffeine 的 expireAfterWrite 建议设短一点(如 30~60 秒),保证多实例之间的数据偏差可控,并配合广播失效(如通过 Redis 的 pub/sub 或消息队列通知各节点清本地缓存)。

Cache<String, String> localCache = Caffeine.newBuilder()
        .maximumSize(100_000)
        .expireAfterWrite(60, TimeUnit.SECONDS)
        .recordStats()
        .build();

public String query(String key) {
    String value = localCache.getIfPresent(key);
    if (value != null) {
        return value;
    }
    value = redis.get(key);
    if (value != null) {
        localCache.put(key, value);
        return value;
    }
    value = loadFromDb(key);
    if (value != null) {
        redis.setex(key, 1800 + ThreadLocalRandom.current().nextInt(300), value);
        localCache.put(key, value);
    }
    return value;
}

对策三:限流降级 + 熔断

Redis 故障时,DB 就是最后一道防线,必须限流。用 Sentinel 对 db 调用做 QPS 保护,超阈值直接返回缓存中的旧值或默认值;接入 Resilience4j 或 Sentinel 的熔断器,当 DB 错误率超过 50% 时快速失败,避免连接池被占满。核心接口可返回 HTTP 200 + 降级文案,非核心接口直接返回空。

对策四:Redis 高可用架构

单点 Redis 本身就是雪崩隐患。生产必须上 Redis Sentinel(主从 + 哨兵,故障自动切换,切换窗口通常在 10~30 秒)或 Redis Cluster(分片 + 副本,单分片故障影响面被限制在 1/N)。同时在客户端配好超时:连接超时 connectTimeout=200ms、读写超时 soTimeout=500ms、重试 maxAttempts=1,超时过长会把线程池拖死,反而放大故障。

对策五:缓存预热与持久化

重启或大促前跑预热任务把热点数据灌回 Redis;开启 appendonly yes + appendfsync everysec,配合 RDB 快照,缩短故障恢复后的冷启动窗口。不建议依赖 FLUSHALL 后的自动恢复,生产上要禁用危险命令:在 redis.conf 中通过 rename-command FLUSHALL "" 将其禁用。

线上验证与常见报错

对策上线后要能验证效果,否则等于没做。

  • 用 redis-cli info stats 看 keyspace_hits / keyspace_misses,穿透治理后 miss 数应显著下降。
  • 用 redis-cli --bigkeys 确认空值缓存没有把内存撑爆;用 info memory 看 used_memory_human 与 mem_fragmentation_ratio。
  • 用 redis-cli monitor(仅限排障短时开启)确认热点 key 的访问模式,注意它会显著影响性能。
  • 压测时用 wrk 或 JMeter 模拟 200 并发,观察 DB 连接池活跃数是否出现尖峰。

几个真实会遇到的报错:

  • ERR unknown command 'BF.ADD':RedisBloom 模块未加载。解决办法是在 redis.conf 加 loadmodule /path/to/redisbloom.so,或用 Docker 启动 redis/redis-stack 镜像。
  • redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool:连接池耗尽。检查是否在缓存 miss 时同步查 DB 过慢导致线程堆积,先加限流再调大 maxTotal。
  • Caused by: io.lettuce.core.RedisCommandTimeoutException: Command timed out after 1 second(s):命令超时。排查是否有大 key(用 redis-cli --bigkeys)或 KEYS * 这类 O(N) 命令。
  • OOM command not allowed when used memory > 'maxmemory':内存打满。检查空值缓存是否未设短 TTL,或布隆过滤器容量设置过小导致误判率飙升。

小结

三个问题的判定核心是「key 在不在数据源」和「失效范围有多大」:穿透是查不存在的数据,对策是空值缓存 + Bloom Filter + 参数校验;击穿是单个热点 key 失效瞬间的并发,对策是互斥锁 / 逻辑过期 / 预热续期;雪崩是大面积同时失效或 Redis 不可用,对策是 TTL 抖动 + 多级缓存 + 限流降级 + 高可用架构。面试时先给出判定依据,再按成本从低到高排列对策,比背名词更有说服力。

FAQ

Q1:空值缓存和布隆过滤器选哪个?

两者不互斥。空值缓存实现简单,但对随机 key 攻击无效且占内存;布隆过滤器能挡住随机 key,但有误判且不支持删除。常规做法是布隆过滤器做前置拦截,空值缓存做兜底,再叠加接口限流。

Q2:互斥锁会不会导致请求变慢?

会。没抢到锁的线程要等待重试,最坏情况延迟增加几十到几百毫秒。所以热点 key 更适合用「逻辑过期 + 异步刷新」,读路径永远不阻塞。互斥锁适合对数据新鲜度要求高、并发量中等的场景。

Q3:TTL 抖动加多少合适?

通常取基础 TTL 的 10%~20%。TTL 为 3600 秒时抖动 0~360 秒,能把失效时间摊到 6 分钟窗口内。抖动太小起不到打散作用,太大则缓存利用率下降、DB 查询量上升。

Q4:Redis 主从切换期间的 10~30 秒怎么扛?

靠本地缓存兜底 + 客户端熔断。切换窗口内 Lettuce/Jedis 会抛连接异常,此时应快速失败转读本地缓存或返回降级数据,绝不能无限重试。生产上还可开启 min-replicas-to-write 1 与 min-replicas-max-lag 10,减少切换期间的数据不一致。

Q5:怎么区分「击穿」和「雪崩」的监控曲线?

看失效 key 的数量和时间分布。击穿是单个或极少数 key 的 expired_keys 在某一秒突增,DB QPS 尖峰后迅速回落;雪崩是大批 key 集中在同一时间失效,或 Redis 连接数骤降为 0,DB 压力持续高位不回落。