一文读懂缓存穿透、击穿与雪崩及应对方案

作者:狼烟四起2026.11.06 22:02浏览量:0

简介:缓存是高并发系统的第一道防线,但穿透、击穿、雪崩三类异常会让流量瞬间压垮数据库。本文系统梳理三者的成因、区别与危害,并结合空值缓存、布隆过滤器、互斥锁、逻辑过期、过期时间打散、熔断限流等主流方案,给出可落地的实践建议与伪代码示例,帮助读者构建稳定可靠的缓存体系。

一、从一次典型请求说起:缓存为什么如此重要

在典型的分层架构中,一次读请求通常遵循这样的路径:请求先到达缓存层(基于内存的键值型存储),命中则直接返回;未命中(缓存 miss)才回源到数据库查询,并将结果写回缓存供后续请求复用。

这种设计之所以有效,是因为内存读写性能比磁盘高出多个数量级,绝大多数请求被消化在缓存层,数据库只需处理少量回源请求。换句话说,缓存是数据库的“挡箭牌”。

而穿透、击穿、雪崩这三类异常,本质上都是“挡箭牌失效”的不同形态:

  • 缓存穿透:请求的数据在缓存和数据库中都不存在,缓存永远无法生效,形同虚设;
  • 缓存击穿:某个热点 key 失效的瞬间,大量并发请求同时回源,数据库承受集中冲击;
  • 缓存雪崩:大面积 key 同时失效或缓存服务整体不可用,数据库面对全量流量。

三者的影响范围依次扩大:穿透针对“不存在的数据”,击穿针对“单个热点 key”,雪崩则是“系统级故障”。理解这个区别,是选择应对方案的前提。

二、缓存穿透:当查询的数据“根本不存在”

2.1 是什么

缓存穿透指客户端请求的数据在缓存中不存在,回源查询数据库后同样不存在。由于查不到数据,也就没有可缓存的有效值,导致每次请求都打到数据库。如果这类请求来自恶意攻击(例如用大量不存在的 ID 高频扫接口),数据库会在短时间内被无效请求打满。

2.2 成因分析

  • 恶意攻击或爬虫:伪造大量不存在的 key 发起请求;
  • 业务代码缺陷:上游未做参数校验,把非法 ID 直接透传到存储层;
  • 数据已被删除:历史数据清理后,外部仍持有旧 ID 持续请求。

2.3 应对方案

方案一:缓存空值(空对象)

数据库查不到时,也向缓存写入一个约定好的“空标记”,并设置较短的过期时间(例如 60 秒左右,可叠加随机偏移)。后续相同请求会在缓存层被快速拦截。

优点是实现简单、维护成本低;缺点是可能造成短期数据不一致(数据刚写入数据库,缓存里还是空标记),且若攻击者每次使用不同的随机 key,空值缓存会占用大量内存。因此空标记的 TTL 要短,并配合 key 合法性的前置校验。

  1. function getData(key):
  2. value = cache.get(key)
  3. if value != null:
  4. if value == "EMPTY": // 命中空标记,直接返回
  5. return null
  6. return value
  7. value = db.query(key)
  8. if value == null:
  9. cache.set(key, "EMPTY", ttl = 60s + random(0, 30s)) // 空值短过期
  10. return null
  11. cache.set(key, value, ttl = 3600s)
  12. return value

方案二:布隆过滤器前置拦截

在缓存之前加一层布隆过滤器,把全量合法 key 预先登记进去。查询时先问布隆过滤器“这个 key 可能存在吗”,若回答“一定不存在”,直接返回,不再访问缓存和数据库。

需要理解它的两个特性:

  • 判断“不存在”是绝对可靠的,判断“存在”可能有误判(把不存在的判成可能存在),因此作为拦截层是安全的;
  • 标准布隆过滤器不支持删除,误判率与容量、哈希函数个数相关,需按数据规模预估容量;有删除场景的业务可考虑计数型变体或定期重建。
  1. # 写入侧:数据落库时同步登记
  2. function saveData(item):
  3. db.save(item)
  4. bloomFilter.add(item.id)
  5. # 查询侧:先过布隆过滤器
  6. function getData(id):
  7. if not bloomFilter.mightContain(id):
  8. return null // 一定不存在,直接拦截
  9. // ... 再走正常 缓存 -> 数据库 流程

