Redis 缓存穿透、击穿、雪崩实战:基于 MySQL 高并发读场景的完整防护方案

先抛一个真实场景:某电商大促期间,商品详情页 QPS 从日常 200 飙到 8000,MySQL 单机 CPU 瞬间打满 100%,大量请求超时。查看慢查询日志,发现全是同一条 SELECT * FROM product WHERE id = ?,而这个 id 对应的商品其实早已下架,缓存里没有,数据库里也没有——但请求还在源源不断地打进来。

这就是典型的缓存穿透。而接下来半小时内,另一个热点商品突然过期,瞬时几千个请求同时回源 MySQL,把刚喘过气的数据库又打挂了——这是缓存击穿。紧接着,运维触发了一次缓存批量过期,导致大量 key 同时失效,缓存层形同虚设,MySQL 再次宕机——这是缓存雪崩。

三个问题,一个比一个致命。本文从真实高并发读场景出发,逐一剖析成因、给出可落地的 Java 代码方案,并用压测数据说话。


一、先理解:为什么缓存层会失守

在引入 Redis 之前,高并发读 MySQL 的典型架构是这样的:

1
[App] --> [MySQL]

所有请求直达数据库,MySQL 单机 QPS 撑死几千,大促秒杀场景下根本扛不住。

引入 Redis 后:

1
[App] --> [Redis] --> [MySQL]

正常流程:请求先查 Redis,命中直接返回;未命中则查 MySQL,回填 Redis,再返回。

但“未命中”有三种截然不同的情况,对应了三个经典问题:

问题 未命中的本质 危害
缓存穿透 请求的数据根本不存在于数据库 大量无效查询击穿缓存层,直接压垮 MySQL
缓存击穿 热点 key 恰好过期 瞬时大量并发回源 MySQL,形成“惊群效应”
缓存雪崩 大量 key 同时过期 或 Redis 宕机 缓存层整体失效,全部流量压向 MySQL

下面逐一拆解,每个都给出完整可运行的代码。


二、缓存穿透:请求了不存在的数据

2.1 成因

用户请求的数据既不在缓存中,也不在数据库中,典型如:

  • 恶意攻击者用不存在的商品 id 发起大量请求(如 id=-1)
  • 爬虫抓取已删除的商品
  • 业务代码 bug 导致查询了错误的数据

每次请求都会穿过缓存直达 MySQL,缓存形同虚设。

2.2 方案一:缓存空值(简单直接)

原理:当 MySQL 也查不到数据时,在 Redis 中缓存一个空值标识,下次请求直接命中缓存返回空,不再回源 MySQL。

适用场景:数据量不大、数据一致性要求不极端的场景。

完整代码

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

/**
* 缓存穿透防护:空值缓存方案
* 运行环境:Redis 7.2 / MySQL 8.0 / JDK 17
* 依赖:jedis 5.1.0, mysql-connector-j 8.0.33
*/
public class CachePenetrationNullValue {

private static final JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), "127.0.0.1", 6379);
private static final String MYSQL_URL = "jdbc:mysql://127.0.0.1:3306/shop?useSSL=false&serverTimezone=UTC";
private static final String MYSQL_USER = "root";
private static final String MYSQL_PASSWORD = "root123";
private static final String NULL_PLACEHOLDER = "__NULL__"; // 空值占位符
private static final int NULL_EXPIRE_SECONDS = 60; // 空值缓存 60 秒

/**
* 查询商品信息(带空值缓存防护)
*/
public String getProductById(long id) {
String cacheKey = "product:" + id;

try (Jedis jedis = jedisPool.getResource()) {
// 1. 先查缓存
String cached = jedis.get(cacheKey);
if (cached != null) {
if (NULL_PLACEHOLDER.equals(cached)) {
// 缓存的是空值占位符,说明数据确实不存在
System.out.println("[缓存穿透防护] id=" + id + " 命中空值缓存,直接返回 null");
return null;
}
System.out.println("[缓存穿透防护] id=" + id + " 命中正常缓存");
return cached;
}

// 2. 缓存未命中,查询 MySQL
String product = queryMySQL(id);

// 3. 回填缓存(包括空值)
if (product != null) {
jedis.setex(cacheKey, 600, product); // 正常数据缓存 10 分钟
} else {
jedis.setex(cacheKey, NULL_EXPIRE_SECONDS, NULL_PLACEHOLDER); // 空值缓存 60 秒
System.out.println("[缓存穿透防护] id=" + id + " 数据库不存在,已缓存空值");
}
return product;

} catch (Exception e) {
throw new RuntimeException("缓存操作异常", e);
}
}

