缓存穿透是指查询一个数据库中根本不存在的数据,由于缓存不命中,请求每次都会落到数据库上。攻击者可利用不存在的ID高频访问,使数据库压力剧增。空值缓存是一种直接有效的应对手段:当查询数据库返回空结果时,仍将空值写入Redis并设置较短的过期时间,使后续相同请求直接命中缓存中的空对象,不再访问数据库。

空值缓存的基本实现原理
在常规的缓存读取逻辑中,我们先从Redis查询key,若存在则直接返回;若不存在则查数据库,数据库有值则回写缓存,无值则不作处理。这种写法在遇上不存在的key时,每一次请求都会穿透到数据库。空值缓存改变了这一行为:当数据库查询结果为空,我们依然向Redis写入一个代表空的标记,例如字符串NULL或空对象{},并赋予一个比正常数据更短的过期时间。
之所以要设置短过期时间,是因为空值本身并不是业务有效数据。若永久保留,Redis中会逐渐堆积大量无意义的key,既浪费内存,又可能掩盖后续该key对应数据被真实写入的情况。一般建议空值缓存的TTL设置为三到五分钟,足以挡住短时间内重复出现的穿透请求,又不会长期占用空间。
下面是一段Java结合Spring Data Redis的示例,展示了在用户查询场景中如何落地空值缓存:
public User getUser(Long id) {
// 先从Redis查询
String cacheKey = "user:" + id;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
// 命中空值标记直接返回null
if ("NULL".equals(cached)) {
return null;
}
return JSON.parseObject(cached, User.class);
}
// 查数据库
User user = userMapper.selectById(id);
if (user == null) {
// 写入空值标记,过期时间设为3分钟
redisTemplate.opsForValue().set(cacheKey, "NULL", 3, TimeUnit.MINUTES);
return null;
}
// 正常数据写缓存,过期时间30分钟
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
return user;
}
空值缓存的局限与随机key攻击
空值缓存能够解决固定不存在key的重复穿透问题,但面对随机生成的无效key仍显无力。攻击者若每次都使用不同的UUID或自增到极大数据范围的ID,那么每一次请求的key在Redis中都不存在,数据库依然会被打到。此时若对每个随机key都写空值,Redis内存会被迅速撑爆,形成另一种形式的资源耗尽攻击。
因此,空值缓存通常不应作为独立方案使用。它更适合作为布隆过滤器或接口参数校验之后的兜底手段。布隆过滤器可以在请求进入Redis之前,快速判断key是否可能存在于集合中;若判断一定不存在,直接拒绝请求。只有布隆过滤器认为可能存在、但数据库实际查不到的情况,才由空值缓存接管,这样写入Redis的空key数量会被控制在极小范围内。
此外,在代码层面对key的格式与范围做基础校验也非常重要。例如用户ID应是正整数且在合理区间,不符合规则的请求在Controller层就返回错误,连Redis都不用查。以下是简单的参数校验示例:
public User getUserApi(@RequestParam Long id) {
if (id == null || id <= 0 || id > 100000000L) {
throw new IllegalArgumentException("非法用户ID");
}
return getUser(id);
}
空值缓存与布隆过滤器的组合方案
在生产环境中,推荐采用布隆过滤器加空值缓存的双层结构。布隆过滤器使用位数组记录已知有效key的哈希特征,查询时若返回不存在则直接拦截;若返回存在,再走Redis与数据库逻辑。由于布隆过滤器有误判率,可能把不存在的key误判为存在,这时空值缓存就能补上最后一环,避免误判key穿透到数据库。
具体落地时,可借助Redisson提供的RBloomFilter实现分布式布隆过滤器,在用户注册或数据写入时同步加入过滤器。查询链路上先问布隆过滤器,再查Redis空值,最后查数据库并回写。这样即便有少量漏网之鱼,空值缓存也能将数据库保护住,同时空key的写入量因为前置过滤而非常有限。
要注意布隆过滤器本身不存储具体值,不能用来判断空值,它只负责存在性预判。空值缓存负责的是存在性预判通过后、真实查询却落空的情形。二者职责清晰,组合后系统既能抵抗固定穿透,也能缓解随机key冲击,对Redis内存的占用也处于可控范围。以下为组合调用的简化逻辑:
public User safeGetUser(Long id) {
if (id == null || id <= 0) {
return null;
}
// 布隆过滤器预判
if (!bloomFilter.contains(id)) {
return null;
}
return getUser(id);
}
通过上述组合,空值缓存不再是孤军奋战,而是作为缓存防御体系中的补充环节,在保障数据库稳定性的同时,也维持了Redis资源使用的合理性。实际配置时,空值TTL、布隆过滤器容量与误判率都需根据业务数据量细致调整。