Redis 缓存层一旦失效,请求就会瞬间穿透到数据库,把数据库冲垮,进而拖垮整个系统。本文按"问题 → 成因 → 预防层 → 响应层"的结构,系统讲透缓存雪崩(大面积失效)、缓存击穿(热点单点失效)、缓存穿透(查询不存在数据)三大经典问题,涵盖 TTL 随机偏移、逻辑过期、缓存预热、互斥锁双重确认、线程池异步重建、降级/等待策略、重试+MQ 兜底、定时任务巡检、布隆过滤器等核心机制。


一、为什么需要 Redis 缓存层:先看清它在系统里的位置

在讲三大缓存问题之前,有必要先把 Redis 在系统里的位置和缓存的作用说清楚,因为后面所有的问题本质都源自"缓存层失效 → 请求穿透到数据库"这个核心矛盾。

1.1 直接访问数据库为什么扛不住

业务数据通常存储在 MySQL 这类关系型数据库里,而 MySQL 的数据最终是落在磁盘上的。磁盘的读写速度几乎是计算机硬件里最慢的一环——即便是 SSD,随机读写延迟也是在百微秒到毫秒级,而内存的读写延迟在纳秒级,两者差了好几个数量级。

更关键的是,MySQL 处理一次查询不仅要走磁盘 IO,还要经过解析、优化、执行器、存储引擎等一整套流程,单机能扛的并发连接数也是有限的(一般几百到几千 QPS 就要开始警惕了)。当用户的请求全部直接打到数据库上,请求数量一上来,数据库的连接池被打满、CPU 飙高、IO 阻塞,很快就会崩溃。数据库一崩,依赖它的所有业务都没法用了。

1.2 Redis 缓存层的位置和作用

为了避免用户请求直接访问数据库,会在业务服务和数据库之间加一层 Redis 作为缓存。因为 Redis 是内存数据库,我们把数据库里的常用数据同步一份到 Redis 里,相当于数据缓存在内存中。内存的读写速度比磁盘快好几个数量级,单机 Redis 能轻松扛住几万到十几万 QPS,远高于 MySQL。

加上 Redis 缓存层后,一次典型的请求流程是这样的:

把这个流程拆开看:

  • T1:用户请求到达业务服务。
  • T2:业务服务先去 Redis 查缓存。
  • T3:如果缓存命中,直接返回缓存数据,请求在内存层就被消化掉了,根本不会到数据库。
  • T4:如果缓存未命中,再去查 MySQL 数据库,拿到数据后回写到 Redis(方便下次直接命中),最后返回给用户。

正常情况下,绝大多数请求都能在 T3 这一步命中缓存返回,数据库的压力会被 Redis 这一层极大地挡住。这就是 Redis 缓存层的核心价值:用内存的速度挡住磁盘的速度,用 Redis 的高并发挡住数据库的低并发

1.3 缓存层失效会发生什么

但缓存层并不是万能的,它本身也会出问题。一旦 Redis 里的缓存大面积失效——不管是 TTL 到期、Redis 宕机,还是查询的根本是不存在的数据——请求就会绕过 Redis 直接打到数据库。这时候数据库承受的并发量会瞬间飙升,远超它能承受的上限,数据库崩溃,依赖它的所有业务也跟着崩溃,整个系统瘫痪。

这就是下面要讲的三类经典问题的共同根源。它们之间的区别在于触发条件不同:

  • 缓存雪崩:大量缓存(不分热点非热点)同时失效,请求大面积穿透。
  • 缓存击穿:单个热点数据的缓存失效,但请求量极大,集中打到数据库。
  • 缓存穿透:查询的是根本不存在的数据,缓存和数据库都没有,每次都穿透。

下面我们逐一展开。


二、缓存雪崩:缓存大面积失效引发的连锁崩溃

2.1 什么是缓存雪崩

缓存雪崩这个名字其实挺形象的——缓存突然消失带来的雪崩效应。从某一个接口的宕机,到整个系统的全面崩溃,就像雪崩一样从局部扩散到整体。

具体来说,就是 Redis 缓存层在某一个时刻大面积失效,相当于缓存层"没有作用了",原本被 Redis 挡住的请求全部直接打到数据库。请求数量远超数据库可承受的最大并发量,数据库直接被冲垮。数据库一崩,依赖它的其他业务也没法用了,整个系统崩溃。

这个连锁反应可以用下图表示:

2.2 缓存雪崩的两种成因

缓存雪崩的触发原因主要有两种,对应的解决方案也不同:

  • 成因一:缓存同时过期。比如某次大批量缓存预热时给所有 key 设置了相同的 TTL,到了某个时刻这些 key 集体过期,缓存层瞬间空了一大块。
  • 成因二:Redis 宕机。Redis 节点本身挂了(比如机器故障、内存打满被 OS kill、网络分区),整个缓存层在一段时间内完全不可用。

下面分别讲对应的解决方案。

