☰
缓存穿透防御实战:从参数校验到布隆过滤器与空值缓存
2026/10/14 5:33:14 网站建设 项目流程

凌晨两点半,值班手机把我从浅睡眠里拽了出来。监控平台上数据库的慢查询曲线直直地往上冲,像是心电图上拉平了的直线。第一反应是缓存挂了,登录服务器去看,Redis的QPS正常,内存占用也正常,数据节点一个没宕。那问题出在哪?拉出接口日志排查,才发现有一批请求在持续查询一些根本不存在的商品ID,每一条都精准地绕过了缓存,直接砸到数据库上。

这就是典型的缓存穿透。它在生产环境里的隐蔽性极高——不像缓存雪崩那样来势汹汹,也不像缓存击穿那样集中在某个热key上,穿透往往是一堆"查无此物"的请求,安安静静地把数据库的资源耗干。这篇文章我想把穿透问题的解决思路完整梳理一遍,从原理到落地,从单点防御到组合打法,把我自己踩过的坑和沉淀下来的方案一次讲清楚,希望对你手头的项目有帮助。

1. 缓存穿透:从一次"查了个不存在的东西"开始

先明确一个最基本的定义。缓存穿透指的是:请求的数据在缓存中不存在,在数据库里也不存在,导致每一次请求都要穿透缓存层,直接访问数据库。

正常情况下,一个查询流程是这样的:请求进来,先从Redis里查,命中就直接返回;没命中,就去数据库查;数据库里有,再把数据回填到缓存,下一次请求就能命中。这套流程之所以高效,是因为绝大多数请求会在缓存层被截住,只有少量"冷数据"或"缓存过期"的请求才需要回源数据库。

但穿透不一样。如果一个请求查的是个不存在的key——比如用户传了一个不存在的商品ID,或者一个已经被系统删除的数据ID——那么缓存永远不可能命中,数据库也永远查不到,缓存自然也没有机会回填。结果是:只要有人一直发起这类请求,数据库就会被持续打到。

在实际项目里,穿透的来源通常有两种:

  • 业务自身的场景:用户输入了错误ID、前端数据异常、爬虫遍历ID、系统间同步数据出现脏数据。
  • 恶意攻击:有人故意构造大量不存在的ID发起高频请求,目的是耗尽数据库连接,让正常业务不可用。

第二种尤其危险。因为正常业务场景下的穿透数量往往有限,而恶意攻击可以在极短时间内发起几十万甚至上百万次无效查询,直接拖垮数据库。我在某电商平台的售后系统里就见过一次典型的攻击:攻击者拿着一段连续但不存在的订单号,用脚本每分钟打几千次请求,订单查询接口的响应时间从50毫秒被拉高到5秒以上。

穿透和另外两个缓存问题经常被混在一起,我在这里先把边界划清楚:

问题触发场景本质
缓存穿透缓存和数据库都没有该数据查询根本不存在的数据
缓存击穿某个热key突然过期,大量请求同时回源热点key失效瞬间的并发冲击
缓存雪崩大量key同一时间过期,或缓存节点故障大规模缓存失效

从防御思路上说,穿透要解决的是"让无效请求别到达数据库",击穿要解决的是"让热key失效瞬间只有一个线程回源",雪崩要解决的是"分散失效时间、保证缓存高可用"。三者可以同时存在,也可以互相叠加,后面我会专门讲穿透和击穿纠缠时的处理。

2. 第一道防线:参数校验的价值比想象中大

很多团队一说到解决缓存穿透,第一个想到的就是上布隆过滤器或者缓存空值。但我觉得,最便宜、最有效、最容易被忽略的防线,其实是入口参数校验。

我在复盘上面提到的那次订单接口故障时发现,攻击者用的订单号根本不符合系统规则——正常订单号是18位数字,他发的是一串20多位包含字母的字符串。如果接口在进入缓存逻辑之前就做一轮基础校验,这些请求根本走不到Redis。

参数校验的核心原则很简单:业务规则上不可能存在的数据,直接返回不合法,不进入缓存查询链路。

常见的校验手段包括:

  • 格式校验:ID是否为空、长度是否符合规则、是否为纯数字、是否在合法范围内。
  • 枚举校验:类型字段是否在业务定义的枚举值内,比如订单状态、商品渠道。
  • 范围校验:数值型ID是否小于等于0,分页参数是否越界。