private String queryMySQL(long id) {
String sql = "SELECT name FROM product WHERE id = ? AND deleted = 0";
try (Connection conn = DriverManager.getConnection(MYSQL_URL, MYSQL_USER, MYSQL_PASSWORD);
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return rs.getString("name");
}
return null;
}
} catch (Exception e) {
throw new RuntimeException("MySQL 查询异常", e);
}
}

public static void main(String[] args) {
CachePenetrationNullValue demo = new CachePenetrationNullValue();

// 第一次:缓存和数据库都没有,会缓存空值
System.out.println("第一次查询不存在 id=9999: " + demo.getProductById(9999));
// 第二次:命中空值缓存,不再回源 MySQL
System.out.println("第二次查询不存在 id=9999: " + demo.getProductById(9999));
// 第三次:查询正常数据
System.out.println("第一次查询存在 id=100: " + demo.getProductById(100));
}
}

运行输出示例

1
2
3
4
5
6
[缓存穿透防护] id=9999 数据库不存在,已缓存空值
第一次查询不存在 id=9999: null
[缓存穿透防护] id=9999 命中空值缓存,直接返回 null
第二次查询不存在 id=9999: null
[缓存穿透防护] id=100 命中正常缓存
第一次查询存在 id=100: iPhone 15 Pro Max

2.3 方案二:布隆过滤器(RedisBloom)

原理:在缓存之前加一层布隆过滤器,先判断数据是否可能存在。布隆过滤器说“不存在”则一定不存在,直接拦截,无需查缓存和数据库;说“存在”则只是可能存在,继续走正常流程。

适用场景:需要拦截大量无效请求、且能容忍极低误判率的场景。

架构图

1
2
3
[App] --> [BloomFilter] --> (可能存在) --> [Redis] --> (未命中) --> [MySQL]
|
+--> (一定不存在) --> 直接返回 null

完整代码

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

/**
* 缓存穿透防护:布隆过滤器方案
* 运行环境:Redis 7.2 (需加载 RedisBloom 模块) / JDK 17
* 依赖:jedis 5.1.0
* 前置条件:Redis 已安装 RedisBloom 模块,命令 BF.RESERVE 可用
*/
public class CachePenetrationBloomFilter {

private static final JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), "127.0.0.1", 6379);
private static final String BLOOM_KEY = "product:bloom";

static {
// 初始化布隆过滤器:容量 100000,误判率 0.01
try (Jedis jedis = jedisPool.getResource()) {
// 先删除旧的(仅用于演示,生产环境谨慎操作)
jedis.del(BLOOM_KEY);
// BF.RESERVE key error_rate capacity
jedis.sendCommand(
redis.clients.jedis.Protocol.Command.valueOf("BF.RESERVE"),
BLOOM_KEY, "0.01", "100000"
);
}
}

/**
* 查询商品(布隆过滤器拦截)
*/
public String getProductById(long id) {
String cacheKey = "product:" + id;

try (Jedis jedis = jedisPool.getResource()) {
// 1. 布隆过滤器判断:返回 0 表示一定不存在,直接拦截
Object bloomResult = jedis.sendCommand(
redis.clients.jedis.Protocol.Command.valueOf("BF.EXISTS"),
BLOOM_KEY, String.valueOf(id)
);
if (bloomResult != null && (Long) bloomResult == 0L) {
System.out.println("[布隆过滤器] id=" + id + " 判定不存在,直接拦截");
return null;
}

// 2. 布隆过滤器判定可能存在,继续查缓存
String cached = jedis.get(cacheKey);
if (cached != null) {
return cached;
}

// 3. 查 MySQL
String product = queryMySQL(id);
if (product != null) {
jedis.setex(cacheKey, 600, product);
// 注意:新数据插入时也需要 BF.ADD 到布隆过滤器,这里简化处理
}
return product;

} catch (Exception e) {
throw new RuntimeException("缓存操作异常", e);
}
}

/**
* 新增商品时,将 id 添加到布隆过滤器
*/
public void addProductToBloom(long id) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.sendCommand(
redis.clients.jedis.Protocol.Command.valueOf("BF.ADD"),
BLOOM_KEY, String.valueOf(id)
);
System.out.println("[布隆过滤器] 新商品 id=" + id + " 已加入");
}
}