2.3 成因一的解决方案:缓存同时过期

对于"缓存同时过期"这种成因,我们可以从两层去防护:预防层响应层

  • 预防层:让缓存尽量不要在同一时刻大面积过期,从源头降低雪崩发生的概率。
  • 响应层:即便预防层做得再好,缓存还是可能失效(数据量太大、TTL 设置不合理等),响应层负责在缓存失效的瞬间挡住请求,避免它们直接冲垮数据库。

2.3.1 预防层:三种策略

预防层的核心目标是避免大量缓存在同一时刻集体过期。一共有三种主流策略。

策略一:TTL 加随机偏移量

这是最简单也最常用的一种。问题根源是"大量 key 的 TTL 相同",那就在设置 TTL 的时候主动加上一个随机偏移量,把过期时间打散。

举个例子,原本 30 分钟过期的 key,我们给它添加一个 -5 到 +5 分钟的随机数,TTL 就变成了 25~35 分钟之间随机分布。这样短时间内大量缓存数据同时过期的概率就低很多了,从源头上实现了预防。

伪代码示意:

1
2
3
4
5
6
7
8
// 原本所有 key 都设置 30 分钟过期,会导致同时过期
redis.set(key, value, 30, TimeUnit.MINUTES);

// 加随机偏移量:30 分钟 ± 5 分钟
int baseTTL = 30;
int randomOffset = ThreadLocalRandom.current().nextInt(-5, 6); // -5 到 +5
long finalTTL = baseTTL + randomOffset; // 25 ~ 35 分钟
redis.set(key, value, finalTTL, TimeUnit.MINUTES);

策略二:逻辑过期

由于缓存有 TTL,所以可能会出现大量缓存同时过期的情况。逻辑过期的思路是反过来——直接不设置 TTL(永不过期),然后在 value 里多存储一个 expireTime 字段,这个字段存储的是逻辑上的过期时间戳。每次访问到该缓存的时候,业务线程主动检查:如果当前时间戳大于缓存里存的 expireTime,就说明这条数据逻辑上已经过期了,需要重建缓存。

数据结构大致是这样:

1
2
3
4
public class CacheData<T> {
private T data; // 真实的业务数据
private long expireTime; // 逻辑过期时间戳(毫秒)
}

访问时的判断逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get(String key) {
CacheData<T> cache = redis.get(key);
if (cache == null) {
// 物理上不存在(缓存被淘汰或未预热),按缓存未命中处理
return null;
}
// 物理存在,但逻辑上可能过期
if (System.currentTimeMillis() > cache.getExpireTime()) {
// 逻辑过期,需要重建缓存
return rebuildCache(key);
}
return cache.getData();
}

这里有几个需要特别注意:

  1. **数据库更新时必须同步更新缓存的 expireTimedata**,并且一定要更新成功。因为逻辑过期没有物理 TTL 兜底,如果数据库更新了但缓存没更新,就会一直留存旧数据,永远不过期。这一点非常关键,后面响应层里会进一步展开讲。
  2. 逻辑过期不等于物理不过期,物理上 key 永远存在(除非显式删除),但业务层根据 expireTime 字段判断是否需要重建。这意味着即使重建失败,旧数据依然能被读到,是一种降级可用的设计。

策略三:缓存预热

在某些大活动开始之前(比如双 11、大促、新品发布),把所有涉及到的数据使用脚本提前批量写入 Redis,确保活动开始时缓存是满的,不会出现"活动一开始缓存还是空的、请求全部打到数据库"这种情况。

1
2
3
4
5
6
7
8
9
10
// 启动脚本:批量预热热点数据
public void warmupCache() {
List<Product> hotProducts = productMapper.findHotProducts();
for (Product p : hotProducts) {
String key = "product:" + p.getId();
// 加随机偏移量,避免同时过期
long ttl = 30 + ThreadLocalRandom.current().nextInt(-5, 6);
redis.set(key, JSON.toJSONString(p), ttl, TimeUnit.MINUTES);
}
}

2.3.2 三种预防策略的选型

这三种策略不是互斥的,而是应该根据数据特性合理选择:

策略 适用场景 不适用场景
TTL 加随机偏移量 通用,几乎所有有 TTL 的缓存都可以加 几乎没有不适用,是最基础的兜底手段
逻辑过期 长期不会更改的数据,或者重建开销大的数据,比如商品详情页(不经常更新,重建缓存可能要查询多张表联表) 经常更新的数据,比如排行榜、库存(数据频繁变化,逻辑过期会导致大量旧数据残留)
缓存预热 大促活动前、新系统上线、缓存集群迁移后 日常增量数据(数据量太大无法全量预热)

举个具体例子:商品详情页就很适合用逻辑过期——商品的基本信息(标题、主图、详情描述)不会频繁变化,重建一次要联表查商品基本信息表、SKU 表、属性表等多张表,开销大。如果给它设置物理 TTL,一旦过期重建的瞬间请求会打到数据库;而用逻辑过期,即使逻辑过期了也能返回旧数据,重建可以异步进行,用户基本无感。