方案三:入口校验与风控

对明显非法的参数(负数 ID、超长字符串、格式错误的请求)在接口层直接拒绝;对单 IP 高频请求、特征异常的调用方做限流或拉黑。这一层成本最低,应最先考虑。

实践中通常组合使用:入口校验 + 布隆过滤器兜底 + 空值缓存兜底的兜底。

三、缓存击穿:单个热点 key 失效的“惊群”瞬间

3.1 是什么

缓存击穿(也叫热点 key 问题)发生在高并发场景:某个被高频访问的 key 到期失效的瞬间,成百上千个并发请求同时发现缓存 miss,同时回源查数据库、同时尝试写回缓存。数据库在这一瞬间承受远超平时的并发压力——像一根针精准地扎穿了缓存层。

它与穿透的区别:穿透的数据“永远不存在”,缓存永远无法生效;击穿的数据“存在但刚好过期”,是时间点上的并发问题。

3.2 应对方案

方案一:互斥锁,只允许一个请求回源

缓存 miss 后,先尝试获取一把分布式锁,只有拿到锁的请求才去查数据库并重建缓存;其他请求短暂等待后重试读缓存。这样无论并发多大,数据库同一时刻只承受一次回源。

  1. function getHotData(key):
  2. value = cache.get(key)
  3. if value != null:
  4. return value
  5. lockKey = "lock:" + key
  6. if tryLock(lockKey, expire = 10s): // 锁必须带过期时间,防止锁泄漏
  7. try:
  8. value = cache.get(key) // 双重检查:可能别的请求已重建完成
  9. if value == null:
  10. value = db.query(key)
  11. cache.set(key, value, ttl = 3600s)
  12. finally:
  13. releaseLock(lockKey)
  14. return value
  15. else:
  16. sleep(50ms)
  17. return getHotData(key) // 未抢到锁,稍后重试

代价是等待锁的请求会有短暂延迟,且需处理“拿到锁的节点宕机”导致的锁泄漏问题。

方案二:逻辑过期,热点数据“永不失效”

对热点 key 不设置物理过期时间,而是把过期时间作为字段存进 value。读取时判断逻辑过期时间:未过期直接返回;已过期则先返回旧值保证可用性,同时异步加锁重建缓存。

优点是不存在等待锁的阻塞,可用性极高;代价是过期后会短暂返回旧数据(最终一致),适合能容忍短暂旧值的场景(如商品详情、活动页)。

  1. # 写入:不设 TTL,把过期时间写进 value
  2. cache.set(key, {data: realData, expireAt: now + 30min})
  3. function getHotData(key):
  4. wrapper = cache.get(key)
  5. if wrapper == null:
  6. return rebuild(key) // 冷启动,走互斥重建
  7. if now < wrapper.expireAt:
  8. return wrapper.data // 逻辑未过期,直接返回
  9. if tryLock("lock:" + key): // 已逻辑过期:返回旧值 + 异步刷新
  10. async {
  11. newData = db.query(key)
  12. cache.set(key, {data: newData, expireAt: now + 30min})
  13. releaseLock("lock:" + key)
  14. }
  15. return wrapper.data // 没抢到锁也直接返回旧值

方案三:热点探测与多级缓存

通过访问统计识别热点 key,由后台任务在其过期前主动重建或续期,消除“到期瞬间”这个时间窗。同时把热点数据下沉到应用本地缓存,形成“本地缓存 -> 分布式缓存 -> 数据库”的多级结构,大幅削减打到存储层的并发量。

选型建议:一致性与正确性优先、可接受短暂阻塞的用互斥锁;可用性优先、可容忍秒级旧值的用逻辑过期;两者也可按 key 的重要程度混用。

四、缓存雪崩:从“个别失效”到“全面崩塌”

4.1 是什么

