缓存穿透防护实战:缓存空值、布隆过滤器与限流策略解析
2026/9/9 22:37:42 网站建设 项目流程

缓存穿透这个问题,很多团队是在线上事故中第一次认识它的。某个周二的晚上,监控大屏突然飘红:数据库连接池被耗尽,接口平均响应时间从 30ms 涨到 5 秒,用户端看到的页面全是超时和错误。开发同学登录服务器看日志,发现同一个商品 ID 在短时间内被请求了上万次,而这个商品 ID 在数据库里根本不存在。

这是一次非常典型的缓存穿透事故。请求绕过缓存,直接打到数据库,把一个 MySQL 实例活生生打挂了。更麻烦的是,这种请求通常是恶意脚本在循环遍历,你杀了一个 ID,他就换下一个,几乎无法通过人工封号解决。

这篇文章就把缓存穿透这件事讲透:它到底是什么,为什么这么难防,以及缓存空值、布隆过滤器、接口限流这三道防线分别怎么落地。最后我会给出一套可运行的 Spring Boot 示例代码,方便你直接在自己的项目中对照实践,把数据库从“裸奔”状态下救回来。

1. 缓存穿透到底“穿透”了什么:一次夜间事故的复盘

先复盘一下文章开头说的那次事故。

你的系统正常情况下是这样的:用户查询商品详情,首先查 Redis,如果 Redis 里面有数据,直接返回,根本不碰数据库。只有当 Redis 里没有这个 key 时,才会去查 MySQL,查完之后再把数据回填到 Redis。

正常请求:用户 -> Redis(命中) -> 返回 缓存未命中:用户 -> Redis(未命中) -> MySQL -> 回填 Redis -> 返回

这个流程看起来没有问题,但里面藏着一个漏洞:当查询的 key 在 Redis 和 MySQL 中都不存在时,缓存层就没有任何作用了。

攻击者构造一个不存在的商品 ID,比如 99999999。你的程序查 Redis,发现没有这个 key,于是去查 MySQL,MySQL 也查不到,程序返回“商品不存在”,同时也不会写入缓存。下一次请求同样的 ID,程序再查 Redis,还是没有,再去查 MySQL。

每一次请求都穿越了缓存,直达数据库。攻击者不需要费多大力气,只要用脚本循环遍历一批不存在的 ID,就能把你数据库的 QPS 打满。这就是“缓存穿透”这个名称的由来:流量绕过了缓存这层盾牌,直接穿透到了数据库身上。

真正容易踩坑的地方是,很多人以为缓存穿透是 Redis 配置问题或者缓存中间件的问题,其实完全不是。缓存组件本身工作正常,它只是没有能力挡住“查不到数据”的请求,因为这类请求天然无法被缓存命中。这是一个分布式系统防御设计的问题,而不是缓存组件故障的问题。

从本质上看,缓存穿透之所以危险,是因为它把数据库暴露在了无差别流量的冲击之下。数据库的性能是有限的,它的连接数、线程数、磁盘 IO 都有上限,而攻击者制造无效请求的成本几乎为零。你越依赖数据库,数据库就越脆弱;你越依赖缓存,缓存就越容易被“查无此 key”的流量绕过。

要解决这个问题,核心逻辑有三条路:第一,让缓存能够处理“查到空结果”的情况;第二,在查询缓存之前,用更廉价的数据结构判断这个 key 到底存不存在;第三,在接口层限制可疑流量的访问频率。后面三个章节会逐一展开。

2. 缓存穿透、缓存击穿、缓存雪崩:三个概念别再傻傻分不清

在讲方案之前,必须先厘清三个经常一起出现的概念。很多人在面试里能背出定义,一到排查问题时就把它们混为一谈,最终导致方案用错。

概念关键特征触发条件典型解决思路
缓存穿透查询的数据在缓存和数据库中都不存在恶意请求遍历不存在的 ID缓存空值、布隆过滤器、参数校验、限流
缓存击穿某个热点 key 过期瞬间,大量请求同时打到数据库热点数据过期,且并发极高互斥锁、逻辑过期、热点数据预热
缓存雪崩大量 key 同时过期,导致数据库瞬间压力暴增大批 key 设置了相同的过期时间过期时间加随机值、多级缓存、熔断降级

三者的区别,用场景来理解最直观。

