恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
缓存穿透解决方案:缓存空对象与布隆过滤器实战解析
首页
资讯中心
/
缓存穿透解决方案:缓存空对象与布隆过滤器实战解析
缓存穿透解决方案:缓存空对象与布隆过滤器实战解析
发布时间:2026/10/7 22:55:46
做黑马点评的时候我把商户查询接口的缓存链路搭好之后自己拿Redis监控和数据库日志对照着看了一遍越看越确认缓存穿透这个问题真的不能轻视。所谓缓存穿透就是请求去查一个缓存和数据库里都不存在的数据Redis里永远命中不了每一次请求都会直接砸到数据库上。你辛辛苦苦搭好的缓存层在这一类请求面前形同虚设。这篇文章就围绕黑马点评里的商户查询场景把缓存穿透的成因、影响面、两种主流解决方案缓存空对象和布隆过滤器完整拆解一遍从核心代码到误判率计算再到面试追问一次性讲清楚。不管你是正在跟项目的新手还是在准备Redis相关面试只要把这一套逻辑吃透了缓存这块的基本功就算打牢了一大半。1. 先看清问题黑马点评里缓存穿透是怎么冒出来的1.1 商户查询的完整缓存链路黑马点评项目的核心业务之一就是商户查询。用户点开一个店铺详情页后端会走这样一条查询链路先去Redis里查店铺信息命中了就直接返回给前端没命中就接着查MySQL查到之后把数据回填到Redis方便后续请求直接走缓存。这套逻辑本身没有太大问题也是绝大多数缓存系统的标准姿势。真正让这条链路出问题的是那些在数据库里压根不存在的Shop ID。比如前端被人恶意刷了一大批随机ID或者有人拿数据库里已被删除的旧ID来请求那么Redis里永远不会命中MySQL里也查不到缓存也就无从回填。结果是每一次请求都彻底穿透缓存层直接落在数据库上。这类请求一旦多起来数据库的连接、CPU、磁盘IO都会急剧恶化极端情况下数据库直接被打崩整个服务跟着雪崩。1.2 为什么“查不到”比“查得到”更危险穿透最大的危害不在于某一次查询有多慢而在于请求可以被无限放大。平时正常的热点数据走Redis压力都落在内存上可穿透请求避开了Redis把压力全部转移到了MySQL。一个Redis实例扛十万级QPS很常见而一个单机MySQL可能并发到两三千就会开始报警了。数据库是整套系统里最脆弱、最容易被击穿的一环。黑马点评这种教学项目里数据库有近万个店铺看起来不大但如果你把“查询不存在的ID”的接口放大到并发两三千数据库照样会被拖垮。更麻烦的是穿透请求往往不是正常的用户行为——爬虫、恶意脚本、过期链接、前端缓存混乱都会造成这种现象。所以处理穿透本质上是在保护数据库这条生命线而不是在优化什么边缘小问题。1.3 容易忽视的一个判断标准很多人看缓存层健不健康只看命中率。其实对于穿透场景更需要关注的是“没有命中缓存、也没有命中数据库”的请求占比。如果这类请求占比突然升高就要立刻警惕很有可能是穿透请求在急剧增加。注意缓存命中率低不一定等于穿透。也可能是冷数据太多、缓存容量不足导致淘汰。判断穿透的核心标志是“数据库里根本不存在这条数据”只有这一类请求才会反复穿缓存。真正确认穿透之后再决定用什么方案去拦截盲目的“调大缓存容量”“增加内存”往往治标不治本。2. 缓存空对象原理、代码改造和三个隐藏坑2.1 核心思想把“没有”也缓存进Redis一句话解释缓存空对象既然数据库里没有这个Shop那我直接把“没有”这个结果也写进Redis。后续再有相同的ID来查就能命中这个空值直接返回“店铺不存在”完全不用再去打扰数据库。关键点是给空值设置一个很短的TTL一般1到5分钟。空值本身没有业务价值它只是用来挡一轮突发穿透请求。TTL太长Redis里会塞满无意义的key白白浪费内存TTL太短又起不到拦截效果。黑马点评原本采用的默认值是两分钟这个时间在大多数业务场景下够用。我自己的一个经验是如果业务上存在“同一种不存在的ID短时间内反复被请求”的现象可以把空值TTL适当调长到5分钟反之尽量往短了设。2.2 黑马点评代码改造实录与逐行说明改造前ShopServiceImpl里的queryById逻辑就是“先查Redis查不到查数据库数据库有就回填缓存”。加上缓存空对象之后核心改动其实只有几行。我习惯把空值TTL单独定义成一个常量跟正常缓存TTL分开管理private static final String CACHE_SHOP_KEY cache:shop:; private static final Long CACHE_SHOP_TTL 30L; // 正常数据缓存 30 分钟 private static final Long CACHE_NULL_TTL 2L; // 空值缓存 2 分钟 public Result queryById(Long id) { String key CACHE_SHOP_KEY id; // 1. 先查 Redis String shopJson stringRedisTemplate.opsForValue().get(key); // 2. 命中非空直接返回店铺信息 if (StrUtil.isNotBlank(shopJson)) { Shop shop JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } // 3. 命中空字符串说明缓存的是“不存在”的结果 if (shopJson ! null) { return Result.fail(店铺不存在); } // 4. 缓存没命中去查数据库 Shop shop getById(id); if (shop null) { // 5. 数据库也没有写入空值TTL 设为 2 分钟 stringRedisTemplate.opsForValue() .set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES); return Result.fail(店铺不存在); } // 6. 数据库有回填缓存 stringRedisTemplate.opsForValue() .set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); }这段代码里有个很容易被忽略的细节空值是用空字符串来表示不是Java里的null。如果用StringRedisTemplate存一个null进去会被当成删除操作根本达不到缓存空值的效果。所以在第5步写入的时候value只能填空字符串。读取的时候则要分清两种情况shopJson是null代表Redis里没有这个key要继续查库shopJson是空字符串代表缓存过“空结果”直接返回失败即可。判断逻辑的顺序也很重要。第2步用了StrUtil.isNotBlank空字符串在这里会返回false所以才会继续往下走。很多人会在这里踩坑直接用if (shopJson ! null)判断命中结果空字符串被当成正常数据去反序列化直接抛异常。2.3 空key膨胀、数据一致性、反序列化缓存空对象最大的问题就是key膨胀。每来一个不存在的IDRedis里就会多一个空key。攻击者只要换着不同的ID不停地请求空key就会像垃圾一样堆积起来内存照样会被消耗干净。应对思路有三个一是短TTL自动回收这是最基本的二是在写入空值之前加一个“空key总量”的前置判断超过阈值就不再写入三是定期用SCAN命令配合cache:shop:*模式扫描把空key批量清掉。SCAN不会阻塞Redis可以放到定时任务里慢悠悠地跑。第二个坑是数据不一致。如果某个Shop之前被当作不存在的ID缓存了空值之后业务上又新建了这个ID的商铺那么在TTL过期之前查询端看到的依然是“店铺不存在”。所以在写入一条新数据时要把对应的缓存key删掉保证下次查询能回到正常链路。正常情况下数据库自增ID不会重复使用这个坑更多出现在订单号、业务单号这类由前端或第三方传入ID的场景里大家根据自己业务的ID生成规则去判断是否需要处理。第三个坑是反序列化。读取Redis拿到的空字符串不能直接拿去做JSONUtil.toBean否则会报错。上面的代码里通过第2步和第3步的两次判断把这种情况挡住了实际开发中要注意保持这个判断顺序别为了省代码把两个判断合并合并之后空字符串就会被误当成正常数据走反序列化逻辑。3. 布隆过滤器位图原理、误判率计算与Redisson实战3.1 为什么不是HashSet而是位数组缓存空对象能拦截重复的穿透请求但“第一次穿透”的那一下还是会打到数据库。如果攻击者一直换不重复的随机ID空key膨胀的问题也会被放大。布隆过滤器换了一种思路不缓存“不存在”的结果而是把所有存在的Shop ID提前放进一组“标记位”里。查询请求到达缓存之前货先让布隆过滤器判断一下“这个ID到底存不存在”。布隆过滤器的底层结构是一个很长的二进制位数组配上多个独立的哈希函数。插入一个Shop ID时用k个哈希函数计算出k个位置把这几个位全部置为1查询某个ID是否存在时同样计算出k个位置检查这些位是不是都是1。只要有一个位是0就能百分百确定这个ID不存在直接返回失败根本不用碰Redis和MySQL。为什么不直接用HashSet呢HashSet判断虽然精确但每个元素都要完整存储空间100万个Long要占好几MB甚至更多而且存在JVM本地内存里多个服务实例不共享。布隆过滤器不存原始key只存位标志100万数据在误判率1%时只需要约1.14MB的内存而且它能落在Redis里多个实例共享同一份数据。这就是它可以长期挂在高并发链路上的原因。3.2 误判率的数学账1%误判率需要多少内存布隆过滤器的判定结果有一个很重要的不对称性过滤器说“不存在”那一定是真的不存在过滤器说“存在”则可能有极小概率误判。误判原因是哈希冲突多个元素映射到了同一位上。某个元素明明没有插入过但它计算出的k个位恰好都被其他元素踩过于是被当成“存在”。这个特征决定了布隆过滤器只能拦截确定不存在的请求不能完全替代“空值缓存”这类兜底方案。误判率可以量化公式是 m -n × ln(p) / (ln2)^2n是预计元素数量p是期望误判率m是需要的位数组长度bit。哈希函数数量 k m/n × ln2。以黑马点评的店铺量为例假设预计数据量100万、期望误判率1%m -1000000 × ln(0.01) / (ln2)^2 ≈ 958万bit约1.14MBk 958万 / 100万 × ln2 ≈ 6.64向上取整为7也就是说100万个店铺ID在1%误判率下只需约1.14MB的内存并使用7个哈希函数。Redisson的tryInit方法底层就是按这套公式去计算的所以初始化参数只需要传元素量和误判率不需要自己手动推到位数组长度。但作为开发者仍然要知道这个计算逻辑因为面试经常会考排障时也需要判断“为什么内存占这么多”或者“为什么误判率比预期高”。3.3 Redisson实战配置、查询和启动预热手写布隆过滤器用Redis的SETBIT和GETBIT也能做但需要自己去管理位数组长度和哈希函数开发效率不高。生产环境我更推荐直接用Redisson的分布式布隆过滤器底层是Redis的bit结构天然支持多实例共享。关键代码分三步。第一步配置Bean。在配置类里定义过滤器指定名称、预计元素量和误判率。Configuration public class RedisConfig { Bean public RBloomFilterLong shopBloomFilter(RedissonClient redissonClient) { RBloomFilterLong bloomFilter redissonClient.getBloomFilter(cache:shop:bloom); // 预计100万元素误判率1% bloomFilter.tryInit(1000000L, 0.01); return bloomFilter; } }第二步查询时先用布隆过滤器做前置判断只有“可能存在”的ID才继续走缓存链路。public Result queryById(Long id) { // 1. 布隆过滤器判断不存在的直接拦截 if (!shopBloomFilter.contains(id)) { return Result.fail(店铺不存在); } // 2. 后续走原来的 缓存 - DB 链路 String key CACHE_SHOP_KEY id; String shopJson stringRedisTemplate.opsForValue().get(key); // ...其余逻辑与缓存空对象版本一致 }第三步启动时预热存量数据。这一步非常重要如果过滤器里没有数据所有请求都会被当成不存在直接拦掉服务直接不可用。Component public class ShopBloomFilterInitializer implements ApplicationRunner { Resource private RBloomFilterLong shopBloomFilter; Resource private ShopMapper shopMapper; Resource private StringRedisTemplate stringRedisTemplate; Override public void run(ApplicationArguments args) { // 用独立key标识是否已经全量加载过避免每次重启都全量重建 String loadFlag cache:shop:bloom:loaded; Boolean loaded stringRedisTemplate.hasKey(loadFlag); if (Boolean.TRUE.equals(loaded)) { return; } ListShop shops shopMapper.selectList(null); shops.forEach(shop - shopBloomFilter.add(shop.getId())); stringRedisTemplate.opsForValue().set(loadFlag, 1); } }注意一个联动逻辑如果店铺数据支持新增那么写入数据后也要同步调用shopBloomFilter.add(shop.getId())否则新店铺永远过不了过滤器这一关。如果Redis开启了持久化RDB或AOF布隆过滤器的位数组也会跟着持久化服务重启后不需要重新预热只需要判断那个加载标志位就能知道是否需要全量初始化。3.4 布隆解决不了的三个问题第一个问题布隆过滤器无法删除元素。位数组里的每一位可能被多个元素共享不能因为某个店铺删除了就把相应的位清成0否则会误伤其他店铺。通常的做法是定期全量重建每天凌晨或者每周用数据库全量ID重新初始化一个新过滤器然后切换引用。第二个问题误判仍然存在1%的误判率意味着有部分不存在的ID会被放行并打到数据库所以在对数据准确性要求极高的场景下布隆过滤器很少单独使用。第三个问题如果ID不是连续递增的数字而是一段极其稀疏的随机字符串位数组的利用率会下降需要结合业务ID生成规则去评估实际的过滤效果。除此之外还有个容易忽视的点布隆过滤器拦截的是“非法ID”但对“合法ID但数据已下架”这种情况无能为力。也就是说过滤器里存在这个ID不代表数据一定可用它只能证明这个ID曾经或现在已经写入过。这一点在业务上要想清楚。4. 两种方案如何选对比表与生产环境组合拳4.1 一张表看穿两种方案的差异缓存空对象和布隆过滤器不是对立关系而是解决不同环节的问题。我把它们放一起对比一下对比维度缓存空对象布隆过滤器是否能拦截初次穿透否第一次仍会打库是可在缓存前直接拦截内存占用每个无效key都要占空间恶意请求多时膨胀明显固定位数组占用可控是否存在误判无有一定误判率是否支持删除key过期自动清理不支持删除元素实现复杂度很低改造几行代码中需要初始化、预热、联动适用场景中小规模、请求量可控的系统数据量大、并发高、要求前置拦截的系统如果让我在生产环境二选一我会组合使用布隆过滤器做前置拦截缓存空对象兜底误判漏进来的那部分请求。黑马点评原项目只用了缓存空对象这个体量其实够用但面试时能把两种方案的组合逻辑讲清楚明显会更占优势。4.2 参数校验、限流把压力挡在缓存之外除了布隆过滤器和缓存空对象还有三个配套手段值得一起考虑而且它们的成本更低。参数合法性校验是最便宜的一道防线。Shop ID是Long类型那么负数、0、超长数字都可以在校验阶段直接拦截返回参数错误完全不需要进入缓存链路。这个动作对代码入侵很小但能过滤掉很大一部分恶意请求。限流和降级是另一道防线。对查询接口做单机或全局限流超过阈值就快速失败。穿透发生时宁可让一部分正常用户短暂失败也不能让数据库被打挂。Redis本身也可以作为限流的存储组件比如用滑动窗口或令牌桶的Lua脚本落地这比业务代码里自己维护状态要可靠得多。还有一层思路是频控。对同一个IP、同一个用户、同一个设备做访问频率控制防止单点刷量。这类逻辑一般放在网关层业务代码里保留最基本的参数校验就够。4.3 从穿透扩展到击穿和雪崩的治理思路缓存穿透是“查不存在的数据”缓存击穿是“热点数据刚好过期”缓存雪崩是“大批key同时过期”。三者最终的表象都是“缓存没扛住流量、压力传导到了数据库”但成因完全不同处理方式也不同。在黑马点评里缓存击穿的经典场景是热点店铺详情key过期后高并发请求同时打到DB。解决思路是加分布式锁让只有持有锁的线程去重建缓存其他线程等待结果后直接命中Redis。分布式锁优先选Redisson封装好的锁不要自己用SETNX拼一个锁无法续期、误删别人的锁这类坑特别多。缓存雪崩则可以靠给TTL加随机抖动来解决比如基础值30分钟再随机加1到5分钟把过期时间打散。穿透、击穿、雪崩放到一起考的时候抓住核心差异去回答思路就会非常清晰。5. 黑马点评面试复盘穿透类问题要怎么答5.1 开场问题你在黑马点评里怎么解决缓存穿透这是高概率出现的开场问题。答题时不要只抛“缓存空对象”一个词要把链路逻辑讲清楚。我的回答习惯是这样的先说明穿透是怎么产生的也就是查询不存在的ID导致缓存永远不命中然后说我采用了缓存空对象方案把空结果也写入Redis并设置短TTL接着说明这样做的优点是速度快、实现简单、能拦截重复请求最后补一句“如果数据量特别大我会用布隆过滤器做前置拦截”。这样从问题、方案、优点到扩展一条线下来很完整。5.2 深入问题布隆过滤器的误判率怎么解释这个问题考察的是对这个工具的理解深度。重点讲两点一是“宁可错杀、不可漏判”的特性即它说“不存在”就是一定不存在说“存在”可能是误判二是误判率可以量化控制。如果能把位数组长度与预计数据量和期望误判率的关系讲出来顺带说出1%误判率、100万数据只需要约1.14MB内存这个例子面试官大概率会认为你真正落地过这个东西而不是只看过八股文。5.3 进阶问题布隆过滤器能不能删除元素标准布隆过滤器确实不支持删除因为多位共享无法确定地清除单个元素。实际方案有三个一是定期全量重建用数据库ID重新初始化一个新过滤器再切换引用二是用计数布隆过滤器把位数组改成计数器删除时把计数减一但占用的存储空间会成倍增加三是根据业务判断是否需要换用Cuckoo Filter之类的变体但实现成本更高。遇到这个问题坦然说明限制、再给出合理方案会比硬着头皮说“不需要删除”好得多。5.4 细节问题空值TTL为什么设置2分钟面试官很喜欢追问“空值TTL设置多久、为什么”。正常回答思路是区分正常数据缓存和空值缓存正常数据缓存可以半小时甚至更久空值缓存只是为了挡住瞬时穿透通常设置1到5分钟2分钟是一个比较均衡的经验值。如果面试官继续追问“TTL太短怎么办”就顺着说短TTL确实可能导致同一批穿透请求反复打库所以还要配合布隆过滤器前置拦截用两层方案去解决。这样回答既承认了方案的局限又展示了处理复杂问题的能力。最后说点个人体会。缓存穿透这个问题方案本身并不复杂复杂的是把整个缓存链路当作一个整体去设计。布隆过滤器、缓存空对象、参数校验、限流、分布式锁每个单拎出来都不难难的是在具体业务里把它们组合成一套完整方案。做黑马点评时我踩过最深的坑就是缓存空对象处理不当导致空key堆积后来把TTL调短、加入布隆过滤器前置拦截问题才彻底解决。这套思路不只适用于Redis换到其他缓存组件也一样核心逻辑始终是四个字少打数据库。