
看到这个标题可能有些同学会觉得我在引战缓存空值不是网上最常见的缓存穿透解决方案吗怎么就成了“初学者”了先别急着反驳我们一起复盘一个真实场景。当业务系统遭遇大量恶意请求每秒钟几万个根本不存在的用户 ID 打到数据库很多人第一反应就是把“查不到”的结果也缓存起来也就是缓存空值。这套方案确实能降低数据库压力但它在内存占用、数据一致性、key 规模上都有天然缺陷。如果只靠这一招应对所有穿透场景就会出现更严重的问题。本文会从缓存穿透的原理讲起给出缓存空值的完整实现深度分析它的局限性再补充布隆过滤器、互斥锁等工程化方案。希望通过这篇文章你能真正理解“为什么说用缓存空值解决缓存穿透的都是初学者”并能在项目中做出合理选型。1. 什么是缓存穿透什么是缓存空值1.1 从一次故障说起假设有一个用户中心服务接口/api/user/{id}根据用户 ID 查询用户信息。正常的处理流程是先查 Redis命中就直接返回如果没有命中查询 MySQL然后把结果写回 Redis再返回给前端。请求 - 查缓存 Redis - 命中则返回 - 未命中则查 MySQL - 回填 Redis - 返回这套流程在数据都存在时没有问题。但如果前端传入一个不存在的 ID比如-1或者999999999Redis 里没有缓存MySQL 里也查不到记录。于是每次请求都会穿透缓存直达数据库。如果这样的请求量很大数据库连接会被耗尽最终拖垮整个服务。这种“查询一个一定不存在的数据导致请求穿过缓存打到数据库”的现象就是缓存穿透Cache Penetration。需要注意的是缓存穿透和缓存雪崩、缓存击穿经常一起被讨论缓存雪崩大量缓存同时过期或 Redis 宕机导致请求全部打到数据库。缓存击穿某个热点 key 在过期的瞬间大量并发请求同时穿透缓存去查询数据库。缓存穿透查询的数据本身就不存在缓存中永远不会命中请求每次都穿透到数据库。三者的触发场景和解决思路都不同刚接触缓存的开发者很容易混淆。1.2 缓存穿透的危害缓存穿透的危害可以从两个维度来看第一数据库压力放大。正常情况下一个热点数据只会回源查询一次后续请求都命中缓存。但在穿透场景中每一个请求都会真实地执行一次数据库查询。如果请求量是 10 万 QPS数据库就要承受 10 万次无效查询这是绝大多数关系型数据库无法承受的。第二恶意攻击成本极低。攻击者只需要不断构造不存在的 key就能轻易绕过缓存层把流量引导到数据库。由于这些 key 各不相同传统的缓存过期策略也起不到保护作用。更危险的是穿透请求还可能触发慢 SQL拖慢整个数据库连接池导致正常业务也受影响。所以在设计高并发系统时缓存穿透防护不是可选项而是必选项。这也是为什么很多架构师会把“缓存空值”“布隆过滤器”“互斥锁”等方案组合起来使用。1.3 缓存空值的实现原理缓存空值字面意思就是把“空的结果”也缓存起来。当数据库查询结果为 null 时我们不以“无缓存”结束而是向 Redis 写入一个空值标记同时设置一个较短的过期时间。请求 - 查缓存 Redis - 命中空值标记直接返回 null不走数据库 - 命中真实数据直接返回数据 - 未命中查数据库 - 数据库有数据回填真实数据 - 数据库无数据回填空值标记设置短 TTL这样下一次相同的 key 再来查询时缓存能够命中请求就会被拦截在 Redis 层不再打到数据库。这个方案的优点是实现简单没有额外引入第三方中间件适合作为第一版兜底方案。不过缓存空值只是“治标”的手段。它把不存在 key 的空结果缓存起来相当于用 Redis 内存换取数据库安全。一旦不存在的 key 数量很大内存就会成为新的瓶颈。这也是接下来我们要重点讨论的内容。2. 环境准备与版本说明为了让你能按照本文的示例实际操作这里给出一个基于 Spring Boot Redis 的完整演示环境。版本号可以参考你本机环境进行调整本文重点是实现思路而不是版本细节。推荐环境如下操作系统Windows / macOS / Linux 均可。JDK8 或 11。Spring Boot2.7.x。Redis5.0 及以上版本本地启动并默认监听 6379 端口。构建工具Maven 3.6。IDEIntelliJ IDEA 或 Eclipse。示例项目名称是cache-penetration-demo包名是com.example.cachepenetration。文中所有代码都会给出文件路径你可以按图索骥。3. 使用缓存空值解决缓存穿透的完整实现这一节我们先按“初学者”最常采用的方式把缓存空值方案完整实现一遍。只有先理解它才能更清醒地看到它的局限。3.1 项目结构创建 Spring Boot 项目后最终目录结构如下cache-penetration-demo ├── pom.xml └── src/main/java/com/example/cachepenetration ├── CachePenetrationApplication.java ├── config │ └── RedisConfig.java ├── controller │ └── UserController.java ├── entity │ └── User.java ├── repository │ └── FakeUserRepository.java └── service └── UserService.java为了简化示例这里用FakeUserRepository模拟数据库重点演示缓存层的处理逻辑。3.2 Maven 依赖在pom.xml中加入 Web、Redis、连接池和 Guava 依赖。Guava 会在后续布隆过滤器示例中使用。?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdcache-penetration-demo/artifactId version1.0.0/version namecache-penetration-demo/name description缓存空值与布隆过滤器解决缓存穿透示例/description properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version32.1.3-jre/version /dependency /dependencies /project3.3 配置文件在src/main/resources/application.yml中配置 Redis 连接信息。密码项没有时留空即可。server: port: 8080 spring: application: name: cache-penetration-demo redis: host: localhost port: 6379 password: timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2这里使用了 Lettuce 作为 Redis 客户端它是 Spring Boot 2.x 的默认客户端。commons-pool2提供了连接池支持避免高并发下连接数过多。3.4 Redis 序列化配置由于我们要向 Redis 中写入User对象和空字符串需要自定义RedisTemplate的序列化器。key 使用StringRedisSerializervalue 使用GenericJackson2JsonRedisSerializer这样存储到 Redis 中的数据是 JSON 格式方便排查问题。// 文件路径src/main/java/com/example/cachepenetration/config/RedisConfig.java package com.example.cachepenetration.config; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }这里之所以不直接使用StringRedisTemplate是因为它只能存字符串无法直接保存对象。自定义RedisTemplate后get 和 set 方法可以自动完成对象的 JSON 序列化与反序列化。3.5 实体类与模拟数据库新建用户实体类User。// 文件路径src/main/java/com/example/cachepenetration/entity/User.java package com.example.cachepenetration.entity; public class User { private Long id; private String name; private Integer age; 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 Integer getAge() { return age; } public void setAge(Integer age) { this.age age; } }再模拟一个数据库查询仓库FakeUserRepository。初始时只写入 ID 为 1 的用户查询其他 ID 都会返回 null。// 文件路径src/main/java/com/example/cachepenetration/repository/FakeUserRepository.java package com.example.cachepenetration.repository; import com.example.cachepenetration.entity.User; import org.springframework.stereotype.Component; import java.util.HashMap; import java.util.Map; Component public class FakeUserRepository { private final MapLong, User data new HashMap(); public FakeUserRepository() { User user new User(); user.setId(1L); user.setName(CSDN博主); user.setAge(18); data.put(1L, user); } public User findById(Long id) { // 模拟真实数据库查询耗时 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return data.get(id); } }真实项目中这里应该替换为 MyBatis、JPA 等 ORM 框架的 Mapper 方法。Thread.sleep(100)是为了演示数据库查询的耗时便于观察缓存命中前后的性能差异。3.6 核心业务逻辑缓存空值方案现在来实现重点的UserService。这里把空值标记设置为空字符串并且给空值设置 60 秒的过期时间正常数据设置 300 秒的过期时间。// 文件路径src/main/java/com/example/cachepenetration/service/UserService.java package com.example.cachepenetration.service; import com.example.cachepenetration.entity.User; import com.example.cachepenetration.repository.FakeUserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; Service public class UserService { private static final String CACHE_KEY_PREFIX user:; private static final String EMPTY_CACHE ; private static final long EMPTY_EXPIRE_SECONDS 60; private static final long NORMAL_EXPIRE_SECONDS 300; Autowired private RedisTemplateString, Object redisTemplate; Autowired private FakeUserRepository fakeUserRepository; public User getUserById(Long id) { String key CACHE_KEY_PREFIX id; // 1. 先查缓存 Object value redisTemplate.opsForValue().get(key); if (value ! null) { // 2. 命中空值标记直接返回 null if (value instanceof String ((String) value).isEmpty()) { return null; } // 3. 命中真实数据直接返回 return (User) value; } // 4. 缓存未命中查询数据库 User user fakeUserRepository.findById(id); // 5. 数据库有数据回填真实缓存 if (user ! null) { redisTemplate.opsForValue().set(key, user, NORMAL_EXPIRE_SECONDS, TimeUnit.SECONDS); return user; } // 6. 数据库无数据回填空值标记设置较短的过期时间 redisTemplate.opsForValue().set(key, EMPTY_CACHE, EMPTY_EXPIRE_SECONDS, TimeUnit.SECONDS); return null; } }这段代码的关键点是数据库返回 null 时不是直接返回结果而是写入一个空值标记。这样就保证了相同的“不存在 key”在 60 秒内不会再穿透到数据库。不过这里有一个并发问题当同一个不存在的 key 同时有大量请求进入时由于缓存未命中所有请求都会执行一次数据库查询。这在单机低并发场景下问题不大但高并发下可能触发缓存击穿。后面我们会用互斥锁来改善。3.7 控制器与启动类新建控制器UserController对外暴露查询接口。// 文件路径src/main/java/com/example/cachepenetration/controller/UserController.java package com.example.cachepenetration.controller; import com.example.cachepenetration.entity.User; import com.example.cachepenetration.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; GetMapping(/{id}) public ResponseEntityUser getUser(PathVariable Long id) { User user userService.getUserById(id); if (user null) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok(user); } }启动类保持 Spring Boot 默认结构// 文件路径src/main/java/com/example/cachepenetration/CachePenetrationApplication.java package com.example.cachepenetration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class CachePenetrationApplication { public static void main(String[] args) { SpringApplication.run(CachePenetrationApplication.class, args); } }3.8 运行与验证启动服务后先用 curl 请求一个存在的 IDcurl http://localhost:8080/api/user/1响应结果{id:1,name:CSDN博主,age:18}再请求一个不存在的 IDcurl http://localhost:8080/api/user/999999响应结果是 404。此时打开 Redis 客户端查看 key keys user:* 1) user:1 2) user:999999可以看到user:999999也被写入 Redisvalue 是空字符串。这就是缓存空值的基本效果。后续对user:999999的查询都不会再穿透数据库直到 60 秒后空值过期。4. 为什么说“用缓存空值解决的都是初学者”实现了上面的代码后很多人会觉得缓存空值挺香的。但如果只学会这一招遇到复杂业务就会踩坑。下面我们深入剖析缓存空值的局限性。4.1 缓存空值方案的三大短板第一内存占用不可控。缓存空值虽然保护了数据库但牺牲的是 Redis 内存。假设攻击者每秒构造一万个不同且不存在的 key每个 key 都写入空值并设置 60 秒过期那么一分钟内就会产生约 60 万个 key。如果每个 key 的存储空间按 80 字节估算1 小时就会产生上千万个 key内存消耗会快速达到瓶颈。更糟糕的是这些 key 大多是无效数据只为了拦截穿透而产生。第二存在数据不一致窗口。一旦某个 key 被缓存为空值在空值过期之前即使数据库中新写入了这条真实数据用户依然会读到“不存在”。比如商品服务中用户查询一个刚下架的商品 ID缓存了空值不久后运营重新上架该商品但空值缓存仍有效用户就会一直看到商品不存在直到缓存过期。对于价格敏感、状态频繁变化的场景这个问题不可接受。第三过期瞬间风险依然存在。空值缓存过期后如果同一时间有大量相同 key 的请求进来仍然会同时穿透到数据库。缓存空值只是把压力分散到了不同的时间点并没有从根上解决“热点不存在 key”在大并发下瞬间压垮数据库的问题。4.2 什么场景下缓存空值不可用缓存空值最适合的场景是不存在的 key 数量相对有限并且请求分布比较集中。比如用户 ID 是连续自增的客户端主要传一些合法范围内的 ID 或少量非法 ID这时缓存空值成本很低效果很好。但在下面这些场景中缓存空值就不适合作为主要方案key 无法枚举且基数巨大例如手机号、随机订单号、外部回调参数。攻击者可以无限生成新 key。数据库数据频繁新增且业务对实时性要求高。空值缓存会导致新增数据无法第一时间被查询到。Redis 内存本身紧张没有足够的空间承载大量空值 key。需要永久屏蔽某些非法参数的场景。每次重建空值缓存成本很高。4.3 一句客观评价回到标题我们说“用缓存空值解决缓存穿透的都是初学者”并不是否定缓存空值本身而是否定“只用缓存空值”这种思维。缓存空值是最容易上手的方案但一个合格的后端开发者应该能够分析业务场景、评估方案成本并选择合适的组合方案。只会缓存空值就像只会用锤子的人看到所有问题都像钉子。5. 更成熟的方案布隆过滤器与互斥锁解决缓存穿透业内常用的方案分为三类合法性校验、布隆过滤器、缓存空值。其中合法性校验属于业务层面比如检查参数格式、ID 范围、黑白名单等。真正在缓存层面有技术含量的是布隆过滤器和互斥锁。5.1 布隆过滤器原理与实现布隆过滤器Bloom Filter是一个很节省内存的概率型数据结构。它由一个位数组和多个哈希函数组成。插入一个元素时计算元素的多个哈希值并把对应的位设为 1判断一个元素是否存在时只要检查这些位是否都为 1。布隆过滤器的特点是判断“不存在”一定准确。判断“存在”可能有误判false positive。也就是说如果布隆过滤器说某个 ID 不存在那这个 ID 一定不在集合中如果它说存在可能有一部分请求是误判但这些误判请求会继续走到后续的缓存和数据库属于可接受范围。使用布隆过滤器的流程如下请求 - 布隆过滤器判断 - 不存在直接返回 null拦截穿透 - 可能存在继续查询缓存 - 未命中查数据库 - 回填缓存布尔过滤器能够显著减少无效请求进入数据库网关。下面用 Guava 的BloomFilter实现改造版UserService。// 文件路径src/main/java/com/example/cachepenetration/service/UserServiceWithBloomFilter.java package com.example.cachepenetration.service; import com.example.cachepenetration.entity.User; import com.example.cachepenetration.repository.FakeUserRepository; import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; Service public class UserServiceWithBloomFilter { private static final String CACHE_KEY_PREFIX user:; private static final long NORMAL_EXPIRE_SECONDS 300; Autowired private RedisTemplateString, Object redisTemplate; Autowired private FakeUserRepository fakeUserRepository; private BloomFilterLong bloomFilter; PostConstruct public void init() { // 预期容量 10000误判率 1% bloomFilter BloomFilter.create(Funnels.longFunnel(), 10000, 0.01); // 启动时把所有可能存在的主键放入布隆过滤器 ListLong allIds fakeUserRepository.findAllIds(); for (Long id : allIds) { bloomFilter.put(id); } } public User getUserById(Long id) { // 1. 布隆过滤器判断不存在则直接返回 if (!bloomFilter.mightContain(id)) { return null; } // 2. 查询缓存 String key CACHE_KEY_PREFIX id; Object value redisTemplate.opsForValue().get(key); if (value ! null) { return (User) value; } // 3. 查询数据库 User user fakeUserRepository.findById(id); // 4. 回填缓存 if (user ! null) { redisTemplate.opsForValue().set(key, user, NORMAL_EXPIRE_SECONDS, TimeUnit.SECONDS); } return user; } }对应的FakeUserRepository需要增加findAllIds()方法public ListLong findAllIds() { return new ArrayList(data.keySet()); }需要注意布隆过滤器不支持删除操作。当系统中新增了真实存在的 ID 时需要将新 ID 重新put进过滤器。如果 ID 总量是动态增长且变化频繁就需要使用 Redis 的RBloomFilter或者每天在低峰期重建一份过滤器缓存。误判率也很重要。误判率越低需要的位数组空间越大。一般建议误判率设置为 1% 到 5%具体可根据内存和 DB 压力权衡。5.2 互斥锁防止缓存击穿前面提到缓存空值过期瞬间可能会有大量相同请求同时打到数据库这在缓存击穿场景中非常常见。互斥锁Mutex Lock可以保证只有一个线程去数据库查询并回填缓存其他线程等待缓存重建完成后再读取。下面是基于 Redis 的setIfAbsent实现的简易互斥锁方案使用 UUID 作为锁标识防止误删// 文件路径src/main/java/com/example/cachepenetration/service/UserServiceWithLock.java package com.example.cachepenetration.service; import com.example.cachepenetration.entity.User; import com.example.cachepenetration.repository.FakeUserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.util.UUID; import java.util.concurrent.TimeUnit; Service public class UserServiceWithLock { private static final String CACHE_KEY_PREFIX user:; private static final String LOCK_KEY_PREFIX lock:user:; private static final String EMPTY_CACHE ; private static final long EMPTY_EXPIRE_SECONDS 60; private static final long NORMAL_EXPIRE_SECONDS 300; private static final long LOCK_EXPIRE_SECONDS 10; Autowired private RedisTemplateString, Object redisTemplate; Autowired private FakeUserRepository fakeUserRepository; public User getUserById(Long id) { String key CACHE_KEY_PREFIX id; Object value redisTemplate.opsForValue().get(key); if (value ! null) { if (value instanceof String ((String) value).isEmpty()) { return null; } return (User) value; } String lockKey LOCK_KEY_PREFIX id; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 双重检查防止拿到锁后缓存已被其他线程回填 value redisTemplate.opsForValue().get(key); if (value ! null) { return (User) value; } User user fakeUserRepository.findById(id); if (user null) { redisTemplate.opsForValue().set(key, EMPTY_CACHE, EMPTY_EXPIRE_SECONDS, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue().set(key, user, NORMAL_EXPIRE_SECONDS, TimeUnit.SECONDS); return user; } finally { // 释放锁前校验避免误删其他线程的锁 String currentLock (String) redisTemplate.opsForValue().get(lockKey); if (requestId.equals(currentLock)) { redisTemplate.delete(lockKey); } } } else { // 未获取到锁自旋等待短暂时间后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } value redisTemplate.opsForValue().get(key); if (value ! null) { if (value instanceof String ((String) value).isEmpty()) { return null; } return (User) value; } // 如果第一次等待后仍然没有值可以递归重试但需要控制次数 return getUserById(id); } } }严格来说互斥锁主要解决的是“缓存击穿”而不是“缓存穿透”。因为互斥锁只能让同一个 key 的并发请求排队对于海量不同 key 的穿透攻击还是无能为力。但在高并发热点场景中互斥锁可以避免缓存回填时数据库被打死是缓存空值方案的重要补充。生产环境不建议自己手写这种简单分布式锁可以使用 Redisson 的RLock它在锁续期、可重入、公平性方面更完善。5.3 分层防御架构真实项目中应对缓存穿透往往是组合拳。下面是一个典型的分层防御架构请求入口 - 参数合法性校验ID 范围、格式、黑白名单 - 布隆过滤器拦截不存在的 key - Redis 查询缓存 - 缓存未命中获取互斥锁 - 查询数据库 - 回填缓存或空值标记流程拆解如下业务层先做参数校验把负数、超长、非法字符直接拦截。布隆过滤器用于拦截那些参数合法但数据库中不存在的 key。缓存中存储真实数据也可以存储空值标记用于拦截误判通过布隆过滤器且不存在的 key。互斥锁保证同一时刻只有一个线程查库回填避免缓存过期瞬间产生并发击穿。如果 Redis 内存允许空值缓存 TTL 可以设置得很短例如 30 到 60 秒并加上随机抖动避免大量空值同时到期。6. 常见问题与排查思路缓存穿透的排查往往依赖监控。你需要至少知道 Redis 的命中率、数据库 QPS 以及接口响应时间。下面整理了一些典型问题和排查思路可以在实际项目中参考。问题现象常见原因解决思路Redis 内存持续上涨大量不存在的 key 被缓存空值TTL 设置过长或未设置缩短空值 TTL限制空值 key 数量引入布隆过滤器数据库存在数据但接口一直返回空空值缓存未过期导致新写入的数据不可见数据写入时主动删除对应缓存或缩短空值过期时间空值缓存过期瞬间数据库 QPS 突增大量相同 key 同时过期引发缓存击穿过期时间加随机抖动配合互斥锁回填缓存布隆过滤器出现误判部分不存在请求仍穿透到数据库误判率设置过低或容量不足适当增大位数组容量降低误判率并配合空值兜底接口响应变慢出现大量等待锁超时锁粒度太粗、锁过期时间太短或自旋竞争激烈使用 Redisson 分布式锁缩短锁内数据库查询耗时热点 key 换成永不过期加后台刷新请求量不大但数据库 CPU 很高存在慢查询数据库索引失效或每次查询时间过长分析慢 SQL优化索引避免在锁内执行复杂查询排查时建议按以下顺序确认查看 Redis key 数量和内存占用判断是否存在空值缓存膨胀。查看数据库慢查询日志确认是否出现大量无效查询。查看接口 QPS 和缓存命中率如果缓存空值命中率高但真实命中率低说明穿透请求比例大。分析业务参数是否可枚举判断适合布隆过滤器还是缓存空值。7. 最佳实践与工程建议7.1 前置校验是第一道防线无论使用什么缓存技术业务参数校验都不应该省略。比如用户 ID 必须是正整数并且小于某个合理上限手机号必须符合正则规则订单号必须满足固定格式。大量穿透攻击其实是无效参数在进入缓存系统之前就该被拦截。if (id null || id 0) { throw new IllegalArgumentException(无效的用户ID); }7.2 不同场景选择不同方案下面给出方案选型建议数据量小、key 有限、不存在比例低优先使用缓存空值实现简单。数据量大、key 可枚举、需要快速拦截优先使用布隆过滤器。热点 key 过期瞬间需要防护配合互斥锁或逻辑过期。需要同时保证实时一致性空值 TTL 缩短并在数据变更时主动删除缓存。7.3 缓存空值要设置短 TTL 和随机抖动不要让空值缓存时间过长。建议设置 30 到 60 秒并且加入随机偏移例如base random.nextInt(30)。这样可以避免大量空值 key 在同一时刻同时过期减少瞬时压力。7.4 布隆过滤器需要定期重建布隆过滤器无法删除元素当数据库中有大量 ID 被删除时过滤器中的位仍然为 1导致误判率不断升高。生产环境可以每日低峰期用当前全量 ID 重建过滤器并替换运行中的引用。如果使用 Redis 布隆过滤器要考虑 key 切换和原子性。7.5 监控与告警为缓存系统接入监控很重要。建议至少监控以下指标Redis 内存使用率和 key 数量。缓存空值命中次数与比例。数据库查询 QPS。缓存击穿、穿透告警事件。接口平均响应时间和慢接口分布。一旦发现数据库 QPS 突增但命中率下降立即排查是否出现新的穿透流量。7.6 生产环境变更需要测试修改缓存方案属于敏感变更尤其在线上环境。建议先在测试环境进行压测对比缓存空值、布隆过滤器、互斥锁组合前后的 QPS、内存、DB 压力指标。发布时可以使用灰度发布先让一部分流量验证再全量切换。8. 总结回到标题用缓存空值解决缓存穿透的都是初学者。这句话的真正含义是只掌握缓存空值这一种方案并且不分场景地套用才是初学者的思维方式。缓存空值简单、有效但存在内存膨胀、数据一致性和瞬间穿透等问题。成熟的架构设计会结合参数校验、布隆过滤器、互斥锁和缓存空值形成分层防御。通过本文你应该理解了缓存穿透与缓存空值的原理也看到了完整的代码实现更重要的是知道了每种方案的边界在哪里。下一步可以继续学习 Redis 的高级数据结构、布隆过滤器在 Redis 中的封装例如 Redisson 的 RBloomFilter以及分布式锁的续期与重试机制。在实际项目中先把当前业务的 key 分布摸清楚再决定主方案和兜底方案这才是解决缓存穿透的正确思路。