简介:缓存是高并发系统的第一道防线,但穿透、击穿、雪崩三类异常会让流量瞬间压垮数据库。本文系统梳理三者的成因、区别与危害,并结合空值缓存、布隆过滤器、互斥锁、逻辑过期、过期时间打散、熔断限流等主流方案,给出可落地的实践建议与伪代码示例,帮助读者构建稳定可靠的缓存体系。
在典型的分层架构中,一次读请求通常遵循这样的路径:请求先到达缓存层(基于内存的键值型存储),命中则直接返回;未命中(缓存 miss)才回源到数据库查询,并将结果写回缓存供后续请求复用。
这种设计之所以有效,是因为内存读写性能比磁盘高出多个数量级,绝大多数请求被消化在缓存层,数据库只需处理少量回源请求。换句话说,缓存是数据库的“挡箭牌”。
而穿透、击穿、雪崩这三类异常,本质上都是“挡箭牌失效”的不同形态:
三者的影响范围依次扩大:穿透针对“不存在的数据”,击穿针对“单个热点 key”,雪崩则是“系统级故障”。理解这个区别,是选择应对方案的前提。
缓存穿透指客户端请求的数据在缓存中不存在,回源查询数据库后同样不存在。由于查不到数据,也就没有可缓存的有效值,导致每次请求都打到数据库。如果这类请求来自恶意攻击(例如用大量不存在的 ID 高频扫接口),数据库会在短时间内被无效请求打满。
方案一:缓存空值(空对象)
数据库查不到时,也向缓存写入一个约定好的“空标记”,并设置较短的过期时间(例如 60 秒左右,可叠加随机偏移)。后续相同请求会在缓存层被快速拦截。
优点是实现简单、维护成本低;缺点是可能造成短期数据不一致(数据刚写入数据库,缓存里还是空标记),且若攻击者每次使用不同的随机 key,空值缓存会占用大量内存。因此空标记的 TTL 要短,并配合 key 合法性的前置校验。
function getData(key):value = cache.get(key)if value != null:if value == "EMPTY": // 命中空标记,直接返回return nullreturn valuevalue = db.query(key)if value == null:cache.set(key, "EMPTY", ttl = 60s + random(0, 30s)) // 空值短过期return nullcache.set(key, value, ttl = 3600s)return value
方案二:布隆过滤器前置拦截
在缓存之前加一层布隆过滤器,把全量合法 key 预先登记进去。查询时先问布隆过滤器“这个 key 可能存在吗”,若回答“一定不存在”,直接返回,不再访问缓存和数据库。
需要理解它的两个特性:
# 写入侧:数据落库时同步登记function saveData(item):db.save(item)bloomFilter.add(item.id)# 查询侧:先过布隆过滤器function getData(id):if not bloomFilter.mightContain(id):return null // 一定不存在,直接拦截// ... 再走正常 缓存 -> 数据库 流程
方案三:入口校验与风控
对明显非法的参数(负数 ID、超长字符串、格式错误的请求)在接口层直接拒绝;对单 IP 高频请求、特征异常的调用方做限流或拉黑。这一层成本最低,应最先考虑。
实践中通常组合使用:入口校验 + 布隆过滤器兜底 + 空值缓存兜底的兜底。
缓存击穿(也叫热点 key 问题)发生在高并发场景:某个被高频访问的 key 到期失效的瞬间,成百上千个并发请求同时发现缓存 miss,同时回源查数据库、同时尝试写回缓存。数据库在这一瞬间承受远超平时的并发压力——像一根针精准地扎穿了缓存层。
它与穿透的区别:穿透的数据“永远不存在”,缓存永远无法生效;击穿的数据“存在但刚好过期”,是时间点上的并发问题。
方案一:互斥锁,只允许一个请求回源
缓存 miss 后,先尝试获取一把分布式锁,只有拿到锁的请求才去查数据库并重建缓存;其他请求短暂等待后重试读缓存。这样无论并发多大,数据库同一时刻只承受一次回源。
function getHotData(key):value = cache.get(key)if value != null:return valuelockKey = "lock:" + keyif tryLock(lockKey, expire = 10s): // 锁必须带过期时间,防止锁泄漏try:value = cache.get(key) // 双重检查:可能别的请求已重建完成if value == null:value = db.query(key)cache.set(key, value, ttl = 3600s)finally:releaseLock(lockKey)return valueelse:sleep(50ms)return getHotData(key) // 未抢到锁,稍后重试
代价是等待锁的请求会有短暂延迟,且需处理“拿到锁的节点宕机”导致的锁泄漏问题。
方案二:逻辑过期,热点数据“永不失效”
对热点 key 不设置物理过期时间,而是把过期时间作为字段存进 value。读取时判断逻辑过期时间:未过期直接返回;已过期则先返回旧值保证可用性,同时异步加锁重建缓存。
优点是不存在等待锁的阻塞,可用性极高;代价是过期后会短暂返回旧数据(最终一致),适合能容忍短暂旧值的场景(如商品详情、活动页)。
# 写入:不设 TTL,把过期时间写进 valuecache.set(key, {data: realData, expireAt: now + 30min})function getHotData(key):wrapper = cache.get(key)if wrapper == null:return rebuild(key) // 冷启动,走互斥重建if now < wrapper.expireAt:return wrapper.data // 逻辑未过期,直接返回if tryLock("lock:" + key): // 已逻辑过期:返回旧值 + 异步刷新async {newData = db.query(key)cache.set(key, {data: newData, expireAt: now + 30min})releaseLock("lock:" + key)}return wrapper.data // 没抢到锁也直接返回旧值
方案三:热点探测与多级缓存
通过访问统计识别热点 key,由后台任务在其过期前主动重建或续期,消除“到期瞬间”这个时间窗。同时把热点数据下沉到应用本地缓存,形成“本地缓存 -> 分布式缓存 -> 数据库”的多级结构,大幅削减打到存储层的并发量。
选型建议:一致性与正确性优先、可接受短暂阻塞的用互斥锁;可用性优先、可容忍秒级旧值的用逻辑过期;两者也可按 key 的重要程度混用。
缓存雪崩指某个时间点,大量本应由缓存承接的请求集中涌向数据库,导致数据库压力骤增、响应变慢,进而拖垮上游服务,形成连锁反应。它有两种典型成因:
前者缓存还活着,只是“恰好都没货”;后者是“整个货架塌了”。应对思路也因此分为“打散失效时间”和“提升可用性 + 兜底”两条线。
(一)针对大面积同时过期
ttl = baseTtl + random(0, 600s) // 例:30 分钟基础 + 0~10 分钟随机cache.set(key, value, ttl)
(二)针对缓存服务故障
(三)体系化的监控与预案
| 维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 触发条件 | 数据在缓存与数据库中都不存在 | 单个热点 key 失效瞬间被高并发访问 | 大量 key 同时过期,或缓存服务整体故障 |
| 影响范围 | 集中在“不存在的数据”相关请求 | 集中在单个热点数据 | 系统级、全局性 |
| 问题本质 | 缓存永远 miss,无法生效 | 并发重建失去协调,瞬时惊群 | 缓存层整体失效 |
| 核心手段 | 入参校验、布隆过滤器、空值缓存 | 互斥锁、逻辑过期、热点续期 | TTL 随机化、高可用集群、熔断限流、多级缓存 |
穿透、击穿、雪崩可以统一理解为“缓存层未能承接住本应承接的流量”的三种形态,应对思路存在共同主线:
缓存方案没有“一招通吃”,落地时应结合业务的一致性要求、可接受的延迟和成本预算做取舍:核心数据宁可复杂一点也要稳,边缘数据则可以简化处理。把这三类异常的成因与对策内化成设计习惯,才能让缓存真正成为系统的“稳压器”,而不是故障的引信。