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 在过期的一瞬间,大量并发请求同时打到数据库。
发生条件:
- key 是热点(QPS 极高)
- key 正好过期
- 大量请求同时到达
解决方案
方案 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 分钟内慢慢过期代码实现:
javaint expireTime = 3600 + new Random().nextInt(1800); cache.set(key, value, expireTime);
防止 Redis 宕机打挂 DB:
服务降级 — Redis 挂 → 不查 DB → 返回默认值/空数据(牺牲体验保命)
请求限流 — 接口限流 → DB 只接受 N QPS,超出的请求排队/降级
预热多副本 — 缓存多个副本(不同 key 名),如 hot_key_v1, hot_key_v2,读取时随机选一个,一个失效还有另一个
三、图解全景
四、实战验证
4.1 模拟缓存穿透与修复
# 模拟穿透(不存在的 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 伪代码:互斥锁解决击穿
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 监控
# 查看过期 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/布隆** — 布隆过滤器的底层实现
- ← 返回 **缓存设计与应用索引**