Skip to content

05.1 - 缓存穿透/击穿/雪崩

定位: Redis 缓存层最常见的三个问题——现象、原因与解决方案 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 遇到问题如何处理、每种方案的优劣对比、实际场景判断


一、问题全景


二、缓存穿透

是什么

查询一个不存在的数据,缓存中没有,数据库也没有,每次查询都落到数据库。

解决方案

方案 1:缓存空值 查询数据库返回空时,缓存空值(短过期时间): SET user:99999 "" EX 60 → 下次请求命中缓存空值 → 问题:大量不存在 key 会占用缓存

方案 2:布隆过滤器(Bloom Filter) ★推荐 请求前先过布隆过滤器:

初始化:将所有存在的 user_id 存入布隆

请求 user:99999:布隆过滤器判断"肯定不存在" → 直接返回,不查缓存也不查 DB

误判率:可配置(约 1%),有概率"误报" 误判后果:请求到 DB,DB 返回空,不影响业务

方案 3:参数校验 简单的参数校验可以拦截大部分非法请求:

  • 用户 ID ≤ 0 → 直接返回
  • 商品 ID 格式不对 → 直接返回
  • 恶意批量请求(扫描 ID)→ 限流

三、缓存击穿

是什么

某个热点 key 在过期的一瞬间,大量并发请求同时打到数据库。

发生条件:

  1. key 是热点(QPS 极高)
  2. key 正好过期
  3. 大量请求同时到达

解决方案

方案 1:互斥锁(SETNX) 获取到锁的请求查 DB,其他请求等待:

GET hot_key → nilSETNX lock:hot_key 1 EX 10 → 成功! → 查 DB → 写入缓存 → 释放锁

其他请求 GET hot_key → nilSETNX lock:hot_key → 失败! → 等待 50ms → 重试 GET hot_key → 命中缓存(被第一个请求写入)

缺点:加锁等待 → 响应延迟增加

方案 2:逻辑过期(hot key 永不过期) 不设物理过期时间,缓存中存逻辑过期时间:

SET hot_key "{data:..., expire:1693000}"

读取时判断逻辑过期: 未过期 → 直接返回 已过期 → 异步线程更新缓存,返回旧数据(不影响响应速度)

优点:无击穿风险 缺点:数据暂时不一致(旧数据)

方案 3:主动续期 后台定时任务,热点 key 持续续期:

定时任务每 30 秒执行: EXPIRE hot_key 60 # 续期 60 秒

非热点自动过期(没续期就慢慢淘汰)


四、缓存雪崩

是什么

大量 key 同时过期,或 Redis 整体宕机,导致大批请求打到数据库。

解决方案

防止大量 key 同时过期:过期时间随机化(±30%):

EXPIRE item:1 3600 + rand(0, 1800) → 过期时间分布在 3600-5400 秒之间 → 100 万个 key 分散在 30 分钟内慢慢过期

代码实现:

java
int expireTime = 3600 + new Random().nextInt(1800);
cache.set(key, value, expireTime);

防止 Redis 宕机打挂 DB:

  1. 服务降级 — Redis 挂 → 不查 DB → 返回默认值/空数据(牺牲体验保命)

  2. 请求限流 — 接口限流 → DB 只接受 N QPS,超出的请求排队/降级

  3. 预热多副本 — 缓存多个副本(不同 key 名),如 hot_key_v1, hot_key_v2,读取时随机选一个,一个失效还有另一个


三、图解全景


四、实战验证

4.1 模拟缓存穿透与修复

bash
# 模拟穿透(不存在的 key)
redis-cli GET nonexist:1
# (nil)

# 方案:缓存空值
redis-cli SET nonexist:1 "" EX 60
redis-cli GET nonexist:1
# ""  ← 命中空缓存,不再穿透

# 模拟大量穿透(布隆过滤器需要服务端实现)
# 伪代码示意:
# if !bloomFilter.mightContain(key):
#     return null  // 肯定不存在
# value = cache.get(key)
# if value == null:
#     value = db.query(key)
#     cache.set(key, value)

4.2 模拟缓存击穿与互斥锁

python
# Python 伪代码:互斥锁解决击穿
import redis
import time

r = redis.Redis()

def get_hot_key(key):
    value = r.get(key)
    if value is not None:
        return value
    
    # 尝试获取锁(SETNX + 过期时间)
    lock_key = f"lock:{key}"
    locked = r.setnx(lock_key, "1")
    if locked:
        r.expire(lock_key, 10)  # 锁 10 秒过期(防死锁)
        try:
            value = query_db(key)  # 查数据库
            r.setex(key, 3600, value)  # 写入缓存
            return value
        finally:
            r.delete(lock_key)
    else:
        # 未获取到锁 → 等待重试
        time.sleep(0.05)
        return get_hot_key(key)  # 递归重试

4.3 查看 Redis 过期 key 监控

bash
# 查看过期 key 数量
redis-cli INFO keyspace
# db0:keys=1000000,expires=900000,avg_ttl=1800000

# 监控过期事件(需要开启 notify-keyspace-events Ex)
redis-cli --csv PSUBSCRIBE '__keyevent@0__:expired'
# 实时看到 key 过期

五、面试视角

追问答案要点
缓存穿透、击穿、雪崩的区别?穿透:查不存在数据;击穿:单个热点 key 过期;雪崩:大量 key 同时过期或 Redis 宕机
布隆过滤器能删除吗?标准的不能删除(计数 bloom filter 可以删除但额外开销),不支持删除。数据变更需要重建
互斥锁解决击穿的缺点?牺牲响应时间(等待锁),可能死锁(锁没释放),对延迟敏感场景不友好
热点 key 永不过期方案的问题?数据不一致,需要异步更新;短时间内用户看到旧数据
缓存雪崩除了过期时间随机化还能做什么?服务降级、限流、多级缓存(本地 + Redis)、主从多副本
空值缓存的时间设置多久合适?短时间 1-5 分钟(防止频繁重建),配合布隆过滤器可以减少空值缓存

📚 相关链接

  • **缓存一致性** — 缓存与数据库数据同步的挑战
  • **限流** — 雪崩场景下的限流保护
  • **BitMap/布隆** — 布隆过滤器的底层实现
  • ← 返回 **缓存设计与应用索引**

Knowledge4J — Java 知识库