一、 什么是缓存穿透?
在Redis面试题中,缓存穿透(Cache Penetration)是一个高频考点。简单来说,缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。但是这个用户或攻击者依然不停地发起请求,这些数据不会进入缓存,导致请求直接打到数据库上。
这就好比去银行办理业务,柜员(缓存)查不到你的信息,直接让你去问行长(数据库),而且这个人还一直问,导致行长被问烦了,甚至累垮了。在计算机术语中,这通常意味着大量的恶意请求或非法请求绕过了缓存层,直接击穿到持久化存储层。
- 请求的数据在数据库和缓存中均不存在。
- 请求通常具有随机性、恶意性(如爬虫、攻击脚本)。
- 每次请求都会穿透缓存,直达数据库。
- 如果并发量极大,数据库连接池可能被耗尽,导致服务不可用。
二、 产生原因与场景
理解Redis面试题缓存穿透的产生原因,有助于我们在面试中展现更深的技术洞察力。主要原因通常包括:
- 业务逻辑漏洞: 用户输入了非法的ID(如负数、不存在的超大ID),系统未做校验直接查询数据库。
- 恶意攻击: 黑客利用工具扫描系统,构造大量不存在的Key,试图拖垮数据库。
- 热点数据删除: 在某些极端情况下,如果缓存失效策略不当,可能导致瞬时大量请求穿透。
例如,在一个电商系统中,商品ID通常是正整数。如果攻击者不断请求 GET product:0 或 GET product:-1,而数据库中根本没有这些商品,每次请求都会穿透Redis,直接查询MySQL。
三、 缓存穿透的危害
如果不加以处理,缓存穿透带来的后果是灾难性的:
⚠️ 严重后果
1. 数据库压力激增: 大量无效请求直接打到DB,导致CPU和IO利用率飙升。
2. 服务瘫痪: 数据库连接池耗尽,正常用户的请求无法获取连接,导致整个系统雪崩。
3. 响应延迟: 数据库处理大量无效查询,响应时间变长,用户体验极差。
四、 解决方案一:空值缓存(Null Cache)
这是最简单、最常用的解决方案。当查询数据库发现数据不存在时,不仅不写入缓存,反而将“空值”写入缓存,并设置一个较短的过期时间。
1. 实现原理
当Key不存在时,在Redis中存储一个特殊的值(如空字符串、"null"或特定的标志位),并设置TTL(Time-To-Live)。下次请求该Key时,直接命中缓存中的空值,返回给前端,不再查询数据库。
2. 代码示例
// 伪代码示例
public Object getData(String key) {
// 1. 查缓存
Object value = redis.get(key);
if (value != null) {
return value;
}
// 2. 查数据库
value = db.query(key);
// 3. 如果数据库也没有,缓存空值
if (value == null) {
// 设置较短的过期时间,如60秒,防止恶意攻击持续穿透
redis.set(key, "", 60);
return null;
}
// 4. 正常数据缓存
redis.set(key, value, 3600);
return value;
}
3. 优缺点分析
- 优点: 实现简单,成本低,能有效缓解大部分非恶意或低频的穿透。
- 缺点: 如果攻击者构造海量的随机Key,缓存中会充斥着大量的空值Key,占用Redis内存,且需要定期清理,增加了复杂度。
五、 解决方案二:布隆过滤器(Bloom Filter)
布隆过滤器是解决缓存穿透的进阶方案,尤其适用于数据量巨大且允许一定误判率的场景。它是面试中的“加分项”。
1. 什么是布隆过滤器?
布隆过滤器(Bloom Filter)是由Burton Howard Bloom在1970年提出的一种数据结构。它本质上是一个很长的二进制向量和一系列随机映射函数。主要用于判断一个元素是否在一个集合中。
2. 核心特性
- 不会漏报: 如果布隆过滤器说元素存在,那它一定存在(可能有误判,即假阳性)。
- 可能误报: 如果布隆过滤器说元素不存在,那它一定不存在(无假阴性)。
- 空间效率高: 相比哈希表,占用空间极小。
3. 工作流程
将数据库中所有可能存在的Key(如所有商品ID)通过多个哈希函数映射到布隆过滤器的位数组中,置为1。
当请求到达时,先通过布隆过滤器判断Key是否存在。
如果布隆过滤器返回“不存在”,则直接返回错误或空值,不查缓存和DB。
如果返回“存在”,则继续查询Redis缓存。
4. 优缺点分析
- 优点: 极大减少数据库压力,内存占用极小,抗恶意攻击能力强。
- 缺点:
- 无法删除数据(删除会影响其他Key的判断),需要全量重建。
- 存在误判率(假阳性),即布隆过滤器认为存在,但实际不存在,此时仍需查缓存/DB,但不会穿透到DB查询大量无效数据。
- 实现复杂,需要引入额外组件(如Redisson提供的BloomFilter)。
六、 解决方案三:互斥锁(Mutex Lock)
当缓存失效时,如果大量并发请求同时到达数据库,可能导致缓存击穿,但也可能加剧穿透风险。互斥锁主要用于解决缓存击穿,但在某些场景下也可配合使用。
1. 实现思路
当缓存失效时,不立即去查数据库,而是先设置一个互斥锁(如Redis的SETNX)。只有获取到锁的线程才去查询数据库并更新缓存,其他线程等待或重试。
2. 代码逻辑
public Object getDataWithLock(String key) {
Object value = redis.get(key);
if (value == null) {
// 尝试获取锁
String lock = redis.setnx("lock:" + key, "1");
if (lock != null) {
try {
// 查询数据库
value = db.query(key);
// 更新缓存
redis.set(key, value, 3600);
} finally {
// 释放锁
redis.del("lock:" + key);
}
} else {
// 没获取到锁,休眠后重试
Thread.sleep(50);
return getDataWithLock(key); // 递归重试
}
}
return value;
}
注意:此方案主要针对缓存击穿,对于缓存穿透,通常结合空值缓存使用效果更佳。
七、 四种方案对比总结
在面试中,能够清晰对比不同方案的优劣是体现专业能力的关键。以下是针对Redis面试题缓存穿透的常见解决方案对比:
| 方案 | 实现难度 | 内存占用 | 适用场景 | 主要缺点 |
|---|---|---|---|---|
| 空值缓存 | ⭐ | 低(少量Key) | 非恶意、低频穿透 | 恶意攻击下内存爆炸 |
| 布隆过滤器 | ⭐⭐⭐ | 极低 | 海量数据、恶意攻击 | 无法删除、有误判率 |
| 互斥锁 | ⭐⭐ | 低 | 缓存击穿、热点Key重建 | 并发性能略有下降 |
| 逻辑过期 | ⭐⭐⭐ | 低 | 高并发、一致性要求不高 | 实现复杂,需后台线程 |
九、 高频面试问答
Q1: 布隆过滤器如何判断元素是否存在?
A: 布隆过滤器通过多个独立的哈希函数将元素映射到位数组的多个位置。如果所有位置都为1,则元素可能存在;如果有一个位置为0,则元素一定不存在。
Q2: 为什么空值缓存要设置较短的过期时间?
A: 因为空值本身没有业务价值,设置过长的TTL会浪费内存。较短的时间(如60秒)可以在一定程度上缓解恶意攻击,同时允许业务数据后续入库后能及时生效。
Q3: Redisson的布隆过滤器是如何实现的?
A: Redisson基于Redis的BitMap和Lua脚本实现了布隆过滤器。它支持动态扩容和误判率调整,提供了类似Java中Guava BloomFilter的API,但支持分布式环境。