兄弟们,欢迎来到 Redis 进化日志的第六天。在 Day 5 里,我们用分布式锁解决了"超卖"问题。现在系统看起来固若金汤了对吧?
错!
在真实的互联网生产环境中,Redis 并不总是我们的守护神。如果使用姿势不对,它反而会成为击垮数据库的"帮凶"。
当海量请求绕过 Redis 直接轰炸脆弱的 MySQL 时,这种现象统称为缓存失效问题。
最经典的三个场景就是:穿透、击穿、雪崩。

一、 缓存穿透
1. 深度解析:什么是穿透?
核心定义 :请求的数据在 Redis 中不存在 ,且在 数据库中也不存在。
场景模拟:
-
恶意攻击 :黑客写了个脚本,疯狂请求
id = -1、id = uuid这种数据库里绝对没有的数据。 -
业务误操作:前端代码 Bug,传了一个不存在的商品 ID。
后果:
这就好比你去饭店点菜,非要点"女神的眼泪"。服务员(Redis)说没有,你就冲进厨房(DB)去找厨师。你每一次问,厨师都要停下来找一圈。如果有 10 万个人同时点"女神的眼泪",厨师(DB)直接累死,导致正常想吃"西红柿炒蛋"的客人也无法服务。
2. 解决方案 & 实战代码
方案 A:缓存空对象 (Cache Null)
即便 DB 查不到,我也存一个 null 或特定标识符到 Redis,并设置较短的过期时间(TTL)。
方案 B:布隆过滤器 (Bloom Filter) ------ 推荐
原理:在请求到达 Redis 之前,先加一道"安检门"。布隆过滤器利用位数组和哈希函数,能快速判断"这个 Key 一定不存在"或"可能存在"。如果它说不存在,直接驳回请求。
生产级代码(基于 Redisson):
java
@Service
public class ProductService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
private RBloomFilter<String> bloomFilter;
// 1. 初始化布隆过滤器(通常在系统启动时预热)
@PostConstruct
public void initBloomFilter() {
bloomFilter = redissonClient.getBloomFilter("product-id-filter");
// 初始化参数:预计元素 100万,误差率 3%
// 注意:误差率越低,占用的位数组越大,性能略微下降
bloomFilter.tryInit(1000000L, 0.03);
// 模拟预热:将所有存在的商品 ID 加载进去
List<String> allIds = productMapper.selectAllIds();
for (String id : allIds) {
bloomFilter.add(id);
}
}
public Product getProduct(String id) {
String cacheKey = "product:" + id;
// 【第一道防线】:布隆过滤器拦截
// 如果布隆过滤器说不存在,那就一定不存在,直接返回,保护 Redis 和 DB
if (!bloomFilter.contains(id)) {
log.warn("非法请求,布隆过滤器拦截: {}", id);
return null;
}
// 【第二道防线】:查 Redis
String json = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(json)) {
// 如果存的是空值标识(防止缓存穿透的兜底方案),直接返回
if ("EMPTY_OBJECT".equals(json)) {
return null;
}
return JSON.parseObject(json, Product.class);
}
// 【第三道防线】:查 DB(代码见下文击穿部分)
// ...
return null;
}
}
二、 缓存击穿
1. 深度解析:什么是击穿?
核心定义 :Redis 中某个 热点 Key (比如"当季爆款")在某一瞬间 刚好过期 ,而此时恰好有 海量并发请求 同时访问这个 Key。
区别:
-
穿透:查"根本不存在"的数据。
-
击穿:查"存在但过期"的数据。
后果:
Redis 这个盾牌瞬间破了一个洞,所有流量像子弹一样穿过这个洞打在 DB 上。DB 的 CPU 和内存瞬间飙升,连接池被占满。
2. 解决方案 & 实战代码
方案:互斥锁 (Mutex Lock)
当发现缓存失效时,不是所有人都去查库。而是只有拿到锁的那一个线程去查库、写缓存,其他线程等待(自旋)或稍后重试。
生产级代码(双重检查锁模式):
java
public Product queryWithMutex(String id) {
String cacheKey = "product:" + id;
// 1. 先查缓存
String json = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(json)) {
return JSON.parseObject(json, Product.class);
}
// 2. 缓存未命中,准备重建缓存。定义分布式锁 Key
String lockKey = "lock:product:" + id;
RLock lock = redissonClient.getLock(lockKey);
try {
// 3. 尝试加锁(阻塞等待 5秒,或者只试一次)
boolean isLocked = lock.tryLock(5, TimeUnit.SECONDS);
if (isLocked) {
try {
// 【关键点】:Double Check (双重检查)
// 为什么?因为在你排队等锁的时候,可能前一个人已经把数据查出来塞进 Redis 了
// 如果不查,你又会去查一遍 DB,这就浪费了
json = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(json)) {
return JSON.parseObject(json, Product.class);
}
// 4. 真的没人查过,我去查 DB
Product product = productMapper.selectById(id);
// 5. 写入 Redis(记得处理空值,防止穿透)
if (product == null) {
redisTemplate.opsForValue().set(cacheKey, "EMPTY_OBJECT", 5, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
} finally {
// 6. 释放锁
lock.unlock();
}
} else {
// 7. 没抢到锁(理论上 tryLock 会阻塞,走到这也是异常情况),降级或重试
Thread.sleep(50);
return queryWithMutex(id); // 递归重试
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("系统繁忙");
}
}
三、 缓存雪崩
1. 深度解析:什么是雪崩?
核心定义:
-
场景 A(绝望):Redis 节点宕机,所有缓存不可用。
-
场景 B(常见) :大量 的 Key 在 同一时间 集中过期。
后果:
想象一下雪崩的场景,漫天的雪(请求)直接压塌了房子(数据库)。DB 瞬间承受平时的几十倍压力,直接报警宕机。
2. 解决方案 & 实战代码
方案 A:打散过期时间 (Random TTL)
在存入 Redis 时,不要让所有 Key 都活 1 小时。给它们加一个 随机值。
代码演示:
java
// 设置缓存时,给 TTL 加一个随机抖动
// 基础过期时间:60分钟
long baseTtl = 60 * 60;
// 随机抖动:0-300秒 (5分钟)
long randomTtl = new Random().nextInt(300);
long finalTtl = baseTtl + randomTtl;
// 这样,10万个 Key 会在 5 分钟内陆续过期,而不是在第 60 分钟那一秒同时过期
redisTemplate.opsForValue().set(key, value, finalTtl, TimeUnit.SECONDS);
方案 B:高可用架构
-
使用 Redis Cluster 或 Sentinel,防止单点故障。
-
配置 Hystrix/Sentinel 限流降级。如果 Redis 真的崩了,直接返回"系统繁忙,请稍后再试",哪怕得罪用户,也要保住数据库。
面试官对线环节 (Interview Combat)
面试官问这块内容,通常有固定的套路。这里给你准备好了满分回答逻辑。
Q1: 简单说一下穿透、击穿、雪崩的区别?
思路:用一句话概括核心差异,不要背长篇大论。
回答:
穿透是查"完全不存在"的数据,流量直接打穿到 DB。
击穿是查"热点且刚好过期"的数据,单点并发把 DB 打崩。
雪崩是"大面积"Key 同时过期或 Redis 宕机,导致 DB 全面崩溃。
Q2: 你们项目中怎么解决击穿的?为什么不用 synchronized?
思路:体现分布式思维。
回答:
因为我们的服务是集群部署的(多台服务器),synchronized 只能锁住当前 JVM 进程,防不住其他服务器的流量。
所以我们用了 Redis 分布式锁(Redisson)。在查 DB 前先抢锁,并且在拿到锁之后做了 Double Check(双重检查),确保不会重复查询数据库。
Q3: 布隆过滤器有误判怎么处理?
思路:承认缺点,说明权衡(Trade-off)。
回答:
是的,布隆过滤器存在误判率(它说存在,可能实际不存在;但它说不存在,就一定不存在)。
在我们的业务中,我们容忍了极低概率的"误判穿透"(比如 1%),因为这已经过滤掉了 99% 的恶意流量,数据库完全扛得住。如果业务要求绝对精确,我们也可以考虑配合 Redis 的 BitMap 白名单机制。
总结:一张表看懂
| 问题 | 关键特征 | 形象比喻 | 核心解法 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | 去饭店点菜单上没有的菜,厨师白忙活 | 布隆过滤器、缓存空对象 |
| 击穿 | 查过期热点数据 | 爆款菜刚好卖完,所有人围着厨师要 | 互斥锁 (分布式锁)、逻辑过期 |
| 雪崩 | 海量Key同时过期 | 饭店大厨突然请假,所有菜都做不出来 | 随机 TTL、Redis 集群、降级 |
下期预告
防住了外部攻击,咱们内部的数据一致性怎么保证?
面试官最爱问的"死锁题"来了:先删缓存还是先改数据库?什么是延时双删?
【Day 7】双写一致性难题:数据库与缓存如何不再"打架"?
关注专栏,带你接着卷!