2022 年我负责一个商品详情接口,QPS 不低。为了扛住流量,我在接口前面加了一层 Redis 缓存:查商品时先看缓存,缓存没有再查 MySQL、然后回填进缓存。加完之后效果立竿见影,数据库压力一下小了很多,平时跑得稳稳当当。直到那年双十一前的一次压测,我亲眼看着事故发生:压测流量一上来,监控里 MySQL 的 CPU 几秒钟内就冲到 100%,慢查询堆积,商品接口大面积超时,整条链路雪崩。我当时第一反应是"缓存不是加了吗,怎么还把库打挂了"。盯着监控复盘了很久才想明白:我以为"加了缓存"就等于"数据库被保护了",可缓存这层保护其实是有很多漏洞的——有人拿一批根本不存在的商品 ID 来刷,缓存永远命中不了,请求全量穿透到库;一个超热的商品缓存恰好过期的那一瞬间,成百上千个并发请求同时发现缓存没了,一起涌向数据库;甚至我图省事给一大批缓存设了相同的过期时间,它们就在同一秒集体失效……这些场景下,缓存非但没挡住流量,反而像一道被瞬间击穿的堤坝。那次之后我才认真搞懂:缓存穿透、缓存击穿、缓存雪崩——这是悬在每一个缓存方案头上的三把刀。这篇文章就把它们一个个讲透:每一种是怎么发生的、为什么会把数据库打垮、分别该怎么防,以及缓存一致性这些绕不开的坑。
问题背景
先把那次事故的现象和我的误判讲清楚,后面所有的方案都是冲着纠正这个误判去的。
现象:商品接口加了 Redis 缓存,平时数据库压力很小、运行平稳;但在流量高峰或某些特定请求模式下,大量请求绕过缓存直接打到 MySQL,数据库 CPU 瞬间打满,接口大面积超时,服务雪崩。
我当时的错误认知:"在数据库前面加一层缓存,数据库就被保护起来了。绝大多数请求都会命中缓存,落到库上的能有多少?"
真相:缓存对数据库的保护,不是无条件的。它建立在一个隐含前提上——"绝大多数请求都能命中缓存"。而缓存穿透、击穿、雪崩这三种情况,恰恰都是从不同角度打破这个前提:让请求大量地、集中地无法命中缓存,从而直接砸向数据库。缓存这层堤坝有几个特定的薄弱点,不主动去加固,它就会在高峰期被精准击穿。
要把缓存方案做稳,需要几块认知:
- 缓存最常用的 Cache-Aside 模式长什么样,三大问题为什么都出在它身上;
- 缓存穿透:查不存在的数据,怎么用空值缓存和布隆过滤器挡住;
- 缓存击穿:热点 key 过期瞬间的并发,怎么用互斥锁和逻辑过期挡住;
- 缓存雪崩:大批 key 同时失效或 Redis 宕机,怎么用随机过期和降级挡住;
- 缓存和数据库的一致性该怎么保证。
一、先看清缓存的基本模式:Cache-Aside
要理解三大问题,得先看清它们共同的"案发现场"——绝大多数业务用的缓存模式,叫 Cache-Aside(旁路缓存)。
它的读流程很朴素,就三步:第一步,先查缓存,命中就直接返回;第二步,缓存没命中,就去查数据库;第三步,把从数据库查到的结果回填进缓存,并设一个过期时间,这样下次同样的查询就能命中了。下面是这个模式最标准的代码:
// Cache-Aside(旁路缓存)读流程:先查缓存,未命中再查库并回填。
public Product getProduct(Long id) {
String key = "product:" + id;
// 1. 先查缓存
String cached = redis.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class); // 命中,直接返回
}
// 2. 缓存未命中,查数据库
Product product = productMapper.selectById(id);
// 3. 把查到的结果回填进缓存,并设一个过期时间
if (product != null) {
redis.opsForValue().set(key, JSON.toJSONString(product),
30, TimeUnit.MINUTES);
}
return product;
}
这段代码看起来天衣无缝,平时也确实跑得很好。但请盯住它的逻辑——它只在一个前提下能保护数据库:大部分请求都走进了第 1 步那个 if 分支。一旦请求大量地从第 1 步漏下来、涌进第 2 步,这段代码就成了一个"高效地把流量转发给数据库"的管道。三大问题,本质上就是三种"让请求大量漏到第 2 步"的方式。
二、缓存穿透:查一笔永远不存在的数据
第一把刀,叫缓存穿透。它指的是:请求查询一个数据库里根本不存在的数据。
看清它为什么致命:一个不存在的 ID,查数据库返回空,于是上面那段代码的第 3 步 if (product != null) 不成立——缓存没有被回填。结果就是,下一次同样的请求来了,缓存照样未命中,又去查了一次库……对于一个不存在的 ID,缓存这层永远命中不了,每一个这样的请求都 100% 穿透到数据库。如果有人恶意拿一批随机的、不存在的 ID 来刷接口,等于直接把全部流量引到了数据库上。
第一种对策是缓存空值:既然问题是"查不到就不写缓存",那就改成——查不到,也往缓存里写一个特殊的"空值"标记。这样同一个不存在的 ID 再来,会命中这个空值标记,直接返回,不再打库。要点是给空值设一个较短的过期时间(防止它一直占着内存,也防止这个 ID 后来真的有了数据时长时间读到旧的空值)。
// 防穿透方案一:把"查不到"这件事本身也缓存起来(缓存空值)。
private static final String EMPTY = "__EMPTY__"; // 空值的特殊标记
public Product getProductSafe(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
// 命中的是空值标记 —— 说明这个 id 确实不存在,直接返回 null
if (EMPTY.equals(cached)) return null;
return JSON.parseObject(cached, Product.class);
}
Product product = productMapper.selectById(id);
if (product == null) {
// 查不到也要写缓存:写一个空值标记,过期时间设短一些
redis.opsForValue().set(key, EMPTY, 5, TimeUnit.MINUTES);
return null;
}
redis.opsForValue().set(key, JSON.toJSONString(product),
30, TimeUnit.MINUTES);
return product;
}
缓存空值能解决"反复查同一个不存在的 ID"。但如果攻击者每次都用不同的不存在 ID,缓存里会堆满大量空值标记,而且每个新 ID 第一次还是会穿透一次。这时需要第二种、更彻底的对策:布隆过滤器(Bloom Filter)。它是一个极省内存的数据结构,能快速回答"某个元素可能在集合里,还是一定不在集合里"。它的特性很关键:说"不存在"时一定准确,说"可能存在"时有极小概率误判。把数据库里所有存在的 ID 都灌进布隆过滤器,查询前先问它一句,凡是它说"不存在"的,直接拦掉:
// 防穿透方案二:布隆过滤器 —— 查缓存之前先问它"这个 id 可能存在吗"。
private BloomFilter<Long> bloomFilter;
@PostConstruct
public void initBloomFilter() {
// 预计 100 万个 id,可接受的误判率 1%
bloomFilter = BloomFilter.create(
Funnels.longFunnel(), 1_000_000, 0.01);
// 服务启动时,把数据库里所有"存在的 id"都灌进去
for (Long id : productMapper.selectAllIds()) {
bloomFilter.put(id);
}
}
public Product getProductWithBloom(Long id) {
// 布隆过滤器说"不存在",那就 100% 不存在 —— 直接拦掉,连缓存都不查
if (!bloomFilter.mightContain(id)) {
return null;
}
// 它说"可能存在",才走正常的查缓存、查库流程
return getProductSafe(id);
}
三、缓存击穿:热点 key 过期的那一瞬间
第二把刀,叫缓存击穿。注意它和穿透完全不同:击穿查的数据是真实存在的,问题出在时间点上。
设想一个超级热点的商品——比如大促主推款,它的缓存每秒被读几万次。某一刻,它的缓存过期了。就在过期的那一瞬间,这几万个并发请求同时发现缓存未命中,于是它们同时涌向数据库去查同一条数据。本来缓存好好的时候数据库一个请求都不用接,过期的瞬间却要硬扛几万个完全相同的查询。一个热点 key 的过期,就能在那一瞬间击穿缓存、给数据库来一记重拳。
第一种对策是互斥锁:既然问题是"几万个请求同时去查库",那就限制成"只让一个请求去查库重建缓存,其余的请求等一下"。用 Redis 的 SETNX(set if not exist)实现一把分布式锁,谁抢到锁谁去查库,没抢到的稍等片刻再重试读缓存——那时缓存通常已经被重建好了:
// 防击穿:热点 key 过期瞬间,只放一个线程去查库重建,其余线程等待。
public Product getProductWithLock(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
String lockKey = "lock:product:" + id;
// 用 SETNX 抢一把分布式锁,只有抢到的线程才去查库
Boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 抢到锁:查库、回填缓存
Product product = productMapper.selectById(id);
redis.opsForValue().set(key, JSON.toJSONString(product),
30, TimeUnit.MINUTES);
return product;
} finally {
redis.delete(lockKey); // 一定要在 finally 里释放锁
}
} else {
// 没抢到锁:稍等片刻再重试(此时缓存大概率已被别人重建好)
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
return getProductWithLock(id);
}
}
互斥锁的代价是:没抢到锁的请求要等待,在重建期间响应会变慢一点。如果连这点延迟都不能接受,可以用第二种对策——逻辑过期:缓存永不真正过期(不设 Redis 的 TTL),而是把一个"逻辑过期时间"写进 value 里。读到数据后自己判断:没到逻辑过期时间就正常返回;已经逻辑过期了,就先返回手里这份旧数据,同时异步起一个线程去重建缓存。这样任何请求都不会被阻塞,代价是逻辑过期到重建完成的这段时间里,会读到旧数据。
// 防击穿方案二:逻辑过期 —— 缓存永不真正过期,过期时间写在 value 里。
class CacheData {
Object data;
long expireAt; // 逻辑过期时间戳,由我们的代码自己判断
}
public Product getByLogicalExpire(Long id) {
String key = "product:" + id;
CacheData cd = JSON.parseObject(
redis.opsForValue().get(key), CacheData.class);
if (cd == null) return null;
// 还没到逻辑过期时间:数据新鲜,直接返回
if (cd.expireAt > System.currentTimeMillis()) {
return (Product) cd.data;
}
// 已逻辑过期:异步起线程重建缓存,但当前请求先拿旧数据返回,不阻塞
CACHE_REBUILD_POOL.submit(() -> rebuildCache(id));
return (Product) cd.data; // 旧数据先顶着
}
互斥锁和逻辑过期,本质是同一个权衡的两端:互斥锁保证数据新鲜,代价是重建期间少量请求要等待;逻辑过期保证永不阻塞,代价是重建期间会读到旧数据。选哪个,取决于你的业务更不能容忍"慢"还是"旧"。
四、缓存雪崩:大批 key 同时失效,或 Redis 整个宕机
第三把刀,叫缓存雪崩。如果说击穿是"一个热点 key 倒下",雪崩就是"一大片 key 同时倒下",甚至是整个缓存层倒下。它有两种典型成因。
成因一:大批 key 在同一时刻集中过期。这恰恰常常是开发者图省事造成的——比如系统启动时批量预热了一万个缓存,代码里全都写 set(key, value, 30, MINUTES)。结果这一万个 key 会在 30 分钟后的同一秒一起过期,那一秒涌进来的所有请求全部未命中、全部砸向数据库。成因二:Redis 实例整个挂了。Redis 宕机,缓存层瞬间彻底消失,100% 的流量直接打到数据库——这是最猛烈的雪崩。
针对成因一,对策简单却极其有效:回填缓存时,给过期时间叠加一个随机量,把原本会"齐步走"的过期时间打散到一个时间范围内,数据库的压力就被摊平了:
// 防雪崩:回填缓存时给过期时间叠加随机量,避免大批 key 同时过期。
public void cacheProduct(Long id, Product product) {
String key = "product:" + id;
// 基础 30 分钟,再叠加 0~10 分钟的随机量,把过期时间打散开
long ttl = 30 * 60 + ThreadLocalRandom.current().nextInt(10 * 60);
redis.opsForValue().set(key, JSON.toJSONString(product),
ttl, TimeUnit.SECONDS);
}
针对成因二(Redis 宕机),随机过期就无能为力了——这时缓存层已经整个没了。这里要靠两道防线。第一道是让 Redis 本身别那么容易整个挂掉:生产环境用主从 + 哨兵或集群模式,别用单实例。第二道、也是最关键的一道,是降级和限流:你必须接受"缓存层有可能彻底失效"这个事实,并为此准备好一个兜底方案——当缓存不可用时,绝不能让海量请求毫无节制地冲向数据库,而要用一个限流器(比如信号量)把"同时查库的请求数"卡在一个数据库能扛住的水平,超出的请求直接快速失败、返回兜底结果:
// 防雪崩最后一道防线:即便缓存全线失效,也只放有限的请求去查库。
private final Semaphore dbPermits = new Semaphore(50); // 最多 50 个并发查库
public Product getProductDegradable(Long id) {
try {
return getProductWithLock(id);
} catch (Exception e) {
// 缓存链路异常,走降级:返回兜底结果,绝不放任请求继续砸数据库
log.warn("缓存链路异常,触发降级", e);
return queryDbLimited(id);
}
}
private Product queryDbLimited(Long id) {
// 拿不到查库名额,说明数据库已接近极限 —— 直接快速失败,保住数据库
if (!dbPermits.tryAcquire()) {
return Product.unavailable();
}
try {
return productMapper.selectById(id);
} finally {
dbPermits.release();
}
}
这里的核心思想要记住:雪崩防护的终点,不是"保证缓存永不失效"——那做不到;而是承认缓存会失效,并保证它失效时数据库不会被一起拖垮。一个健康的系统,在缓存全挂的最坏情况下,应该是"变慢、部分降级",而不是"彻底雪崩"。
五、绕不开的坑:缓存与数据库的一致性
讲完三大杀手,还有一个所有缓存方案都绕不开的问题:数据更新时,缓存和数据库怎么保持一致?数据库一旦更新了,缓存里那份就成了旧数据。
处理更新有两个动作:更新数据库、清理缓存。它们的顺序很有讲究。常见的错误是"先更新数据库,再删缓存"——如果删缓存那一步失败了,缓存里就一直是旧数据。而"先删缓存,再更新数据库"也有一个经典的并发漏洞:线程 A 删了缓存、还没来得及更新数据库,线程 B 进来一看缓存没有,就去查了数据库(查到的还是旧值)并回填进了缓存;然后 A 才更新完数据库——此刻数据库是新值,缓存却被 B 填回了旧值,不一致了。
实践中一个被广泛采用的缓解方案是延迟双删:先删一次缓存,再更新数据库,然后延迟一小段时间,再删一次缓存。第二次删除,正是为了清掉"在前面那个并发窗口里被其他读请求回填进去的旧值":
// 缓存一致性:先删缓存、再更数据库,并延迟一会儿再删一次(延迟双删)。
public void updateProduct(Product product) {
String key = "product:" + product.getId();
// 1. 先删缓存
redis.delete(key);
// 2. 再更新数据库
productMapper.updateById(product);
// 3. 延迟一小段时间后再删一次缓存。
// 目的:清掉"第 1 步到第 2 步之间,被其他读请求回填进去的旧值"。
DELAY_POOL.schedule(() -> redis.delete(key),
500, TimeUnit.MILLISECONDS);
}
这里要诚实地说一句:延迟双删也只是降低不一致的概率和持续时间,并不能 100% 消除。要真正强一致,代价会很高(比如加分布式锁、或用 binlog 订阅来更新缓存)。所以更现实的工程态度是:接受缓存存在短暂不一致,只要给每个缓存都设了过期时间,最坏情况下,过期后也会自动回到一致——关键是想清楚你的业务能不能容忍这个"短暂"。
六、工程坑:大 key、热 key、缓存预热、命中率监控
把缓存真正用稳,还有几个工程细节绕不开。
坑 1:警惕大 key。把一个巨大的对象(比如一个几 MB 的列表)整个塞进一个缓存 key,会有麻烦:读它会占用大量网络带宽,而删除或过期一个大 key 甚至可能阻塞 Redis。大对象要拆分存储,或者只缓存必要的字段。
坑 2:热 key 可能压垮单个 Redis 节点。缓存击穿讲的是热点 key 过期,但即使不过期,一个超热的 key 也会让承载它的那个 Redis 分片承受远超其他分片的压力。极端热点可以考虑在多个节点上放副本,或者在应用层再加一层本地缓存(多级缓存)来分流。
坑 3:缓存预热。系统刚启动或刚发布时,缓存是空的,这时如果直接放全量流量进来,所有请求都会未命中、一起打库——相当于一次自找的雪崩。重要的热点数据,应该在系统对外提供服务之前就主动加载进缓存,这叫预热。
坑 4:一定要监控缓存命中率。命中率是缓存健康状况的第一指标。命中率突然下跌,往往就是穿透、击穿或雪崩正在发生的前兆。没有这个监控,你只能等数据库告警了才后知后觉。
// 监控缓存命中率:命中率是判断缓存是否健康的第一指标。
public Product getProductMonitored(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
meterRegistry.counter("cache.hit").increment(); // 命中,计数 +1
return JSON.parseObject(cached, Product.class);
}
meterRegistry.counter("cache.miss").increment(); // 未命中,计数 +1
Product product = productMapper.selectById(id);
if (product != null) {
cacheProduct(id, product);
}
// 命中率 = hit / (hit + miss)。它一旦骤降,
// 基本就是穿透 / 击穿 / 雪崩三者之一正在发生,应立刻告警。
return product;
}
下面这张图把"防穿透 + 防击穿 + 防雪崩"串成一条完整的查询链路:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| Cache-Aside | 旁路缓存:先查缓存,未命中查库并回填;三大问题都出在它身上 |
| 缓存穿透 | 查数据库里不存在的数据,缓存永远命中不了,请求全量打库 |
| 缓存空值 | 查不到也写一个空值标记进缓存,挡住反复查同一不存在 key |
| 布隆过滤器 | 极省内存地判断元素是否存在,说"不存在"一定准,可彻底挡穿透 |
| 缓存击穿 | 某个热点 key 过期瞬间,大量并发同时未命中、一起打库 |
| 互斥锁 | 用 SETNX 让只有一个线程去查库重建,其余等待,防击穿 |
| 逻辑过期 | 缓存不真过期,过期时间写进 value,过期后返旧值并异步重建 |
| 缓存雪崩 | 大批 key 同时过期,或 Redis 整体宕机,流量集中砸向数据库 |
| 随机过期 | 过期时间叠加随机量,把集中过期打散,防雪崩成因之一 |
| 降级限流 | 缓存失效时用信号量限制并发查库数,超出快速失败,保住数据库 |
避坑清单
- 加了缓存不等于数据库被保护;缓存的保护建立在"绝大多数请求命中缓存"这个前提上。
- 缓存穿透查的是不存在的数据,缓存永远 miss;查不到也要写缓存(空值标记),并设较短过期。
- 空值缓存挡不住"每次用不同的不存在 ID"的攻击,需用布隆过滤器在查缓存前先拦掉。
- 缓存击穿查的是真实存在的热点数据,问题在它过期的瞬间被大量并发同时打库。
- 防击穿用互斥锁(只放一个线程查库重建)或逻辑过期(返旧值 + 异步重建),按"怕慢还是怕旧"取舍。
- 缓存雪崩有两种成因:大批 key 同时过期、Redis 整体宕机,要分别应对。
- 批量设缓存时别用相同 TTL,给过期时间叠加随机量,把集中过期打散开。
- Redis 用主从哨兵或集群别用单实例;并接受"缓存会整体失效",用降级限流给数据库兜底。
- 更新数据时缓存与数据库会不一致;延迟双删能缓解但不能根除,务必给缓存设过期时间兜底。
- 警惕大 key 和热 key;系统上线前做缓存预热;务必监控命中率,它骤降是三大问题的前兆。
总结
回头看那次压测把数据库打垮的事故,最该记住的不是某一个具体的防护代码,而是我上线前那个想当然的判断——"加了缓存,数据库就安全了"。这个判断错在它把缓存当成了一堵无条件挡住一切的墙。可缓存从来不是墙,它是一条有特定薄弱点的堤坝:不存在的数据会从穿透处漏过去,热点 key 过期会在击穿处裂开一道口子,大批 key 同时失效会让整段堤坝一起垮掉。你不去逐个加固这些薄弱点,它平时看着固若金汤,高峰期却会从最意想不到的地方溃决。
所以用好缓存,核心是想清楚一件事:我的缓存,会在什么情况下大面积失去保护作用?这篇文章的三大杀手,其实就是这个问题的三个标准答案——查不存在的数据(穿透)、热点 key 过期的瞬间(击穿)、大批 key 同时失效或 Redis 宕机(雪崩)。把它们对应的防护一一就位:布隆过滤器和空值缓存堵住穿透,互斥锁和逻辑过期顶住击穿,随机过期和降级限流扛住雪崩,再加上对一致性的清醒认识——你的缓存方案才算是经得起高峰检验的。
你会发现,这三种防护背后是同一种工程思维:不要假设理想情况,要主动设想"最坏会怎样",并为最坏情况准备好应对。穿透,是设想"有人专门查不存在的数据";击穿,是设想"最热的那个 key 偏偏在最忙的时候过期";雪崩,是设想"整个 Redis 突然就没了"。优秀的系统设计,从来不是把正常流程写得多漂亮,而是把每一种异常情况都想到了、都接住了。缓存只是一个具体的舞台,这套"为最坏情况做准备"的思维,在限流、熔断、容灾里,处处都用得上。
最后想说,缓存的三大问题,有一个共同的残酷之处:它们在 Demo 和日常低峰期完全不会暴露。平时命中率高得很,穿透?偶尔几个不存在的请求而已;击穿?并发量根本不够触发;雪崩?key 又没多到能压垮库。它们只在真实的高并发、真实的流量高峰、真实的恶意请求面前才会瞬间引爆——而那一刻,往往就是你最不希望出事的大促当天。所以别等监控告警把你从睡梦中叫醒,在你写下第一行 redis.get 的时候就该问自己:这个 key 查不到怎么办?它过期的那一秒怎么办?整个 Redis 没了怎么办?这三个问题都有了答案,你的缓存才不只是 Demo 里那个漂亮的命中率数字,而是一道真正能在洪峰面前守住数据库的堤坝。
—— 别看了 · 2026