缓存穿透是“打了一个不存在的靶子”,靶子本来就不在,所以缓存永远挡不住。缓存击穿是“打一个很热的靶子,但靶子刚好被撤了下来”,也就是热点 key 在过期的一瞬间,缓存失效,并发请求全部落到数据库。缓存雪崩则是“所有靶子一起被撤下来”,大量 key 在同一时间过期,数据库承受不了瞬间的请求洪峰。

从解决方向上看,三者完全不同。缓存穿透的核心是“如何低成本地判断 key 不存在”,缓存击穿的核心是“如何保证热点 key 过期时只有一个请求去重建缓存”,缓存雪崩的核心是“如何避免过期时间过于集中”。

本文只解决缓存穿透,但你需要知道它的边界。一个完整的缓存防护体系,往往需要同时考虑穿透、击穿和雪崩,少了任何一个环节,数据库都可能被找到新的突破口。

3. 最容易被打穿的业务场景:哪些项目风险最高

并非所有项目都会遇到缓存穿透。它通常需要满足两个条件:一是业务中存在“查询不到数据”的合法场景;二是接口接收外部传入的 ID 或者编码。如果你的系统满足这两个条件,那么缓存穿透就只是一个时间问题。

以下四类场景是最常见的重灾区。

商品详情类接口。商品 ID 是用户可猜测的,而且商品可能存在下架、删除等状态。攻击者只需要从 1 开始递增遍历,就能制造大量不存在的 ID。电商大促期间,这类接口往往没有严格限流,一旦被打穿,数据库会立刻成为瓶颈。

订单查询类接口。订单号通常是业务号,可预测性较强。很多系统在查询订单时,先查缓存,再查数据库,最后返回“订单不存在”。但一个不存在的订单号,依然会触发完整的数据库查询链路。

用户 Token 或账号校验。这类接口使用频繁,流量大,而且外部攻击者可以随机生成无效 Token 来试探。每一次无效 Token 校验都会穿透缓存,直接把压力施加到用户表或登录日志表上。

短链解析和邀请码核销类业务。短链码、邀请码的字符空间有限,攻击者可以批量遍历。如果数据库中没有对应记录,缓存又无法命中,请求就会全部打到数据库。

这些场景有一个共同特点:接口对外开放,参数可预测,业务存在“查不到是正常情况”的语义。只要你的系统满足这三个条件,就必须主动考虑缓存穿透防护,而不是等监控报警之后再去救火。

另外,还要警惕一种容易忽略的情况:业务逻辑里“软删除”的数据。比如商品状态被置为 1(已删除),查询主流程可能不会返回,但数据库里确实存在这条记录。如果查询条件只过滤状态位,不带主键白名单,这类数据也会表现出“查不到”的特征,从而成为穿透流量的目标。

4. 方案一:缓存空值,给不存在的 key 也安排一个座位

缓存穿透最直接的原因是“查不到的数据无缓存可命”。那么思路自然就来了:既然查不到,那我把“查不到”这个结果也缓存起来,问题不就解决了吗?

这就是缓存空值方案。

4.1 原理与实现思路

当数据库查询结果为空时,不要直接返回,而是把一个特殊标记写入缓存,并设置一个较短的过期时间。这样,下一次同样的 key 过来,缓存能命中这个空值标记,接口直接返回“不存在”,不再查询数据库。

关键代码如下:

// 文件路径:src/main/java/com/example/demo/service/ProductService.java @Service public class ProductService { private static final String PRODUCT_CACHE_PREFIX = "product:detail:"; private static final String EMPTY_MARK = "EMPTY"; @Autowired private StringRedisTemplate redisTemplate; @Autowired private JdbcTemplate jdbcTemplate; private final ObjectMapper objectMapper = new ObjectMapper(); public Product getProductById(Long id) { // 第 1 步:参数校验,后面会细讲 if (id == null || id <= 0) { return null; } String cacheKey = PRODUCT_CACHE_PREFIX + id; // 第 2 步:查缓存 String cachedValue = redisTemplate.opsForValue().get(cacheKey); if (cachedValue != null) { if (EMPTY_MARK.equals(cachedValue)) { return null; } try { return objectMapper.readValue(cachedValue, Product.class); } catch (JsonProcessingException e) { // 缓存数据损坏时,按穿透处理,回源数据库 redisTemplate.delete(cacheKey); } } // 第 3 步:查数据库 Product product = queryFromDb(id); // 第 4 步:回填缓存 if (product == null) { // 空值也缓存,过期时间短一点,并加随机抖动,防止集中过期 int emptyCacheSeconds = 180 + new Random().nextInt(120); redisTemplate.opsForValue().set( cacheKey, EMPTY_MARK, emptyCacheSeconds, TimeUnit.SECONDS ); return null; } // 真实数据缓存 30 分钟,同时加随机抖动 int realCacheSeconds = 1800 + new Random().nextInt(300); try { String json = objectMapper.writeValueAsString(product); redisTemplate.opsForValue().set( cacheKey, json, realCacheSeconds, TimeUnit.SECONDS ); } catch (JsonProcessingException e) { throw new RuntimeException("商品序列化失败", e); } return product; } private Product queryFromDb(Long id) { try { return jdbcTemplate.queryForObject( "SELECT id, name, price FROM product WHERE id = ?", new BeanPropertyRowMapper<>(Product.class), id ); } catch (EmptyResultDataAccessException e) { // 查询结果为空,返回 null 给上层做空值缓存 return null; } } }