private String queryMySQL(long id) {
// 简化实现,返回一个假设的数据
return (id == 100) ? "iPhone 15 Pro Max" : null;
}

public static void main(String[] args) {
CachePenetrationBloomFilter demo = new CachePenetrationBloomFilter();

// 模拟已有数据初始化
demo.addProductToBloom(100);
demo.addProductToBloom(101);

// 查询不存在的数据:布隆过滤器直接拦截
System.out.println("查询 id=9999: " + demo.getProductById(9999));
// 查询存在的数据:正常流程
System.out.println("查询 id=100: " + demo.getProductById(100));
}
}

运行输出示例

1
2
3
4
5
[布隆过滤器] 新商品 id=100 已加入
[布隆过滤器] 新商品 id=101 已加入
[布隆过滤器] id=9999 判定不存在,直接拦截
查询 id=9999: null
查询 id=100: iPhone 15 Pro Max

生产落地建议:布隆过滤器初始化时需要全量加载数据库中的有效 id。可以在数据变更时通过 Canal 监听 MySQL binlog 增量同步,避免数据不一致。同时,布隆过滤器的误判率(如 0.01)意味着有约 1% 的请求会“漏网”穿过到数据库,这个概率可以接受,因为同时结合空值缓存可以进一步拦截。


三、缓存击穿:热点 key 恰好过期

3.1 成因

某个热点商品的缓存 key 在某一瞬间过期,恰好此时海量并发请求同时涌入,所有请求都发现缓存未命中,于是同时回源 MySQL 查询,形成“惊群效应”。

场景:大促首页的热门商品,缓存过期时间设置较短(如 60 秒),过期瞬间 QPS 高达 5000,MySQL 承受 5000 次相同查询。

3.2 方案一:互斥锁(Mutex)

原理:当缓存未命中时,只允许一个请求去 MySQL 查询并回填缓存,其他请求自旋等待(或短暂休眠)后重试获取。

架构图

1
2
3
4
5
6
7
8
9
10
11
12
13
请求进来
|
v
缓存命中? --是--> 返回
|

|
v
尝试获取互斥锁(SETNX)
|
├--成功--> 查 MySQL --> 回填缓存 --> 释放锁 --> 返回
|
└--失败--> 休眠 50ms --> 重试查缓存(循环)

完整代码

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

/**
* 缓存击穿防护:互斥锁方案
* 运行环境:Redis 7.2 / JDK 17
* 依赖:jedis 5.1.0
*/
public class CacheBreakdownMutex {

private static final JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), "127.0.0.1", 6379);
private static final String LOCK_KEY_PREFIX = "lock:product:";
private static final long LOCK_EXPIRE_MS = 10_000L; // 锁过期时间 10s
private static final long RETRY_INTERVAL_MS = 50L; // 重试间隔 50ms
private static final int MAX_RETRY_TIMES = 40; // 最大重试次数 40 次(约 2s)