但如果是排行榜这种数据,每分钟都在变,用逻辑过期就会导致用户看到的永远是旧数据,体验很差,这种情况就不能用逻辑过期,应该用 TTL 加随机偏移量 + 短 TTL 的方案。

2.4 响应层:缓存失效瞬间的请求处理

即使采取了上面的三种预防策略,我们的缓存还是可能会过期的。如果数据量非常庞大,即使使用了上面这三种策略,还是有可能会有大量缓存重建的请求冲向数据库,使得数据库宕机。所以在响应层我们还需要进行对应的处理。

2.4.1 互斥锁:避免重复重建

此时我们可以想象一下这个场景:如果两条线程同时访问同一个缓存,此时发现缓存已经逻辑过期了或者不存在了,两条线程都需要去重建缓存。如果这两条线程都去重建,显然是没必要的——因为最终更新的只是同一条缓存,重复重建就是浪费资源,而且会让多个请求同时打到数据库上。此时让一条线程去重建就好了,其他线程等待或者降级返回。

这就需要加互斥锁:重建之前先去抢锁,抢到锁的线程才能重建,没抢到的就等待或降级。这样就避免了重复的重建,直接打向数据库的请求数量也就少了。

互斥锁可以用 Redis 的 SET NX EX 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public T getWithMutex(String key) {
T data = redis.get(key);
if (data != null) {
return data;
}
// 缓存未命中,尝试抢锁
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
boolean locked = redis.set(lockKey, requestId, 10, TimeUnit.SECONDS, SetParams.nx());

if (locked) {
try {
// 双重确认:拿到锁之后再查一次缓存
data = redis.get(key);
if (data != null) {
return data; // 别的线程已经重建好了,无需重复重建
}
// 查询数据库并回写缓存
data = db.query(key);
redis.set(key, data, 30, TimeUnit.MINUTES);
return data;
} finally {
// 释放锁(用 Lua 脚本保证原子性)
releaseLock(lockKey, requestId);
}
} else {
// 没抢到锁,降级处理
return degrade(key);
}
}

2.4.2 双重确认:避免拿到锁后重复重建

这里有一个关键的细节:拿到锁之后应该再确认一次缓存是否已经重建成功。如果缓存是新的或者已经存在了,那就不用重建了。

为什么要这么做?考虑下面这个时序:

把这个时序图按时间轴拆开看就清楚了:

  • T0:线程 A 和线程 B 几乎同时发现缓存未命中。
  • T1:A 抢到锁,B 抢锁失败,进入等待或降级流程。
  • T2~T3:A 查数据库、回写缓存、释放锁。
  • T4:B 等待一段时间后重试,此时 A 已经释放锁,B 抢到了锁。
  • T4~T5:如果不做双重确认,B 会再次查 DB 重建缓存,这是纯粹的资源浪费——A 刚刚已经重建好了。双重确认就是在拿到锁后再查一次缓存,如果缓存已经存在就直接返回,避免重复重建。

完整的互斥锁 + 双重确认流程图如下:

2.4.3 线程池异步重建:减少线程开销

上面是基础的互斥锁方案,还可以进一步优化:重建的时候开启线程池进行异步重建,把重建任务交给一个专门的线程池来执行,而不是让业务线程自己同步重建。

这样做有两个好处:

  1. 减少线程开销:重建任务交给专门的线程池,线程池可以复用线程,避免为每次重建都创建新线程。
  2. 业务线程快速返回:原本的业务线程把重建任务丢给线程池后就可以立即返回,无需等待重建完成,能快速给前端返回信息(返回旧数据或友好提示)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 专门的缓存重建线程池
private final ExecutorService rebuildExecutor = Executors.newFixedThreadPool(8);

public T getWithAsyncRebuild(String key) {
CacheData<T> cache = redis.get(key);
if (cache != null) {
if (System.currentTimeMillis() <= cache.getExpireTime()) {
// 未逻辑过期,直接返回
return cache.getData();
}
// 逻辑过期,尝试抢锁异步重建
String lockKey = "lock:" + key;
boolean locked = redis.set(lockKey, "1", 10, TimeUnit.SECONDS, SetParams.nx());
if (locked) {
// 双重确认
cache = redis.get(key);
if (cache != null && System.currentTimeMillis() <= cache.getExpireTime()) {
releaseLock(lockKey);
return cache.getData();
}
// 异步重建
rebuildExecutor.submit(() -> {
try {
T newData = db.query(key);
CacheData<T> newCache = new CacheData<>(newData,
System.currentTimeMillis() + 30 * 60 * 1000);
redis.set(key, newCache);
} catch (Exception e) {
// 重试逻辑(见 2.4.5)
handleRebuildFailure(key);
} finally {
releaseLock(lockKey);
}
});
}
// 无论是否抢到锁,都先返回旧数据(降级处理)
return cache != null ? cache.getData() : null;
}
// 物理不存在的情况,按缓存未命中 + 互斥锁处理
return getWithMutex(key);
}

