news 2026/9/9 22:34:02

缓存穿透怎么办?五种防护方案从入门到实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
缓存穿透怎么办?五种防护方案从入门到实战

缓存穿透这个概念,很多后端开发者第一次接触时都会觉得“不就是在缓存没命中的时候去查数据库吗”,但真正处理过线上事故的人都知道,最头疼的恰恰是那些在数据库里根本不存在的 key。因为缓存永远不可能命中,每次请求都会穿透到数据库层,一旦密集请求上来,数据库 QPS 被瞬间拉满,连接池耗尽,正常业务跟着全部超时。给数据库“请保安”,本质就是在缓存和数据库之间多设几道闸门,把无效请求挡在半路,而不是让它们全部冲到数据库门口排队。

这篇文章适合正在做 Web 后端、微服务接口、网关层或者在线查询业务的开发者。看完之后你可以按照自己的业务规模,选择参数校验、限流、空值缓存、布隆过滤器、互斥锁这几种手段里的两到三层组合使用,并且能判断出每种方案到底能挡住什么、挡不住什么。

1. 缓存穿透不是缓存雪崩,它更像“敲门党”

1.1 一个真实场景

商品详情页、订单查询、用户信息查询这类接口,标准流程都是先查缓存,缓存没命中再查数据库。正常情况下,热点数据会被缓存挡住,数据库只需要处理小部分回源请求。但有一种场景会让缓存彻底失效:客户端不断请求一个不存在的商品 ID,比如999999999

第一次请求过来,缓存没有,数据库查不到,返回空结果。第二次请求过来,因为数据库里根本没有这条数据,Redis 里也没有被写入任何东西,所以依然查不到。第三次、第四次、第 N 次都是同样的结果。如果这些请求来自正常用户,顶多是多几次慢查询;但它们一旦是脚本或异常流量,比如每秒几千个随机 ID,数据库的连接池很快就会被占满,那些本来能正常处理的读请求也跟着排队超时。

这类问题最坑的地方在于,它不是并发量真的有多大,而是大量请求在做无效查询,把资源浪费在不存在的数据上。数据库 QPS 可能从几百瞬间抬到几千甚至几万,但业务上完全没有任何新增的有效查询。

1.2 缓存为什么挡不住不存在的 key

缓存的优点是“把读多写少的热数据放在更快的位置”。它适合保存已经存在、会被反复读取的数据。但一个数据如果在数据库里根本不存在,它就不会成为热数据,缓存里也永远不会出现这个 key。

很多人容易把缓存穿透、缓存击穿、缓存雪崩搞混。击穿是某个热点 key 刚好过期,同一时间大量请求同时回源;雪崩是大批 key 在同一时间段失效,导致数据库压力同步上升。这两个问题都有一个前提:数据本身是存在的,只是缓存暂时没有。而穿透是“查什么都不存在”,缓存无论如何都不可能命中。正因为这样,穿透问题的防护思路和其他两个完全不同,它需要拦截的是“无效查询”,而不是单纯加速“有效回源”。

1.3 怎么判断已经发生了穿透

不要靠感觉判断,看指标更可靠。如果出现以下情况,大概率是发生了缓存穿透:

  • 缓存命中率突然下降,但业务流量没有明显增长。
  • 数据库查询次数远高于缓存未命中次数,二者比例异常。
  • 日志里出现大量“数据不存在”或者“空结果”记录。
  • 数据库 CPU、连接数、慢查询数在某个时段同时飙升,持续时间较长。

这里有一个容易忽略的点:只看缓存命中率其实是滞后的,因为命中率下降只能说明缓存效率变低了,不能说明是不是穿透。更好的监控指标是“缓存未命中且数据库结果为空”的次数,把这个数单独统计出来。如果这个数字异常高,那么穿透请求一定不少。

2. 第一道保安:请求入口的参数校验与限流

2.1 先拦截明显不合法的请求

很多穿透流量其实用的是明显不合理的参数。比如一个商品 ID 是负数、0、超长字符串,或者一个订单号包含了太多特殊字符。这些请求根本不需要进入业务层,在网关或 Controller 入口直接校验参数,就能挡掉一大半。