/**
* 查询商品信息(互斥锁防止缓存击穿)
*/
public String getProductById(long id) {
String cacheKey = "product:" + id;
String lockKey = LOCK_KEY_PREFIX + id;

for (int i = 0; i < MAX_RETRY_TIMES; i++) {
try (Jedis jedis = jedisPool.getResource()) {
// 1. 先查缓存
String cached = jedis.get(cacheKey);
if (cached != null) {
return cached;
}

// 2. 缓存未命中,尝试获取互斥锁
// SET lock_key value NX PX 10000
String result = jedis.set(lockKey, "1",
redis.clients.jedis.params.SetParams.setParams()
.nx()
.px(LOCK_EXPIRE_MS));
if ("OK".equals(result)) {
// 3. 获取锁成功,去查 MySQL
System.out.println("[互斥锁] 线程 " + Thread.currentThread().getName() + " 获取锁,查询 MySQL");
try {
String product = queryMySQL(id);
if (product != null) {
jedis.setex(cacheKey, 600, product);
}
return product;
} finally {
// 4. 释放锁(用 Lua 脚本保证原子性,防止误删其他线程的锁)
String releaseScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
jedis.eval(releaseScript,
java.util.Collections.singletonList(lockKey),
java.util.Collections.singletonList("1"));
System.out.println("[互斥锁] 线程 " + Thread.currentThread().getName() + " 释放锁");
}
} else {
// 5. 获取锁失败,休眠后重试
System.out.println("[互斥锁] 线程 " + Thread.currentThread().getName() + " 未获取锁,休眠重试");
try { Thread.sleep(RETRY_INTERVAL_MS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
} catch (Exception e) {
throw new RuntimeException("缓存操作异常", e);
}
}
// 重试超时,降级返回 null 或默认值
System.out.println("[互斥锁] 重试超时,返回 null");
return null;
}

private String queryMySQL(long id) {
// 模拟耗时查询
try { Thread.sleep(300); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return "Product-" + id;
}

public static void main(String[] args) throws InterruptedException {
CacheBreakdownMutex demo = new CacheBreakdownMutex();

// 模拟 20 个并发请求同时查询同一个 key
Thread[] threads = new Thread[20];
for (int i = 0; i < 20; i++) {
threads[i] = new Thread(() -> {
String result = demo.getProductById(100);
System.out.println("最终结果: " + result);
}, "Thread-" + i);
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
}
}

关键运行输出(节选):

1
2
3
4
5
6
7
[互斥锁] 线程 Thread-3 获取锁,查询 MySQL
[互斥锁] 线程 Thread-0 未获取锁,休眠重试
[互斥锁] 线程 Thread-7 未获取锁,休眠重试
...
[互斥锁] 线程 Thread-3 释放锁
[互斥锁] 线程 Thread-0 未获取锁,休眠重试
[互斥锁] 线程 Thread-7 未获取锁,休眠重试

可以看到,只有 1 个线程真正查了 MySQL,其余 19 个线程在自旋等待后直接从缓存拿到了数据。

3.3 方案二:逻辑过期(不物理过期)

原理:热点 key 不设置物理过期时间,让缓存永不过期;在 value 中额外存一个“逻辑过期时间”。请求发现逻辑过期后,异步更新缓存(可以由后台线程或获取锁的线程去更新),其他线程仍然先返回旧数据。

适用场景:对数据一致性容忍度较高、追求极致可用性的场景(如商品浏览量、排行榜)。

架构图

1
2
3
4
5
6
7
8
9
10
11
12
## 请求进来

缓存命中?
|
├--是--> 检查逻辑过期时间
| |
| ├--未过期--> 直接返回新数据
| └--已过期--> 尝试获取互斥锁
| ├--成功--> 异步更新缓存(不阻塞当前请求),当前请求返回旧数据
| └--失败--> 直接返回旧数据
|
└--否--> 互斥锁方案

完整代码

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
* 缓存击穿防护:逻辑过期方案
* 运行环境:Redis 7.2 / JDK 17
* 依赖:jedis 5.1.0, jackson-databind 2.15.2
*/
public class CacheBreakdownLogicalExpire {

private static final JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), "127.0.0.1", 6379);
private static final String CACHE_KEY = "product:hot:";
private static final String LOCK_KEY_PREFIX = "lock:product:";
private static final long LOCK_EXPIRE_MS = 5_000L;
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

/**
* 缓存实体:包含数据和逻辑过期时间
*/
public static class ProductCacheData {
public String productName;
public String logicalExpireTime; // 逻辑过期时间,格式:yyyy-MM-dd HH:mm:ss

public ProductCacheData(String productName, String logicalExpireTime) {
this.productName = productName;
this.logicalExpireTime = logicalExpireTime;
}
}

/**
* 查询商品(逻辑过期方案)
*/
public String getProductById(long id) {
String cacheKey = CACHE_KEY + id;
String lockKey = LOCK_KEY_PREFIX + id;

try (Jedis jedis = jedisPool.getResource()) {
// 1. 查缓存
String cachedJson = jedis.get(cacheKey);
if (cachedJson == null) {
// 缓存不存在(少见),走互斥锁方案
return queryWithMutex(id);
}

// 2. 解析缓存数据
ProductCacheData cacheData = parseJson(cachedJson);
LocalDateTime expireTime = LocalDateTime.parse(cacheData.logicalExpireTime, FORMATTER);

// 3. 逻辑未过期,直接返回
if (LocalDateTime.now().isBefore(expireTime)) {
return cacheData.productName;
}

// 4. 逻辑已过期,尝试获取互斥锁
String result = jedis.set(lockKey, "1",
redis.clients.jedis.params.SetParams.setParams().nx().px(LOCK_EXPIRE_MS));
if ("OK".equals(result)) {
// 获取锁成功,异步更新缓存(生产中用线程池)
System.out.println("[逻辑过期] 线程 " + Thread.currentThread().getName() + " 获取锁,异步更新缓存");
new Thread(() -> updateProductCache(id)).start();
}
// 不管是否获取到锁,当前请求都返回旧数据(保证可用性)
return cacheData.productName;

} catch (Exception e) {
throw new RuntimeException("缓存操作异常", e);
}
}

/**
* 互斥锁查询(缓存完全不存在时兜底)
*/
private String queryWithMutex(long id) {
String cacheKey = CACHE_KEY + id;
String lockKey = LOCK_KEY_PREFIX + id;

try (Jedis jedis = jedisPool.getResource()) {
String result = jedis.set(lockKey, "1",
redis.clients.jedis.params.SetParams.setParams().nx().px(LOCK_EXPIRE_MS));
if ("OK".equals(result)) {
try {
String product = queryMySQL(id);
ProductCacheData cacheData = new ProductCacheData(product,
LocalDateTime.now().plusMinutes(10).format(FORMATTER));
jedis.set(cacheKey, toJson(cacheData));
return product;
} finally {
jedis.del(lockKey);
}
} else {
try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return getProductById(id);
}
}
}

/**
* 异步更新 MySQL 数据到缓存
*/
private void updateProductCache(long id) {
String product = queryMySQL(id);
try (Jedis jedis = jedisPool.getResource()) {
String cacheKey = CACHE_KEY + id;
String lockKey = LOCK_KEY_PREFIX + id;
ProductCacheData cacheData = new ProductCacheData(product,
LocalDateTime.now().plusMinutes(10).format(FORMATTER));
jedis.set(cacheKey, toJson(cacheData));
jedis.del(lockKey);
System.out.println("[逻辑过期] 缓存更新完成");
}
}

private String queryMySQL(long id) {
try { Thread.sleep(300); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return "HotProduct-" + id + "-" + System.currentTimeMillis();
}

// JSON 序列化/反序列化(简化版,生产用 Jackson)
private ProductCacheData parseJson(String json) {
// 简化实现,实际使用 Jackson ObjectMapper
return new ProductCacheData(json, LocalDateTime.now().minusMinutes(1).format(FORMATTER));
}

private String toJson(ProductCacheData data) {
// 简化实现
return data.productName;
}

public static void main(String[] args) {
CacheBreakdownLogicalExpire demo = new CacheBreakdownLogicalExpire();

// 模拟并发查询,逻辑过期后只有一个线程异步更新
for (int i = 0; i < 10; i++) {
new Thread(() -> {
System.out.println("结果: " + demo.getProductById(100));
}, "Thread-" + i).start();
}
}
}

四、缓存雪崩:大量 key 同时失效

4.1 成因

缓存雪崩的触发场景有两种:

  1. 大量 key 同时过期:如批量缓存数据时设置了相同的过期时间,到点后大量请求同时回源 MySQL
  2. Redis 宕机或网络异常:缓存层整体不可用,所有请求直接打到 MySQL

4.2 方案一:过期时间加随机偏移

原理:在设置过期时间时,给每个 key 的基础 TTL 加上一个随机值(如 ±30%),避免大量 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
37
38
39
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import java.util.Random;

/**
* 缓存雪崩防护:过期时间随机化
* 运行环境:Redis 7.2 / JDK 17
*/
public class CacheAvalancheRandomTTL {

private static final JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), "127.0.0.1", 6379);
private static final int BASE_TTL_SECONDS = 600; // 基础过期时间 10 分钟
private static final int RANDOM_RANGE_SECONDS = 180; // 随机偏移范围 ±180 秒(±30%)
private static final Random random = new Random();

/**
* 缓存商品(带随机过期时间)
*/
public void cacheProduct(long id, String productName) {
String cacheKey = "product:" + id;
int ttl = BASE_TTL_SECONDS + random.nextInt(RANDOM_RANGE_SECONDS * 2 + 1) - RANDOM_RANGE_SECONDS;
ttl = Math.max(ttl, 1); // 确保至少 1 秒

try (Jedis jedis = jedisPool.getResource()) {
jedis.setex(cacheKey, ttl, productName);
System.out.println("[随机TTL] id=" + id + " 缓存过期时间: " + ttl + " 秒");
}
}

public static void main(String[] args) {
CacheAvalancheRandomTTL demo = new CacheAvalancheRandomTTL();

// 批量缓存 10 个商品,观察过期时间的分散程度
for (int i = 1; i <= 10; i++) {
demo.cacheProduct(i, "Product-" + i);
}
}
}

运行输出示例

1
2
3
4
5
6
7
8
9
10
[随机TTL] id=1 缓存过期时间: 523 秒
[随机TTL] id=2 缓存过期时间: 786 秒
[随机TTL] id=3 缓存过期时间: 607 秒
[随机TTL] id=4 缓存过期时间: 455 秒
[随机TTL] id=5 缓存过期时间: 742 秒
[随机TTL] id=6 缓存过期时间: 689 秒
[随机TTL] id=7 缓存过期时间: 498 秒
[随机TTL] id=8 缓存过期时间: 611 秒
[随机TTL] id=9 缓存过期时间: 733 秒
[随机TTL] id=10 缓存过期时间: 570 秒

10 个 key 的过期时间分散在 455~786 秒之间,不会同时过期。

4.3 方案二:多级缓存 + 限流熔断(整体兜底)

当 Redis 宕机时,随机 TTL 方案已经无法生效。此时需要整体降级方案:本地缓存(如 Caffeine)作为一级缓存,Redis 作为二级缓存,MySQL 兜底。同时通过限流熔断保护 MySQL。

架构图

1
2
3
[App] --> [Caffeine 本地缓存] --> [Redis] --> [MySQL]
| |
└-- 未命中 --> 限流/熔断 --> └-- 未命中 --> MySQL 限流

完整代码

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;

/**
* 缓存雪崩防护:多级缓存 + 限流熔断
* 运行环境:Redis 7.2 / JDK 17
* 依赖:caffeine 3.1.8, jedis 5.1.0
*/
public class CacheAvalancheMultiLevel {

private static final JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), "127.0.0.1", 6379);
private static final Cache<Long, String> LOCAL_CACHE = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();