2.4.4 降级 vs 等待:两种业务策略

异步重建之后,业务线程在重建期间要返回什么?根据业务的选择有两种策略:

策略一:降级处理

给前端返回旧数据(逻辑过期的情况)或者友好提示(缓存物理不存在的情况)。同时,没抢到锁的线程也是一样的处理。

  • 适用场景:对数据实时性要求不高的业务,比如商品详情页、推荐列表、文章内容。延迟几秒看到新数据用户基本无感,但接口能快速响应,体验比卡住等待好得多。
  • 代价:会有一段时间的数据不一致窗口(用户看到的是旧数据)。

策略二:等待缓存重建完毕

业务线程阻塞等待,直到缓存重建完成,把新缓存的数据返回去。

  • 适用场景:对数据实时性要求高的业务,比如订单状态、支付结果、库存余额。这种场景下返回旧数据可能引发资损或客诉,宁愿让用户多等一会儿也要返回最新数据。
  • 代价:用户等待时间变长,如果数据库压力大可能超时。
策略 业务线程行为 适用业务 代价
降级处理 立即返回旧数据或友好提示 商品详情页、推荐、文章等容忍旧数据的业务 短暂数据不一致
等待重建 阻塞等待新缓存返回 订单、支付、库存等强一致性业务 用户等待时间变长

需要注意的是,对于选择"等待"策略的业务,如果重建失败,应该快速告知处理失败,然后返回友好提示,而不是让用户一直等下去。下面就要讲重建失败时的兜底机制。

2.4.5 重试 + MQ:重建失败的兜底

