Java开发者必备:深入理解Redis缓存穿透
一、 什么是缓存穿透 (Cache Penetration)?
1. 核心定义
缓存穿透是指查询一个在缓存和数据库中都确定不存在的数据。由于缓存中查不到(Cache Miss),请求会直接穿透到后端的数据库。数据库也查不到该数据,因此无法将结果回写到缓存中。当大量针对这类不存在数据的请求同时涌入时,缓存系统就形同虚设,所有请求压力都将直接传导至数据库,可能导致数据库过载甚至崩溃。
2. Java项目中的场景比喻
想象你在开发一个电商系统,用Redis
做商品信息缓存。一个恶意用户通过爬虫,用productId
从-1、-2、-3… 或者一堆随机生成的UUID来请求你的商品详情接口/api/product/{productId}
。
- 你的
ProductServiceImpl
首先会去Redis
(缓存)里通过productId
查询。结果自然是null
。 - 然后,代码会继续调用
ProductMapper.selectById(productId)
去查询MySQL
(数据库)。 MySQL
中也不存在这些商品,再次返回null
。- 这个查询过程对每一个恶意
productId
都会完整地走一遍,Redis完全没有起到保护作用。数据库的I/O被大量无效查询占满,正常用户的请求变得极慢。
3. 与“缓存击穿”、“缓存雪崩”的区别
这个概念在任何语言背景下都是一致的,在此重申以作区分:
- 缓存穿透 (Penetration):查根本不存在的数据。关键词:不存在的Key。
- 缓存击穿 (Breakdown):一个热点Key突然过期,大量并发同时请求这个Key。关键词:单个热点Key过期。
- 缓存雪崩 (Avalanche):大量Key同时集中过期,或Redis服务宕机。关键词:大量Key过期或Redis宕机。
二、 缓存穿透产生的原因和危害
1. 产生原因
- 恶意攻击:最常见。攻击者利用业务漏洞,构造大量非法参数发起请求。
- 业务逻辑错误:代码逻辑有缺陷,例如,对一个已被删除的对象的关联查询没有正确处理。
- 前端误传或非法参数:前端未对用户输入做校验,将非法参数(如
id=null
,id=-1
)传到后端。
2. 带来的危害
- 数据库成为瓶颈:数据库连接池被打满,CPU、I/O资源耗尽。
- 服务性能雪崩:依赖数据库的其他接口全部受到影响,响应超时,整个系统可用性下降。
- 服务瘫痪:最终导致应用因无法连接数据库而频繁重启,甚至整个服务宕机。
三、 缓存穿透的Java解决方案
解决核心:在请求到达数据库前,有效拦截对不存在Key的查询。
方案一:缓存空对象 (Cache Null Objects)
思路:
当从数据库查询返回null时,不直接返回,而是在Redis中缓存一个特殊的“空对象”,并设置一个较短的TTL(Time-To-Live)。
Java 实现思路:
在Java中,这个“空对象”可以是一个有特殊标识的常量。
定义一个常量来表示空值。这比直接缓存
null
要好,因为很多缓存框架不支持缓存null
值。// 在常量类或工具类中定义 public static final String CACHE_NULL_VALUE = "NULL"; // 或者定义一个静态的空对象实例,如果缓存对象是序列化的 private static final Product EMPTY_PRODUCT = new Product(-1L, "EMPTY");
在Service层实现逻辑。
代码示例 (基于Spring Boot + RedisTemplate):
@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductMapper productMapper;
public static final String CACHE_KEY_PREFIX = "product:";
public static final String CACHE_NULL_VALUE = "NULL";
public static final long CACHE_NULL_TTL = 60; // 空值缓存60秒
@Override
public Product getProductById(Long id) {
String key = CACHE_KEY_PREFIX + id;
// 1. 从Redis获取
Object cachedObject = redisTemplate.opsForValue().get(key);
// 2. 命中缓存
if (cachedObject != null) {
// 2.1 如果是约定的空值,直接返回null
if (CACHE_NULL_VALUE.equals(cachedObject.toString())) {
return null;
}
return (Product) cachedObject;
}
// 3. 未命中,查询数据库
Product productFromDb = productMapper.selectById(id);
// 4. 数据库中不存在
if (productFromDb == null) {
// 缓存空值,并设置较短的过期时间
redisTemplate.opsForValue().set(key, CACHE_NULL_VALUE, CACHE_NULL_TTL, TimeUnit.SECONDS);
return null;
}
// 5. 数据库中存在,正常缓存
redisTemplate.opsForValue().set(key, productFromDb, 3600, TimeUnit.SECONDS);
return productFromDb;
}
}
优点:
- 实现简单:逻辑清晰,代码改动小,易于集成到现有项目中。
- 效果显著:能有效抵御对同一个不存在Key的重复攻击。
缺点:
- 额外的内存开销:缓存了大量无意义的空值Key。
- 数据一致性问题:在空值缓存的有效期内,若数据库中新增了该数据,应用层读到的仍是
null
。
方案二:布隆过滤器 (Bloom Filter)
思路:
在所有请求的最前端,放置一个布隆过滤器,用于快速判断一个Key是否可能存在。如果它断定不存在,则直接返回,从而保护了后面的Redis和数据库。
Java 实现思路:
在Java生态中,有非常成熟的布隆过滤器实现。
- Google Guava:提供了单机版的布隆过滤器,非常适合在单个服务实例中使用。
- Redisson:如果你的服务是分布式的,你需要一个共享的布隆过滤器。
Redisson
(一个强大的Redis Java客户端)提供了RBloomFilter
,它将布隆过滤器的位数组存储在Redis中,从而实现分布式共享。
代码示例 (使用Google Guava):
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.Charset;
@Service
public class ProductServiceImpl {
// 假设这个布隆过滤器在服务启动时已经初始化并加载了所有商品ID
// private BloomFilter<Long> productBloomFilter = ...;
public Product getProductById(Long id) {
// 1. 先通过布隆过滤器拦截
if (!productBloomFilter.mightContain(id)) {
System.out.println("布隆过滤器拦截,ID: " + id + " 确定不存在。");
return null;
}
// 2. 布隆过滤器认为可能存在,再走缓存查询逻辑
// ... 后续逻辑同上(查询Redis,再查询DB)
// 注意:这里可以不使用“缓存空对象”方案了,因为绝大部分非法请求已被拦截
// 即使有漏网之鱼(假阳性),也只是极少数请求会穿透到DB,影响可控。
// ...
return null;
}
// 初始化布隆过滤器(通常在服务启动后执行)
@PostConstruct
public void initBloomFilter() {
// 1. 从数据库查询所有商品ID
List<Long> productIdList = productMapper.selectAllProductIds();
// 2. 初始化布隆过滤器
// 预计元素数量100万,期望误判率0.01
BloomFilter<Long> productBloomFilter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01);
// 3. 将所有ID加入过滤器
for (Long productId : productIdList) {
productBloomFilter.put(productId);
}
}
}
优点:
- 内存效率极高:空间占用远小于实际存储Key。
- 过滤效率高:在应用逻辑的最前端拦截请求,性能极佳。
缺点:
- 存在误判率:有小概率将不存在的Key误判为存在(但绝不会把存在的误判为不存在)。
- 实现和维护复杂:需要预加载数据,并且当数据库新增Key时,需要有机制同步更新布隆过滤器。
方案三:接口层前置校验
思路:
很多穿透攻击的Key本身就是不合法的(如负数ID)。在代码逻辑的最开始,甚至在Controller层或使用AOP切面,对入参进行严格的格式校验。
Java 实现思路:
使用Bean Validation (JSR 303):在Controller的方法参数上添加校验注解。
@RestController @RequestMapping("/api/product") @Validated // 开启校验 public class ProductController { @GetMapping("/{id}") public Product getProductById(@PathVariable("id") @Min(value = 1, message = "商品ID必须为正数") Long id) { // ... service call } }
网关层拦截:使用
Spring Cloud Gateway
或Zuul
等API网关,编写全局过滤器,对不符合规范的请求路径或参数直接拒绝。
四、 Java面试中如何回答“缓存穿透”
面试官您好,我从以下几个方面来谈谈对缓存穿透的理解:
首先,什么是缓存穿透。
“缓存穿透是指程序去查询一个缓存和数据库里都确定不存在的数据。这样缓存就完全不起作用,导致每个请求都直接打到数据库上。如果请求量很大,比如被恶意攻击,数据库的压力会骤增,甚至可能被拖垮。”
其次,它的解决方案。
“针对这个问题,我有了解几种在Java项目中常用的解决方案:”
- (简单方案)缓存空对象:“一个简单有效的方法是‘缓存空对象’。当数据库查询未命中时,我们不直接返回,而是在Redis里缓存一个特殊的字符串常量,比如
"NULL"
,并给它设置一个较短的过期时间。在Java代码里,我们查询缓存时,如果拿到了这个特殊值,就直接返回null
给调用方。这个方案实现简单,但缺点是会额外消耗Redis内存,且存在短暂的数据不一致风险。”- (更优方案)布隆过滤器:“一个更优、更彻底的方案是引入‘布隆过滤器’。在Java生态中,我们可以使用像Google Guava提供的单机版布隆过滤器,或者在分布式架构下使用Redisson客户端提供的分布式布隆过滤器。我们的做法是在服务启动时,将所有合法的Key(比如所有商品ID)加载到过滤器中。每次请求进来,先问布隆过滤器这个Key是否存在,如果它说‘不存在’,我们就直接拒绝,这样就能拦截掉绝大多数的无效请求,极大地保护了后端的Redis和数据库。”
再次,还可以结合其他防御手段。
“除了这两种核心方法,我们还可以做一些前置校验。比如在Controller层使用Bean Validation注解(像@Min(1))来保证ID的合法性,或者在API网关层(如Spring Cloud Gateway)通过过滤器直接拦截掉不规范的请求。”
最后,是方案的选择。
“在实际选型时,我会根据业务场景权衡。对于内部系统或写操作不频繁的场景,‘缓存空对象’方案因其简单高效而成为不错的选择。但对于高并发、读密集且Key集合相对稳定的核心业务(如电商的商品系统),我更倾向于引入‘布隆过滤器’,因为它能从根本上解决问题,提供更强的保护。”