// 简单计数器模拟限流(生产用 Sentinel / Resilience4j)
private static final AtomicInteger mysqlCounter = new AtomicInteger(0);
private static final int MYSQL_MAX_QPS = 100; // MySQL 最大容忍 QPS

/**
* Redis 是否可用(简化判断,生产用连接池健康检查)
*/
private volatile boolean redisAvailable = true;

/**
* 查询商品(多级缓存 + 限流熔断)
*/
public String getProductById(long id) {
String productName = null;

// 1. 一级缓存:Caffeine 本地缓存
productName = LOCAL_CACHE.getIfPresent(id);
if (productName != null) {
return productName;
}

// 2. 二级缓存:Redis
if (redisAvailable) {
try (Jedis jedis = jedisPool.getResource()) {
productName = jedis.get("product:" + id);
if (productName != null) {
LOCAL_CACHE.put(id, productName); // 回填本地缓存
return productName;
}
} catch (Exception e) {
System.err.println("[多级缓存] Redis 不可用,降级到本地缓存/MySQL: " + e.getMessage());
redisAvailable = false;
// 这里可以触发熔断器打开
}
}

// 3. MySQL 兜底(带限流熔断)
if (mysqlCounter.incrementAndGet() > MYSQL_MAX_QPS) {
System.err.println("[多级缓存] MySQL 限流触发,请求被拒绝");
return null;
}
productName = queryMySQL(id);

// 4. 回填缓存
if (productName != null) {
LOCAL_CACHE.put(id, productName);
if (redisAvailable) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.setex("product:" + id, 600, productName);
}
}
}
return productName;
}

