从缓存空值到布隆过滤器:构建高并发缓存穿透防护体系
2026/9/8 8:41:56 网站建设 项目流程

最近在 Code Review 时看到一句话:“用缓存空值解决缓存穿透的,都是初学者。” 这句话虽然有点绝对,但背后确实点出了一个很常见的问题:很多团队一遇到缓存穿透,第一反应就是“把空值也缓存起来”,却很少去思考这个方案在高并发、大数据量场景下的副作用。缓存空值并不是不能用,而是要搞清楚它适合什么场景,以及它和布隆过滤器、互斥锁、参数校验这些手段之间的边界在哪里。

这篇文章会从缓存穿透的本质讲起,分析缓存空值方案的原理和隐患,再给出布隆过滤器、互斥锁、请求校验等多种方案的对比,最后用一个完整的 Java 示例把几种手段组合起来,做成一个相对健壮的缓存穿透防护方案。无论你是刚接触 Redis 的初学者,还是已经在生产环境维护缓存服务的开发者,都能从中找到可以参考的落地思路。

1. 缓存穿透到底是怎么发生的

1.1 先从一条正常的缓存链路说起

在讲缓存穿透之前,先回顾一下大多数业务系统里的缓存访问流程。

假设我们有一个商品详情接口,用户通过商品 ID 查询商品信息。在没有缓存的情况下,每次请求都会直接查询数据库,数据库压力会随着请求量线性增长。引入 Redis 之后,流程变成了这样:

  1. 请求到达服务端,先查 Redis。
  2. Redis 中命中了数据,直接返回。
  3. Redis 中没有命中,查询数据库。
  4. 数据库中存在数据,把数据写回 Redis,设置过期时间。
  5. 返回数据给调用方。

这个流程本身没有问题,它能挡住大多数重复查询。但有一个特殊情况:如果某个商品 ID 在数据库中根本不存在,那么 Redis 中永远不会有它的缓存,于是每次请求这个不存在的 ID 时,都会穿透 Redis,直接打到数据库上。

1.2 缓存穿透的定义

缓存穿透指的就是:查询一个数据库中不存在的数据,因为缓存中也没有,所以每次请求都会绕过缓存,直接访问数据库。当这种请求量很大时,数据库会被大量无效查询拖垮。

这里要注意区分穿透、击穿和雪崩,很多文章会把这三个概念混在一起:

缓存穿透:查询一个不存在的数据,缓存和数据库都没有。绕过缓存打到数据库。

缓存击穿:某个热点 key 在缓存过期的瞬间,大量请求同时访问数据库。缓存里有数据,但刚好过期了。

缓存雪崩:大量 key 在同一时间段内集中过期,导致大量请求同时访问数据库。

三者的共同点是“大量请求打到数据库”,但原因完全不同。缓存穿透是数据本身不存在,击穿是热点 key 过期,雪崩是大面积 key 过期。搞清楚这个区别,才能对症下药。

1.3 哪些场景容易引发缓存穿透

缓存穿透不是只会出现在恶意攻击场景里,日常业务中也经常遇到:

用户查询不存在的订单:用户手动输入了一个错误的订单号,或者订单已经被删除。

恶意请求遍历 ID:攻击者用脚本连续请求不存在的 ID,比如 1、2、3……一直往后遍历,造成大量无效查询。

内部系统数据不一致:调用方传入的 ID 超出了正常范围,或者上游系统写入了一条脏数据。

运营后台误操作:管理端删除了一条业务数据,但调用方还持有旧 ID,短时间内不断重试。

在这些场景下,如果没有防护措施,数据库就会白白承受大量无用查询。更麻烦的是,如果查询本身就是慢 SQL,比如对一个大表做全表扫描,那么穿透请求很可能直接把数据库连接池打满。

2. 缓存空值方案:原理与隐患

2.1 缓存空值的基本思路

缓存空值的想法非常朴素:既然数据库里没有这条数据,那我就在 Redis 里也存一个“空标记”,下次再来查这个 key,发现缓存里是空值,就直接返回,不再查数据库。

