尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

缓存穿透解决方案:从缓存空值到布隆过滤器分层防御

发布时间:2026/9/8 9:42:02

资讯中心
01
ARTICLE

缓存穿透解决方案:从缓存空值到布隆过滤器分层防御

缓存穿透解决方案:从缓存空值到布隆过滤器分层防御
先看一个在很多缓存教程里都会出现的结论解决缓存穿透可以把数据库里查不到的数据也缓存一个空值。这个方案思路简单代码量少确实能解决一部分问题。但如果有人告诉你“生产环境靠缓存空值就足够了”那这个方案大概率只停留在教学层面。标题里说“用缓存空值解决缓存穿透的都是初学者”并不是否定缓存空值本身而是指出一种典型的思维方式只看到“查不到就缓存一个空值”这一步却没有继续追问这个空值会带来哪些新问题也没有考虑恶意流量、数据一致性、存储成本这些真实生产环境里绕不开的因素。这篇文章会从缓存穿透的本质出发先用一个最小实现把缓存空值的思路讲清楚再逐个分析它为什么不适合单独扛生产流量然后引入布隆过滤器、互斥锁、限流等组合手段最后给出一套可落地的分层防御方案。整篇文章围绕“缓存穿透怎么从源头拦截、查不到时怎么办、并发请求怎么控制、出了问题怎么排查”展开适合正在做 Redis 缓存设计、接口性能优化或系统防刷的开发者阅读。1. 缓存穿透是什么别把“查不到”和“缓存没命中”混为一谈1.1 一次查询穿透的全过程先用一句话概括缓存穿透查询一个缓存和数据库中都不存在的数据导致每次请求都绕过缓存直接打到数据库。假设有一个用户信息接口正常的查询链路是请求到达先查 Redis。Redis 命中直接返回缓存数据。Redis 未命中去数据库查询。数据库存在回写缓存返回结果。数据库不存在直接返回空结果。问题出在第 5 步。如果数据库确实不存在这条记录那么本次请求不会回写任何缓存。下一次同样 key 的请求进来Redis 还是不命中又得再去数据库查。在高并发场景下一个不存在的 id 如果被刷上几万次数据库就会收到几万次无效查询连接数被打满业务接口整体变慢。这里要注意一个容易混淆的问题缓存未命中并不等于穿透。如果是缓存过期后第一次查询数据库里数据是存在的只有第一个请求会回源数据库后续请求会重新命中缓存这种是正常的缓存过期行为。只有“数据库里根本不存在”这个前提下缓存未命中才会导致反复回源这才是穿透。1.2 缓存穿透与缓存击穿、缓存雪崩的边界很多文章会把穿透、击穿、雪崩放在一起讲但它们的成因和解决手段完全不同。名称核心现象直接原因典型后果缓存穿透缓存和数据库都不存在目标数据每次请求都查询数据库恶意请求或正常业务查询了非法 key数据库压力飙升可能被无效查询拖垮缓存击穿某个热点 key 失效大量并发请求同时回源数据库热点数据过期互斥手段缺失数据库承受瞬时大流量缓存雪崩大批 key 同时失效大量请求同时回源数据库大量 key 设置了相同过期时间数据库在短时间内被打爆从关联性上看穿透解决的是“数据不存在”的问题击穿解决的是“热点数据短暂失效”的问题雪崩解决的是“批量失效”的问题。三者可以同时存在例如一个不存在的数据刚好在某个热点接口中被大量轮询那就同时具备穿透和击穿的特征。1.3 穿透请求为什么在生产环境更危险教学环境里穿透请求往往只是测试脚本里的一条查询影响不大。生产环境则不同攻击者可以遍历 id例如从 1 到 1000000 的连续 id只要数据库里没有对应记录请求就会全部落到数据库。数据库的慢查询日志会大量记录这种“查不到”的 SQL干扰正常慢 SQL 分析。缓存命中率被拉低监控面板上看到命中率骤降很难区分是业务波动还是被穿透流量影响。所以解决缓存穿透不只是性能优化问题也是接口安全与系统稳定性问题。方案设计的目标应该是在数据库之前尽量拦截无效请求而不是等请求真的打到数据库之后再做补偿。2. 缓存空值入门教程最常给的解法也是争议的开端2.1 缓存空值到底做了什么缓存空值的思路非常直接既然数据不存在导致无法回写缓存那就主动创建一个“代表不存在”的缓存项。下一次相同 key 的请求过来Redis 能命中这个空值缓存业务层识别到空值标记后直接返回不再查询数据库。以用户查询为例当数据库查不到用户时向 Redis 写入一个 keyvalue 可以是空字符串、null字符串或者一个专门的对象。并给它设置一个较短的过期时间比如 3 到 5 分钟。这样在过期前所有相同 key 的请求都会命中缓存数据库的查询次数从“每次请求一次”降为“第一次回源一次之后全部命中缓存”。2.2 缓存空值的技术动机把“不存在”也变成可命中从缓存命中率的角度看普通缓存只能缓存“已存在”的数据而缓存空值把“不存在”也纳入缓存管理范围。这样做的好处非常明显实现简单不需要额外引入中间件。对业务代码入侵小只需要在查询为空的分支里补一段写入逻辑。对偶发性的无效查询效果明显例如用户传入一个已经被删除的订单号短时间内的重复查询不会再打到数据库。这也是大部分教学资料把缓存空值作为“标准答案”的原因。它能把问题的表象迅速压住适合演示也适合在低并发、低风险场景下快速止血。2.3 一个最小实现示例下面这段代码模拟一个典型的查询用户信息接口。为了简化绕过 Controller直接看查询核心逻辑。Service public class UserQueryService { private final StringRedisTemplate redisTemplate; private final UserMapper userMapper; public UserQueryService(StringRedisTemplate redisTemplate, UserMapper userMapper) { this.redisTemplate redisTemplate; this.userMapper userMapper; } public UserVO queryUser(String userId) { String cacheKey user:info: userId; // 第一步查缓存 String cachedValue redisTemplate.opsForValue().get(cacheKey); if (cachedValue ! null) { // 这里使用了空字符串作为空值标记 if (cachedValue.isEmpty()) { return null; } return JSON.parseObject(cachedValue, UserVO.class); } // 第二步缓存未命中回源数据库 User user userMapper.selectById(userId); // 第三步数据库也不存在缓存空值并设置较短的过期时间 if (user null) { redisTemplate.opsForValue().set(cacheKey, , 300, TimeUnit.SECONDS); return null; } // 第三步数据库存在缓存真实数据并设置正常过期时间 redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 1800, TimeUnit.SECONDS); return convert(user); } }这个实现有几个关键点空值使用空字符串判断时用isEmpty()不会和真实数据混淆。空值缓存过期时间设置为 300 秒正常数据是 1800 秒避免空值长期占用内存。回源数据库的操作没有加锁所以并发很高时同一 key 仍可能多次回源需要额外处理。到这里缓存空值方案已经能跑通一个完整链路请求进来查缓存不存在回源查不到就写空值之后命中空值直接返回。接下来要讨论的是这个方案单独使用时会暴露出哪些问题。3. 只靠缓存空值扛不住生产流量问题出在三个地方3.1 恶意穿透下空值缓存会成为新的存储压力缓存空值能拦截相同 key 的重复请求但它拦不住大量不同 key 的请求。假设攻击者遍历 10 万个不存在的 id每个 id 在第一次查询后都会产生一个空值缓存。即使每个空值只占几十字节10 万个 key 也会占用几十兆甚至上百兆内存而且这些 key 必须在几百秒后才会过期。更麻烦的是如果攻击速率大于空值过期速率Redis 内存会被持续消耗。业务方为了控制内存往往会给 Redis 设置maxmemory和淘汰策略。当内存达到上限缓存系统会开始淘汰 key。最危险的是如果淘汰策略选择了allkeys-lru真实用户的高价值缓存可能被挤掉影响正常业务。这就出现了一个反直觉的结果为了解决穿透而写的空值缓存反而加剧了 Redis 的内存压力并可能造成正常缓存被误淘汰。3.2 空值缓存与数据库写入之间存在一致性窗口缓存空值会引入一个新的数据一致性问题。考虑这个场景用户查询一个订单号数据库中暂时不存在。系统写入空值缓存预计保留 5 分钟。3 分钟后其他系统创建了这个订单数据库里已经有数据了。用户再次查询同一订单号命中的是空值缓存返回“订单不存在”。这时缓存和数据库的数据不一致。虽然这种不一致最终会随着空值过期而消失但在电商、支付、库存等业务中几分钟的“查不到”可能造成严重问题。用户可能因此反复刷新、重新提交、或者投诉订单状态不对。解决不一致有几种常见手段缩短空值过期时间降低不一致窗口但不能彻底消除。在数据写入时主动删除对应缓存让下一次查询重新回源。引入消息通知机制数据变更时发送 Redis key 删除指令。第一种最简单第二种需要业务写入链路配合第三种适合跨服务场景。单独使用缓存空值方案时这几个手段都容易被忽略。3.3 过期时间设置导致内存放大和过期集中初学者通常会给空值缓存设置一个固定过期时间例如 5 分钟。这个做法的隐患是大量空值 key 会在同一时间点附近到期。假设一批恶意请求在某一秒内制造了 1 万个空值 key这批 key 都会在同一时刻过期。过期后相同 key 的下一次请求又会穿透到数据库如果攻击流量仍然存在就会形成一波一波的回源脉冲。更合理的做法是给空值过期时间增加随机扰动。例如在 180 秒到 300 秒之间随机取值把集中过期打散。这个思路和解决缓存雪崩时给 key 增加随机过期时间是类似的。3.4 为什么说“都是初学者”思维停留在单点方案把这个问题拉回标题。缓存空值本身没有错它只是方案组合中的一块拼图。说“用缓存空值解决缓存穿透的都是初学者”是因为这个方案暴露出一种单点思维只考虑怎么挡住已出现的请求没有考虑恶意流量会不断换 key。只考虑缓存命中率没有考虑空值缓存的存储成本。只考虑读链路没有考虑数据写入后缓存何时失效。只考虑眼前的 Redis 和数据库没有考虑布隆过滤器、限流、参数校验等其他防线。真正进入生产环境方案必须分层。缓存空值可以作为其中一层但不能是唯一一层。4. 先聊布隆过滤器如何从源头判断“一定不存在”4.1 布隆过滤器的核心结论一定不存在 vs 可能存在布隆过滤器是一个很经典的概率型数据结构它的核心能力是判断一个元素是否可能在集合中并且能确定“一定不在集合中”。这个特性正好适合解决缓存穿透。如果一个 id 不在布隆过滤器中那就可以直接判定数据库里不存在这条记录请求根本不用继续往下走。如果 id 在布隆过滤器中那就继续执行缓存查询和数据库回源。要注意“可能存在”这个关键词。布隆过滤器存在误判率误判只可能出现在“元素明明不在集合中却被判断为存在”这种情况。它在业务上的表现是一个不存在的 id 穿过了布隆过滤器走到了缓存和数据库层。当误判率较低时穿透流量已经很接近正常流量规模不会造成灾难性后果。4.2 用 Redisson 初始化一个可用的布隆过滤器在 Redis 生态中Redisson 提供了现成的布隆过滤器实现使用方式比较简单。Configuration public class BloomFilterConfig { Bean public RBloomFilterString userIdBloomFilter(RedissonClient redissonClient) { RBloomFilterString bloomFilter redissonClient.getBloomFilter(bloom:user:id); // 初始化预计插入 100 万个元素误判率 1% bloomFilter.tryInit(1000000L, 0.01); return bloomFilter; } }初始化参数需要结合业务评估预计插入数量如果业务 id 规模会增长建议预留 1.5 到 2 倍余量。误判率设置得越低占用的内存越大。1% 在大多数场景已经够用。tryInit只在 Redis 中不存在该过滤器时执行初始化重复调用不会重置已有数据。在用户创建接口里写入用户后同步往布隆过滤器里添加 idpublic void createUser(User user) { userMapper.insert(user); userIdBloomFilter.add(user.getId()); }在查询接口里先用布隆过滤器拦截public UserVO queryUser(String userId) { // 第一道布隆过滤器判断不存在直接返回 if (!userIdBloomFilter.contains(userId)) { return null; } // 继续走缓存和数据库 return doQuery(userId); }4.3 布隆过滤器的成本和边界布隆过滤器并不能解决所有问题使用前要看清它的边界布隆过滤器不支持删除。如果用户被删除过滤器里仍然保留这个 id 的标记。后续查询仍然会尝试走缓存和数据库数据库查询结果为空这时还是需要缓存空值或者其他手段来拦截。布隆过滤器初始化后数据源需要有一个预热过程。如果直接上线过滤器而内存中的数据是逐步写入的那么过滤器里没有的合法 id 会被误拦截。所以上线前需要对已有数据做一次重新初始化或者通过任务把存量 id 批量刷入。布隆过滤器只能减少穿透请求不能完全消灭。误判率存在一天缓存空值作为兜底就有存在一天的意义。5. 生产级方案多道防线组合空值缓存依然有一席之地5.1 分层架构校验层、过滤器层、缓存层、回源层、兜底层生产环境解决缓存穿透重点不是选一个“最好”的方案而是把不同手段放在正确的层级上让每一层只处理自己最擅长的问题。推荐的分层方式是参数校验层在入口处拦截明显非法的请求例如 id 为空、id 格式错误、id 不在合法范围内。布隆过滤器层对格式合法但大概率不存在的 key 做快速拦截有效缩小回源范围。缓存层使用正常的缓存查询命中直接返回。对不存在的数据写入短时空值缓存作为布隆过滤器误判的兜底。回源层使用互斥锁控制同一 key 的并发回源避免缓存过期后多个请求同时打数据库。限流层针对异常流量做全局或接口级限流防止极端攻击耗尽系统资源。这个链条的意义在于每一层都能挡掉一部分无效请求。布隆过滤器把“大部分明显不存在”的请求挡住缓存空值把“布隆过滤器误判放行、数据库也确实不存在”的请求挡住互斥锁把“缓存刚过期瞬间的并发回源”控制住。任意一层失效后面的层还能继续兜底。5.2 组合方案代码示例下面用一个CacheService的通用方法展示组合方案的核心流程。这里假设使用 RedisTemplate、Redisson 布隆过滤器和一个简单的分布式锁工具。Service public class SafeCacheService { private final StringRedisTemplate redisTemplate; private final RedissonClient redissonClient; public SafeCacheService(StringRedisTemplate redisTemplate, RedissonClient redissonClient) { this.redisTemplate redisTemplate; this.redissonClient redissonClient; } public T T queryWithProtection( String key, String bloomFilterName, String id, long ttl, long emptyTtl, CacheLoaderT loader) { // 第一层参数校验在 Controller 或调用方完成这里直接判断布隆过滤器 RBloomFilterString bloomFilter redissonClient.getBloomFilter(bloomFilterName); if (!bloomFilter.contains(id)) { return null; } // 第二层查缓存 String cachedValue redisTemplate.opsForValue().get(key); if (cachedValue ! null) { if (cachedValue.isEmpty()) { return null; } return JSON.parseObject(cachedValue, (TypeReferenceT) getType()); } // 第三层加锁回源控制同一 key 的并发 String lockKey lock: key; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(1, 3, TimeUnit.SECONDS); if (!locked) { // 拿不到锁就等一小会然后重查缓存 Thread.sleep(50); String retryValue redisTemplate.opsForValue().get(key); if (retryValue ! null retryValue.isEmpty()) { return null; } if (retryValue ! null) { return JSON.parseObject(retryValue, (TypeReferenceT) getType()); } return null; } // 二次查询缓存防止拿到锁之前其他线程已经回源并写缓存 String doubleCheckValue redisTemplate.opsForValue().get(key); if (doubleCheckValue ! null) { return doubleCheckValue.isEmpty() ? null : JSON.parseObject(doubleCheckValue, (TypeReferenceT) getType()); } // 加载数据 T loaded loader.load(); if (loaded null) { // 第四层布隆过滤器误判或数据删除后的兜底写空值 redisTemplate.opsForValue().set(key, , emptyTtl, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue().set(key, JSON.toJSONString(loaded), ttl, TimeUnit.SECONDS); return loaded; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } finally { if (locked) { lock.unlock(); } } } }这段代码把前面几层防线串起来了。布隆过滤器负责拦截明显不存在的 key缓存负责读取已有数据互斥锁控制并发回源空值缓存负责最后一道兜底。真实项目中还需要处理泛型反序列化、异常打点、超时设置等细节但整体框架可以直接参考。5.3 空值缓存在组合方案里的正确姿势引入布隆过滤器和互斥锁之后缓存空值不再是主力防线而是退化成兜底手段。这反而让它的位置更清晰只在布隆过滤器误判且数据库确实查不到时写入空值。空值过期时间建议设置成 120 到 300 秒并加入随机扰动。空值 key 的数量应该受到监控如果短时间内激增说明布隆过滤器误判率过高或过滤器中数据没有及时同步。数据写入链路中如果创建了新的记录需要主动删除对应的空值缓存避免一致性窗口。6. 落地要点序列化、过期时间、命中率与可观测性6.1 空值缓存的序列化与反序列化问题缓存空值的实现方式不止一种常见的有三种空值表达方式优点缺点适用场景空字符串存储占用小判断简单必须区分字符串值为空的业务含义值类型是字符串或 JSON 字符串null字符串判断条件直观和正常字符串值可能冲突需要和其他字符串值区分时固定标记对象语义清晰可以附带时间戳序列化体积更大需要对空值做精细化控制实际项目中用空字符串标记空值最省内存但要注意序列化配置。如果使用StringRedisTemplate写入的是原始字符串读取时直接比较字符串即可。如果使用GenericJackson2JsonRedisTemplate或自定义序列化器空字符串也可能被序列化成带类型信息的形式读取时可能出现ClassCastException或JSON parse error。建议在项目里定义一个常量统一管理空值标记不要在不同代码位置直接写魔法值。6.2 过期时间怎么选才不会造成穿透复发空值缓存过期时间的选择要同时考虑一致性和防穿透效果。过期时间优点缺点推荐场景60 秒以内数据一致性窗口小穿透压力容易复发数据变更频繁的业务5 分钟防穿透效果好一致性窗口较长数据基本不变或删除后不再创建10 分钟以上对数据库保护强长时间返回错误结果必须配合写入时删缓存更稳妥的做法是动态计算过期时间。给一个基础值加上随机数例如180 random.nextInt(120)秒避免同一批空值 key 在同一时刻过期。如果数据写入链路可控可以在写入真实数据时删除空值缓存这样空值过期时间就不需要设得太长。6.3 监控指标该看哪些 Redis 和 DB 数据没有监控的缓存方案等于在盲飞。建议至少关注以下几类指标Redis 层面空值缓存 key 数量、空值 key 占比、缓存整体命中率。数据库层面慢查询数量、按表统计的查询次数、由缓存穿透导致的无效查询比例。接口层面每个查询接口的 QPS、平均耗时、错误率以及同一个 key 在短时间内的回源次数。排查穿透问题时最容易发现异常的指标是 Redis 命中率突然下降同时数据库查询量上升。这时候要快速定位是哪些 key 造成的可以给回源逻辑增加日志记录没有命中布隆过滤器、没有命中缓存、需要回源的 key 样本。7. 常见坑与排错清单7.1 坑位一布隆过滤器只增不减删除数据后依然拦截布隆过滤器不能删除元素。如果业务支持删除用户、删除订单这类操作被删除的 id 仍然留在过滤器里查询时会穿过过滤器走到缓存和数据库。数据库查不到后空值缓存会兜底所以功能上不会出错但会多一份空值缓存。处理方式是接受这部分多余的空值缓存或者定期重建布隆过滤器把当前存量数据重新刷入。对大多数业务来说定期重建比实现支持删除的过滤器更简单。7.2 坑位二缓存空值使用字符串null被前端当字符串返回有些实现把空值写成字符串null同时缓存层的返回类型是 Object。如果 Controller 层直接把这个字符串返回给前端前端拿到的不是真正的空而是字符串null会导致页面把不存在的数据当成异常处理。推荐做法是在缓存读取完成后统一转成业务对象如果识别到空值标记直接返回null。不要在业务层把原始字符串向外透传。7.3 坑位三互斥锁回源时锁没有续期导致超时穿透当数据库查询很慢时简单使用tryLock设置固定的锁过期时间可能不够锁在业务执行完成前就释放了。此时其他线程拿到锁后继续回源穿透问题再次出现。处理方式是把锁的过期时间设置成明显大于最大回源耗时或者使用带有看门狗续期能力的锁。Redisson 的tryLock默认会启动看门狗对锁自动续期使用时要确认这段逻辑没有被人为关闭。7.4 排错清单问题现象可能原因检查方式处理建议数据库查询量仍然很高布隆过滤器没有包含目标 id检查布隆过滤器是否完成存量预热上线前把存量 id 批量刷入过滤器空值缓存 key 数量快速增长穿透流量使用大量不同 key统计空值 key 的分布和来源增加参数校验和限流缩小过滤范围缓存命中率下降大量数据过期集中在同一时刻查看 Redis 过期 key 趋势给过期时间加入随机扰动查询返回了过期的空值数据已创建但空值缓存未失效对比数据库写入时间和空值缓存过期时间写入数据时删除对应 key反序列化时报错序列化器配置不一致查看 Redis 中存的值格式统一使用 StringRedisTemplate 并约定 JSON 格式拿到锁后还是回源了锁获取失败后直接执行了查询检查锁的分支逻辑获取锁失败应重查缓存或直接返回不继续回源8. 如何从“会用缓存空值”走向“能设计缓存方案”8.1 按业务场景选择方案缓存穿透的处理不是越复杂越好。方案选择和业务规模、数据特征、团队维护成本直接相关。业务场景推荐方案原因内部系统、低并发接口缓存空值实现简单出问题容易排查面向 C 端的读多接口id 可穷举缓存空值 参数校验能拦截大部分偶发无效查询公开接口key 不可控存在刷量风险布隆过滤器 缓存空值从源头拦截不存在的 key电商订单、支付回调等强一致场景布隆过滤器 空值 写入删缓存缩短数据不一致窗口极端恶意流量参数校验 布隆过滤器 限流 缓存空值靠单层方案无法保证稳定性8.2 不同并发量下的推荐组合并发低、key 固定只用缓存空值就够了。并发中等、key 可能被遍历加布隆过滤器空值缓存作为兜底。并发高、热点 key 明显布隆过滤器 互斥锁 空值缓存同时为热点 key 做更长的缓存时间。并发极高且存在明显攻击流量在上面基础上增加限流、黑名单、接口风控。方案每增加一层代码复杂度和运维成本都会上升。不要为了炫技把所有手段堆到一起而是先看监控数据确认当前主要问题是穿透流量大、还是热点 key 失效、还是数据不一致。8.3 真实项目中最该关注的三件事第一上线前给布隆过滤器做数据预热。否则过滤器会拦掉存量合法 id造成大面积“数据不存在”。第二空值缓存的过期时间一定要比真实数据短而且加上随机扰动。它只负责“短期内兜住误判请求”不能变成长期存储。第三把回源数据库的代码节点加上日志和计数器。缓存方案最终是否生效不是看代码写得多完整而是看数据库查询趋势是否真的降下来了。回到标题的观点缓存空值解决不了所有的缓存穿透问题但它是理解完整解决方案最好的起点。从缓存空值开始你才会意识到布隆过滤器、互斥锁、限流、数据一致性这些概念是如何围绕同一个问题组合起来的。真正从“初学者”走向“有经验”不是抛弃缓存空值而是知道它该放在哪一层、该在什么条件下使用、该在什么场景下警惕它带来的新问题。学习时可以先在本地把缓存空值的最小实现跑通观察 Redis key 的变化再逐步加入布隆过滤器模拟一批不存在的 id 验证拦截效果最后加入互斥锁和随机过期时间看数据库回源次数是否下降。这三步做完你对缓存穿透的理解会从“有一个办法”变成“有一套方案”而这正是项目中真正需要的能力。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。