当前位置: 首页 > news >正文

面试Redis篇-深入理解Redis缓存穿透

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. 产生原因

  1. 恶意攻击:最常见。攻击者利用业务漏洞,构造大量非法参数发起请求。
  2. 业务逻辑错误:代码逻辑有缺陷,例如,对一个已被删除的对象的关联查询没有正确处理。
  3. 前端误传或非法参数:前端未对用户输入做校验,将非法参数(如id=null, id=-1)传到后端。

2. 带来的危害

  • 数据库成为瓶颈:数据库连接池被打满,CPU、I/O资源耗尽。
  • 服务性能雪崩:依赖数据库的其他接口全部受到影响,响应超时,整个系统可用性下降。
  • 服务瘫痪:最终导致应用因无法连接数据库而频繁重启,甚至整个服务宕机。

三、 缓存穿透的Java解决方案

解决核心:在请求到达数据库前,有效拦截对不存在Key的查询。

方案一:缓存空对象 (Cache Null Objects)

思路:

当从数据库查询返回null时,不直接返回,而是在Redis中缓存一个特殊的“空对象”,并设置一个较短的TTL(Time-To-Live)。

Java 实现思路:

在Java中,这个“空对象”可以是一个有特殊标识的常量。

  1. 定义一个常量来表示空值。这比直接缓存null要好,因为很多缓存框架不支持缓存null值。

    // 在常量类或工具类中定义
    public static final String CACHE_NULL_VALUE = "NULL"; 
    // 或者定义一个静态的空对象实例,如果缓存对象是序列化的
    private static final Product EMPTY_PRODUCT = new Product(-1L, "EMPTY");
    
  2. 在Service层实现逻辑

代码示例 (基于Spring Boot + RedisTemplate):

@Service
public class ProductServiceImpl implements ProductService {@Autowiredprivate RedisTemplate<String, Object> redisTemplate;@Autowiredprivate 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秒@Overridepublic Product getProductById(Long id) {String key = CACHE_KEY_PREFIX + id;// 1. 从Redis获取Object cachedObject = redisTemplate.opsForValue().get(key);// 2. 命中缓存if (cachedObject != null) {// 2.1 如果是约定的空值,直接返回nullif (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;}// 初始化布隆过滤器(通常在服务启动后执行)@PostConstructpublic void initBloomFilter() {// 1. 从数据库查询所有商品IDList<Long> productIdList = productMapper.selectAllProductIds();// 2. 初始化布隆过滤器// 预计元素数量100万,期望误判率0.01BloomFilter<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 GatewayZuul等API网关,编写全局过滤器,对不符合规范的请求路径或参数直接拒绝。


四、 Java面试中如何回答“缓存穿透”

面试官您好,我从以下几个方面来谈谈对缓存穿透的理解:

首先,什么是缓存穿透。

“缓存穿透是指程序去查询一个缓存和数据库里都确定不存在的数据。这样缓存就完全不起作用,导致每个请求都直接打到数据库上。如果请求量很大,比如被恶意攻击,数据库的压力会骤增,甚至可能被拖垮。”

其次,它的解决方案。

“针对这个问题,我有了解几种在Java项目中常用的解决方案:”

  1. (简单方案)缓存空对象:“一个简单有效的方法是‘缓存空对象’。当数据库查询未命中时,我们不直接返回,而是在Redis里缓存一个特殊的字符串常量,比如"NULL",并给它设置一个较短的过期时间。在Java代码里,我们查询缓存时,如果拿到了这个特殊值,就直接返回null给调用方。这个方案实现简单,但缺点是会额外消耗Redis内存,且存在短暂的数据不一致风险。”
  2. (更优方案)布隆过滤器:“一个更优、更彻底的方案是引入‘布隆过滤器’。在Java生态中,我们可以使用像Google Guava提供的单机版布隆过滤器,或者在分布式架构下使用Redisson客户端提供的分布式布隆过滤器。我们的做法是在服务启动时,将所有合法的Key(比如所有商品ID)加载到过滤器中。每次请求进来,先问布隆过滤器这个Key是否存在,如果它说‘不存在’,我们就直接拒绝,这样就能拦截掉绝大多数的无效请求,极大地保护了后端的Redis和数据库。”

再次,还可以结合其他防御手段。

“除了这两种核心方法,我们还可以做一些前置校验。比如在Controller层使用Bean Validation注解(像@Min(1))来保证ID的合法性,或者在API网关层(如Spring Cloud Gateway)通过过滤器直接拦截掉不规范的请求。”

最后,是方案的选择。

“在实际选型时,我会根据业务场景权衡。对于内部系统或写操作不频繁的场景,‘缓存空对象’方案因其简单高效而成为不错的选择。但对于高并发、读密集且Key集合相对稳定的核心业务(如电商的商品系统),我更倾向于引入‘布隆过滤器’,因为它能从根本上解决问题,提供更强的保护。”

http://www.lryc.cn/news/591240.html

相关文章:

  • 基于YOLOv11的水面垃圾智能检测系统
  • halcon 模板匹配
  • 高精度加法模版介绍
  • 阿里云-通义灵码:隐私保护机制—为数据安全筑起铜墙铁壁
  • USRP中心频率与采样率联合设置
  • MyBatis 之配置与映射核心要点解析
  • CPU架构、三级缓存以及内存优化屏障
  • 指针数组和数组指针的应用案例
  • 「Trae IDE 全流程实战」——从 0 下载安装,到在本地跑起一个可玩的 2048 小游戏
  • SpringBoot使用ThreadLocal共享数据
  • 永磁同步电机MTPA与MTPV曲线具体仿真实现
  • 大语言模型Gemini Deep Research 全流程解读+使用攻略
  • 杨耀东老师在ICML2025上对齐教程:《语言模型的对齐方法:一种机器学习视角》
  • 死信队列:springboot+RabbitMQ实现死信队列
  • GitHub Jekyll博客本地Win开发环境搭建
  • NumPy 数组存储字符串的方法
  • 算法提升之字符串练习-02(字符串哈希)
  • 【leetcode】852. 山脉数组的封顶索引
  • React 18 vs Vue3:状态管理方案深度对比
  • 深入理解Map.Entry.comparingByValue()和Map.Entry.comparingByKey()
  • 我爱学算法之—— 前缀和(下)
  • 第十四章 gin基础
  • 深入理解React Hooks:从使用到原理
  • Qt CMake 学习文档
  • 【安卓按键精灵辅助工具】adb调试工具连接安卓模拟器异常处理
  • QT之openGL使用(二)
  • 端到端神经网络视频编解码器介绍
  • 电脑截图软件排行榜 Windows和mac电脑截图软件TOP10
  • 基于Rust游戏引擎实践(Game)
  • ZKmall开源商城架构助力增长:多端流量聚合与用户体验