重建过程中可能出现异常(数据库连接超时、网络抖动等),需要兜底机制。最简单的是重试次数:如果重建过程中出现异常就重试,一般是 3 次。这个通过代码就能实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public T rebuildWithRetry(String key) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
T data = db.query(key);
redis.set(key, data, 30, TimeUnit.MINUTES);
return data;
} catch (Exception e) {
log.warn("缓存重建失败,第 {} 次重试, key={}", i + 1, key, e);
if (i == maxRetry - 1) {
throw new RuntimeException("缓存重建失败", e);
}
// 指数退避:1s, 2s, 4s...
try {
Thread.sleep(1000L * (1 << i));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
throw new RuntimeException("不应到达此处");
}

如果重试 3 次都失败,就交给 MQ 处理,MQ 再进行重试。而且 MQ 可以进行指数退避的重试,避免短时间内大量重试压垮数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 重试 3 次都失败,投递到 MQ
public void sendToMQ(String key) {
CacheRebuildMessage msg = new CacheRebuildMessage(key);
mqTemplate.send("cache.rebuild.retry", msg);
}

// MQ 消费者
@MQListener(topic = "cache.rebuild.retry")
public void onRebuildMessage(CacheRebuildMessage msg) {
String key = msg.getKey();
try {
T data = db.query(key);
redis.set(key, data, 30, TimeUnit.MINUTES);
} catch (Exception e) {
// MQ 中间件会自动重试,配合指数退避策略
throw e; // 抛出异常触发 MQ 重试
}
}

对于选择"等待"策略的业务,如果重试 3 次失败,应该快速告知处理失败,然后返回友好提示,避免用户长时间等待。

这里需要特别强调逻辑过期场景下重试的重要性。前面讲过,逻辑过期的缓存物理上永不过期,靠 expireTime 字段判断是否需要重建。这意味着如果重建失败,缓存会一直停留在"逻辑过期但物理存在"的状态,带来两个严重后果:

  1. 旧数据永久残留:逻辑过期没有物理 TTL 兜底,如果重建一直失败,缓存里的旧数据会永远被返回。如果数据库的数据已经更新(比如商品价格调整),用户看到的就一直是旧价格,数据不一致窗口被无限拉长,这对业务来说往往是不可接受的。
  2. 每个请求都触发重建流程:缓存处于逻辑过期状态后,后续每一个请求都会发现"逻辑过期",尝试抢锁、查 DB、重建。如果重建一直失败,每个请求都要走一遍这个流程,抢锁的开销、查 DB 的开销都会被放大,反而加重了系统负担,甚至可能把数据库拖垮。

所以对逻辑过期的业务来说,重试不是"可选项"而是"必选项"——它是消除数据不一致窗口、让缓存恢复健康的唯一手段。重试 3 次还失败就交给 MQ 兜底,是为了保证最终一定能重建成功,避免旧数据永久残留。相比之下,物理 TTL 的场景下即使重建失败,缓存 key 也会在 TTL 到期后被删除,下一个请求会重新走未命中流程,不会一直返回旧数据,所以重试的重要性没那么高。这也是为什么前面强调"逻辑过期适用于不经常更新的数据"——更新越频繁,重建失败的代价就越大。

2.4.6 进阶:定时任务巡检 + MQ 兜底

对于一些核心业务(比如电商的商品详情页、首页推荐),还能有进一步的处理:设置定时任务定期检测对应的热点数据是否过期,发现不存在就发消息给 MQ,快速进行缓存重建

这套机制的核心思路是把缓存重建从"被动触发"变成"主动巡检",在用户感知到缓存失效之前就主动重建好。

这里为了避免重复发送消息也可以借助 Redis 做去重:发送 MQ 消息之前先设置对应的 key,如果 key 存在就代表对应的消息已经传递过了,没必要再次重建;重建完成之后再删除这个 key 就好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Scheduled(fixedRate = 30000) // 每 30 秒巡检一次
public void checkHotKeys() {
List<String> hotKeys = getHotKeys();
for (String key : hotKeys) {
CacheData<?> cache = redis.get(key);
boolean needRebuild = false;
if (cache == null) {
needRebuild = true;
} else if (System.currentTimeMillis() > cache.getExpireTime()) {
needRebuild = true;
}
if (needRebuild) {
// 去重:检查是否已有重建任务在排队
String dedupKey = "rebuild:" + key;
boolean set = redis.set(dedupKey, "1", SetParams.nx().ex(60));
if (set) {
// 首次发送重建消息
mqTemplate.send("cache.rebuild", new CacheRebuildMessage(key));
}
}
}
}

@MQListener(topic = "cache.rebuild")
public void onRebuild(CacheRebuildMessage msg) {
String key = msg.getKey();
try {
Object data = db.query(key);
CacheData<Object> newCache = new CacheData<>(data,
System.currentTimeMillis() + 30 * 60 * 1000);
redis.set(key, newCache);
} finally {
// 重建完成后删除去重 key
redis.del("rebuild:" + key);
}
}

注意:这个去重 key 也要设置对应的过期时间作为兜底,避免删除操作出现问题导致 key 永远残留,后续重建消息永远发不出去。上面代码里 ex(60) 就是 60 秒后自动过期。

这套机制其实就很复杂了,一般来说没必要上。对于普通业务,第一种"重试 3 次"就够了,不用引入 MQ。但是对于某些重点业务——比如核心链路、保证不能塌的业务——MQ 的引入和定时任务巡检就很有必要了。

2.4.7 响应层策略选型:不同业务怎么选

响应层方案这么多,实际业务里应该怎么选?下面举几个具体的例子。

场景一:商品详情页

  • 数据特性:更新不频繁,重建开销大(联表查询),对实时性要求中等。
  • 推荐组合:逻辑过期 + 互斥锁 + 线程池异步重建 + 降级返回旧数据。
  • 理由:用户看商品详情时,看到 1 分钟前的旧数据基本无感,但接口必须快。逻辑过期保证缓存物理上不过期永远有数据返回,异步重建让业务线程秒回,降级返回旧数据让用户无感知。

场景二:库存扣减

  • 数据特性:更新极频繁,强一致性要求高。
  • 推荐组合:短 TTL + 随机偏移量 + 互斥锁 + 等待重建。
  • 理由:库存不能返回旧数据(会引发超卖),宁可让用户等几百毫秒也要拿到最新数据。不能用逻辑过期,否则会一直返回旧库存。

场景三:首页推荐位

  • 数据特性:核心入口,QPS 极高,绝不能塌。
  • 推荐组合:缓存预热 + 逻辑过期 + 互斥锁 + 异步重建 + 定时任务巡检 + MQ 兜底。
  • 理由:首页是核心业务,一旦缓存击穿整个站都受影响,必须用最重的方案保证万无一失。定时任务主动巡检 + MQ 异步重建,能在用户感知到之前就把缓存重建好。

场景四:用户中心个人资料

  • 数据特性:QPS 不高,更新频率低,对实时性要求一般。
  • 推荐组合:TTL 加随机偏移量 + 互斥锁 + 重试 3 次。
  • 理由:QPS 不高,即便缓存失效也不会瞬间冲垮数据库,用最简单的方案就够了,没必要上 MQ 和定时任务。

2.5 成因二的解决方案:Redis 宕机

前面讲的是"缓存同时过期"的解决方案,下面讲第二种成因——Redis 宕机。这种情况下不是单个 key 失效,而是整个 Redis 节点挂了,所有缓存都不可用。

2.5.1 高可用:主从 + 哨兵 / 集群

最根本的解决方案是保证 Redis 本身的高可用,避免 Redis 宕机。这就是前面几篇文章讲过的主从架构 + 哨兵机制,或者Cluster 集群

  • 主从 + 哨兵:主节点挂了,哨兵会自动把从节点提升为新的主节点,整个切换过程对业务透明。
  • Cluster 集群:数据分片存储在多个节点上,每个分片有自己的主从,单个节点挂了只影响该分片,且从节点会顶上。

这套机制保证了 Redis 不会因为单点故障而整体不可用,是 Redis 高可用的最后一道防线。

2.5.2 服务熔断 / 请求限流

即使有高可用方案,主从切换的瞬间依然会有几秒到十几秒的不可用窗口,这段时间请求依然会打到数据库。而且如果是整个机房的网络问题,高可用也无能为力。这时候就需要服务熔断请求限流

服务熔断:Redis 宕机之后,直接拒绝访问跟 Redis 相关的所有接口,全部熔断,返回友好提示或进行其他的降级处理。这能保护数据库不被冲垮,但代价是整体业务不可用——对于某些要求高可用的业务来说是不可容忍的。

请求限流:相比熔断更温和。允许一定量的请求到达数据库(在数据库能承受的范围内),对于超出阈值的请求直接拒绝。这样既能保护数据库不被冲垮,又能保证一定比例的用户能正常使用,是更常用的方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 限流器示例:每秒只允许 100 个请求查数据库
private final RateLimiter dbLimiter = RateLimiter.create(100);

public T getWithRateLimit(String key) {
T data = redis.get(key);
if (data != null) {
return data;
}
// 缓存未命中,走限流
if (dbLimiter.tryAcquire()) {
try {
data = db.query(key);
redis.set(key, data, 30, TimeUnit.MINUTES);
return data;
} catch (Exception e) {
return degrade(key);
}
} else {
// 超过限流阈值,直接降级
return degrade(key);
}
}
策略 行为 优点 缺点 适用场景
服务熔断 拒绝所有相关接口请求 数据库零压力 业务完全不可用 非核心业务,宁可不可用也不能拖垮数据库
请求限流 允许限量请求通过,其余拒绝 数据库可控压力,部分用户可用 实现稍复杂,部分用户被拒 核心业务,保证部分用户可用

应当根据业务特性选择合适的方法:非核心业务可以熔断,核心业务用限流保证部分可用。


三、缓存击穿:热点数据缓存失效

3.1 什么是缓存击穿

缓存击穿其实和缓存雪崩差不多,都是缓存没了导致请求打到数据库。区别在于:

  • 缓存雪崩:大量缓存(不分热点非热点)同时失效。
  • 缓存击穿单个热点数据的缓存失效,但由于这个数据是热点,会有大量的请求集中打过来,导致数据库瞬时压力过大而宕机。

可以理解为:缓存雪崩是"大面积失效",缓存击穿是"单点失效但请求量极大"。

3.2 解决方案

缓存击穿的解决方案和缓存雪崩成因一的响应层方案高度重合,本质都是"缓存失效瞬间避免请求集中打到数据库"。

3.2.1 互斥锁方案

和前面讲的一样:保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待释放后重新读取缓存,要么就返回空值或者默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public T getHotData(String key) {
T data = redis.get(key);
if (data != null) {
return data;
}
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
boolean locked = redis.set(lockKey, requestId, 10, TimeUnit.SECONDS, SetParams.nx());
if (locked) {
try {
// 双重确认
data = redis.get(key);
if (data != null) {
return data;
}
data = db.query(key);
redis.set(key, data, 30, TimeUnit.MINUTES);
return data;
} finally {
releaseLock(lockKey, requestId);
}
} else {
// 没抢到锁,短暂休眠后重试读取缓存
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
data = redis.get(key);
return data != null ? data : degrade(key);
}
}

3.2.2 永不过期 + 后台异步更新

针对热点数据,可以不给它设置过期时间(物理不过期),由后台异步线程负责更新缓存。或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间。

这其实就是前面讲的"逻辑过期"方案的延伸——热点数据用逻辑过期 + 异步重建,业务线程永远能读到数据(哪怕是旧的),重建在后台异步进行,用户无感知。

两种方案的取舍:

方案 优点 缺点 适用场景
互斥锁 保证数据一致性 等待锁期间响应变慢 强一致性要求的热点数据
永不过期 + 异步更新 响应永远快 短暂数据不一致 容忍旧数据的热点数据

需要说明的是,缓存击穿这两种方案的本质都是正常的访问——数据库里面还是有正常的数据的,只要缓存层重建成功,就可以解决了。但下面要讲的缓存穿透就不一样了,它涉及的是不存在的数据,重建缓存也救不了。


四、缓存穿透:查询根本不存在的数据

4.1 什么是缓存穿透

缓存穿透是指访问了不存在的数据——即不在缓存中,也不存在数据库中。这种访问是一定会打到数据库的:缓存未命中 → 查数据库 → 数据库也没有 → 返回空。每一次这样的请求都会完整走一遍"缓存 → 数据库"的链路,一旦多了,也会导致数据库崩溃。

和缓存击穿、缓存雪崩的根本区别在于:

  • 缓存雪崩 / 击穿:数据库里数据,只是缓存没了,重建缓存就能解决。
  • 缓存穿透:数据库里没有数据,重建缓存也没用,每次查询都会穿透。

导致缓存穿透的情形一般有两种:

  1. 业务操作错误:把数据库里面的数据删了,但前端入口还在,导致一直查不到数据。比如商品下架了但详情页链接还在传播,用户点进来查询不到。
  2. 黑客恶意攻击:攻击者构造大量不存在的 key(比如随机 ID),疯狂打接口,每个请求都穿透到数据库,意图拖垮数据库。

4.2 解决方案

缓存穿透一般有三种解决策略:

  1. 非法请求的限制:在请求进入系统前就拦截掉明显的非法请求。
  2. 缓存空值或默认值:把"查不到"这个结果也缓存起来。
  3. 布隆过滤器:在缓存未命中后、查数据库前,先用布隆过滤器快速判断数据是否存在。

4.2.1 策略一:非法请求的限制

这种策略的思路是在请求进入系统前就拦掉非法请求,分为两层:

网关层:限流 / 熔断

在 API 网关层对异常高频的请求 IP 或用户进行限流,防止恶意刷接口。比如同一个 IP 每秒查询同一个接口超过 100 次,直接限流。

服务层:参数合法性校验

在业务服务层对请求参数做合法性校验,比如:

  • ID 长度是否符合数据库存储规则(比如商品 ID 应该是 18 位数字,传 100 位的明显是非法)。
  • ID 范围是否合理(比如自增 ID 不应该是负数)。
  • 必填字段是否齐全。
  • 数据格式是否正确(邮箱、手机号格式等)。
1
2
3
4
5
6
7
8
9
10
11
12
public Product getProduct(String id) {
// 参数合法性校验
if (StringUtils.isBlank(id) || id.length() > 18 || !id.matches("\\d+")) {
throw new IllegalArgumentException("非法的商品 ID");
}
long idValue = Long.parseLong(id);
if (idValue <= 0) {
throw new IllegalArgumentException("商品 ID 必须为正数");
}
// 后续走缓存查询...
return doGetProduct(id);
}

这种策略能拦掉一部分明显的非法请求,但对于"格式合法但实际不存在"的请求(比如一个合法格式但不存在的商品 ID)就无能为力了,需要配合下面的策略。

4.2.2 策略二:缓存空值或默认值

这个策略的思路很简单:查询数据库发现为空后,在 Redis 里缓存对应的空值或者默认值进去,设置一个合理的 TTL,下次还访问同样的 key 时直接返回空值就好了,打不到数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public Product getProduct(String id) {
// 1. 查缓存
String cached = redis.get("product:" + id);
if (cached != null) {
if ("NULL".equals(cached)) {
return null; // 命中空值缓存,直接返回 null
}
return JSON.parseObject(cached, Product.class);
}
// 2. 查数据库
Product product = productMapper.findById(id);
if (product == null) {
// 数据库也没有,缓存空值,TTL 设短一些(比如 5 分钟)
redis.set("product:" + id, "NULL", 5, TimeUnit.MINUTES);
return null;
}
// 3. 回写缓存
redis.set("product:" + id, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
return product;
}

优点:实现简单,能解决"误删导致的穿透"问题。

缺点

  1. 对恶意攻击效果有限:如果攻击者使用大量随机不存在的 key 进行攻击,每个 key 都会在 Redis 里缓存一个空值,Redis 内存会被大量空值占满,造成缓存资源浪费。
  2. 存在短暂的数据不一致窗口:数据新增后,Redis 里的空值缓存还没过期,这段时间内查询依然会返回 null,需要等 TTL 到期或主动删除空值缓存才能查到新数据。

为了缓解第一个问题,可以给空值缓存设置较短的 TTL(比如 5 分钟),让空值缓存尽快过期。为了缓解第二个问题,可以在数据新增时主动删除对应的空值缓存:

1
2
3
4
5
public void addProduct(Product product) {
productMapper.insert(product);
// 主动删除可能存在的空值缓存
redis.del("product:" + product.getId());
}

4.2.3 策略三:布隆过滤器

布隆过滤器是解决缓存穿透最优雅的方案。它的思路是:在写入数据库数据时,使用布隆过滤器做个标记;然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。

请求流程变成这样:

注意布隆过滤器的位置:在缓存未命中之后、查数据库之前。它是一层"前置过滤",把"肯定不存在的请求"直接拦掉,只有"可能存在的请求"才放行到数据库。

4.3 布隆过滤器的工作机制

布隆过滤器是怎么做到"快速判断数据是否存在"的?它由两部分组成:

  • 一个全为 0 的位图(bitmap):一段连续的二进制位,初始全为 0。
  • N 个哈希函数:每个哈希函数都能把输入数据映射到位图的某个位置。

4.3.1 写入流程

写入一个数据时,这个数据会与每一个哈希函数做运算,然后对位图长度求余,得到对应的在位图中的位置,然后把对应位置标记成 1。

举个例子:假设位图长度为 8,有 3 个哈希函数。写入数据 id=100001

  • 哈希函数 1 计算 100001,对 8 求余,得到位置 1,把位图位置 1 标记成 1。
  • 哈希函数 2 计算 100001,对 8 求余,得到位置 4,把位图位置 4 标记成 1。
  • 哈希函数 3 计算 100001,对 8 求余,得到位置 6,把位图位置 6 标记成 1。

写入后的位图:

位图位置 0 1 2 3 4 5 6 7
位图状态 0 1 0 0 1 0 1 0
标记来源 - h1(100001)%8 - - h2(100001)%8 - h3(100001)%8 -

可以看到位置 1、4、6 被标记成了 1,其余位置仍然是 0。

4.3.2 查询流程

下次数据进来,在缓存中查找发现不存在之后,再让这个数据与布隆过滤器的 N 个哈希函数做运算,得到对应的位图位置,判断这些位置是不是都是 1

  • 如果都是 1:说明数据可能存在,需要进行数据库查询(因为可能是哈希冲突导致的误判)。
  • 如果至少有一个位置为 0:说明数据一定不存在,直接返回 null,无需查数据库。

4.3.3 关键特性:不存在一定不存在,存在不一定存在

布隆过滤器有一个非常重要的特性:**"不存在"的判断一定是准确的,"存在"的判断可能不准确**。

为什么?看下面这个例子。假设有两条数据 X 和 Y:

  • 数据 X 标记位置:1, 4, 6
  • 数据 Y 标记位置:2, 3, 5

写入 X 和 Y 之后的位图(同一位置可能被多条数据共同标记,但状态只能是 0 或 1):

位图位置 0 1 2 3 4 5 6 7
位图状态 0 1 1 1 1 1 1 0
标记来源 - X Y Y X Y X -

现在查询数据 Z,假设 Z 经过 3 个哈希函数计算得到位置 1, 3, 5。把 Z 的查询位置和位图状态对应起来看:

Z 的查询位置 1 3 5
位图状态 1 1 1
实际标记者 X Y Y

三处全是 1,布隆过滤器判断"Z 可能存在"。但实际上 Z 根本没有被写入过——位置 1 是 X 标记的,位置 3 是 Y 标记的,位置 5 也是 Y 标记的,是 X 和 Y 的标记"恰好凑出"了 Z 的查询位置。这就是哈希冲突导致的误判。

反过来,如果布隆过滤器判断"Z 不存在",那一定不存在。因为只要有一个位置为 0,就说明没有任何一个写入过的数据能把这个位置标记成 1,所以 Z 一定没被写入过。

用一句话总结:

布隆过滤器说"不存在"一定不存在;说"存在"可能不存在(哈希冲突)。

这个特性对缓存穿透来说刚好够用:误判(把不存在的判断成存在)最多让请求多查一次数据库,不会造成灾难;而正确判断(把不存在的判断成不存在)则直接挡住了请求,保护了数据库。

4.4 三种策略对比与选型

策略 实现复杂度 优点 缺点 适用场景
非法请求限制 拦掉明显非法请求,从源头减负 拦不住格式合法的不存在请求 所有业务都应做的基础防护
缓存空值/默认值 实现简单,对误删场景效果好 内存浪费,攻击下失效,存在不一致窗口 误删导致的穿透,数据量小的场景
布隆过滤器 内存占用极小,能挡住绝大多数不存在请求 有误判率,删除困难(一般不支持删除) 数据量大、攻击风险高的核心场景

实际生产中通常是组合使用:网关层限流 + 服务层参数校验作为基础防护,核心接口叠加布隆过滤器,针对偶尔的误删用空值缓存兜底。


五、三大问题对比总结

最后用一张表把三大缓存问题的核心区别总结清楚:

维度 缓存雪崩 缓存击穿 缓存穿透
本质 大量缓存同时失效 单个热点缓存失效 查询不存在的数据
触发条件 TTL 同时到期 / Redis 宕机 热点 key 过期 数据被删 / 恶意攻击
数据库是否有数据 没有
影响范围 大面积接口 单个热点接口 单个接口(但可能拖垮 DB)
核心解决思路 预防(打散 TTL)+ 响应(互斥锁) 互斥锁 / 永不过期 + 异步更新 拦非法请求 + 缓存空值 + 布隆过滤器
重建缓存能否解决 不能(数据不存在)

理解这三大问题的核心在于抓住一个主线:缓存层的作用是挡住数据库,任何让缓存层失效、请求穿透到数据库的场景都是潜在风险。雪崩是大面积穿透,击穿是单点高并发穿透,穿透是永远穿透。解决方案的设计思路也是统一的:要么不让缓存失效(预防),要么失效了别让请求集中打到数据库(互斥锁、限流),要么提前判断请求是否合理(参数校验、布隆过滤器)。

实际生产中没有银弹,应当根据业务特性(数据更新频率、QPS 大小、一致性要求、是否核心链路)选择合适的组合方案,并在"实现复杂度"和"系统可用性"之间取得平衡。