缓存穿透这个问题,只要做过高并发接口的同学,基本都会碰到。它的核心现象是:请求查询的数据在缓存里没有,在数据库里也没有,于是每一次请求都会绕过缓存直接打到数据库。正常用户偶尔查一个不存在的 ID 问题不大,真正危险的是恶意脚本专门挑不存在的 key 轮番轰炸,数据库 QPS 瞬间被拉满,缓存层形同虚设。很多教程遇到穿透就推荐“缓存空值”,也就是查询为空时也往 Redis 里写一个空结果。这个手段不是不能用,但它只能算入门级应急处理。如果你把防穿透的全部希望押在缓存空值上,生产环境迟早要出问题。下面把穿透的成因、缓存空值的硬伤,以及真正适合生产的分层防护方案一起拆开讲。
1. 先分清:缓存穿透、缓存击穿、缓存雪崩不是一回事
很多入行两三年的同学,遇到缓存问题第一反应就是“加缓存、调 TTL”,但穿透、击穿、雪崩这三类问题成因完全不同,用错方案等于白做。排查之前,先把三者的本质区别讲清楚。
1.1 穿透:查的数据根本不存在
缓存穿透指的是查询一个缓存和数据库里都不存在的数据。
举个例子。订单系统里用户查询订单详情,正常订单肯定存在,但攻击者可以循环请求 order_id = -1、0、99999999 这样不存在的 ID。缓存里没有这类 key,数据库里也没有对应记录,所以每次查询都是缓存 miss,然后去数据库执行一次无结果查询。单次无结果查询本身很轻,但如果请求量是每秒几千次,数据库连接池、SQL 执行、日志输出都会被拖垮。
穿透的关键特征是“数据不存在”。这一点决定了它和另外两个问题有本质区别。很多人把穿透和击穿混为一谈,是因为两者表象都是“缓存没命中”,但穿透是查不到东西,击穿是东西存在但缓存刚好过期。
1.2 击穿和雪崩:数据存在,只是缓存没了
缓存击穿是某一个热点 key 突然过期,大量并发请求同一时刻查同一个 key,缓存 miss 后全部涌向数据库。数据本身是存在的,只是缓存刚好失效,所以数据库那边能查到结果,回填缓存后压力就会降下来。
缓存雪崩是大量 key 在同一时间过期,或者缓存节点整体不可用,导致大量请求同时落到数据库。数据和 key 本身没问题,问题出在过期时间设置不分散,或者缓存服务本身挂了。
判断方法很简单:数据库里到底有没有这条数据。有,就是击穿或雪崩;没有,才是穿透。这个判断是后续所有方案选择的前提,判断错了,方案必然错。
1.3 为什么穿透最难提前发现
击穿和雪崩有相对明显的预警信号。热点 key 即将过期、批量 key 设置了相同 TTL、缓存节点状态异常,这些都能在监控里看到。穿透不一样,它在缓存里看不到任何痕迹,因为不存在的数据本来就不会进入缓存,你只能从数据库这一侧的“高 QPS 但低命中结果”去反推。
更麻烦的是,穿透不仅可能是攻击。业务上的脏数据、前端传参错误、数据同步延迟,都会造成大量不存在 key 的查询。如果一上来就按攻击处理,容易误伤正常请求;如果不管,数据库压力又降不下来。这就是为什么需要分层防护,而不是靠某一个技巧硬撑。
2. 缓存空值方案:能做应急,扛不住真实攻击
缓存空值的思路很直观:数据库查询结果为空时,也把 key 写进缓存,value 记为一个标记空值,并设置一个较短的 TTL。这样短时间内同一个不存在 key 的重复请求就会命中缓存,不再穿透到数据库。
2.1 基本实现:查询为空时也写缓存
用 Redis 的伪代码表示,核心逻辑大致是这样:
key = "order:detail:" + orderId value = redis.get(key) if value != null: if value == "EMPTY_MARK": return null return parse(value) row = db.query(orderId) if row == null: redis.set(key, "EMPTY_MARK", ttl = 60s) return null redis.set(key, serialize(row), ttl = 30min) return row这段逻辑看起来没问题,小流量、少量不存在 key 的场景下也确实能降低数据库压力。但它有三个硬伤,决定它只能当应急手段,不能当完整方案。
2.2 第一个坑:缓存空值会把内存吃成“垃圾场”
缓存空值只对“同一个 key 被反复查询”有效。攻击者如果不用同一个 key,而是随机拼接一批不存在的 ID,比如 100 万个不同的非法 ID,那么每次请求都会写入一个新的空值缓存。
假设每个 key 带标记和过期时间大约占用 100 字节,100 万个 key 就是 100MB,1000 万个就是 1GB。这些空值既没有业务价值,又占着 Redis 内存,还得靠淘汰策略慢慢清掉。更糟糕的是,如果攻击持续制造新 key,Redis 内存会持续增长,最终把正常业务数据的缓存挤出去。
换句话说,这个方案在“少量 key 高频查询”时有效,在“大量 key 低频查询”时反而会放大问题。攻击者只要不断换新 ID,就能让你的缓存内存持续失血。
2.3 第二个坑:TTL 一到,攻击照样穿透
空值缓存必须设置短 TTL。因为数据可能在未来被写入,TTL 太长会导致新数据写入后,用户仍然在短期内读到空值,造成数据不一致。
但 TTL 一短,问题也来了。攻击者完全可以等待 TTL 过期后,对同一批 key 再发起一轮请求。每过 60 秒就能穿透一次,数据库依然会被周期性打穿。TTL 设置在 5 分钟,数据库就每 5 分钟承受一次峰值;TTL 设置在 1 分钟,数据库就每 1 分钟承受一次。你并没有消除穿透,只是把穿透频次从“每秒一次”降到了“每分钟一次”。
2.4 第三个坑:空值无法区分“不存在”和“暂时查不到”
缓存空值会把所有返空的结果都标记成同一个语义:这个 key 不存在。但生产环境里,查询为空的原因非常复杂:
- 数据真的不存在。
- 数据已经入库,但主从同步延迟,查询从库没查到。
- 接口权限或租户隔离导致当前用户看不到。
- 下游服务超时返回了空集合。
如果这些场景都统一写成空值缓存,就会出现短时间内的数据不一致。用户刚创建了一条数据,却因为之前查询过一次空值被缓存,在 TTL 内一直看不到新数据。这种问题在订单、库存、支付场景里是不可接受的。
所以我的判断是:缓存空值可以用,但它只是兜底手段,不能作为第一防线,更不能作为唯一方案。
3. 分层防护才是正解:四层防线的落地顺序
真正适合生产环境的防穿透方案,应该是多层组合,每一层解决一类问题,而不是把压力全部交给某一层。下面按落地顺序讲。
3.1 第一层:接口参数校验,把明显非法的请求挡在外面
很多穿透请求在入口就能被拦住。订单 ID 是否为正整数、长度是否合法、用户是否有权限查询该资源、分页参数是否越界,这些校验不需要动用缓存和数据库。
参数校验看似简单,实际是最容易被忽略的一层。很多团队图省事,把所有参数直接交给业务逻辑处理,结果恶意请求轻松穿过接口层打到数据库。这一层的目的不是防御高级攻击,而是过滤掉那些“明显不该查”的请求。
如果接口本身需要登录,还要考虑用户维度和风控维度:同一用户短时间内的异常查询频率、IP 维度的请求频率,都可以在网关或服务层做限流。不要觉得“这点请求量无所谓”,攻击流量往往是正常流量的几十倍。
3.2 第二层:布隆过滤器,拦截绝大多数不存在 key
布隆过滤器是一个空间效率极高的概率型数据结构,用来判断“某个 key 是否可能存在”。
它的特性是:判断“不存在”是确定的,判断“存在”可能有误判。也就是说,如果布隆过滤器说某个 key 不存在,那它一定不存在;如果说存在,则可能是误判,实际并不存在。
用法是在系统启动时,把所有合法 ID 加载进布隆过滤器。请求进来后,先查过滤器:
if not bloomFilter.contains(orderId): return null # 这个 ID 肯定不存在,直接返回,不走缓存和数据库这样绝大多数不存在的 key 在进入缓存之前就被拦截了。布隆过滤器本身非常省内存,几亿个 key 也只需要几百 MB,而且查询速度是 O(1)。
实现时有几个细节要注意:
- 初始化时要确认数据量级,选择合理的位数组大小和哈希函数个数,误判率通常设置在 0.1% 到 1% 之间。
- 每次新增合法 ID 时,要同步往布隆过滤器里添加。删除 ID 时不需要处理,因为布隆过滤器不支持删除,误判多几个空值请求是可以接受的。
- 如果业务数据量变化大,可以考虑定时重建过滤器,而不是在线上动态调整。
注意:布隆过滤器只能判断“可能存在”。它说存在时,仍然要走到缓存和数据库,不能直接当成“一定存在”返回结果。
关于误判多说一句:误判本身不致命,因为误判的 key 走到缓存和数据库时依然查不到数据,最多是浪费一次查询。真正的问题是不应该把所有不存在 key 都放过去,布隆过滤器已经把 99% 以上的不存在 key 挡掉了,剩下的误判数量级已经非常低。
3.3 第三层:短 TTL 空值兜底,处理布隆过滤器的误判
布隆过滤器有误判,所以还需要一层兜底。这一层才轮到缓存空值。
误判率如果是 1%,攻击者每发 100 个不存在的 key,依然有 1 个会穿透到数据库。这时候用短 TTL 的空值缓存,把这 1% 的 key 在短时间内缓存起来,数据库压力就能被控制住。
注意这一层的定位和文章开头说的“初学者方案”完全不同:这里缓存空值不是主力,而是给主力防线打补丁。TTL 可以设置得比较短,比如 30 到 60 秒,因为穿过布隆过滤器的误判 key 数量已经很少,即使 TTL 过期后再次穿透,量级也是可控的。
如果你连布隆过滤器都不想维护,那就必须接受更高的数据库压力:只能靠短 TTL 空值缓存降低重复请求的频率。这在低量级业务里可行,但你要明确这是有代价的,别等项目大了再回头补。
3.4 第四层:限流、降级与监控,保证数据库不被打挂
即便前面三层都到位,也不能保证万无一失。数据库本身要有最后一道防线:
- 对数据库单库或单表设置 QPS 上限,超过阈值后直接拒绝非核心查询。
- 在服务层做熔断降级,数据库响应变慢时,优先保证写路径和核心读路径。
- 监控指标要覆盖:缓存命中率、空值缓存写入量、数据库查询耗时、SQL 返回空结果的比例。
这里要强调一个观点:防穿透的目标不是“让数据库永远不收到空查询”,而是“让数据库收到的无效查询量级可控”。完全消灭不可能,也不需要。只要量级控制住了,数据库就能稳定服务正常请求。
4. 互斥锁和请求合并:要不要用,什么时候用
有些资料会把互斥锁也放进防穿透方案里,这其实混淆了两个问题。互斥锁主要解决的是缓存击穿,也就是热点 key 过期后,避免大量线程同时去查数据库。它和穿透有关系,但关系不是很多人想象的那样。
4.1 互斥锁解决的是击穿,不是穿透
当某个 key 缓存 miss 后,用分布式锁保证只有一个线程去数据库查询并回填缓存,其他线程等待缓存回填完成后再读缓存。这个机制的前提是“数据库里真的有数据”,否则回填的还是一个空结果,等待的线程照样拿不到数据。
所以,如果查询的 key 本身不存在,互斥锁并不能防止穿透。它只是把并行的数据库压力变成串行压力,避免瞬间击穿数据库,但每轮攻击照样会打到数据库一次。你只是让数据库从“被一万个线程同时打”变成了“被一个线程打,然后其余线程等着”,数据库压力并没有真正消失。
4.2 实现方式和最常见问题
以 Redis 分布式锁为例,思路是:
value = redis.get(key) if value == null: lock = redis.setnx("lock:" + key, "1", ttl = 3s) if lock: row = db.query(key) redis.set(key, serialize(row)) redis.del("lock:" + key) return row else: # 没拿到锁,短暂 sleep 后重新读缓存 sleep(50ms) return redis.get(key)这里最常出问题的点有两个:
- 锁的 TTL 太短,数据库查询还没结束锁就过期了,其他线程拿到锁后重复查询,等于锁白加。
- 锁的 TTL 太长,数据库查询失败时锁一直不释放,导致后续请求全部阻塞,接口超时率飙升。
实际使用时要给锁设置合理的过期时间,并加上失败重试和超时释放的兜底逻辑。不要以为加了锁就一定安全,锁本身也是需要监控的。
4.3 什么时候可以不用锁
如果数据库查询本身很快,比如主键查询在 5 毫秒内完成,热点 key 过期瞬间的并发量又不到数据库上限,那么完全不需要互斥锁,直接放行反而更简单。
互斥锁的价值在于:热点 key 回填成本高(比如复杂聚合查询耗时 200 毫秒以上)、并发量又特别大的场景。回填成本低时,锁带来的复杂度和潜在的线程阻塞风险不划算。
一句话总结:互斥锁是用来保护“热点 key 回填”的,不是用来防穿透的。别把它和布隆过滤器、缓存空值混在一起选型。
5. 生产环境选型:按场景判断,而不是按“最新方案”判断
技术选型最怕的是为了用方案而用方案。防穿透的方案不能只看“哪种最前沿”,要看当前业务的 key 构成、请求量、数据库压力和数据一致性要求。
5.1 先看方案对比表
下面把常见方案做一个对比,方便直接对照选型。
| 方案 | 解决的核心问题 | 内存开销 | 实现复杂度 | 数据一致性风险 | 适合场景 |
|---|---|---|---|---|---|
| 参数校验 | 拦截非法请求 | 无 | 低 | 无 | 所有系统都必须做 |
| 缓存空值 | 重复查询不存在 key | 高,空 key 多时膨胀快 | 低 | 有,TTL 内新数据不可见 | 少量 key 高频查询 |
| 布隆过滤器 | 拦截绝大多数不存在 key | 低,亿级 key 几百 MB | 中,需要维护 | 低,误判不致命 | key 全集可枚举、相对稳定 |
| 互斥锁 | 避免热点 key 回填并发 | 无 | 中 | 无 | 热点 key 回填成本高 |
| 限流降级 | 保护数据库上限 | 无 | 中 | 无 | 所有生产系统建议配置 |
5.2 判断前先回答三个问题
判断时先回答三个问题:
- 不存在的 key 是少量固定,还是大量变化?固定用空值缓存就够了,变化必须上布隆过滤器。
- 数据全集是否可枚举、相对稳定?订单 ID、用户 ID、商品 ID 适合布隆过滤器;搜索词、日志 ID 这类高基数且快速变化的 key 就不太适合,因为你没法把全集及时同步进过滤器。
- 数据写入后,用户能不能容忍短时间查不到?不能容忍,空值 TTL 就必须设置得很短,或者只在布隆过滤器误判时兜底。
这三个问题回答完,方案基本就清楚了,不需要在技术选型会上反复争论。
5.3 小团队和低频系统的务实做法
如果是小团队、日请求量在万级以下,数据库压力本来就不大,我不建议一上来就引入布隆过滤器。布隆过滤器需要构建、加载、同步,维护成本并不低。低频系统直接用短 TTL 空值缓存加参数校验就够了,大部分场景撑得住。
这类系统的正确做法是:先加参数校验和基础限流,再把空值缓存 TTL 控制在 1 到 3 分钟内,同时监控空值缓存的数量。如果空值 key 数量开始快速增长,说明请求模式异常,再去考虑布隆过滤器。
注意:低频系统可以不上布隆过滤器,但必须接受更高的数据库压力,并且要监控空值缓存数量。这个“接受”是明确写在技术方案里的,而不是出了问题再补救。
5.4 大流量系统的检查清单
如果是大流量系统,特别是订单、支付、库存这类核心接口,建议按这个清单逐项确认:
- 接口层是否对关键参数做了严格校验。
- 布隆过滤器的数据源是哪个表,构建脚本是否可重复执行。
- 布隆过滤器误判率配置是多少,有没有预留位数组空间。
- 空值缓存 TTL 多长,是否和业务一致性能接受的周期匹配。
- 数据库连接池和 QPS 上限是否配置了保护值。
- 空值缓存数量、数据库空结果比例、缓存命中率有没有监控告警。
- 上线后是否存在数据删除后 key 仍然通过布隆过滤器进入查询的情况。
这个清单每一项都可以在上线前检查完,不需要等出了问题再补。
6. 上线后被穿透了,怎么排查和复盘
防穿透方案上线后,仍然可能出现数据库压力异常。这时候最重要的不是急着加缓存,而是先确认现象到底是什么类型的问题。方向错了,后面全白做。
6.1 先确认现象是不是穿透
三个指标可以帮你判断:
- 缓存命中率是否明显下降。穿透会导致命中率持续走低。
- 数据库查询返回空结果的比例是否异常升高。如果空结果比例从 5% 涨到 60%,大概率是穿透。
- 请求的 key 是否有规律。比如连续递增、固定前缀、明显超出正常范围,通常是脚本请求。
如果只是数据库慢,但空结果比例不高,可能是慢查询、锁等待、连接池不足,不要误判成穿透。这几类问题的处理方式完全不同。
6.2 沿着调用链逐层看
确认是穿透后,按顺序排查:
- 先看接口层有没有把非法参数拦下来。没拦,先补校验。
- 再看布隆过滤器有没有生效。常见问题是过滤器没加载数据、加载的是旧数据、或者 key 序列化方式不一致导致查不到。
- 再看空值缓存有没有写入成功。常见问题是 TTL 设置太短、Redis 内存达到上限后空值被淘汰、或者空值写入逻辑被异常分支跳过。
- 最后看限流和降级有没有触发。如果这些都没配置,数据库异常是必然的。
一个容易被忽略的细节是 key 的序列化方式。布隆过滤器里存的是字符串,业务代码里传入的是 Long 类型 ID,如果转换时带上了类型信息,两边就永远匹配不上。这类问题看起来像过滤器失效,实际是代码细节没对齐。
6.3 复盘时最该留意的几个数据
复盘时不要只看“数据库有没有挂”,要看几个能说明问题的数据:
- 穿透请求的 key 分布:是集中在一小批,还是分散在大量不同 key。集中说明可以被空值缓存解决,分散必须靠布隆过滤器。
- 空值缓存 key 数量变化曲线:有没有在短时间内从几百涨到几十万。涨得快说明过滤器没生效或误判率偏高。
- 布隆过滤器误判率实测值:理论值不一定准确,要结合线上数据评估。
- 数据库空结果查询的 QPS 峰值:用来判断是否需要继续加防线。
这些数据能帮你判断当前的防线配置是过强还是不足。比如空值 key 数量涨得很快,说明布隆过滤器误判率偏高或者没有真正生效,需要重新构建过滤器。
最后再强调一遍我个人的习惯:先把参数校验做扎实,再部署布隆过滤器,空值缓存放在第三层兜底,限流降级作为最后防线。这个顺序不是我拍脑袋定的,而是每一层都解决前面一层漏过去的问题。缓存空值不是不能用,但它应该是组合方案的一部分,而不是你对缓存穿透的全部理解。