代码上的实现也不复杂。以最常见的商品查询为例:

public ProductInfo queryProduct(String productId) { // 第一层:基础参数校验 if (productId == null || productId.length() != 18) { return null; } if (!StringUtils.isNumeric(productId)) { return null; } long id = Long.parseLong(productId); if (id <= 0) { return null; } // 再走缓存查询逻辑 // ... }

你可能觉得这太基础了,但根据我的经验,**大量线上穿透事故的起点就是一个没做参数校验的老接口。**比如早期的商品详情接口,入参是用户直接传的ID,没有任何约束,后来加了个布隆过滤器才堵上。如果一开始就在网关层把参数校验做扎实,很多后续方案根本不需要上。

还有一个容易被忽略的细节:参数校验不只是"防穿透",它对下游所有环节都有保护作用。一个非法的超大ID如果进入Redis,会增加一次不必要的网络IO;进入数据库,会多一次无效SQL执行;万一数据库对这类查询没有合适的索引,还可能引发慢查询。

所以我的建议是:**参数校验放在最前面,多层逻辑共用。**如果项目有网关层,在网关层做一次基础校验;在业务层再做一次业务规则校验。宁可重复校验,也不要留缺口。

3. 缓存空值:朴素方案的正确打开方式

参数校验能挡住一部分明显不合法的情况,但挡不住"合法规则的ID,业务上确实不存在"。比如用户手滑输了一个格式正确、范围合理、但没有任何商品对应的ID,这种请求还是会穿透到数据库。

对付这类请求,最直接的手段就是缓存空值:当数据库查出结果为空时,把"空"这个结果本身也缓存起来,并设置一个较短的过期时间。后续同样的请求再来,命中的就是这个空值缓存,不会再去数据库查。

核心逻辑用一个简单的流程描述:

1. 查询缓存 2. 命中 -> 返回缓存值(可能是空值) 3. 未命中 -> 查询数据库 4. 数据库查到数据 -> 回填缓存,设置正常TTL,返回数据 5. 数据库没查到 -> 回填空值到缓存,设置短TTL,返回空

Redis侧的实现很简单,代码类似:

def query_product(product_id): # 先查缓存 cache_key = f"product:detail:{product_id}" value = redis.get(cache_key) if value is not None: # 命中了,包括空值 if value == EMPTY_FLAG: return None return deserialize(value) # 缓存未命中,回源数据库 product = db.query_product(product_id) if product is None: # 数据库也没有,缓存空值,TTL设置短一点 redis.set(cache_key, EMPTY_FLAG, ex=60) return None # 正常数据,正常TTL redis.set(cache_key, serialize(product), ex=300) return product

这里有几个参数和细节,我强烈建议你按生产标准来设计。

3.1 空值TTL怎么定

空值缓存的过期时间不宜太长,也不宜太短。太短了起不到保护作用——比如设成5秒,攻击者每秒发起10个不同ID的请求,每个ID的缓存5秒过期后都有一次穿透机会,数据库还是会被打到;太长了又可能导致一个已恢复的数据在较长时间内"被误认为不存在"。

我常用的经验值是1到5分钟。对于那些业务上极少出现"存在性变化"的场景,比如订单号、已归档的日志ID,可以设到10分钟;对于商品详情这种实时性较强的场景,1分钟就够。原则是:空值TTL = 业务能够容忍的"短暂不可见"时间。

3.2 空值必须和正常值区分

这道坑我踩过。之前有同事实现缓存空值方案时,直接在缓存里存了一个空字符串,没有做标记。结果业务方在读取缓存时,把空字符串反序列化后得到了一个默认对象,直接返回给了前端,用户看到的是一个"不存在"的商品被当成了"空壳商品",造成了一堆数据异常。

正确做法是给空值设一个特殊的占位标记,和正常缓存值在结构上区分开。比如统一加一个字段:{ "is_null": true, "data": null },或者用一个业务上不可能出现的字符串常量,比如__NULL__。读取的时候,先判断是否为空值标记,再决定是否返回空。

另外一个习惯是,给空值缓存key加上特殊前缀,比如empty:product:detail:{id},这样在监控Redis内存和统计key数量时,可以方便地识别出空值缓存有多少,也方便清理。

3.3 内存膨胀问题

这是缓存空值方案的硬伤。一张表里如果有100万个不存在的ID被查过,Redis里就会多出100万个空值key。每个key就算只占几十字节,100万个也是几十MB到上百MB的量。如果攻击者故意生成大量随机ID,这个量级还会继续增长。

要有总量控制机制。我见过比较靠谱的做法是:

  • 给空值缓存设置一个最大key数量上限,比如预估支撑100万个空key够用,超出上限就触发清理。
  • 对已有的空值key做抽样扫描,把长期没被访问的提前删除。
  • 在入口做单IP/单用户的访问频率控制,从源头减少空值缓存的新增速率。

最后再提醒一下:缓存空值不解决"同一个key的高并发同时回源"问题。比如一个不存在的ID,同一秒内来了1000个请求,第一个请求还没执行完"查库+回填空值"的流程,剩余999个请求因为没命中缓存,还是会全部打到数据库。这就是穿透和击穿的叠加场景,我会在第5节专门展开。

4. 布隆过滤器:把不存在变成"免查清单"

缓存空值的特点是"查到了没有,我才缓存没有"。而布隆过滤器更进一步,它的思路是:在查询之前就告诉你,这个key大概率不存在,根本不用进缓存。

布隆过滤器(Bloom Filter)是一个很经典的数据结构,它的核心是:用一个很长的二进制位数组和几个哈希函数,来表示一个集合的成员关系。判断一个元素是否在集合里,把它经过多个哈希函数映射到几个位,如果这些位都是1,就说它"可能存在";只要有一个位是0,就确定"必然不存在"。

这个设计带来了两个特性,缺一不可:

  • 绝不漏判:如果数据真的在集合里,查询时它对应的位一定都是1,所以布隆过滤器永远不会把一个存在的数据误判为不存在。
  • 可能有误判:因为不同元素可能映射到相同的位,当位数组里1越来越多时,一个不在集合里的元素也可能恰好命中所有位,被误判为"可能存在"。

对缓存穿透场景来说,这两个特性刚刚好。我们要拦截的是"确定不存在的ID",布隆过滤器能保证不拦截"真实存在的ID";至于它把少量不存在的ID误判成可能存在,也无所谓,因为接下来的缓存和数据库查询环节会继续兜底。

4.1 内存开销怎么算

很多人担心布隆过滤器会占用大量内存,其实恰恰相反,它的内存效率非常高。位数组大小和预期元素数量、可容忍的误判率之间存在一个公式:

位数组大小 m = -n × ln(p) / (ln2)²

其中 n 是预期元素数量,p 是误判率。

举个例子。假设你的商品表里有1000万个ID,你希望误判率控制在1%,那么需要的位数组大小是:

m = -10,000,000 × ln(0.01) / (ln2)² ≈ 95,850,000 bit ≈11.4 MB

你没看错,给1000万个ID做一层"存在性过滤",只需要大约12MB内存。哈希函数的数量按公式计算:

k = (m / n) × ln2 ≈ 6.64,取7

也就是说,每个元素映射到7个位就够了。这在Redis里就是个小case。

计算过程我简化处理了,实际项目中你可以直接用现成工具算。Java里有Google的Guava库,BloomFilter类可以直接创建:

BloomFilter<Long> filter = BloomFilter.create( Funnels.longFunnel(), 10_000_000, // 预期元素量 0.01); // 期望误判率1%

Redis从4.0开始提供了布隆过滤器模块,可以用BF.ADD、BF.EXISTS命令直接操作,非常方便,不需要自己在业务代码里维护位数组。

4.2 数据初始化与更新

布隆过滤器在工程落地时,最让人头疼的不是数据结构本身,而是和业务数据的同步。

初始化阶段,需要把当前数据库里所有有效ID都加载到过滤器里。1000万个ID的加载通常不是实时逐个加的,而是批量刷入,或者直接用离线任务预热。这一步的时序要注意:如果过滤器还没加载完就上线,会误杀真实存在的ID,导致业务异常。

更新阶段,每新增一个数据,就要往过滤器里加一个ID。删除数据时问题就来了——标准布隆过滤器不支持删除。因为一个位可能被多个元素映射,你不能把一个元素对应的位清0,否则可能把其他元素也清掉了。解决思路有两个(后面详述)。因此实际项目中,布隆过滤器往往只在"判断存在性"这个环节使用,删除后的ID在过滤器里仍然占位,影响的只是极少数误判查询,无害。

4.3 误判率的代价

布隆过滤器的误判不是零成本的。当误判发生时,一个事实上不存在的ID会被放进缓存查询链路,走一遍缓存和数据库。但因为这类请求数量通常不多,数据库完全扛得住。

我见过有的团队因为担心误判,把布隆过滤器误判率设成0.001%,结果内存暴增,性能也下降。我的建议是:1%甚至5%的误判率对穿透防护来说完全够用,没必要追求极致。过滤器的价值是挡掉99%以上的无效请求,剩下的1%交给缓存空值去兜底,形成互补。

4.4 和缓存空值的取舍

这里说点实际经验。布隆过滤器和缓存空值不是二选一,而是根据场景分主次:

  • 如果业务中的ID集合比较稳定,不会频繁增删,且总量可预期,优先用布隆过滤器做前置拦截,效果最好,内存最省。
  • 如果ID集合变化很快,比如每天新增大量短时效数据,布隆过滤器的更新链路会变得复杂,这时候缓存空值更灵活,不用维护全量ID集合,只要在"查不到"时回填空值就行。
  • 实际操作里,我所在团队的做法是:**网关层放布隆过滤器挡恶意穿透,业务缓存层放空值缓存挡正常误判。**两层各干各的,互不冲突。
  • 对稳定性要求极高的核心链路,会同时上。布隆过滤器档住绝大多数无效请求,空值缓存顶住那1%的漏网请求,数据库压力趋近于零。

5. 穿透和击穿纠缠时,互斥锁怎么接

前面提到一个容易被忽视的场景:同一个不存在的key,在同一时间窗口内被大量请求同时访问。这种情况下,缓存空值的"第一次回填"其实还是会让所有请求同时打到数据库。

举个具体例子。攻击者构造了一个productId=123456789的请求,这个ID在缓存里没有值,在数据库里也不存在。缓存空值方案下,第一个请求发现缓存未命中,去数据库查,没查到,回填空值,整个过程可能耗时50毫秒。就在这50毫秒里,如果这个ID的请求量是每秒10000次,那前几千个请求都会处在"缓存未命中"状态,全部并发打向数据库。这一波冲击,数据库很可能顶不住。

这个场景的实质是:**一个不存在的key在"尚未缓存空值"的窗口期,承受了高并发的回源压力。**它和缓存击穿在表现上非常像,区别在于击穿的是"真实存在但刚好过期"的热key,这里击穿的是"本就不存在"的key。

解决思路也类似:在回源数据库查数据之前,保证同一个key只有一个线程在查,其他线程等待结果。

这一段我直接给一个通用实现方案。Java里可以用Redisson的分布式锁,也可以用Guava的本地锁,核心逻辑是一致的:

public ProductInfo queryProduct(String productId) { // 先查缓存 String cacheKey = "product:detail:" + productId; Object value = redis.get(cacheKey); if (value != null) { return deserialize(value); } // 缓存未命中,尝试获取锁 String lockKey = "lock:product:detail:" + productId; RLock lock = redisson.getLock(lockKey); boolean locked = false; try { // 尝试获取锁,等待时间3秒 locked = lock.tryLock(3, TimeUnit.SECONDS); if (locked) { // 拿到锁后再次查缓存,防止第一个线程已经回填 value = redis.get(cacheKey); if (value != null) { return deserialize(value); } // 真正回源数据库 ProductInfo product = db.queryProduct(productId); if (product == null) { redis.set(cacheKey, EMPTY_FLAG, 60); } else { redis.set(cacheKey, serialize(product), 300); } return product; } else { // 其他线程拿不到锁,短暂等待后直接查缓存 Thread.sleep(50); value = redis.get(cacheKey); if (value != null) { return deserialize(value); } return null; } } finally { if (locked) { lock.unlock(); } } }

这段代码有两个细节值得注意。

第一个是拿到锁之后必须先查一次缓存,这就是经典的"双重检查锁定"。因为抢锁失败的线程可能已经先把缓存填好了,拿到锁的线程如果直接查库,就白白浪费一次回源。

第二个是拿锁失败的线程不要无限等待。我给的方案是睡50毫秒再查一次缓存,还是没查到就返回空。实际生产中,等待时间可以根据业务对一致性的要求调整。有的团队选择直接自旋查缓存,直到超时;有的团队选择直接返回"正在查询中"的提示——具体取决于你的业务能不能接受短暂的空结果。

有了互斥锁,同一个key在高并发下的回源次数就被压缩到了1次。但如果空值缓存到期了,下一次请求又会重复这个过程。所以对于持续被攻击的key,建议同时把这个key加入短时黑名单,在黑名单有效期内直接拒绝查询,或者延长它的空值TTL。

6. 生产环境的组合防御:我习惯的分层结构

解决缓存穿透,没有一个方案是银弹。参数校验挡不住"合法格式的不存在ID",缓存空值挡不住首次回源的并发,布隆过滤器挡不住误判。真正稳妥的做法是分层防御,每一层拦截一部分风险,最后落到数据库的请求趋近于零。

我在团队里常用的分层结构是这样的:

6.1 第一层:入口限流与参数校验

网关层做两件事。第一,基础参数校验,把明显非法的请求直接返回;第二,对单IP、单用户、单接口维度做访问速率控制,阻断高频恶意请求。限流本身不是防穿透的核心,但它能有效压制穿透攻击的请求量级。

从根上看,穿透攻击和普通的高频刷接口在威胁模型上有重叠:只要请求到达不了业务层,后面的缓存和数据库就安全。

6.2 第二层:布隆过滤器拦截不存在的key

对核心查询接口,在进入缓存查询之前先查布隆过滤器。如果过滤器说不存在,直接返回空结果,不碰缓存,不碰数据库。这里是拦截效率最高的一层,能挡掉绝大多数无效请求。

千万不要把布隆过滤器放在Redis缓存之后——那样无效请求仍然会打到Redis,增加了不必要的IO压力,布隆过滤器的价值就缩水了。

6.3 第三层:缓存空值兜底

布隆过滤器有误判,缓存空值负责把这些漏网的"不存在ID"变成短期可命中的key。当数据库返回空时,写入短TTL空值,保护后续重复请求。

这层还要配合互斥锁,确保同一个key在同一时间窗口内只有一个线程回源,避免首次回填瞬间的并发击穿。

6.4 第四层:数据库查询兜底与降级

走到这一步的请求已经是少数。数据库侧还需要加两个保险:一是所有查询必须走索引,避免全表扫描放大压力;二是数据库连接池设置最大上限,超出的请求直接排队或快速失败,而不是无限堆积。

如果缓存和数据库同时出现异常,比如Redis集群不可用,这时候还要有熔断降级机制——比如直接返回一个精简版的页面数据,或者提示用户"稍后重试",而不是让请求无限重试把数据库拖死。

6.5 监控指标要盯哪些

方案上线后,监控比方案本身更重要。我建议至少要盯这几个指标:

  • DB QPS:回源数据库的请求速率,这是衡量穿透防御效果最直接的指标。
  • 缓存命中率:命中率突然下降,往往意味着有大量新key涌入或空值缓存被刷爆。
  • 空值缓存key数量:监控空值key的增长率,如果短期内暴增,说明有人在大量扫描不存在的ID。
  • 布隆过滤器误判率:虽然很难线上实时统计,但可以通过拦截后"仍然命中数据库空查询"的比例来估算。

我习惯给DB QPS设置双层告警:超过日常峰值30%触发黄色告警,超过100%触发红色告警。空值key的数量增长也设一个速率阈值,比如每分钟新增超过1万个就告警。

6.6 一个从现实项目里沉淀的组合案例

之前给某电商的售后订单查询接口做过一次完整改造。订单表大概2000万条数据,每天正常的查询量是500万次,其中约3%是无效订单号查询——有用户输入的,有爬虫扫描的。

改造前,数据库每天因为无效查询产生的压力占到总QPS的15%左右,高峰时段偶尔抖动。

改造方案是分两步走。第一步,在入口加订单号格式校验(18位纯数字),暴力过滤掉了一部分非标输入。第二步,用Redis的布隆过滤器缓存所有有效订单号,在查询入口前置拦截;同时对误判的请求做缓存空值兜底,空值TTL设为5分钟。改造后,DB QPS下降了12个百分点,高峰时段数据库稳定了很多,订单查询接口的P99响应时间从400毫秒降到120毫秒。

这个案例里布隆过滤器和缓存空值同时存在,各管一段:布隆过滤器拦截大量无效请求,缓存空值吸收误判和首次穿透。

7. 几个容易翻车的细节,我替你们先踩一遍

方案原理说完了。但真正落到工程上,总有些细节会把你绊一跤。我捡几个亲身踩过的坑,按从高到低的翻车概率说一说。

7.1 空值缓存是否污染了业务数据

我见过一个团队把空值缓存和正常缓存放在同一个key空间里,结果下游做数据统计时,把空值key的个数也算进了"商品数量",数据报表直接失真。这个其实很好规避,就是之前说的:**空值缓存用单独的key前缀,并且跟正常缓存域名分开。**这样既能统计监控,又方便一键清理。

7.2 布隆过滤器数据加载的时序坑

重新上线布隆过滤器的时候,如果数据库里已有存量数据,必须先做全量加载,加载完成后再切流。你要是先切流再加载,真实存在的ID在过滤器里还是空的,就会被误杀。我自己遇到过,因为加载过程用了异步任务,前端业务已经跑起来了,导致新上线的所有商品详情都返回"不存在",排查了半小时才发现是过滤器冷启动问题。

7.3 布隆过滤器"删除"怎么处理

前面说了标准布隆过滤器不支持删除,那业务上删了一个商品之后,过滤器里的对应位怎么办?我的建议是不处理。这个位会继续留在那里,因为其他ID也可能映射到这个位,不能为了它单独清位。它的影响只是让过滤器对某些ID的"不存在判断"失效,但这些ID其实已经不存在了,被缓存空值兜底后没有任何危害。

如果业务上确实频繁删除且量很大,可以考虑定期重建过滤器,比如每周凌晨从数据库全量重建一次,把无效的位清掉。

7.4 不要把过滤器用于小集合

如果集合只有几千个元素,布隆过滤器的内存优势体现不出来,而且初始化、更新链路的复杂度会超过它带来的收益。这种情况下用普通缓存,或者直接让缓存空值去兜,反而更简单可靠。我的经验是,预期元素量在百万级别以上,才值得上布隆过滤器。

7.5 空值TTL和业务一致性

给空值设置了5分钟TTL,意味着如果一个数据在5分钟之内被创建出来,在这段时间内,它的查询结果仍然是"不存在"。这在有些业务里不可接受。比如秒杀系统,商品是在活动开始前几分钟才创建并上线的,如果空值TTL设得比创建时间还长,就会造成"商品已创建但前端还是查不到"的体验问题。解决方案是:创建数据时主动删除对应的空值缓存key,让下一次请求立即回源数据库,拿到新数据。

7.6 测试用例要覆盖"穿透路径"

很多团队在测试缓存逻辑时,只测了"缓存命中"和"缓存未命中后回源成功"两条路径,恰恰没测"缓存未命中后回源为空"这条路径。结果空值缓存逻辑上线后,第一个不存在的ID就触发了NPE或者序列化异常。写测试时,一定要把"查询不存在的ID"作为一条独立的用例覆盖到,而且要模拟并发场景,验证互斥锁是否真的只回源了一次。

我在实际项目里发现,**这六条中的前三条,几乎每个新接手缓存模块的同事都会踩一遍。**你提前绕过这些坑,后续的线上问题至少能少一半。遇到缓存穿透类告警时,按这套思路排查和加固,基本都能在半小时内定位到根因并拿出方案。

最后说个小技巧。如果你不确定自己的方案对"空值"和"不存在"的边界处理是否完善,可以在上线后做一次小范围的流量染色测试——故意发一批格式合格、内容不存在的请求,观察数据库回源量是否如预期趋近于零。这个动作成本很低,但对验证整体链路的作用非常大。

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

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

立即咨询