举个例子:

用户查询商品 ID=10086,数据库里没有这条记录。

服务端把 key=product:10086,value=null 写入 Redis,并设置一个较短的过期时间,比如 60 秒。

后续 60 秒内,再有请求查询 ID=10086,直接命中 Redis 的空缓存,返回“商品不存在”。

代码如下:

public Product getProductById(Long id) { String cacheKey = "product:" + id; // 1. 先查缓存 String cacheValue = redisTemplate.opsForValue().get(cacheKey); // 2. 缓存命中,直接返回 if (cacheValue != null) { // 这里需要判断是否为空标记 if ("EMPTY".equals(cacheValue)) { return null; } return JSON.parseObject(cacheValue, Product.class); } // 3. 缓存未命中,查数据库 Product product = productMapper.selectById(id); // 4. 数据库也没查到,缓存空值 if (product == null) { redisTemplate.opsForValue().set(cacheKey, "EMPTY", 60, TimeUnit.SECONDS); return null; } // 5. 查到数据,写缓存 redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 3600, TimeUnit.SECONDS); return product; }

这段代码看起来没什么问题,也能解决一部分穿透请求。但从工程角度看,它有几个明显的短板。

2.2 缓存空值的三大隐患

隐患一:无效 key 占用 Redis 内存

如果一个攻击者恶意遍历 ID,假设每秒生成一亿个不存在的 ID,那么服务端就会在 Redis 里写入一亿个空缓存。即便每个 key 只占几十字节,总体内存消耗也非常可观。更危险的是,这些空缓存会不断写入新的 key,可能导致 Redis 内存被占满,进而触发内存淘汰策略,把正常业务缓存挤掉。

要缓解这个问题,只能给空缓存设置较短的过期时间,比如 30 到 60 秒。但这样一来,攻击者可以等空缓存过期后再次发起穿透请求,防护效果就会打折。

隐患二:空值语义不清晰

数据库返回 null 可能有很多种含义:数据不存在、数据尚未生成、数据被逻辑删除、调用方权限不足等。如果全部统一缓存为 EMPTY,下游调用方就无法区分这些情况。

举个实际案例:某个订单系统里,订单创建后需要异步处理,处理完成前订单数据不存在。如果此时来了查询请求,服务端把空值缓存了 5 分钟,那么订单处理完成后的 5 分钟内,用户查询到的仍然是“订单不存在”,造成数据不一致。

隐患三:缓存与数据库的一致性变复杂

正常缓存只要在更新数据时删除或更新缓存即可,空缓存则要求业务在数据真正写入时,主动删除对应的空标记。如果漏删,就会出现数据已经存在、但缓存还返回空值的情况。很多团队在接入消息队列后,数据更新是通过 Binlog 监听进行的,空缓存的删除逻辑很容易被遗漏。

2.3 为什么说“只用缓存空值”不够工程化

单个缓存空值实现起来很简单,但它只是个“补救手段”,不算一个完整的防护方案。真正的问题是:缓存空值本身无法判断哪些 key 是合法的、哪些 key 是恶意的。它只能把“每次穿透数据库”变成“每 60 秒穿透一次数据库”,并没有从源头过滤掉无效请求。

所以在中大型项目中,更合理的做法是:先用布隆过滤器做第一层拦截,把不存在的 key 挡在门外;再用缓存空值作为兜底,处理那些通过布隆过滤器但数据库仍然没有数据的少数情况;最后配合参数校验、限流和监控告警,形成完整的防线。

3. 布隆过滤器:从源头拦截不存在的 key

3.1 布隆过滤器是什么

布隆过滤器是一种空间效率很高的概率型数据结构,它可以告诉你“某个元素一定不存在”或者“某个元素可能存在”。注意这里的关键词:可能存在。布隆过滤器不会漏报,但会有一定的误判率。

它的核心原理是:

初始化一个很长的二进制数组,所有位都置为 0。

插入元素时,用多个哈希函数对元素计算哈希值,得到多个数组下标,把这些下标对应的位都置为 1。

查询元素时,同样计算多个哈希值,检查这些下标对应的位是否都为 1。如果有一个位是 0,说明元素一定不存在;如果全部是 1,说明元素可能存在(因为可能有其他元素占用了这些位)。

用通俗的话解释:布隆过滤器就像一张“大名单”,你把所有已知的商品 ID 都登记上去。查询 ID 时,如果名单里查不到,那这个 ID 肯定不在名单里,就不用去数据库了;如果名单里查得到,也不能完全确定它真实存在,因为可能只是哈希碰撞导致的误判。

3.2 用布隆过滤器解决缓存穿透

使用方式是在缓存查询之前加一道过滤:

  1. 系统启动时,把数据库中的所有商品 ID 加载到布隆过滤器里。
  2. 请求进来后,先用布隆过滤器判断 ID 是否存在。
  3. 如果不存在,直接返回,不查缓存、不查数据库。
  4. 如果存在,继续走原有的缓存查询流程。

这样的好处是:大部分不存在的 ID 会在第一层被拦截,数据库几乎不会被无效请求打到。相比缓存空值,布隆过滤器不会往 Redis 里写入大量空 key,内存占用更稳定。

3.3 Redis 中的布隆过滤器实现

布隆过滤器可以用 Redis 原生实现,也可以使用 Redisson 封装好的 RBloomFilter。如果你使用的是 Redis 4.0 及以上版本,还可以通过 RedisBloom 模块直接使用 BF.ADD、BF.EXISTS 等命令。

下面是 Redisson 的使用示例:

@Configuration public class BloomFilterConfig { @Bean public RBloomFilter<Long> productBloomFilter(RedissonClient redissonClient) { RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("productBloomFilter"); // 初始化布隆过滤器,预计元素数量为 100000,误判率为 0.01 bloomFilter.tryInit(100000L, 0.01); return bloomFilter; } }

在业务代码中,查询前先判断:

public Product getProductById(Long id) { // 1. 布隆过滤器拦截不存在的 ID if (!productBloomFilter.contains(id)) { return null; } // 2. 后续走正常的缓存查询逻辑 String cacheKey = "product:" + id; // ... }

需要注意的是,布隆过滤器对“新增数据”的响应有延迟。如果系统启动时只加载了存量 ID,之后新创建的商品 ID 没有同步到布隆过滤器里,就会出现“数据已存在但布隆过滤器判断不存在”的问题。因此,生产环境需要在商品创建时同步更新布隆过滤器,或者定期重建。

3.4 布隆过滤器的局限性

布隆过滤器不是万能的,它有这几个问题:

  • 无法删除元素:标准的布隆过滤器不支持删除操作,因为一个二进制位可能被多个元素共享。如果要删除,需要使用 Counting Bloom Filter 变种。这意味着当商品 ID 被删除时,布隆过滤器里依然保留它,无法主动移除。
  • 有误判率:布隆过滤器会把一部分不存在的元素判断为“可能存在”,这些请求仍然会穿透到数据库。
  • 需要提前初始化:如果没有提前加载全量数据,就会出现误判为不存在的场景。

正因为布隆过滤器存在这些局限,它通常和缓存空值搭配使用,而不是互相替代。

4. 互斥锁与逻辑过期:解决热点 key 场景

4.1 互斥锁解决缓存击穿

缓存穿透的一种极端情况是热点 key 过期。假设某个商品 ID 是真实存在的,而且访问量极高,那么在缓存过期的瞬间,大量请求同时发现缓存没有数据,会一起打到数据库。这种现象也叫缓存击穿,可以理解为“单个 key 的穿透”。

解决思路是加互斥锁:在缓存未命中时,只允许一个线程去查数据库并重建缓存,其他线程等待缓存重建完成后再读取。

Redis 中可以使用 SETNX 命令模拟分布式锁:

public Product getProductByIdWithLock(Long id) { String cacheKey = "product:" + id; String cacheValue = redisTemplate.opsForValue().get(cacheKey); if (cacheValue != null) { return JSON.parseObject(cacheValue, Product.class); } String lockKey = "lock:product:" + id; String requestId = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 3, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 二次检查缓存,防止等待期间缓存已被其他线程重建 cacheValue = redisTemplate.opsForValue().get(cacheKey); if (cacheValue != null) { return JSON.parseObject(cacheValue, Product.class); } Product product = productMapper.selectById(id); if (product == null) { // 这里可以配合缓存空值或布隆过滤器 return null; } redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 3600, TimeUnit.SECONDS); return product; } finally { // 只删除自己加的锁,避免误删其他线程的锁 String lockValue = redisTemplate.opsForValue().get(lockKey); if (requestId.equals(lockValue)) { redisTemplate.delete(lockKey); } } } // 没有拿到锁,休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductByIdWithLock(id); }

这段代码实现了互斥重建缓存的效果,适合热点 key 的缓存击穿场景。但需要注意,锁的粒度是每个商品 ID 一个锁,不要把多个 key 共用一把锁,否则会导致大量请求互相阻塞。

4.2 逻辑过期方案

逻辑过期是另一种解决缓存击穿的方式,它的思路是:缓存里不设置真正的过期时间,而是在 value 中额外保存一个过期时间戳。查询时判断逻辑时间是否过期,如果过期,则尝试获取锁,由拿到锁的线程异步重建缓存。

逻辑过期方案的优点是查询请求不会被阻塞,即使缓存已过期,也能立刻返回旧数据;缺点是实现复杂,需要额外的过期时间字段,而且可能短暂返回旧数据。

public Product getProductByIdLogicExpire(Long id) { String cacheKey = "product:" + id; String cacheValue = redisTemplate.opsForValue().get(cacheKey); if (cacheValue == null) { // 缓存不存在,可能是数据库没有该数据,需要结合布隆过滤器判断 return null; } // 解析缓存内容 CacheData<Product> cacheData = JSON.parseObject(cacheValue, new TypeReference<CacheData<Product>>() {}); long currentTime = System.currentTimeMillis(); // 逻辑未过期,直接返回 if (cacheData.getExpireTime() > currentTime) { return cacheData.getData(); } // 逻辑过期,获取锁后异步重建缓存 String lockKey = "lock:product:" + id; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (locked) { // 开启异步线程重建缓存 CompletableFuture.runAsync(() -> { try { Product product = productMapper.selectById(id); if (product != null) { CacheData<Product> newCacheData = new CacheData<>(product, System.currentTimeMillis() + 3600 * 1000); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(newCacheData)); } } finally { redisTemplate.delete(lockKey); } }); } // 先返回旧数据 return cacheData.getData(); }

逻辑过期方案更贴近线上高并发场景,但要注意线程池的隔离,避免异步任务占满业务线程池。

5. 请求参数校验与限流兜底

5.1 参数合法性校验

很多缓存穿透是由非法请求参数引起的。比如商品 ID 应该是正数,但调用方传入了负数;订单号应该符合特定格式,但调用方传入了随机字符串。如果能在接口入口做一次参数校验,很多无效请求根本不会进入缓存和数据库。

使用 Spring Boot 的 Validation 注解可以简单实现:

@GetMapping("/product/{id}") public Product getProduct(@PathVariable @Min(value = 1, message = "商品ID必须为正数") Long id) { return productService.getProductById(id); }

除了参数范围校验,还可以校验 ID 格式、请求签名、用户权限。对于内部服务之间的调用,推荐在 API 网关层统一做基础校验,业务服务内部再做一次完整校验。

5.2 限流与降级

即使有了布隆过滤器和参数校验,仍然可能会出现突发流量。比如活动期间大量用户同时访问同一个商品,或者调用方出现 Bug,无限循环调用同一个接口。此时需要通过限流来保护数据库。

常见的限流策略包括:单机限流,使用 Guava RateLimiter 或 Resilience4j;分布式限流,使用 Redis + Lua 脚本实现令牌桶或滑动窗口。

下面是基于 Redis 的简单限流示例:

public boolean tryAcquire(String key, int limit, int windowSeconds) { String luaScript = "local current = redis.call('incr', KEYS[1]) " + "if current == 1 then " + " redis.call('expire', KEYS[1], ARGV[1]) " + "end " + "if current > tonumber(ARGV[2]) then " + " return 0 " + "else " + " return 1 " + "end"; Long result = redisTemplate.execute( new DefaultRedisScript<>(luaScript, Long.class), Collections.singletonList(key), windowSeconds, limit ); return Long.valueOf(1).equals(result); }

限流不能完全避免缓存穿透,但它能保证即使在最坏情况下,数据库也不会被无限打爆,是最后一道安全兜底。

5.3 缓存空值与布隆过滤器的组合策略

讲完各种方案后,来梳理一下它们的关系:

参数校验是第一层,拦截非法参数。

布隆过滤器是第二层,拦截绝大多数不存在的 ID。

缓存空值是第三层,兜住布隆过滤器误判之后仍然打到数据库的请求。

互斥锁或逻辑过期是第四层,解决热点 key 缓存过期后的击穿问题。

限流是整个系统的最后防线。

这四层不是必须全部实现,而是要根据业务规模和请求特征选择。如果业务非常简单,数据库压力不大,只做参数校验和缓存空值就够了;如果是高并发核心链路,建议完整组合。

6. 完整实战:组合方案最小实现

6.1 场景假设

假设我们有一个商品查询接口,要求:

  • 商品 ID 必须为正整数。
  • 不存在的商品 ID 不会穿透数据库。
  • 热点商品缓存过期时,数据库不会被打垮。
  • 空缓存只保留很短的时间,避免占用过多内存。

使用 Spring Boot + Redis + Redisson 实现。

6.2 项目结构

src/main/java/com/example/cache/ ├── CacheApplication.java ├── config/ │ ├── RedisConfig.java │ └── BloomFilterConfig.java ├── controller/ │ └── ProductController.java ├── service/ │ ├── ProductService.java │ └── ProductServiceImpl.java ├── mapper/ │ └── ProductMapper.java ├── entity/ │ └── Product.java └── common/ └── CacheData.java src/main/resources/ └── application.yml

6.3 核心代码

先看项目配置文件:

spring: data: redis: host: localhost port: 6379 timeout: 3000ms redisson: config: | singleServerConfig: address: "redis://localhost:6379"

然后看一下布隆过滤器配置:

// 文件路径:src/main/java/com/example/cache/config/BloomFilterConfig.java @Configuration public class BloomFilterConfig { @Bean public RBloomFilter<Long> productBloomFilter(RedissonClient redissonClient) { RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("productBloomFilter"); // 预计数据量 100000,误判率 1% bloomFilter.tryInit(100000L, 0.01); return bloomFilter; } }

接下来是业务实现:

// 文件路径:src/main/java/com/example/cache/service/ProductServiceImpl.java @Service public class ProductServiceImpl implements ProductService { private static final String CACHE_KEY_PREFIX = "product:"; private static final String EMPTY_VALUE = "EMPTY"; private static final long EMPTY_TTL_SECONDS = 30; private static final long NORMAL_TTL_SECONDS = 3600; @Autowired private RedisTemplate<String, String> redisTemplate; @Autowired private ProductMapper productMapper; @Autowired private RBloomFilter<Long> productBloomFilter; @Override public Product getProductById(Long id) { // 第一层:参数校验 if (id == null || id <= 0) { throw new IllegalArgumentException("商品ID不合法"); } // 第二层:布隆过滤器拦截不存在的 ID if (!productBloomFilter.contains(id)) { return null; } String cacheKey = CACHE_KEY_PREFIX + id; // 第三层:读取缓存 String cacheValue = redisTemplate.opsForValue().get(cacheKey); if (cacheValue != null) { if (EMPTY_VALUE.equals(cacheValue)) { return null; } return JSON.parseObject(cacheValue, Product.class); } // 第四层:互斥锁解决缓存击穿 String lockKey = "lock:" + cacheKey; String requestId = UUID.randomUUID().toString(); boolean locked = Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 3, TimeUnit.SECONDS) ); if (locked) { try { // 二次检查缓存 cacheValue = redisTemplate.opsForValue().get(cacheKey); if (cacheValue != null) { if (EMPTY_VALUE.equals(cacheValue)) { return null; } return JSON.parseObject(cacheValue, Product.class); } // 查询数据库 Product product = productMapper.selectById(id); if (product == null) { // 第五层:缓存空值,并设置较短 TTL redisTemplate.opsForValue().set(cacheKey, EMPTY_VALUE, EMPTY_TTL_SECONDS, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), NORMAL_TTL_SECONDS, TimeUnit.SECONDS); return product; } finally { // 释放锁,只释放当前线程持有的锁 String lockValue = redisTemplate.opsForValue().get(lockKey); if (requestId.equals(lockValue)) { redisTemplate.delete(lockKey); } } } // 未获取到锁,休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductById(id); } }

这段代码把参数校验、布隆过滤器、缓存空值、互斥锁整合在了一起,每一层的职责都很清晰。

6.4 数据初始化

系统启动后,需要把所有存在的商品 ID 加载进布隆过滤器。可以通过 ApplicationRunner 实现:

// 文件路径:src/main/java/com/example/cache/config/DataInitializer.java @Component public class DataInitializer implements ApplicationRunner { @Autowired private RBloomFilter<Long> productBloomFilter; @Autowired private ProductMapper productMapper; @Override public void run(ApplicationArguments args) { List<Long> productIds = productMapper.selectAllIds(); for (Long id : productIds) { productBloomFilter.add(id); } log.info("布隆过滤器初始化完成,共加载 {} 个商品ID", productIds.size()); } }

商品创建时,也需要同步把新 ID 添加进布隆过滤器:

public void createProduct(Product product) { productMapper.insert(product); productBloomFilter.add(product.getId()); }

6.5 运行与验证

启动服务后,可以用 curl 测试:

# 请求一个不存在的 ID,观察是否直接返回 curl http://localhost:8080/product/99999 # 请求一个合法 ID curl http://localhost:8080/product/1

在测试环境,可以关闭布隆过滤器,只保留缓存空值,对比两种情况下数据库的查询次数,能直观看到布隆过滤器的拦截效果。

7. 常见问题与排查思路

在实际使用缓存穿透防护方案时,下面这些问题是出现频率较高的,可以先收藏起来,遇到类似情况时快速对照排查。

问题现象常见原因解决思路
布隆过滤器误杀正常数据新数据没有同步到布隆过滤器在数据创建时同步调用 add,或定期重建过滤器
缓存空值导致数据不一致空值 TTL 过长,业务数据已写入但缓存仍返回空缩短空值 TTL,数据写入时主动删除空缓存
Redis 内存增长过快空缓存 key 过多,没有设置合理的 TTL为不同场景设置不同的空值过期时间,配合布隆过滤器
热点 key 过期后数据库压力突增没有互斥锁或逻辑过期保护增加 SETNX 互斥锁,或改用逻辑过期方案
锁误删问题释放锁时没有校验持有者,删除了其他线程的锁使用 requestId 校验,只删除自己加的锁
布隆过滤器误判率过高预计元素数量设置偏小,或哈希函数数量不合适调大预计元素数量,降低误判率,或改用 Counting Bloom Filter
异步重建缓存出现重复查询锁过期时间太短,线程执行时间超过锁持有时间合理设置锁过期时间,或使用 Redisson 的看门狗机制

其中,布隆过滤器的误杀问题在工程中尤其常见。很多团队只在启动时加载了一次数据,后续新产生的数据没有及时加入过滤器,导致新用户、新订单被误判为不存在。解决办法有两种:一是在写入数据库的业务代码中同步更新布隆过滤器;二是周期性重建布隆过滤器,比如每 5 分钟把最近新增的数据重新加载一遍。

另外,缓存空值和布隆过滤器一起使用时,要注意缓存 key 的规范。建议统一使用业务前缀加 ID 的格式,比如 product:123,并在缓存服务层面做好 key 的拆分和分类,方便排查问题。

8. 工程最佳实践与设计建议

8.1 根据业务规模选择方案

不是所有业务都需要完整组合布隆过滤器、互斥锁、限流。这里给出一个参考:

  • 小型项目、内部管理系统:数据库压力小,使用参数校验 + 缓存空值即可,空值 TTL 设置 30 到 60 秒。
  • 中型互联网项目:推荐增加布隆过滤器,解决大量不存在的 key 穿透问题,同时配合缓存空值兜底。
  • 大型高并发核心链路:在上述基础上,增加互斥锁或逻辑过期、限流、监控告警,形成完整防护体系。

8.2 缓存空值的 TTL 设计

如果决定使用缓存空值,TTL 不宜太长,也不宜太短。太长会导致数据不一致,太短则防护效果有限。常见经验值:

普通业务空值:30 到 60 秒。

高频访问场景空值:可以延长到 5 分钟,但必须确保业务写入时能主动删除。

动态生成数据的场景:建议不缓存空值,改用布隆过滤器结合延迟重试。

8.3 监控与告警

缓存穿透防护方案上线后,要关注以下监控指标:

数据库慢查询数量:如果穿透防护无效,慢查询会明显上升。

Redis 内存使用量:如果空缓存没有及时回收,内存曲线会持续上升。

布隆过滤器误判率:可以通过日志统计过滤后的请求数量。

缓存命中率:正常业务缓存命中率应该在 90% 以上,异常下降时要排查。

如果发现数据库查询量突然上升,第一时间检查是哪些 key 导致的,再确认是参数校验、布隆过滤器还是缓存空值没有生效。

8.4 生产环境变更流程

缓存防护方案涉及的配置和代码变更,在生产环境要遵循最小权限和灰度发布原则:

先在测试环境压测,模拟大量不存在的 ID 请求,观察数据库连接数和响应时间。

灰度一台机器,观察监控指标是否正常。

逐步放量,确认没有数据一致性问题和内存异常增长。

上线后持续观察 24 小时,尤其是空缓存回收和布隆过滤器误判情况。

8.5 团队规范

最后是工程层面的建议。缓存穿透防护不是某个开发同学一个人的事,团队里需要有统一约定:

明确空值缓存的 key 规范和使用场景,避免不同模块各自为战。

布隆过滤器的数据初始化逻辑必须放在应用的启动流程里,而且要有日志输出加载数量。

所有缓存查询的关键路径都要有 trace 日志,便于排查链路问题。

代码 Review 时,重点检查锁的释放逻辑、空值缓存的删除逻辑和数据写入时布隆过滤器的同步逻辑。

9. 总结与下一步方向

“用缓存空值解决缓存穿透的,都是初学者”这种说法虽然有些绝对,但它提醒我们:缓存空值只是缓存穿透防护体系中的一环,不是银弹。真正的工程化方案,是把参数校验、布隆过滤器、缓存空值、互斥锁、限流和监控组合起来,让每一层各司其职。

文中给出的组合示例是直接可运行的思路,你可以把它改造成适合自己业务的形式。下一步建议重点学习 Redis 底层数据结构、Redisson 的分布式锁实现,以及高并发场景下的缓存一致性保障。缓存穿透只是 Redis 使用中的一个经典问题,后续还有缓存一致性、多级缓存、缓存热 key 发现等技术点,都是深入掌握缓存体系的好方向。

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

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

立即咨询