以商品查询为例,一个很简单的判断是:商品 ID 必须大于 0,且格式必须为纯数字。如果不符合,直接返回参数错误,不查缓存也不查数据库。

public Product getProduct(Long productId) { // 非法参数直接拦掉,不进入缓存和数据库 if (productId == null || productId <= 0 || productId > MAX_VALID_ID) { throw new IllegalArgumentException("invalid product id"); } // ... 后续缓存、数据库逻辑 }

这种校验成本极低,但效果很直接。凡是格式明显不对的请求,连 Redis 都不需要访问。

2.2 限流和降级要放在哪一层

只做参数校验还不够,因为恶意请求可以伪装成合法参数。比如一个不存在的 8 位数字 ID,格式上完全合法,但数据库里就是没有。这时候需要限流。

限流可以放在网关层,也可以放在业务代码里。常见维度有:单 IP 每秒请求数、单个用户每秒请求数、接口总 QPS。超过阈值后直接返回“操作频繁”,或者降级为默认提示,不进入数据库查询。

对数据库来说,限流并不能真正阻止穿透,但可以防止穿透发生后数据库被打爆。如果接口 QPS 是 20000,但数据库只能承受 5000,那么限流到 3000 或 4000,起码能保证数据库不宕机。限流参数要结合实际压测结果去调,不要拍脑袋定。一般来说,限流阈值可以定为数据库能够承受的峰值 QPS 的 80% 左右。

2.3 为什么不能只靠输入校验

参数校验只能挡住“非法参数”,挡不住“参数合法但数据不存在”的情况。比如:

  • 用户查询一个已下架或已删除的商品。
  • 查询一个从未创建过的订单号。
  • 查询一个已经清空的历史数据。

这些 key 在格式上完全合法,数据库里却查不到。参数校验根本没有办法区分它们。所以输入校验只能作为第一道门,不能作为核心防护方案。如果你发现穿透问题还在,不要只在入口校验上纠结,而是要往下看空值缓存和布隆过滤器。

3. 第二道保安:缓存空值

3.1 空值缓存思路

空值缓存的思路很简单:当数据库查询结果为空时,把“这个 key 不存在”也写进缓存,并设置一个较短的过期时间。后续相同 key 的请求在 TTL 内直接命中这个空值,不再进入数据库。

EMPTY_FLAG = "__EMPTY__" def get_product_detail(product_id): # 先查缓存 cached = redis.get(f"product:{product_id}") if cached is not None: # 缓存命中的是空标记,说明数据库查不到 if cached == EMPTY_FLAG: return None return cached # 缓存未命中,查数据库 product = db.query_product(product_id) if product is None: # 把空结果写入缓存,TTL 设置为 30 秒 redis.set(f"product:{product_id}", EMPTY_FLAG, ex=30) return None redis.set(f"product:{product_id}", product, ex=3600) return product

这里的关键判断标准是:缓存里有值,即使是空标记,就直接返回,不再进入数据库。这样同一个不存在的 ID 在 30 秒内的后续请求都打在 Redis 上,数据库压力会明显下降。

3.2 TTL 怎么设置

空值缓存的 TTL 是一个非常关键的参数。设太短,恶意请求每隔一段时间就能穿透一次,数据库还是会频繁被访问;设太长,数据库后来真的新增了这条数据,但缓存里还是空标记,用户在一段时间内查不到新数据。

我的经验是,TTL 按业务对数据一致性的容忍度来定:

  • 商品、订单这类“新增后很少变”的数据,可以设 3 到 5 分钟。
  • 库存、价格、活动状态这类变化快的数据,设 30 秒到 60 秒。
  • 如果业务对“新增数据立即可见”要求很高,不要只依赖自然过期,应该在写数据库时主动删除对应的空值缓存。

还可以在写入空值缓存时附带一个最后写入时间,后台任务定期清理过期时间过长的空 key,避免内存里堆积太多无效数据。

3.3 空值缓存的问题

空值缓存有两个最明显的问题。

第一个是内存浪费。如果恶意请求用大量随机 ID 刷,Redis 里会堆积很多空 key。虽然每个 key 占用的内存不大,但数量上来之后,内存会明显增长。解决办法是控制空值缓存的 TTL,并且定期清理。如果内存非常紧张,可以设置一个空值缓存的条数上限,超过上限后优先淘汰最久未访问的空 key。

第二个是数据一致性问题。数据库新增一条记录后,如果对应的空值缓存还没过期,用户会短暂看不到这条新数据。对一致性要求高的场景,应该在写入数据库时主动删除对应的空值缓存,而不是等它自然过期。比如商品创建成功后,执行一次redis.delete("product:" + productId)

注意:空值缓存适合中低频率的无效 key 查询,但如果攻击方使用“每次随机生成不同 key”的方式,空值缓存就无法形成有效命中,因为每个 key 都只被访问一次。这时候必须靠布隆过滤器。

4. 第三道保安:布隆过滤器

4.1 布隆过滤器在干什么

布隆过滤器是一个概率型数据结构,用来判断“一个元素是否可能存在”。它的特点是只用很少的内存,就能在极短时间内告诉你:这个 key 肯定不存在,还是可能存在。

这里的关键词是“可能存在”。布隆过滤器会误判,但它只会把“不存在的元素”误判成“可能存在”,不会把“存在的元素”误判成“不存在”。所以在缓存穿透场景里,它的作用就是拦截那些“肯定不存在”的请求。

举个例子,如果系统里一共有 100 万个合法商品 ID,把这 100 万个 ID 都加入布隆过滤器。当一个请求传入 ID888888时,过滤器如果判断这个 ID 不在集合里,那就可以直接返回“商品不存在”,连缓存都不查。如果过滤器判断存在,那才继续走缓存和数据库。

4.2 参数设计

布隆过滤器有三个核心参数:预期元素数量n、误判率p、位数组大小m和哈希函数数量k。前两个是输入,后两个是需要计算的。

常用公式:

m = - (n * ln p) / (ln 2)^2 k = (m / n) * ln 2

举个例子。假设业务里预期会有 100 万个合法 ID,误判率允许 1%,那么:

  • 位数组大小约 958 万 bit,约 1.2MB。
  • 哈希函数数量约 7 个。

如果误判率要求降到 0.1%,那么位数组大小约 1438 万 bit,约 1.8MB,哈希函数数量约 10 个。

这个内存占用在绝大多数业务场景下都可以接受。我的建议是:预期元素数量按未来半年业务量的峰值来估算,不要只按当前数据量。误判率按业务场景取 1% 到 3% 就够,没有必要追求极低误判率,因为布隆过滤器只是第一层筛选,后面还有缓存和数据库兜底。

4.3 布隆过滤器的初始化与更新

布隆过滤器必须提前初始化,不能等到请求来了再往里加。常见做法有几种:

  • 系统启动时从数据库读取所有合法 ID,批量加入布隆过滤器。
  • 每天定时全量刷新一次,用脚本把当天所有有效 ID 重新构建。
  • 增量场景下,通过消息队列在新增记录时更新。

这里最容易踩坑的是增量更新。比如商品 ID 是每天新增的,如果布隆过滤器只在启动时初始化,之后新增加的商品 ID 没有同步进去,这些合法 ID 就会被过滤器误判成“不存在”,导致正常用户查询商品也返回不存在。这种事故比穿透更严重。

更麻烦的是删除操作。布隆过滤器本身不支持删除单个元素,因为多个元素可能映射到同一个 bit 位,直接清零会影响其他元素。如果业务频繁删除 ID,就需要引入计数布隆过滤器,或者在业务层面定期全量重建布隆过滤器。

4.4 布隆过滤器的局限

布隆过滤器能挡住“大部分不存在的 ID”,但挡不住“存在但已删除”的数据。一个商品 ID 在布隆过滤器里存在,但商品已经被删除,数据库查不到,请求依然会穿透到数据库。所以布隆过滤器通常需要和空值缓存一起用:

  • 布隆过滤器挡掉绝大多数随机不存在的 key。
  • 穿过布隆过滤器的那一小撮请求,落到数据库以后,用空值缓存暂时挡住,避免短时间内重复回源。

另外,布隆过滤器不能作为唯一的拦截手段。它只是说“可能存在”,那 1% 到 3% 的误判流量仍然需要系统能够承受。不要把保护全押在过滤器身上。

5. 第四道保安:互斥锁与回源控制

5.1 穿透场景下的“回源风暴”

即使做了空值缓存和布隆过滤器,还可能出现一种情况:缓存还没建立,大量相同请求同时到达数据库。常见于一个冷门数据第一次被查询,或者缓存刚好过期,同一时间来了很多相同请求。每个请求都发现缓存没有,然后一起去数据库查询,数据库瞬时压力暴增。

这和穿透不同,因为数据本身可能是存在的,问题出在“并发回源”这个动作。如果把数据库响应时间从 10ms 变成 100ms,这批请求就会越积越多,可能把数据库连接池打满。所以在做穿透防护时,也要顺带考虑回源并发控制。

5.2 互斥锁思路

互斥锁的思路是:当多个请求发现缓存没有时,只允许一个请求去查数据库并写回缓存,其他请求等待锁释放后直接读缓存。

def get_product_detail_with_lock(product_id): cached = redis.get(f"product:{product_id}") if cached is not None: return cached lock_key = f"lock:product:{product_id}" # 尝试获取锁,拿到锁的请求才允许查数据库 if redis.set(lock_key, "1", nx=True, ex=3): try: product = db.query_product(product_id) redis.set(f"product:{product_id}", product, ex=3600) return product finally: redis.delete(lock_key) else: # 拿不到锁,短暂等待后重试 time.sleep(0.05) return redis.get(f"product:{product_id}")

这里有几个参数需要理解:

  • nx=True表示只有当 key 不存在时才能设置成功,这是实现锁的关键,相当于“只有一个请求能抢到锁”。
  • ex=3是锁的自动过期时间,防止持有锁的线程崩溃后锁永远不释放。
  • 拿不到锁的请求不要无限重试,最多重试几次或者设置一个超时时间,否则请求堆积会拖垮业务线程。

5.3 锁的粒度与超时

锁的粒度越细越好,最好精确到单个 key。如果对整个查询接口加锁,等于把所有商品查询串行化,即使缓存失效了,吞吐量也上不去。粒度细到lock:product:12345这种级别,不同商品的查询互不影响。

锁的超时时间要结合数据库的慢查询时间设置。如果数据库查询平均 10ms,但极端情况下可能 500ms,锁超时设置 1 秒基本够用。如果设置太短,数据库还没查完锁就提前释放,其他请求又会重复查数据库;设置太长,一旦持有锁的线程卡住,其他请求会长时间拿不到锁,发生大量等待。

这类互斥锁主要解决的是“热点 key 失效”或“冷门 key 首次查询”时的回源风暴,严格来说属于缓存击穿防护。但它在穿透防护的组合拳里也很有价值,因为空值缓存和布隆过滤器都不能保证“同一时刻只有一个人去查数据库”。生产环境通常把四层方案一起启用,而不是只选一个。

6. 组合拳落地:从最小方案到生产配置

6.1 三层方案怎么选

不同业务规模适合不同方案组合,不要一上来就全部实现。

业务规模推荐方案原因
学习项目、低流量小站参数校验 + 空值缓存实现简单,覆盖大部分场景
中型业务,有真实用户参数校验 + 布隆过滤器 + 空值缓存能挡随机 key,也能挡重复穿透
高并发、核心交易链路参数校验 + 布隆过滤器 + 空值缓存 + 互斥锁兼顾穿透拦截和回源并发控制

如果你之前已经实现了空值缓存,再引入布隆过滤器时,不要急着把空值缓存去掉。两者职责不同:布隆过滤器挡“大概率不存在的随机 key”,空值缓存挡“布隆过滤器误判的和已删除但过滤器未清理的 key”。

6.2 监控指标怎么配

线上环境不能只靠代码,必须有监控指标。建议在 Redis 层和数据库层分别埋点:

  • 缓存命中率:总命中次数 / 总查询次数。
  • 空值缓存命中数:标记为空结果的缓存被命中的次数。
  • 数据库空结果查询数:数据库返回空集合的次数。
  • 布隆过滤器拦截数:在过滤器位置被直接拦截的请求数。
  • 数据库慢查询数和连接池占用率。

其中“数据库空结果查询数”是最直接的穿透信号。可以为它单独设置一个阈值,比如每分钟超过 200 次就告警。如果这个指标持续走高,说明有异常流量在查询不存在的 key,需要立即检查布隆过滤器是否需要更新、空值缓存 TTL 是否合理。

6.3 常见问题和排查顺序

如果接口已经出现延迟飙升,按这个顺序排查,不要乱动参数:

  1. 先看数据库慢查询和连接数。如果数据库确实被打满了,先把限流阈值降下来,保证数据库不宕机。
  2. 再看 Redis 命中率。命中率低且请求量高,穿透可能性大。
  3. 打开请求日志,统计访问次数最多的 key 模式。如果都是不存在的 ID,基本可以确认是穿透。
  4. 检查布隆过滤器是否需要更新。新业务数据有没有同步到过滤器里。
  5. 最后检查空值缓存 TTL 和互斥锁超时时间。如果这些参数之前没做过压测验证,不要随意调整。

这里最容易犯的错误是:看到 Redis 命中率低,就认为扩大缓存时间能解决问题。但缓存时间再长,也不能让“不存在的 key”变成存在。缓存穿透的根因是无效数据请求,不是缓存时长不足。先把请求的有效性过滤清楚,再谈缓存优化。

另外一个容易忽略的点是日志和输出一致性。批量任务或者定时任务如果触发了大量不存在的 key 查询,比如每天跑一次商品同步脚本,这个脚本会定时刷一批已经下架的 ID,同样会造成周期性穿透。这种情况需要在脚本入口做一次布隆过滤器预校验,或者把这批 ID 手动从库里排除。


最后留几个我自己的判断。

空值缓存是性价比最高的第一道防线,布隆过滤器适合拦截大规模随机穿透,互斥锁用来防回源风暴,参数校验和限流则是上游兜底。真正落地时,不要追求把所有层都做得很重,而是先确认当前业务最大的风险点是随机不存在的 key,还是合法 key 被高频反复查数据库,然后针对性地选两层做扎实。

预防缓存穿透没有“装一次保安就完事”的终点,这更像持续调整保安配置的过程。你要经常观察空值命中率、布隆过滤器拦截数、数据库空结果查询数这些指标,发现哪一层没发挥作用,就去更新数据、调整参数或者补齐增量逻辑。把无效查询控制在数据库之前,吞吐量和稳定性自然会好转。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 22:32:18

MySQL关联查询与JOIN优化:从执行原理到慢SQL排查实战

刚处理完线上一个订单查询接口的慢SQL&#xff0c;排查下来又是关联查询没写好导致的。其实这类问题在MySQL里太常见了&#xff0c;多表查询几乎每个业务系统都躲不开&#xff0c;但真要说清楚JOIN的用法、执行逻辑和优化思路&#xff0c;很多写了几年SQL的人也是一知半解。这篇…

作者头像 李华
网站建设 2026/9/9 22:30:16

面对“张雪峰猝死”谣言,如何理性验证与应对

半夜刷手机&#xff0c;一条标题砸进眼睛——“张雪峰猝死&#xff1a;人生真好玩&#xff0c;下辈子还来”。那一刻我脑子里冒出的不是悲伤&#xff0c;不是震惊&#xff0c;而是一连串问号&#xff1a;这消息从哪儿来&#xff1f;谁第一个发的&#xff1f;有没有权威渠道佐证…

作者头像 李华
网站建设 2026/9/9 22:29:48

Python面向对象高级特性:从属性管理到元类实战指南

最近不少读者问我一个问题&#xff1a;Python基础语法学完了&#xff0c;类也会写了&#xff0c;但一碰到真正的项目&#xff0c;代码还是乱成一锅粥。要么一个类里堆了几百行&#xff0c;要么想扩展功能时发现哪儿都改不动。这说明你已经走到了一个关键路口——从“会写类”到…

作者头像 李华