缓存雪崩与击穿的系统化防御 — vibrant cover for technical blog article

缓存挂掉那几秒,数据库如何不被压垮

缓存崩了 DB 瞬间被压垮——雪崩和击穿是缓存系统最危险的两类故障。

2025年11月2日·返回文章列表
文章大纲

Matrix 人群底座缓存改造前,命中率长期在 40% 左右徘徊。这意味着六成的人群计算请求直接落到存储层,每次大促前压测,DB 的连接池和 CPU 都是第一个亮红的。后来花了很大力气做缓存治理,命中率拉到 99%,人群服务 RT 从 45ms 降到 30ms,上游策略服务的 QPS 扛到 2800。这组数字看着漂亮,但真正让我后怕的是改造过程中暴露的几个隐患,差一点就在某次大促引爆雪崩。

缓存系统的危险在于它的失效不是线性的。缓存正常工作时,挡掉 95% 的流量,DB 只扛 5%,一切岁月静好。一旦缓存大面积失效,95% 的流量在一瞬间全部涌向 DB,DB 的水位从 5% 跳到 100%,几分钟内被打挂,整个系统瘫痪。这种非线性跳变,是雪崩和击穿比一般故障危险的根本原因。它们不像慢调用那样给你慢慢恶化的窗口,而是断崖式的。

雪崩、击穿、穿透,先分清这三件事

这三个词经常被混着用,但它们的成因和修法完全不同,分不清就会开错药。

PlantUML
正在加载图表…
PlantUML diagram

图:雪崩是面、击穿是点、穿透是无,三者成因不同修法几乎不重叠

雪崩是面的失效。大量 key 在同一时刻集体过期,原本由这些 key 承接的请求全部穿透到 DB。它的特征是范围大,一次影响一大批 key。最常见的成因是批量预热时给所有 key 设了相同的 TTL,时间一到集体殉职。

击穿是点的失效。单个热点 key 在过期瞬间,高并发请求同时打向 DB 重建。它的特征是聚焦,一个 key 拖垮一条链路。首页数据、热搜词条、核心配置这类高频访问的 key 最容易触发。

穿透是查根本不存在。请求查询一个 DB 里就没有的数据,缓存自然也存不了,每个请求都穿过缓存打 DB。恶意攻击或爬虫用不存在的 ID 轮询,穿透能把 DB 慢慢拖死。

这三类问题的修法几乎不重叠,下面分开说。

雪崩的根因和防御

雪崩最经典的成因是 TTL 整齐。上线前批量预热缓存,代码里写死 setex(key, 3600, value),所有 key 拿到一样的 TTL,3600 秒后同时过期,那一秒所有请求涌向 DB 重建。Matrix 改造前就埋着这个雷,预热脚本里 TTL 是常量,只是平时流量没大到暴露。

PlantUML
正在加载图表…
PlantUML diagram

图:雪崩三道防线层层兜底——事前抖动分散过期、事中多级缓存分流、事发 DB 限流保命

最简单的防御是 TTL 随机抖动。每个 key 的 TTL 在基础值上加一个随机偏移,让过期时间分散在不同时刻,不会同时失效。

int baseTtl = 3600;
int jitter = ThreadLocalRandom.current().nextInt(300); // 0-300秒随机
cache.setex(key, baseTtl + jitter, value);

这行代码改动极小,但能消除雪崩最直接的成因。预热脚本里千万别写死 TTL,这个习惯要刻进规范。

第二道防御是多级缓存。Redis 作为 L2,本地缓存作为 L1。Redis 集体失效时,本地缓存还能挡一部分流量,给 DB 争取重建的时间窗口。多级缓存的复杂度在于一致性,本地缓存在多实例间是各自一份,更新时怎么让所有实例失效是个问题。常见做法是 Redis 发布失效消息,各实例监听后清自己的本地缓存。这引入了最终一致性,对实时性要求极高的数据不适合,但对人群标签、商品配置这类容忍秒级延迟的数据够用。

第三道防御是 DB 层限流。雪崩已经发生时,光靠缓存重建来不及,必须保护 DB 不被打挂。在 DB 访问层加限流,超出的请求降级返回兜底数据,保住 DB 存活,等缓存逐步重建恢复。这是最后的兜底,属于事发应急。

击穿的根因和防御

击穿的成因比雪崩更隐蔽。单个热点 key 过期的那一毫秒,几千个并发请求同时发现缓存没了,全部涌向 DB 重建。这个窗口极短,但并发量集中,足以让 DB 瞬间承压。

PlantUML
正在加载图表…
PlantUML diagram

