在面试中,Redis 的"缓存三兄弟"(穿透、击穿、雪崩)是必问考题。很多同学背得滚瓜烂熟,但一到项目实战,代码写得漏洞百出,线上故障频发。
"老板,我的系统崩了,数据库 CPU 飙到 100% 了!"
"不是加了 Redis 缓存吗?"
"呃......好像没防住......"
今天,我们不讲枯燥的理论,直接上实战场景 和代码方案,带你彻底搞定这三个缓存杀手,让你的系统稳如老狗。
🛠️ 场景假设
我们有一个电商系统,有一个查询商品详情的接口 getProduct(Long id)。
- 数据库:MySQL
- 缓存:Redis
- 并发量:高并发场景
1. 👻 缓存穿透 (Cache Penetration)
现象 :
查询一个根本不存在 的数据(例如 id = -1)。
Redis 查不到 -> 去查数据库 -> 数据库也查不到。
如果有黑客利用大量不存在的 ID 发起攻击,请求会全部打到数据库,直接把数据库打挂。
错误写法:
Java
public Product getProduct(Long id) {
// 1. 查缓存
Product product = redisTemplate.opsForValue().get("product:" + id);
if (product != null) {
return product;
}
// 2. 查数据库
product = productMapper.selectById(id);
// 3. 写入缓存
if (product != null) {
redisTemplate.opsForValue().set("product:" + id, product);
}
return product;
}
问题:如果 product 为 null,就不会写缓存。下次查 id=-1,还是会打到数据库。
✅ 解决方案 A:缓存空对象 (简单有效)
即使数据库查不到,也往 Redis 存一个 null 或特殊标记,并设置一个较短的过期时间(防止数据后来真的有了)。
Java
public Product getProduct(Long id) {
String key = "product:" + id;
// 1. 查缓存
Object cacheValue = redisTemplate.opsForValue().get(key);
if (cacheValue != null) {
// 如果是空对象标记,直接返回 null
if (cacheValue instanceof NullValue) {
return null;
}
return (Product) cacheValue;
}
// 2. 查数据库
Product product = productMapper.selectById(id);
// 3. 写入缓存
if (product != null) {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
} else {
// 关键点:数据库没查到,也缓存一个空对象,过期时间设置短一点(例如 5 分钟)
redisTemplate.opsForValue().set(key, new NullValue(), 5, TimeUnit.MINUTES);
}
return product;
}
✅ 解决方案 B:布隆过滤器 (Bloom Filter) (进阶)
在访问 Redis 之前,先用布隆过滤器判断 ID 是否存在。如果布隆过滤器说"不存在",那就一定不存在,直接返回,连 Redis 都不用查。
适合数据量巨大,且 ID 相对固定的场景。
2. 🔨 缓存击穿 (Cache Breakdown)
现象 :
一个热点 Key (例如"iPhone 16 发布"),在过期的一瞬间 ,有大量并发请求同时访问。
Redis 没数据 -> 大量请求同时涌入数据库。
数据库瞬间压力过大崩溃。
错误写法 :
同上,普通的 get -> db -> set 逻辑无法防止并发击穿。
✅ 解决方案:互斥锁 (Mutex Lock)
当缓存失效时,不是所有线程都去查数据库,而是只让一个线程去查,其他线程等待。
Java
public Product getProduct(Long id) {
String key = "product:" + id;
String lockKey = "lock:product:" + id;
// 1. 查缓存
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 2. 缓存未命中,尝试获取分布式锁
// setIfAbsent 相当于 SETNX,原子操作
Boolean isLock = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (isLock) {
try {
// 3. 获取锁成功,查询数据库
product = productMapper.selectById(id);
// 4. 重建缓存
if (product != null) {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
} else {
// 防止穿透
redisTemplate.opsForValue().set(key, new NullValue(), 5, TimeUnit.MINUTES);
}
} finally {
// 5. 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 6. 获取锁失败,说明有其他线程正在查库,休眠一会再重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
return getProduct(id); // 递归重试
}
return product;
}
注:生产环境建议使用 Redisson 实现更健壮的分布式锁。
3. ❄️ 缓存雪崩 (Cache Avalanche)
现象 :
大量缓存 Key 在同一时间集中过期 ,或者 Redis 宕机 。
此刻大量请求全部打到数据库,导致数据库挂掉。
错误写法:
Java
// 所有商品都设置固定的 30 分钟过期
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
✅ 解决方案 A:随机过期时间
给每个 Key 的过期时间加上一个随机值,让失效时间分散开来。
codeJava
ini
// 基础过期时间 30 分钟
long expireTime = 30 * 60;
// 随机增加 0-300 秒
long randomTime = new Random().nextInt(300);
redisTemplate.opsForValue().set(key, product, expireTime + randomTime, TimeUnit.SECONDS);
✅ 解决方案 B:高可用架构
- Redis 哨兵 (Sentinel) 或 集群 (Cluster) :防止 Redis 单点故障。
- 限流降级 (Sentinel/Hystrix) :如果 Redis 真的挂了,限制访问数据库的流量,或者直接返回默认值/错误提示,保住数据库。
📝 总结一张表
| 问题 | 核心原因 | 解决方案 | 关键词 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | 缓存空对象、布隆过滤器 | Null Object, Bloom Filter |
| 击穿 | 热点 Key 过期 | 互斥锁、逻辑过期 | Mutex Lock, SETNX |
| 雪崩 | 大量 Key 同时过期 | 随机过期时间、高可用集群 | Random TTL, Cluster |
💡 架构师建议
- 代码健壮性:不要只依赖 Redis,数据库层也要有兜底保护(如限流)。
- 监控告警:对 Redis 的命中率、内存使用率、慢查询进行监控,提前发现异常。
- 不要过度设计:如果是内部小系统,并发量很低,简单的缓存逻辑就够了,不用非得上布隆过滤器和分布式锁。
希望这篇文章能让你在面对 Redis 缓存问题时,不再只是背书,而是能从容地写出高质量的实战代码!