缓存雪崩指某个时间点,大量本应由缓存承接的请求集中涌向数据库,导致数据库压力骤增、响应变慢,进而拖垮上游服务,形成连锁反应。它有两种典型成因:

  • 成因一:大面积 key 同时过期。 例如系统初始化时批量预载数据并设置相同的过期时间,或某次批量写入让一批 key 在同一时刻到期。
  • 成因二:缓存服务本身故障。 缓存节点宕机、网络抖动、集群切换期间无法提供服务,所有请求被迫回源数据库。

前者缓存还活着,只是“恰好都没货”;后者是“整个货架塌了”。应对思路也因此分为“打散失效时间”和“提升可用性 + 兜底”两条线。

4.2 应对方案

(一)针对大面积同时过期

  1. 过期时间加随机偏移:在基础 TTL 上叠加随机值,把失效时间点在时间轴上摊开,避免集中到期。
  1. ttl = baseTtl + random(0, 600s) // 例:30 分钟基础 + 0~10 分钟随机
  2. cache.set(key, value, ttl)
  1. 后台定时刷新:对核心数据采用定时任务或消息队列异步刷新缓存的机制,在数据过期前主动更新,让“过期”不再依赖被动触发。
  2. 多级缓存:本地缓存 + 分布式缓存两级结构,即使分布式缓存层出现集中失效,本地缓存仍能承接一部分流量,降低回源峰值。

(二)针对缓存服务故障

  1. 高可用集群:通过主从复制、自动故障转移、分片集群等方式构建缓存集群,主节点故障时从节点快速接管,把“缓存完全不可用”的概率降到最低。云上托管缓存类产品通常默认提供主备与自动切换能力,这也是使用托管服务的核心价值之一。
  2. 服务熔断:当数据库压力超出承受阈值时,主动对部分非核心接口熔断,直接返回默认值或降级提示,避免数据库被彻底压垮后引发全站不可用。熔断相对“粗暴”,属于保命手段。
  3. 请求限流:按数据库实际承载能力对回源请求限流,超出阈值的请求排队、快速失败或走降级逻辑。相比熔断的一刀切,限流更温和,能保证系统始终有一部分请求被正常处理。两者常配合使用:先限流削峰,压力仍失控时熔断保护。
  4. 缓存预热:在大促、发布、批量任务等已知流量高峰前,提前把热点数据加载进缓存,避免冷启动阶段集中回源。

(三)体系化的监控与预案

  • 监控缓存命中率、回源 QPS、数据库连接数与慢查询等核心指标,命中率骤降往往就是雪崩或击穿的前兆,配合告警做到早发现;
  • 提前演练:明确熔断阈值、限流阈值、降级开关,事故发生时按预案执行而不是临时救火。

五、三类异常速查对比

维度 缓存穿透 缓存击穿 缓存雪崩
触发条件 数据在缓存与数据库中都不存在 单个热点 key 失效瞬间被高并发访问 大量 key 同时过期,或缓存服务整体故障
影响范围 集中在“不存在的数据”相关请求 集中在单个热点数据 系统级、全局性
问题本质 缓存永远 miss,无法生效 并发重建失去协调,瞬时惊群 缓存层整体失效
核心手段 入参校验、布隆过滤器、空值缓存 互斥锁、逻辑过期、热点续期 TTL 随机化、高可用集群、熔断限流、多级缓存

六、总结

穿透、击穿、雪崩可以统一理解为“缓存层未能承接住本应承接的流量”的三种形态,应对思路存在共同主线:

  1. 事前预防:合理的 TTL 策略(随机化、热点延长)、参数校验、布隆过滤器、缓存预热、高可用集群;
  2. 事中控制:互斥锁或逻辑过期协调并发重建,限流削峰、熔断保命、降级兜底;
  3. 事后治理:监控命中率与回源压力,复盘异常 key 的产生原因,持续完善预案。

缓存方案没有“一招通吃”,落地时应结合业务的一致性要求、可接受的延迟和成本预算做取舍:核心数据宁可复杂一点也要稳,边缘数据则可以简化处理。把这三类异常的成因与对策内化成设计习惯,才能让缓存真正成为系统的“稳压器”,而不是故障的引信。