图:互斥锁保证仅一个请求重建 DB,双重检查避免重复重建,其余请求等待后重读缓存

第一种防御是互斥锁,也叫 singleflight。缓存未命中时,用分布式锁保证只有一个请求去 DB 重建,其他请求短暂等待后重读缓存。

Object val = cache.get(key);
if (val == null) {
if (redisLock.tryLock("rebuild:" + key, 3, TimeUnit.SECONDS)) {
try {
val = cache.get(key); // 双重检查, 拿锁期间可能已被别人重建
if (val == null) {
val = db.query(key);
cache.setex(key, ttl, val);
}
} finally { redisLock.unlock(); }
} else {
Thread.sleep(50);
return cache.get(key); // 等一下重试, 这时第一个请求多半已写好缓存
}
}

这里有个细节容易漏,双重检查。拿到锁之后要再查一次缓存,因为等锁的这段时间里,前一个持锁者可能已经把缓存写好了,不查就直接查 DB 就是浪费一次重建。互斥锁的代价是其他请求要短暂阻塞等待,对实时性要求高的场景可以接受。

第二种防御是热点 key 永不过期。物理上不设 TTL,缓存永远在,但 value 里带一个逻辑过期时间。请求读到逻辑过期时,返回旧值,同时触发后台异步重建。用户永远不等缓存重建,看到的最多是稍旧的值。

互斥锁和永不过期怎么选

互斥锁实现简单,但其他请求要等。永不过期无等待,但实现复杂,要管后台异步重建线程,还要接受返回旧值。对金额、库存这类不能容忍旧值的强一致数据,用互斥锁。对推荐、榜单、统计这类能容忍秒级延迟的数据,永不过期体验更好。两者不是互斥的,可以根据 key 的语义分别选。

穿透的防御

穿透的修法和前两类都不一样,因为它的问题是数据根本不存在。

缓存空值是最直接的。DB 查不到也把这个 null 缓存起来,设一个短 TTL,下次同样的请求命中空值缓存直接返回,不再打 DB。代价是缓存里会存一批空值占内存,所以 TTL 要短,几分钟足够。

布隆过滤器适合海量 key 的场景。在缓存之前加一层布隆过滤器,请求先问布隆过滤器这个 key 可能存在吗,不存在就直接拒绝,连缓存都不查。布隆过滤器的特点是可能有误判(说不存在一定不存在,说存在可能不存在),但对防穿透足够,因为穿透的攻击恰恰是大量查不存在的 ID。布隆过滤器的难点在于要随数据更新维护,新写入的数据要加进过滤器,否则会被误拦。

参数校验是第一层防线,明显非法的 ID(负数、超长、格式错误)直接拒绝,根本不进缓存逻辑。这层最便宜,能挡掉大量无意义的穿透。

监控是预防的前提

雪崩和击穿都有明确的监控信号。缓存命中率是第一指标,正常时稳定在一个高位(比如 99%),骤降到比如 80% 就是异常信号。DB QPS 是第二指标,正常时平稳,突然飙升说明流量在绕过缓存打 DB。命中率骤降加 DB QPS 飙升,基本可以判定缓存层出了问题。

Matrix 改造后我们把命中率做成核心告警指标,低于 95% 就告警。大促期间这个阈值还会临时调高。监控的价值在于给你反应时间,雪崩从触发到 DB 挂掉可能就几分钟,没有监控只能等用户投诉才发现。

预热和平滑启动是预防的另一面。服务重启或缓存集群重建时,缓存是空的,全量流量瞬间涌进来直接打 DB。正确做法是先预热热点数据到缓存,TTL 分散设置,然后逐步放量,等缓存建得差不多了再放开全量流量。Matrix 大促前的预热脚本就是这套流程,先灌缓存再放流量,避免冷启动打爆 DB。

缓存本身的高可用

前面讲的都是缓存失效后保护 DB,但缓存系统本身也得高可用。Redis 单点挂了等于全量雪崩。Redis 集群加主从哨兵是基础配置,主节点故障自动切换从节点,对外基本无感。

即使 Redis 集群整体故障,也得有兜底。本地缓存作为第一层降级,Redis 不可用时本地还能挡一部分。本地也挂了,就降级返回默认值或预设的兜底数据。核心原则是绝不让 DB 直接面对全量流量,缓存层的任何一环都要有备援。

Continue Reading

关联文档推荐

查看全部
降级开关治理:统一管控与一键熔断 — editorial cover photo容量治理:水位监控与弹性伸缩 — editorial cover photo限流降级治理:统一规则中心与执行链路 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。