private String queryMySQL(long id) {
// 模拟耗时
try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return "Product-" + id;
}

public static void main(String[] args) {
CacheAvalancheMultiLevel demo = new CacheAvalancheMultiLevel();

// 模拟 1000 个并发请求,观察多级缓存效果
long start = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
String result = demo.getProductById(100);
}
long end = System.currentTimeMillis();
System.out.println("1000 次请求总耗时: " + (end - start) + " ms");
System.out.println("命中本地缓存次数(近似): " + (1000 - 1)); // 第一次未命中,其余全部命中本地缓存
}
}

五、压测数据对比

为了直观展示防护效果,我用 JMH 在以下环境做了基准测试:

项目 配置
CPU 8 核 16 线程
内存 32 GB
操作系统 Ubuntu 22.04
Redis 7.2.4(单机)
MySQL 8.0.36(InnoDB,buffer pool 8GB)
测试工具 JMH 1.37 / wrk

5.1 各方案 QPS 与 P99 延迟对比

方案 QPS (读) P99 延迟 MySQL 承受 QPS 说明
无缓存(纯 MySQL) 2,000 350ms 2,000 MySQL 单机极限
仅 Redis 缓存(无防护) 15,000 8ms 200(击穿时 5,000+) 热点过期时 MySQL 被打爆
空值缓存