Redis 缓存三大问题:穿透、击穿、雪崩¶
1. 引入:为什么会有这三大问题?¶
Redis 作为缓存层,正常流程是:先查缓存,命中则返回;未命中则查数据库,并将结果写入缓存。
三大问题都是这个流程被破坏的情况:
| 问题 | 本质 | 后果 |
|---|---|---|
| 缓存穿透 | 查询的数据根本不存在,缓存永远不会命中 | 每次请求都打到 DB |
| 缓存击穿 | 单个热点 Key 突然过期,大量并发同时未命中 | 瞬间大量请求打到 DB |
| 缓存雪崩 | 大量 Key 同时过期,缓存层集体失效 | 大规模请求打到 DB,DB 崩溃 |
2. 三大问题对比图¶
flowchart TB
subgraph ct["缓存穿透 - 查询不存在的数据"]
direction LR
A1["大量请求\n查询 ID=-1"] --> B1{"Redis 缓存?"} -->|未命中| C1["查 MySQL\n数据不存在"] --> D1["每次都穿透到 DB\n→ DB 被打垮"] --> E1["解决方案:\n① 布隆过滤器拦截\n② 缓存空值(TTL 短)"]
end
subgraph cb["缓存击穿 - 热点 Key 突然过期"]
direction LR
A2["秒杀商品 Key\n突然过期"] --> B2{"Redis 缓存?"} -->|未命中| C2["大量并发请求\n同时查 DB"] --> D2["DB 瞬间压力骤增"] --> E2["解决方案:\n① 互斥锁(只有一个请求重建)\n② 逻辑过期(不设 TTL)"]
end
subgraph ca["缓存雪崩 - 大量 Key 同时过期"]
direction LR
A3["大量 Key\n设置相同 TTL"] --> B3["同一时刻集体过期"] --> C3["大量请求同时穿透\n→ DB 崩溃"] --> D3["解决方案:\n① TTL 加随机值\n② 多级缓存\n③ 熔断降级"]
end
ct --> cb --> ca
3. 缓存穿透¶
3.1 问题描述¶
攻击者或异常请求不断查询不存在的数据(如 id=-1、id=99999999),由于数据不存在,缓存中永远没有,每次都穿透到数据库。
典型场景: - 恶意攻击:构造大量不存在的 ID 发起请求 - 业务 Bug:查询逻辑错误,传入了无效参数
3.2 解决方案一:布隆过滤器¶
原理:在缓存层前加一个布隆过滤器,存储所有合法的 Key。请求进来先经过布隆过滤器,如果判断 Key 不存在,直接返回,不查缓存和 DB。
flowchart LR
subgraph 布隆过滤器原理
Key["Key: user_123"] --> H1["Hash函数1\n→ 位置 3"]
Key --> H2["Hash函数2\n→ 位置 7"]
Key --> H3["Hash函数3\n→ 位置 12"]
H1 --> Bit["位数组\n[0,0,0,1,0,0,0,1,0,0,0,0,1,0...]"]
end
Query["查询 user_999"] -->|"3个位置都为1才可能存在\n有一个为0则一定不存在"| Bit
布隆过滤器特点: - 误判率:可能误判"不存在的 Key 存在"(假阳性),但不会误判"存在的 Key 不存在" - 不可删除:标准布隆过滤器不支持删除(可用 Counting Bloom Filter) - 为什么用多个 Hash 函数:单个 Hash 函数碰撞率高,多个 Hash 函数降低误判率
Redis 实现布隆过滤器:
# 方式1:使用 RedisBloom 模块(推荐)
BF.ADD users user:123
BF.EXISTS users user:999 # 返回 0 表示一定不存在
# 方式2:用 String 的 SETBIT 手动实现
SETBIT bloom:users 3 1 # 将位置3设为1
GETBIT bloom:users 3 # 查询位置3
Java 代码示例(Guava 布隆过滤器):
// 初始化布隆过滤器(预期100万数据,误判率0.01%)
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(), 1_000_000, 0.001);
// 数据库中所有合法 ID 加入布隆过滤器
bloomFilter.put(userId);
// 查询前先检查
public User getUser(Long userId) {
// 布隆过滤器判断不存在,直接返回
if (!bloomFilter.mightContain(userId)) {
return null;
}
// 查缓存
User user = redis.get("user:" + userId);
if (user != null) return user;
// 查数据库
user = db.findById(userId);
if (user != null) redis.set("user:" + userId, user, 300);
return user;
}
3.3 解决方案二:缓存空值¶
原理:查询数据库发现数据不存在时,将空值也缓存起来(设置较短的 TTL,如 5 分钟),下次相同请求直接从缓存返回空值。
public User getUser(Long userId) {
String cacheKey = "user:" + userId;
String cached = redis.get(cacheKey);
// 命中缓存(包括空值缓存)
if (cached != null) {
return "NULL".equals(cached) ? null : JSON.parse(cached, User.class);
}
// 查数据库
User user = db.findById(userId);
if (user != null) {
redis.set(cacheKey, JSON.toJSON(user), 300); // 正常数据缓存5分钟
} else {
redis.set(cacheKey, "NULL", 60); // 空值缓存1分钟(TTL 要短)
}
return user;
}
两种方案对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 布隆过滤器 | 内存占用极小,拦截效果好 | 有误判率,不支持删除 | 数据量大,Key 相对固定 |
| 缓存空值 | 实现简单,无误判 | 占用缓存空间,可能缓存大量空值 | 数据量小,Key 变化频繁 |
4. 缓存击穿¶
4.1 问题描述¶
单个热点 Key(如秒杀商品、热门文章)突然过期,此时大量并发请求同时未命中缓存,全部打到数据库,造成 DB 瞬间压力骤增。
与缓存穿透的区别: - 穿透:数据根本不存在,任何时候都不会命中缓存 - 击穿:数据存在,只是热点 Key 在某一时刻过期了
4.2 解决方案一:互斥锁¶
原理:缓存未命中时,只允许一个请求去查数据库重建缓存,其他请求等待。
flowchart TD
A["请求到来"] --> B{"查缓存"}
B -->|命中| C["返回缓存数据"]
B -->|未命中| D{"尝试获取互斥锁"}
D -->|获取成功| E["双重检查缓存"]
E -->|仍未命中| F["查数据库\n重建缓存"]
F --> G["释放锁\n返回数据"]
D -->|获取失败| H["等待50ms后重试"]
H --> B
Java 代码实现:
public String getWithMutex(String key) {
// 1. 查缓存
String value = redis.get(key);
if (value != null) return value;
// 2. 缓存未命中,尝试获取互斥锁
String lockKey = "lock:" + key;
boolean locked = redis.set(lockKey, "1", "NX", "PX", 30000); // 30秒超时防死锁
if (locked) {
try {
// 3. 双重检查(防止其他线程已重建缓存)
value = redis.get(key);
if (value != null) return value;
// 4. 查数据库重建缓存
value = db.query(key);
redis.set(key, value, 300);
return value;
} finally {
redis.del(lockKey); // 释放锁
}
} else {
// 5. 未获取到锁,等待后重试
Thread.sleep(50);
return getWithMutex(key); // 递归重试
}
}
⚠️ 注意:互斥锁方案会降低并发性能(大量请求在等待),适合对一致性要求高的场景。
4.3 解决方案二:逻辑过期¶
原理:Key 不设置 TTL(永不过期),在 Value 中存储一个逻辑过期时间。查询时检查逻辑过期时间,如果过期则异步重建缓存,当前请求返回旧数据。
// Value 结构
class CacheData {
Object data; // 实际数据
LocalDateTime expireTime; // 逻辑过期时间
}
public Object getWithLogicalExpire(String key) {
CacheData cached = redis.get(key);
// 1. 未命中(Key 不存在),直接返回 null
if (cached == null) return null;
// 2. 检查逻辑过期时间
if (cached.expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回
return cached.data;
}
// 3. 已过期,尝试获取互斥锁
String lockKey = "lock:" + key;
boolean locked = redis.set(lockKey, "1", "NX", "PX", 30000);
if (locked) {
// 4. 异步重建缓存(不阻塞当前请求)
THREAD_POOL.submit(() -> {
try {
Object newData = db.query(key);
CacheData newCache = new CacheData(newData, LocalDateTime.now().plusSeconds(300));
redis.set(key, newCache); // 不设 TTL
} finally {
redis.del(lockKey);
}
});
}
// 5. 返回旧数据(可能是过期数据)
return cached.data;
}
两种方案对比:
| 方案 | 一致性 | 可用性 | 适用场景 |
|---|---|---|---|
| 互斥锁 | 高(等待重建完成) | 低(等待期间请求阻塞) | 对数据一致性要求高 |
| 逻辑过期 | 低(可能返回旧数据) | 高(始终有数据返回) | 对可用性要求高,允许短暂数据不一致 |
5. 缓存雪崩¶
5.1 问题描述¶
大量 Key 在同一时刻集体过期,或 Redis 服务宕机,导致大量请求同时打到数据库,DB 被压垮。
典型场景: - 系统启动时批量加载缓存,所有 Key 设置了相同的 TTL,到期时集体失效 - Redis 集群发生故障,缓存层整体不可用
5.2 解决方案¶
方案一:TTL 加随机偏移量(最简单有效)
// ❌ 错误:所有 Key 相同 TTL
redis.set(key, value, 300);
// ✅ 正确:TTL 加随机偏移量,错开过期时间
int ttl = 300 + new Random().nextInt(60); // 300~360秒随机
redis.set(key, value, ttl);
方案二:多级缓存
flowchart LR
Client[客户端] --> L1["本地缓存\nCaffeine/Guava\n(进程内,最快)"]
L1 -->|未命中| L2["Redis 缓存\n(分布式,快)"]
L2 -->|未命中| DB["数据库\n(持久化,慢)"]
即使 Redis 雪崩,本地缓存仍能抵挡大部分请求。
方案三:熔断降级
// 使用 Sentinel 或 Hystrix 配置熔断
// 当 DB 请求失败率超过阈值,触发熔断,直接返回降级数据
@SentinelResource(value = "getUser", fallback = "getUserFallback")
public User getUser(Long userId) {
return db.findById(userId);
}
public User getUserFallback(Long userId) {
return new User(userId, "服务繁忙,请稍后重试");
}
方案四:Redis 高可用(防止 Redis 宕机导致雪崩)
- 部署 Redis 哨兵模式或集群模式,避免单点故障
- 详见 04-高可用架构.md
6. 三大问题总结对比¶
| 问题 | 触发条件 | 影响范围 | 核心解决方案 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 每次请求都打 DB | 布隆过滤器 / 缓存空值 |
| 缓存击穿 | 单个热点 Key 过期 | 瞬间大量并发打 DB | 互斥锁 / 逻辑过期 |
| 缓存雪崩 | 大量 Key 同时过期 | 大规模请求打 DB | TTL 加随机值 / 多级缓存 |
7. 面试高频问题¶
Q:缓存穿透和缓存击穿的区别?
穿透是查询根本不存在的数据,缓存永远不会命中;击穿是查询存在的数据,但热点 Key 在某一时刻过期了。穿透是持续性问题,击穿是瞬时性问题。
Q:如何保证缓存与数据库的一致性?
推荐旁路缓存模式(Cache Aside): - 读:先读缓存,未命中再读 DB 并写缓存 - 写:先更新 DB,再删除缓存(而非更新缓存)
为什么删除而不是更新缓存?避免并发场景下的脏数据:若两个请求同时更新 DB,后写入 DB 的请求可能先更新缓存,导致缓存中是旧数据。
延迟双删:写 DB 后删缓存,延迟一段时间(如 500ms)再删一次,防止并发读写导致脏数据残留。
Q:布隆过滤器的误判率如何控制?
误判率由位数组大小和哈希函数个数决定。位数组越大、哈希函数越多,误判率越低,但内存占用越大。实际使用时根据数据量和可接受的误判率来选择参数(Guava 的
BloomFilter.create可直接指定误判率)。