4.2 这里真正容易踩坑的地方

缓存空值方案实现简单,效果立竿见影,但它有几个容易被忽略的坑。

第一个坑是空值 key 占内存。恶意流量遍历 100 万个不存在的 ID,就会在 Redis 中产生 100 万个空值 key。这些 key 虽然设置了过期时间,但在过期之前依然占用内存。如果业务量大,Redis 内存会快速上涨。解决办法是给空值 key 设置统一前缀,比如null:,后续可以写定时任务批量清理,或者降低空值缓存时间。

第二个坑是空值缓存时间不宜过长。如果你把不存在的商品 ID 缓存 30 分钟,而商品刚好在 10 分钟后被重新上架,用户在 20 分钟内仍然查不到这个商品。这会直接导致数据一致性问题,而且很难排查。因此空值缓存时间建议控制在 2 到 5 分钟,加上随机抖动,既起到防穿透作用,又能接受短时间内的数据延迟。

第三个坑是要区分“缓存空值”和“缓存数据异常”。当缓存中读取到EMPTY_MARK时,返回空结果;当读到非空字符串但 JSON 解析失败时,说明缓存数据已经损坏,应该删除缓存并回源数据库,而不是直接返回“不存在”。这段逻辑在示例代码中已经体现出来,很多初学者会漏掉。

缓存空值适合的业务形态是:数据量不大、攻击流量有限、希望快速落地解决。它是防穿透方案的“下限”,几乎每个项目都应该先做这一步。

5. 方案二:布隆过滤器,把非法 key 拦截在查询之前

缓存空值方案是事后拦截:请求先打到缓存,发现没命中,然后缓存空值把后续请求挡住。但空值 key 本身还会短暂占内存,而且如果攻击流量在空值过期后再次发起,依然会穿透。

布隆过滤器则是一种前置拦截方案:在查缓存之前,先判断 key 到底存不存在,如果过滤器认为不存在,直接返回,连 Redis 都不查。

5.1 布隆过滤器是怎么工作的

布隆过滤器是一个非常节省内存的概率性数据结构。它的核心逻辑是:把所有合法 key 通过多个哈希函数映射到一个很长的位数组上,在位数组中把对应位置置为 1。查询时,同样计算多个哈希值,如果所有对应位置都是 1,则认为 key “可能存在”;只要有任何一个位置为 0,则可以确定 key “一定不存在”。

简单理解:它是一个“大概率在名单里”的检查员。如果它说“不在”,那就一定不在;如果它说“在”,只是大概率在,也有小概率误判。

对于缓存穿透场景,这个特性非常合适。我们只关心“这个 ID 到底是不是合法商品 ID”,如果一个 ID 被过滤器判定为不存在,就直接返回“商品不存在”,根本不用继续查缓存和数据库。即使误判了一个不存在的 ID 为可能存在,最坏的结果也就是多查一次数据库,不会返回错误结果。

5.2 使用 Guava 实现布隆过滤器

在 Java 项目中,最常用的布隆过滤器实现是 Google Guava。以下代码示例展示了如何在应用启动时加载所有商品 ID,并在查询时进行过滤判断:

// 文件路径:src/main/java/com/example/demo/filter/ProductBloomFilter.java @Component public class ProductBloomFilter { // 预计要放入过滤器的元素数量 private static final int EXPECTED_INSERTIONS = 100000; // 误判率,越低越占内存,这里设置为 1% private static final double FPP = 0.01; private final BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(), EXPECTED_INSERTIONS, FPP); @Autowired private JdbcTemplate jdbcTemplate; @PostConstruct public void init() { List<Long> ids = jdbcTemplate.queryForList( "SELECT id FROM product", Long.class ); ids.forEach(bloomFilter::put); } /** * 判断商品 ID 是否可能存在。 * false 表示一定不存在,true 表示可能存在。 */ public boolean mightContain(Long id) { return bloomFilter.mightContain(id); } }

接入查询链路时,只需要在ProductService.getProductById方法的最前面加一段判断:

@Autowired private ProductBloomFilter productBloomFilter; public Product getProductById(Long id) { if (id == null || id <= 0) { return null; } // 布隆过滤器前置判断:不存在直接返回 if (!productBloomFilter.mightContain(id)) { return null; } // 后续查缓存、查数据库的逻辑保持不变 }

5.3 布隆过滤器的局限与更新策略

布隆过滤器也不是银弹,使用它之前必须想清楚数据更新策略。

最大的问题在于新增数据的同步。布隆过滤器在应用启动时一次性加载所有商品 ID,但业务是动态的,每天都会有新商品上架。如果新增商品后没有把新 ID 放入过滤器,这个新商品会被误判为“不存在”,用户永远查不到。

解决做法有三种:

  • 增量更新:在商品创建的 Service 方法中,同步调用bloomFilter.put(id)。实现简单,但需要保证业务代码中所有创建入口都不遗漏,一旦遗漏就会产生线上问题。
  • 定时全量重建:每天凌晨从数据库重新加载全部商品 ID,构建新的过滤器。需要解决新旧过滤器切换的问题,可以用双缓冲,即维护两份过滤器,重建完成后切换引用。
  • 混合策略:增量更新保证实时性,定时重建兜底,防止增量遗漏积累。

另一个局限性是过滤器启动加载大 key 集合时耗时较长。如果平台有上亿商品数据,一次性加载可能耗时几分钟。可以考虑只用热点或高权重商品的 ID 构建过滤器,或使用 Redis 的 BitMap 实现分布式布隆过滤器,避免单应用内存受限。

布隆过滤器适用的业务形态是:数据量大、相对稳定、存在可预知的合法 key 全集,并且希望把无效流量在 Redis 之前就拦截掉。在中小项目里,它不是第一优先级;但在高并发 C 端系统中,它通常是防穿透体系里最前面的一道闸门。

6. 方案三:接口层防护,流量层面的最后一道保险

缓存空值和布隆过滤器解决的是“数据层面”的穿透问题,但还有一个维度需要防守:如果攻击者流量足够大,即使每个 key 只查一次数据库,也能把你的数据库打挂。

这时候,光靠缓存层的方案就不够了,还需要在接口层和流量层做防护。

6.1 参数校验是第一道口子

很多缓存穿透事故的根源,是接口没有对入参做基本校验,任何数字都可以进入查询链路。先看你的接口是不是也存在这种情况。

一个商品 ID 不可能小于等于 0,不可能超过某个合理的业务范围,也通常不会是负数。如果这些非法参数都能直接进入 Service 层,说明你的参数校验还不到位。在 Spring Boot 项目中,可以引入spring-boot-starter-validation,或在 Controller 层手动校验。

@GetMapping("/{id}") public Result<Product> getProduct(@PathVariable Long id) { if (id == null || id <= 0 || id > 999999999L) { return Result.error("无效的商品 ID"); } return productService.getProductById(id); }

注意,参数校验只是过滤掉“明显非法”的请求,并不能防御“ID 本身合法但商品不存在”的穿透流量。所以参数校验是地基,但不能只靠它。

6.2 基于 key 维度的限流:把恶意请求挡在业务逻辑之外

限流是接口层保护数据库的关键手段。我们的目标是:对同一个 key 的访问频率进行限制,一旦超过阈值,直接拒绝请求。

在实践中,更专业的做法是基于 Redis 的滑动窗口或者令牌桶实现分布式限流。这里先给一个本地内存版的时间窗口限流示例,虽然只适用于单机演示,但限流思路和代码结构完全一样:

// 文件路径:src/main/java/com/example/demo/limit/KeyRateLimiter.java @Component public class KeyRateLimiter { // key -> 访问时间戳队列 private final ConcurrentHashMap<String, Deque<Long>> visitWindow = new ConcurrentHashMap<>(); // 每个 key 在 60 秒内最多允许访问 50 次 private static final int LIMIT = 50; private static final long WINDOW_MILLIS = 60_000L; /** * 检查当前 key 是否超过限流阈值。 */ public boolean isOverLimit(String key) { long now = System.currentTimeMillis(); Deque<Long> deque = visitWindow.computeIfAbsent(key, k -> new ArrayDeque<>()); synchronized (deque) { // 清理窗口之外的时间记录 while (!deque.isEmpty() && now - deque.peekFirst() > WINDOW_MILLIS) { deque.pollFirst(); } if (deque.size() >= LIMIT) { return true; } deque.addLast(now); } return false; } }

然后在查询商品前,先判断当前 key 是否已经被限流:

@Autowired private KeyRateLimiter keyRateLimiter; public Product getProductById(Long id) { String rateLimitKey = "rate:product:" + id; if (keyRateLimiter.isOverLimit(rateLimitKey)) { throw new BizException("请求过于频繁,请稍后重试"); } // 其余查询逻辑不变 }

这个本地实现有几个明显问题:第一,它只在单机内存中生效,多实例部署时必须换成 Redis + Lua 实现;第二,它把同一个 key 的正常并发请求也限住了,比如秒杀活动中有 1000 人同时抢购同一个商品,第一个请求到达后,后面 949 个请求都会被误伤。因此限流阈值必须根据业务压测结果来决定,并且针对抢购等热点场景要单独设计。

6.3 限流解决的是“恶意流量”问题,而不是“数据不存在”问题

需要说明的是,接口限流并不能在数据结构层面解决缓存穿透,它的作用是当缓存层和布隆过滤器都被绕过时,限制攻击速率,避免数据库瞬间被打挂。

一套完整的接口防护还应该包括:IP 维度的限流、用户维度的限流、短期失败次数的黑名单机制,以及第一时间对异常流量来源进行告警。这些能力通常由网关层承担,但如果你没有网关,这些逻辑就必须写在应用层。

接口层防护适用任何对外提供服务的系统。它不一定能完全消除穿透流量,但它能保证你的数据库在最坏情况下依然有喘息的空间。

7. 三种方案选型对比:别把全部武器一次堆上去

讲了三种方案,你可能会纠结到底该用哪一种。这里做一个直接了当的选型建议。

方案实现成本对内存的占用防穿透效果误伤/数据风险适用阶段
缓存空值较高,空值 key 也会占内存好,能挡住重复无效请求低,但存在短暂的数据一致性问题中小项目、快速上线兜底
布隆过滤器低,位数组非常省内存好,能在缓存之前拦截大部分无效 key如果新增数据不同步,会“误杀”真实数据数据量大、key 集合稳定的系统
接口限流与参数校验中高辅助性兜底,不能根除穿透阈值设置不当会误伤正常用户公网接口、恶意攻击场景

从实践角度看,我建议按业务体量分三档来选:

第一档,刚起步的项目或者内部系统,直接做参数校验 + 缓存空值。不要引入布隆过滤器,因为动态数据同步的成本比收益还高。缓存空值能挡住 90% 的重复穿透流量,足够应对常规风险。

第二档,有一定流量的 C 端系统,参数校验 + 缓存空值 + 接口限流。重点是限流阈值要经过压测,不能拍脑袋。这个组合能应对大多数恶意遍历场景。

第三档,大规模高并发平台,在前两档基础上增加布隆过滤器,并配套完善的数据同步机制。同时把限流下沉到网关层,应用层只保留业务校验和最后的保护逻辑。

这里有一个常见的决策误区:很多人喜欢一次性把三种方案都堆上去,觉得越多的方案越安全。但实际上,每多一个组件,就多一份维护成本和一个故障点。布隆过滤器的误杀风险、限流的误伤风险,都比缓存空值更隐蔽,如果团队没有足够的监控和快速回滚能力,不建议在项目初期就全量上线。

记住一句话:缓存空值是兜底,布隆过滤器是拦截,限流是保命。三个方案不是互相替代的关系,而是按风险等级逐层设防的关系。

8. 完整实战:Spring Boot 集成缓存空值与接口限流

前面讲完了原理和选型,这一节提供一个可以直接运行的 Spring Boot 示例工程。示例会组合“参数校验 + 缓存空值 + 本地 key 维度限流”三套逻辑,并附加布隆过滤器的可选接入方式,方便你对照练习。

8.1 环境准备与项目结构

本文示例以 Spring Boot 3.2.x 为基础,使用 JDK 17,操作系统不限。Redis 建议使用 5.0 以上版本,MySQL 使用 5.7 或 8.0 均可。如果你本机没有 MySQL,也可以用内存数据库 H2 代替,SQL 部分需要做少量调整。

<!-- 文件路径:pom.xml --> <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>cache-penetration-demo</artifactId> <version>1.0.0</version> <name>cache-penetration-demo</name> <description>缓存穿透防护示例</description> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>32.1.3-jre</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> </project>
# 文件路径:src/main/resources/application.yml server: port: 8080 spring: application: name: cache-penetration-demo redis: host: 127.0.0.1 port: 6379 timeout: 3000ms datasource: url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver

示例工程结构如下:

src/main/java/com/example/demo ├── CachePenetrationDemoApplication.java ├── controller │ └── ProductController.java ├── service │ └── ProductService.java ├── entity │ └── Product.java ├── limit │ └── KeyRateLimiter.java └── common └── Result.java

8.2 核心代码实现

产品实体类:

// 文件路径:src/main/java/com/example/demo/entity/Product.java public class Product { private Long id; private String name; private BigDecimal price; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public BigDecimal getPrice() { return price; } public void setPrice(BigDecimal price) { this.price = price; } }

统一返回体:

// 文件路径:src/main/java/com/example/demo/common/Result.java public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.code = 500; result.message = message; return result; } // getter / setter 省略,建议使用 lombok 简化 }

限流器,就是前面提到的时间窗口本地版:

// 文件路径:src/main/java/com/example/demo/limit/KeyRateLimiter.java @Component public class KeyRateLimiter { private final ConcurrentHashMap<String, Deque<Long>> visitWindow = new ConcurrentHashMap<>(); private static final int LIMIT = 50; private static final long WINDOW_MILLIS = 60_000L; public boolean isOverLimit(String key) { long now = System.currentTimeMillis(); Deque<Long> deque = visitWindow.computeIfAbsent(key, k -> new ArrayDeque<>()); synchronized (deque) { while (!deque.isEmpty() && now - deque.peekFirst() > WINDOW_MILLIS) { deque.pollFirst(); } if (deque.size() >= LIMIT) { return true; } deque.addLast(now); } return false; } }

产品 Service,组合了参数校验、缓存空值和限流逻辑:

// 文件路径:src/main/java/com/example/demo/service/ProductService.java @Service public class ProductService { private static final String PRODUCT_CACHE_PREFIX = "product:detail:"; private static final String EMPTY_MARK = "EMPTY"; @Autowired private StringRedisTemplate redisTemplate; @Autowired private JdbcTemplate jdbcTemplate; @Autowired private KeyRateLimiter keyRateLimiter; private final ObjectMapper objectMapper = new ObjectMapper(); public Product getProductById(Long id) { if (id == null || id <= 0) { return null; } String cacheKey = PRODUCT_CACHE_PREFIX + id; String rateLimitKey = "rate:product:" + id; if (keyRateLimiter.isOverLimit(rateLimitKey)) { throw new RuntimeException("请求过于频繁,请稍后重试"); } String cachedValue = redisTemplate.opsForValue().get(cacheKey); if (cachedValue != null) { if (EMPTY_MARK.equals(cachedValue)) { return null; } try { return objectMapper.readValue(cachedValue, Product.class); } catch (JsonProcessingException e) { redisTemplate.delete(cacheKey); } } Product product = queryFromDb(id); if (product == null) { int emptyCacheSeconds = 180 + new Random().nextInt(120); redisTemplate.opsForValue().set(cacheKey, EMPTY_MARK, emptyCacheSeconds, TimeUnit.SECONDS); return null; } int realCacheSeconds = 1800 + new Random().nextInt(300); try { String json = objectMapper.writeValueAsString(product); redisTemplate.opsForValue().set(cacheKey, json, realCacheSeconds, TimeUnit.SECONDS); } catch (JsonProcessingException e) { throw new RuntimeException("商品序列化失败", e); } return product; } private Product queryFromDb(Long id) { try { return jdbcTemplate.queryForObject( "SELECT id, name, price FROM product WHERE id = ?", new BeanPropertyRowMapper<>(Product.class), id ); } catch (EmptyResultDataAccessException e) { return null; } } }

Controller:

// 文件路径:src/main/java/com/example/demo/controller/ProductController.java @RestController @RequestMapping("/product") public class ProductController { @Autowired private ProductService productService; @GetMapping("/{id}") public Result<Product> getProduct(@PathVariable Long id) { if (id == null || id <= 0 || id > 999999999L) { return Result.error("无效的商品 ID"); } try { Product product = productService.getProductById(id); if (product == null) { return Result.error("商品不存在"); } return Result.success(product); } catch (RuntimeException e) { return Result.error(e.getMessage()); } } }

8.3 可选的布隆过滤器接入方式

如果你在真实项目中决定使用布隆过滤器,可以在上述工程基础上增加ProductBloomFilter类,然后在ProductService.getProductById的最前面增加一次判断。注意,只有在你能保证商品 ID 增量同步到过滤器时才建议开启,否则不要贸然接进去。

// 文件路径:src/main/java/com/example/demo/filter/ProductBloomFilter.java @Component public class ProductBloomFilter { private static final int EXPECTED_INSERTIONS = 100000; private static final double FPP = 0.01; private final BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(), EXPECTED_INSERTIONS, FPP); @Autowired private JdbcTemplate jdbcTemplate; @PostConstruct public void init() { List<Long> ids = jdbcTemplate.queryForList( "SELECT id FROM product", Long.class ); ids.forEach(bloomFilter::put); } public boolean mightContain(Long id) { return bloomFilter.mightContain(id); } }

接入查询服务:

@Autowired private ProductBloomFilter productBloomFilter; public Product getProductById(Long id) { if (id == null || id <= 0) { return null; } if (!productBloomFilter.mightContain(id)) { return null; } // 其余逻辑不变 }

启动类:

// 文件路径:src/main/java/com/example/demo/CachePenetrationDemoApplication.java @SpringBootApplication public class CachePenetrationDemoApplication { public static void main(String[] args) { SpringApplication.run(CachePenetrationDemoApplication.class, args); } }

8.4 初始化数据表

执行下面的 SQL 创建商品表,并插入两条测试数据:

CREATE TABLE product ( id BIGINT PRIMARY KEY, name VARCHAR(64) NOT NULL, price DECIMAL(10, 2) NOT NULL ); INSERT INTO product (id, name, price) VALUES (1, 'iPhone 15 Pro', 8999.00), (2, 'MacBook Pro 14', 14999.00);

9. 运行结果与效果验证

启动 Redis 和 MySQL 后,在项目根目录执行:

mvn spring-boot:run

应用启动成功后,用 curl 验证效果。

第一次查询存在的商品 ID:

curl "http://localhost:8080/product/1"

预期返回:

{"code":200,"message":"success","data":{"id":1,"name":"iPhone 15 Pro","price":8999.00}}

连续查询第二次,Redis 中已经有缓存,观察日志会发现不会再次查询数据库。

查询不存在的商品 ID:

curl "http://localhost:8080/product/999"

第一次会查数据库,并写入空值缓存。预期返回:

{"code":500,"message":"商品不存在","data":null}

然后再次请求相同的 ID,预期返回结果相同,但数据库不会收到新的查询。可以到 Redis 中确认空值 key 已写入:

redis-cli 127.0.0.1:6379> GET product:detail:999 "EMPTY" 127.0.0.1:6379> TTL product:detail:999 (integer) 172

验证限流:在短时间内用同一个不存在的 ID 连续请求超过 50 次,预期会看到接口返回“请求过于频繁,请稍后重试”。

如果需要模拟真实压测效果,可以用ab等工具对同一个不存在的 ID 发起高频请求,同时观察数据库的查询日志或者慢日志。你会发现,在没有空值缓存之前,每一次请求都会落到数据库;而接入空值缓存和限流之后,除了第一批请求,后续请求都被 Redis 和限流逻辑拦截了。

ab -n 10000 -c 100 "http://localhost:8080/product/999"

判断成功的标准有三个:第一,应用日志中 MySQL 查询次数不再随请求数线性增长;第二,Redis 中空值 key 数量保持稳定;第三,数据库连接池水位和 QPS 没有明显波动。

10. 常见问题与排查思路

下面整理了缓存穿透防护落地过程中比较常见的几类问题,大家可以对照排查。

问题现象可能原因排查方式解决方案
Redis 内存快速上涨空值 key 过多redis-cli --bigkeysSCAN统计空值前缀 key 数量降低空值过期时间,写清理任务清理null:前缀 key,或考虑布隆过滤器前置拦截
新上架商品查询返回“不存在”布隆过滤器没有同步增量 ID查看商品创建方法中是否调用了put方法在创建入口同步更新过滤器,或采用定时全量重建策略
正常用户请求被限流误伤限流阈值设置过低查看限流日志和 QPS 峰值根据压测数据调整阈值,对抢购热点单独配置,或改用令牌桶算法
空值缓存过期后再次打爆数据库空值时间太短,攻击流量持续查看 Redis 空值 key 的过期时间和 DB QPS 曲线调长空值缓存时间并加随机抖动,叠加布隆过滤器拦截
并发第一次请求时数据库瞬间冲高缓存未命中时没有互斥重建查看 DB QPS 峰值与 Redis key 过期时间是否对应对缓存重建增加分布式锁,同一 key 只允许一个线程查 DB
商品数据库更新后缓存还是旧值缓存没有及时失效检查更新方法是否删除缓存采用 Cache Aside 模式:先更新数据库,再删除缓存,并做好重试机制

排查时有一个基本顺序:先看 Redis 缓存是否生效,再看空值 key 是否存在,再看限流日志,最后才去翻数据库慢查询日志。很多问题并不是第一层防线失效,而是多层防线中的某一层配置出了问题,逐层检查能快速缩小范围。

11. 生产环境最佳实践:从“能防”到“防得住”

方案落地之后,更重要的是把防护体系变成可持续运行的生产能力。从工程实践角度看,有四个方向值得投入。

第一个方向是监控指标。缓存穿透防护是否生效,不能靠感觉,要靠数据。建议至少监控以下指标:Redis 缓存命中率、数据库查询 QPS、空值 key 数量、限流拒绝次数。其中,空值 key 数量是一个很敏感的指标,如果它在短时间内急剧增长,说明要么有攻击流量,要么业务上出现了大量无效查询,需要第一时间告警。

第二个方向是缓存过期时间的随机性。无论是真实数据还是空值缓存,过期时间都要加随机抖动。如果所有 key 都在同一秒过期,缓存穿透问题还没解决,就可能先引发缓存雪崩。随机值的范围可以参考基础过期时间的 10% 到 30%。

第三个方向是优雅降级。在极端情况下,即使有缓存空值和布隆过滤器,数据库仍可能因为瞬时流量过大而濒临崩溃。此时需要应用层具备快速降级能力:对非核心接口直接返回降级数据,对核心接口启用简化版查询,必要时直接熔断数据库查询,先保住系统可用性,再恢复数据一致性。

第四个方向是全链路压测。不要在事故发生后才验证方案是否有效。上线前,用 ab、JMeter 或 Locust 模拟高频穿透流量,观察数据库 QPS 和连接池水位。压测目标不是“不报错”,而是“在攻击流量翻倍时数据库依然有冗余容量”。如果你发现数据库 QPS 已经打满,说明防线层级不够,需要继续往前加拦截。

第五个方向是安全边界。对于公网接口,建议在网关层增加 IP 维度的限流和黑名单机制,并记录攻击特征。对明显的遍历行为,比如短时间内大量不同 ID 的 404 请求,可以自动识别并加入临时黑名单。这些机制的实现成本和运维成本不低,但一旦遇到恶意攻击,它们往往是保护数据库不被拖垮的关键。

12. 最后要说的话

缓存穿透从来都不是缓存的错,而是你的系统在数据访问链路上缺少“安保意识”。如果把数据库比作一家银行的金库,缓存就是金库外面的接待大厅,而布隆过滤器是大门处的安检员,缓存空值是给“找不到人”的情况做的登记簿,限流则是保安队伍。每一层防线都有自己的作用,少了任何一层,金库的直接暴露风险都会升高。

如果你是在接手一个老项目,我建议先不要急着把三种方案全部堆上去。第一步,翻一翻线上日志,确认是否存在大量“查无此数据”的重复请求;第二步,把接口参数校验补上,给每个读接口加一个合理的边界约束;第三步,实现缓存空值,这是投入产出比最高的一步。等观察一段时间,确认确实存在恶意穿透流量,再逐步引入布隆过滤器和分布式限流。

数据库是慢变量,保护它的关键永远是把流量挡在更前面的位置。希望这篇文章能帮你少踩一个洞,也少熬一个夜。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询