资讯动态

缓存穿透详解:从空值缓存到布隆过滤器的分层防御实践

发布时间:2026/9/9 22:32:13 来源:尧图企业网站定制
深夜十一点半客服群里弹出一条消息有用户反映在 App 里反复搜索一个不存在的商品 ID页面一直转圈最后直接超时。我心里一沉。凌晨的访问量并不大之前回归测试也都正常怎么突然接口大面积超时打开监控面板数据库 CPU 已经冲到 90% 以上慢查询日志里全是同一条 SQL——按主键查商品信息但主键 ID 在表里根本不存在。这不是并发太高也不是 SQL 写错了。这是缓存穿透。那晚我和值班同事花了半个多小时先临时加了空值缓存又把非法参数请求在网关层挡了一波数据库才慢慢缓过来。第二天复盘时我们才意识到这个系统性缺口其实早就存在只是一直没有被触发。而所有防穿透的方案最终指向同一个问题——缓存只是挡在前面的一层“保安”但它并不知道每一次来访的人到底是真用户还是恶意探测者。这个场景不是个例。任何一个缓存加数据库的两层架构只要业务键里有“用户可控”的部分都可能被穿透打穿。这篇文章想认真聊透缓存穿透这件事它到底是什么为什么难防主流方案各自的边界在哪里以及真正落地时最容易踩哪些坑。1. 先分清楚穿透、击穿、雪崩不是同一件事很多人一聊到缓存就爱把穿透、击穿、雪崩三个词混着说好像都是“缓存没扛住”。实际上这三件事的根因、表现、危害范围完全不一样处理方式也完全不同。1.1 三个“缓存事故”的核心差异缓存穿透Cache Penetration的本质是查询的数据在缓存里没有在数据库里也没有。也就是说这次查询无论查多少次都不可能命中缓存也不可能命中数据库数据。因为数据根本不存在所以每次请求都会穿过缓存层直接打到数据库。缓存击穿Cache Breakdown的核心是某个热点 key 正好在缓存过期的瞬间被大量并发请求同时访问。数据本身在数据库里是存在的缓存里也本该存在只是刚好那几毫秒过期了于是海量请求同时冲进数据库。缓存雪崩Cache Avalanche指的是大量 key 在同一时间段集中过期导致请求在一段时间内大面积落到数据库。看起来像是击穿的放大版但这是整体性的过期问题不是单个 key 的问题。这三者最容易被忽略的区别在于穿透是数据本身不存在导致的。击穿是热点 key 过期的瞬间并发导致的。雪崩是大量 key 在时间上集中失效导致的。所以排查思路上第一步永远是先判段“数据库里到底有没有这条数据”。如果数据库里没有那就是穿透如果数据库里有但缓存刚好过期那是击穿或雪崩。1.2 判段类型的一个简单框架遇到服务响应变慢、数据库压力陡增的情况可以先按下面这个顺序快速定位看缓存命中和缓存请求量如果缓存请求量很大但命中率突然下降先从 key 的分布入手。看慢查询对应的查询条件按主键查还是按索引查查的是哪张表表里到底有没有这条数据。看数据库连接数和活跃会话如果会话数暴增且大量处于查询等待状态再进一步看这些查询是不是都在打同一个 key 或同一批不存在的 key。看现象的时间线是某次发布后开始还是某个时间点突然出现如果是发布后先看是否有新的查询路径和缓存的 key 规则变更。这种先判断类型、再决定处理方案的方式比一上来盲目加缓存、加机器可靠得多。因为击穿和雪崩的应对手段是错峰过期、互斥重建穿透的应对手段是空值缓存、布隆过滤器、参数校验。如果类型判断错了方案就会用反。2. 为什么“查一个不存在的数据”会把数据库打穿这个问题听起来很简单但从实际工程角度来看不是“有人在反复查不存在的数据”这么单一。深入了解穿透的触发路径才能理解为什么一个看起来不起眼的攻击行为会造成灾难。2.1 单次查询无害真正致命的是流量放大单次查询一个不存在的商品 ID数据库开销其实极小。一次索引查找发现没有记录返回空结果耗时可能不到 1 毫秒。真正让数据库打满的原因往往是流量被放大了一个量级恶意用户或爬虫用脚本批量遍历不存在的 ID。业务代码里对单个接口的失败结果做了重试。前端在超时后自动重发请求同时多个端在重试。网关或服务框架层面的重试机制叠加。假设数据库单机单条空查询耗时 0.5 毫秒理论上每秒可以处理 2000 次空查询。但一旦重试倍数叠加上来一秒内涌入几万次查询数据库的连接池和 CPU 就会被耗尽。而且空查询不会命中任何缓存也就没有任何“削峰”机制相当于所有请求都直接压在数据库上。2.2 穿透请求往往不是随机的而是“有规律的脏数据”很多人以为穿透只会来自恶意攻击。实际上正常的业务代码里也有大量穿透数据源比如用户手动输入了某个已下架并被物理删除的商品 ID。两个系统之间数据同步延迟A 系统生成了业务数据B 系统已经发出查询。前端缓存了历史页面用户翻到一条旧记录后点击进入详情但后端数据已被清理。数据分析或运营后台直接拼接参数调用接口参数里带了不少过期 ID。这些请求的共同特征是key 不是完全随机而是由业务过期数据、错误输入或历史缓存造成。正因为它们看起来像是正常的业务请求单纯在网关做 IP 限流并不总能拦截住。2.3 缓存层能挡住“存在但尚未缓存”的数据却挡不住“根本不存在”的数据缓存的本质是“短暂的高速副本”。它存在的意义是把读过一次的数据暂存起来供后续请求快速读取。它天然假设了一件事大部分查询命中过的数据是有可能被再次访问的。对于不存在的 key缓存里永远不可能有副本因此缓存层对这种请求没有记忆能力。除非我们显式地写入一条“空数据”来标记它否则每次请求都会重复执行“查缓存 - 未命中 - 查数据库 - 发现不存在 - 返回空”这个完整链路。这就是穿透和击穿、雪崩最大的不同——穿透不是缓存节点或 key 的偶发问题而是数据本身缺失造成的结构性缺口。3. 防穿透的第一道门空值缓存先把重复的空查询拦住在绝大多数系统里防穿透的第一道防线不是布隆过滤器而是空值缓存。3.1 什么是空值缓存空值缓存的思路非常直接当数据库查询结果为空时也把这次查询结果缓存起来只不过 value 是一个约定好的“空值标记”。例如查询订单详情订单号是ORD2025010001数据库里没有这条记录可以在缓存里写入ORD2025010001 - EMPTY TTL: 60秒这样一来后续 60 秒内再有请求查询同一个订单号缓存层直接返回空值标记请求不再穿透到数据库。这个方案的好处是对现有代码的侵入很小。不依赖额外的中间件。实现成本很低几行代码即可完成。对重复穿透流量有立竿见影的拦截效果。3.2 空值缓存要设置的几个关键参数空值缓存的实现并不难难在参数设置。从实际经验看这几个点最需要认真对待TTL过期时间空值缓存的 TTL 不宜设置太长。因为空值代表“数据当前不存在”但数据库的数据是动态变化的过一分钟可能有新数据写入过一小时可能有数据恢复。TTL 太长会导致“数据已存在但缓存还返回空”的消息丢失问题。一般建议设置在 30 秒到 5 分钟之间对于变化不频繁的数据比如商品、订单可以设置 2-5 分钟。对于变化频繁的数据比如库存、价格建议设置 30 秒到 60 秒。对于活动类数据要结合数据创建延迟来设置不能拍脑袋。空值标记的设计不要把空值直接设置成 null 或空字符串建议使用一个固定的字节数组或特殊字符串比如EMPTY_CACHE_FLAG。原因在于有些缓存客户端会把 null 当成不存在的值处理你写进去反而等于没写。使用一个不可读的字段或一个业务上不冲突的值可以明确区分“真实数据”和“空值标记”。是否要区分空值类型在某些业务里“无数据”可能有多种含义订单不存在、订单已删除、订单被隐藏、订单无权访问。如果空值缓存混为一谈后续做业务逻辑判断就会出错。建议在缓存 value 里带一个类型字段或者在 key 后缀上做区分避免业务层面被误导。3.3 空值缓存依然不能满足所有场景空值缓存最大的局限在于它只能拦截“已经查过一次空结果”的 key。如果恶意请求每次都用不同的、不存在的 key比如随机生成 UUID 去查询空值缓存就无法发挥作用——因为每个 key 第一次都不会命中缓存都会穿透到数据库。举个例子恶意用户每次访问都带上一个随机生成的用户 IDUSER_9F3D...每秒钟生成一千万个不同 ID。空值缓存能记住最近查过的那一批不存在的 ID但对不断新增的随机 key 无能为力。这时就需要新的手段。4. 拦截大量不存在 key布隆过滤器到底该怎么用布隆过滤器Bloom Filter是防穿透的关键武器。它可以用来回答“某个元素一定不存在吗”这个问题。它的核心价值在于用极小的内存空间判断一个 key 是否有可能存在。这个“有可能”很关键。布隆过滤器的特性是如果它说“不存在”那这个 key 一定不存在。如果它说“存在”那只能说“可能存在”因为存在一定的误判率。4.1 布隆过滤器的工作原理布隆过滤器本质上是一个很长的位数组和一组哈希函数。当一个 key 要加入过滤器时它会被多个哈希函数计算出多个位置并把位数组上对应的位都置为 1。查询一个 key 是否存在时同样计算多个哈希位置如果这些位置中任何一位是 0就说明该 key 一定不在集合里如果所有位都是 1则只能说“可能存在”因为也许这些位恰好来自另外几个 key 的叠加。这里可以打个比方就像一道检查名单的门禁门禁卡片上并不是记录每个人的完整信息而只是记录了“这个人是否可能在某段时间内来过”。如果你想找的人不在名单上门禁会直接拒绝如果在名单上门禁会放行但进的可能是同名同姓的人。这种方案用极小的空间代价换来了“大概率正确但绝不漏判”的能力。4.2 布隆过滤器的落地方式如果系统规模不大可以在应用启动时加载全量有效 ID 到布隆过滤器中查询前先判断 key 是否存在// 伪代码使用 Guava 的 BloomFilter BloomFilterString bloomFilter BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), expectedInsertions, fpp ); // 查询前判断 if (!bloomFilter.mightContain(orderId)) { // 布隆过滤器认为一定不存在直接返回 null return null; } // 可能存在的 key继续走缓存和数据库查询这里的expectedInsertions是预估的数据量fpp是可接受的误判率。常见设置是预估容量根据业务表未来的数据量预估宁高勿低。误判率建议设置在 1% 到 5% 之间。误判率越低位数组越大内存开销越高。哈希函数数量会由库根据位数组大小和数据量自动计算。如果是 Redis 环境可以使用 Redis 的布隆过滤器模块通过 BF.ADD、BF.EXISTS 命令操作适合多实例共享的场景。4.3 布隆过滤器注意的三个问题布隆过滤器不是银弹。使用中有几个点必须提前想清楚数据删除与同步如果业务数据有物理删除比如用户注销账后删除了所有记录布隆过滤器里对应的位并不能被精确清零。因为多个 key 可能映射到同一批位删掉某个 key 可能会影响其他 key 的判断结果。处理方式有两种业务层面不物理删除数据而是做逻辑删除标记。定期全量重建布隆过滤器把当前有效 ID 重新灌入。数据新增的延迟布隆过滤器里的数据需要提前预热。如果新增数据的同步不及时可能会出现“新插入的 key 还没加入过滤器就被拦截在门外”的情况。建议先用数据库当作最终判断依据也就是说布隆过滤器只用来挡住明显不存在的请求对于“可能存在”的请求再走缓存和数据库。设计目标要清晰布隆过滤器的设计目标不是百分之百判断正确而是把绝不可能存在的请求拦截在数据库之外。只要误判率控制在合理范围内数据库收到的无效查询就会大幅减少但同时不会影响正常数据的读取。5. 更完整的防线参数校验、限流降级、缓存重建控制布隆过滤器和空值缓存是预防穿透最强力的两种手段。但真正的高可用系统不能只靠单点策略。防穿透应该是一套分层的体系每一层负责拦截一部分请求让真正到达数据库的请求数量可控。5.1 参数校验在没有数据之前先校验数据格式很多穿透请求从一开始就不符合业务规则。比如订单号通常有固定格式用户 ID 通常是一个数字或具体编号如果请求参数是随机字符串就应该直接拒绝。这一层要做的校验包括参数格式验证ID 是否为数字、是否为有效的 UUID、长度是否符合规则。范围验证ID 是否在合理的数值范围内是否超出业务允许的上限。权限验证当前用户是否有权访问该资源。频率验证单用户或单 IP 的访问频率是否超过阈值。网关层做参数校验要谨慎因为有些请求是从内部服务发起的内部服务的参数不一定都带网关需要的上下文。通常会把参数校验放在业务入口的统一拦截器里方便根据业务规则调整。5.2 限流和熔断防止瞬时流量压垮数据库即使做了参数校验和布隆过滤仍然可能有大量正常请求在极端情况下集中打过来。此时需要限流和熔断机制。限流可以从几个维度做按接口维度限流限制某个接口每秒最多接收多少请求。按用户维度限流限制单个用户每秒最多请求多少次。按 IP 维度限流限制来源 IP 的访问频率。熔断则更关注依赖状态如果数据库连接池的等待队列超过阈值就直接返回降级结果而不是让请求继续堆积在数据库层。一个容易忽略的点限流和熔断不应该等到数据库已经出问题才生效。好的限流策略应该是平时就配置好阈值并且通过监控告警来动态调整。如果只在出问题时才人工开启流量峰值可能已经打进来响应过程会很被动。5.3 互斥锁重建缓存穿透和击穿同时出现时的处理当某个 key 数据存在但缓存已过期同时大量请求并发访问时就需要避免所有请求都去数据库重建缓存。常见的做法是使用互斥锁请求到达缓存发现 key 不存在或已过期。尝试获取分布式锁或进程内锁。只有拿到锁的请求去查询数据库并重写缓存。其他请求等待短暂的间隔再重新从缓存读取数据。这个方案也能间接解决一部分穿透请求造成的压力但它不等于防穿透。它的重点是防止“同一个存在的 key”在过期瞬间被打穿并不是拦截“不存在的 key”。实际使用时要区分场景不能混用。5.4 缓存隔离会话级缓存与应用级缓存要分清楚在防穿透体系里还有一个容易踩坑的设计缓存层要做缓存隔离。最常见的错误是把所有类型的 key 都放在同一个缓存中也不区分 value 的大小、访问频次。这会导致两个问题某个冷门数据或大 value 把缓存空间占满热点 key 频繁被淘汰缓存命中率下降。缓存过期策略无法针对不同业务做精细化控制。建议按业务类型拆分缓存命名空间或者使用多级缓存本地缓存Caffeine 或 Guava Cache负责热点数据Redis 负责大范围共享数据数据库兜底。本地缓存命中时不需要走网络对穿透流量的拦截效果更好但也要注意本地缓存的一致性问题。6. 排查链路当数据库压力突然上升该怎么一步步定位不管事前做了多少防护生产环境出一堆意外情况总会出现。关键在于问题发生时能不能快速定位是不是缓存穿透以及当前被穿透的是哪类 key。6.1 第一步看现象判断是不是缓存相关问题数据库压力上升不一定都是缓存穿透。首先要确认的是数据库连接数是否达到上限。CPU 和磁盘 IO 是否异常升高。慢查询数量是否突增。缓存服务的命中率是否突然下降。如果这些指标同时出现而且时间点和某次发布、某个活动上线或某个异常流量时间段吻合就要高度怀疑是缓存相关问题。6.2 第二步看查询特征判断是哪种缓存问题把慢查询日志中 Top N 的 SQL 提取出来看查询条件如果大量慢查询是同一个 key且缓存中无此数据、数据库中也查不到穿透。如果大量慢查询是同一个存在的 key且缓存只因刚好过期而失效击穿。如果大量慢查询的 key 各不相同但数据库中的数据本身存在雪崩或批量缓存失效。这一步建议做成自动化巡检脚本把慢查询按“查询条件哈希”和“是否存在数据库”两个维度聚合并能大幅缩短定位时间。6.3 第三步按“输入 - 缓存 - 数据库 - 参数 - 依赖”顺序排查当基本确认是缓存穿透后按以下顺序排查具体原因输入层请求参数是否可枚举、可预测攻击者是否可以生成大量不存在的 key 来探测接口缓存层空值缓存是否启用TTL 是多少穿透请求的 key 是否被缓存住了布隆过滤器是否启用了布隆过滤器过滤器和数据库数据是否同步误判率设置是否合理参数校验层接口是否接受了明显非法的参数比如超大数字、超长字符串、非法字符限流降级层网关和服务的限流策略是否生效数据库连接池和线程池是否配置了合理的拒绝策略6.4 第四步快速止血方案与长期方案分开处理线上出现穿透时不要一开始就追求完美方案。建议先做两步止血止血动作一先把不存在的 key 批量预热成空值缓存。如果当前超标请求正好集中在少数几个 key直接写脚本把这批 key 查询一次让缓存层提前生成空值可以立刻减轻压力。止血动作二在网关或拦截器里临时加一层参数校验。如果异常请求有明显的特征比如 ID 是 UUID 格式但业务 ID 本应是数字可以在入口直接拒掉避免压力持续传递到下游。长期方案则是前文讲的完整链路布隆过滤器 空值缓存 参数校验 限流降级 监控告警。7. 写了一套防穿透方案之后还需要靠最后的“数据闭环”验证7.1 防穿透方案的验收指标不能只看“响应时间”很多人做完防穿透改造后只看接口平均响应时间有没有下降。这个指标容易骗人。真正应该看的是数据库空查询次数在接入层打点统计“查询数据库但返回空”的次数。若方案生效这个数字应该大幅下降。缓存空值命中率统计空值缓存命中的次数如果一直为零说明空值缓存可能没生效或者异常请求 key 一直在变化。布隆过滤器拦截率统计布隆过滤器拒绝的请求数占总请求数的比例。如果拦截率很低要检查过滤器是否灌入了正确的有效 ID。数据库连接池使用率核心指标这个数字直接反映数据库压力。7.2 构造压测场景验证穿透防护通过压测可以验证防护体系是否有效。建议构造这样的压测场景准备一批完全不存在的 key比如 10 万个随机订单号。模拟用户高频访问将这些请求打到防护体系上。观察数据库空查询量、缓存命中率、布隆过滤器拦截率。检查在极端 QPS 下数据库连接池是否打满慢查询是否突增。只有在这个压测下数据库压力仍能被控制在一定范围内才算真正防住。7.3 数据闭环与告警最后不要忘了监控告警。防穿透方案不是配置完就万事大吉后续数据库如果出现新类型的穿透 key监控应该能够自动发现。建议配置三类告警数据库空查询比例告警空查询 / 总查询比例超过阈值说明可能有异常流量。缓存命中率持续下降告警持续下降意味着新的 key 空间在出现。布隆过滤器误拦截比例告警如果误拦截增多说明过滤器需要重建。数据闭环的意义在于把一次性的拦截动作转化为可持续观测和调优的能力。否则方案只是上线时验证过几个月后数据量增长、业务字段变化可能防护已经失效但没人察觉。8. 绕不开的边界什么情况下布隆过滤器不再合适再好的方案也有不适用的时候。布隆过滤器虽然强但在一些场景下并不是最优选择。8.1 数据量小、请求量也不太高的系统如果系统数据库里的有效 ID 只有几万条请求量也只有每秒几十次那么布隆过滤器就不是必须的。空值缓存 参数校验基本足够引入布隆过滤器反而增加了数据同步和重建运维成本。判断标准可以看数据库每秒因空查询产生的请求是否超过 100 次。如果长期低于这个数数据库有足够余量可以先不加布隆过滤器。8.2 频繁删除且无法逻辑删除的场景如果业务里大量数据是短期存在然后物理删除的布隆过滤器会积累大量“已删除 key 的标记”导致误判率升高。因为位数组一旦置 1 就不可逆除非重建否则所有历史 key 的“幽灵”都会一直在位数组里存在。对于这种场景要么定期重建过滤器要么改用别的方案比如将有效 ID 维护到数据库索引里配合较严格的参数校验来拦截非法请求。8.3 多实例部署时的数据一致性问题布隆过滤器如果放在应用内存里各实例之间是独立的。当一个实例预热了某些 key另一个实例并不会有这些数据。如果系统有多个应用实例且业务数据频繁变化使用 Redis 布隆过滤器模块会更合适否则会出现同一请求在不同实例上表现不一致的问题。不过这里也要注意Redis 布隆过滤器本身是一个独立组件会增加运维成本和故障点。小型系统要谨慎引入。8.4 判断一个方案是否要引入先从“最坏情况”倒推对于要不要为防穿透引入额外组件我更建议先做一个“最坏情况估算”系统每天有多少请求可能携带不存在的业务 key数据库能在不被压垮的情况下承受多少空查询 QPS如果故意被遍历系统能扛多久如果最坏情况数据库 5 分钟就挂那布隆过滤器或空值缓存是必要的。如果数据库扛得住只是偶尔有慢查询那先用参数校验 空值缓存垫底观察一阵再决定是否升级方案。9. 防穿透这件事真正考的不是方案多高级回到文章开头那个深夜问题。我们最后解决的其实不只是“加了一个空值缓存”这个动作而是把防穿透这件事变成了一个可以持续评估和迭代的体系。现在回头看这个事故教会我的几件事第一穿透请求是必然的不是偶然的。只要有用户可控的参数只要数据有删除和过期穿透流量就一定会存在。差别只是攻击者是主动的还是被动触发的。第二单点方案只能解决单点情况。空值缓存能拦住重复空查询但拦不住随机 key布隆过滤器能拦截不存在的 key但管不了新数据的同步和删除参数校验能挡住一部分格式违规的请求但挡不住“格式正确但确实不存在”的 ID。只有多组策略叠加时数据库才能真正被保护住。第三防穿透的本质不是优化数据库查询而是让请求进入数据库之前就有尽量多的判断依据。缓存层承担一部分判断布隆过滤器承担一部分判断参数校验承担一部分判断限流承担一部分判断。数据库只应该看到那些“大概率合理”的请求。所以如果你现在正准备给自己的系统加入防穿透方案建议从一个小步骤开始先给“查无此数据”的场景加上空值缓存设置 5 分钟的 TTL然后观察一周数据库的空查询量和缓存命中率。如果数据量已经很大再考虑布隆过滤器。不要在第一周就同时上线所有方案否则问题发生时你根本分不清是哪个环节没有生效。防穿透不是一次改造而是一种对流量边界的持续认知。先守住最脆弱的那一层再逐步把防御网织密一点这比一次弄一个“完美方案”要可靠得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价