Redis缓存穿透、击穿、雪崩解决方案

前言

在高并发的互联网应用中,Redis作为缓存层扮演着至关重要的角色。然而,缓存系统也面临着三大经典问题:缓存穿透、缓存击穿、缓存雪崩。这三个问题如果处理不当,可能会导致数据库瞬间崩溃,整个系统不可用。

本文将深入剖析这三个问题的本质,并提供切实可行的解决方案。


一、缓存穿透

1.1 什么是缓存穿透

缓存穿透是指查询一个缓存和数据库中都不存在的数据。

正常流程:

1
请求 → 查询缓存(未命中) → 查询数据库(有数据) → 写入缓存 → 返回

穿透情况:

1
2
请求 → 查询缓存(未命中) → 查询数据库(无数据) → 返回空
每次请求都会打到数据库!

1.2 危害分析

假设有恶意用户不断请求不存在的数据:

1
2
3
4
5
// 恶意请求示例
for (int i = 0; i < 100000; i++) {
String userId = "user_" + UUID.randomUUID();
getUserInfo(userId); // 查询不存在的用户
}

后果:

  • 缓存完全失效
  • 每个请求都打到数据库
  • 数据库连接池耗尽
  • 系统崩溃

1.3 解决方案一:布隆过滤器

布隆过滤器可以快速判断一个元素是否存在于集合中。

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
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

@Service
public class UserService {

// 创建布隆过滤器,预计100万个元素,误判率0.01
private BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1000000,
0.01
);

@PostConstruct
public void init() {
// 应用启动时,加载所有用户ID到布隆过滤器
List<Long> userIds = userMapper.selectAllUserIds();
userIds.forEach(bloomFilter::put);
}

public User getUserById(Long userId) {
// 1. 先用布隆过滤器判断
if (!bloomFilter.mightContain(userId)) {
return null; // 一定不存在,直接返回
}

// 2. 查询缓存
String key = "user:" + userId;
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}

// 3. 查询数据库
user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}

return user;
}
}

优点:

  • 内存占用小(100万数据只需约1.2MB)
  • 查询速度快(O(k),k是hash函数个数)

缺点:

  • 存在误判(可能判断存在但实际不存在)
  • 无法删除元素

1.3 解决方案二:缓存空值

如果数据库查询结果为空,也将空值写入缓存。

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
public User getUserById(Long userId) {
String key = "user:" + userId;

// 1. 查询缓存
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
if (value instanceof User) {
return (User) value;
} else {
// 缓存的是空值标识
return null;
}
}

// 2. 查询数据库
User user = userMapper.selectById(userId);

if (user != null) {
// 存在,缓存30分钟
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
} else {
// 不存在,缓存空值,过期时间短一些(5分钟)
redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
}

return user;
}

优点:

  • 实现简单
  • 防止相同请求重复打到数据库

缺点:

  • 占用额外的缓存空间
  • 可能缓存大量无效数据

1.4 解决方案三:接口限流

在接口层面做限流和参数校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
public class UserController {

@GetMapping("/user/{userId}")
@RateLimiter(limit = 10, timeout = 1000) // 限流:1秒10次
public Result<User> getUser(@PathVariable Long userId) {
// 参数校验
if (userId == null || userId <= 0) {
return Result.error("参数错误");
}

// 防止ID过大
if (userId > 10000000) {
return Result.error("用户不存在");
}

User user = userService.getUserById(userId);
return Result.success(user);
}
}

二、缓存击穿

2.1 什么是缓存击穿

缓存击穿是指一个热点key在缓存过期的瞬间,大量并发请求同时打到数据库。

场景:

1
2
3
4
某个热门商品的缓存在12:00:00过期
12:00:00.001 - 10000个并发请求同时到达
所有请求都发现缓存不存在
10000个请求同时查询数据库!

2.2 解决方案一:互斥锁

使用Redis的SETNX实现分布式锁。

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
public Product getProductById(Long productId) {
String key = "product:" + productId;
String lockKey = "lock:product:" + productId;

// 1. 查询缓存
Product product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}

// 2. 缓存未命中,尝试获取锁
Boolean locked = redisTemplate.opsForValue().setIfAbsent(
lockKey,
"1",
10,
TimeUnit.SECONDS
);

try {
if (Boolean.TRUE.equals(locked)) {
// 获取锁成功,查询数据库
product = productMapper.selectById(productId);

if (product != null) {
// 写入缓存
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
}

return product;
} else {
// 未获取到锁,等待后重试
Thread.sleep(50);
return getProductById(productId); // 递归重试
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取数据失败", e);
} finally {
// 释放锁
if (Boolean.TRUE.equals(locked)) {
redisTemplate.delete(lockKey);
}
}
}

优点:

  • 确保只有一个线程查询数据库
  • 其他线程等待,减轻数据库压力

缺点:

  • 性能较低(其他线程需要等待)
  • 可能出现死锁

2.3 解决方案二:热点数据永不过期

逻辑过期,而不是物理过期。

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
@Data
public class CacheData {
private Object data;
private Long expireTime; // 逻辑过期时间
}

public Product getProductById(Long productId) {
String key = "product:" + productId;

// 查询缓存(永不过期)
CacheData cacheData = redisTemplate.opsForValue().get(key);

if (cacheData == null) {
// 缓存不存在,初次加载
Product product = productMapper.selectById(productId);
if (product != null) {
CacheData newCache = new CacheData();
newCache.setData(product);
newCache.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000);
redisTemplate.opsForValue().set(key, newCache); // 不设置过期时间
}
return product;
}

// 检查逻辑过期时间
if (cacheData.getExpireTime() < System.currentTimeMillis()) {
// 已过期,异步更新
String lockKey = "lock:product:" + productId;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(
lockKey, "1", 10, TimeUnit.SECONDS
);

if (Boolean.TRUE.equals(locked)) {
// 获取锁成功,异步更新缓存
CompletableFuture.runAsync(() -> {
try {
Product product = productMapper.selectById(productId);
if (product != null) {
CacheData newCache = new CacheData();
newCache.setData(product);
newCache.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000);
redisTemplate.opsForValue().set(key, newCache);
}
} finally {
redisTemplate.delete(lockKey);
}
});
}
}

// 返回旧数据(即使过期也返回,保证可用性)
return (Product) cacheData.getData();
}

优点:

  • 高可用性(即使数据过期也能返回)
  • 性能好(异步更新)

缺点:

  • 可能返回过期数据
  • 实现复杂度较高

三、缓存雪崩

3.1 什么是缓存雪崩

缓存雪崩是指大量缓存key在同一时间过期,导致所有请求都打到数据库。

场景:

1
2
3
4
凌晨2点,系统预热了10000个热点数据,过期时间都是30分钟
凌晨2:30,10000个key同时过期
凌晨2:30:01,大量请求涌入
数据库瞬间崩溃!

3.2 解决方案一:过期时间打散

给每个key的过期时间加上随机值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void setCache(String key, Object value, long baseExpireMinutes) {
// 基础过期时间 + 随机时间(0-5分钟)
long randomMinutes = ThreadLocalRandom.current().nextInt(5);
long expireMinutes = baseExpireMinutes + randomMinutes;

redisTemplate.opsForValue().set(
key,
value,
expireMinutes,
TimeUnit.MINUTES
);
}

// 使用示例
setCache("product:1001", product, 30); // 过期时间:30-35分钟之间

3.3 解决方案二:多级缓存

构建多级缓存架构。

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
@Service
public class ProductService {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

// 本地缓存(Caffeine)
private Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();

public Product getProductById(Long productId) {
String key = "product:" + productId;

// 1. 查询本地缓存
Product product = localCache.getIfPresent(key);
if (product != null) {
return product;
}

// 2. 查询Redis缓存
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
localCache.put(key, product);
return product;
}

// 3. 查询数据库
product = productMapper.selectById(productId);
if (product != null) {
// 写入Redis和本地缓存
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
localCache.put(key, product);
}

return product;
}
}

架构图:

1
2
请求 → 本地缓存(Caffeine) → Redis → 数据库
↓ 5分钟 ↓ 30分钟

3.4 解决方案三:限流降级

使用Sentinel进行流量控制。

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
@Service
public class ProductService {

@SentinelResource(
value = "getProductById",
blockHandler = "handleBlock",
fallback = "handleFallback"
)
public Product getProductById(Long productId) {
// 正常业务逻辑
return productMapper.selectById(productId);
}

// 限流处理
public Product handleBlock(Long productId, BlockException e) {
log.warn("触发限流: productId={}", productId);
return getProductFromCache(productId); // 降级:返回缓存数据
}

// 异常降级
public Product handleFallback(Long productId, Throwable e) {
log.error("查询异常: productId={}", productId, e);
return getDefaultProduct(); // 降级:返回默认数据
}
}

3.5 解决方案四:Redis集群

使用Redis主从+哨兵模式或Redis Cluster,提高可用性。

1
2
3
4
5
6
7
8
9
10
11
12
spring:
redis:
cluster:
nodes:
- 192.168.1.101:6379
- 192.168.1.102:6379
- 192.168.1.103:6379
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5

四、综合实战方案

结合以上方案,提供一个生产级的缓存工具类。

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
@Component
public class CacheService {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Autowired
private BloomFilter<String> bloomFilter;

/**
* 查询缓存(带布隆过滤器、互斥锁、随机过期时间)
*/
public <T> T getWithSafety(String key,
Class<T> type,
Function<String, T> dbFallback,
long expireMinutes) {
// 1. 布隆过滤器判断
if (!bloomFilter.mightContain(key)) {
return null;
}

// 2. 查询缓存
T value = (T) redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}

// 3. 缓存未命中,加锁
String lockKey = "lock:" + key;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(
lockKey, "1", 10, TimeUnit.SECONDS
);

try {
if (Boolean.TRUE.equals(locked)) {
// 获取锁成功,查询数据库
value = dbFallback.apply(key);

if (value != null) {
// 写入缓存,随机过期时间
long randomMinutes = ThreadLocalRandom.current().nextInt(5);
redisTemplate.opsForValue().set(
key,
value,
expireMinutes + randomMinutes,
TimeUnit.MINUTES
);
} else {
// 缓存空值
redisTemplate.opsForValue().set(
key,
"NULL",
5,
TimeUnit.MINUTES
);
}

return value;
} else {
// 未获取到锁,等待后重试
Thread.sleep(100);
return getWithSafety(key, type, dbFallback, expireMinutes);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("缓存查询失败", e);
} finally {
if (Boolean.TRUE.equals(locked)) {
redisTemplate.delete(lockKey);
}
}
}
}

// 使用示例
@Service
public class ProductService {

@Autowired
private CacheService cacheService;

@Autowired
private ProductMapper productMapper;

public Product getProductById(Long productId) {
String key = "product:" + productId;

return cacheService.getWithSafety(
key,
Product.class,
k -> productMapper.selectById(productId),
30L
);
}
}

五、监控与告警

5.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
@Aspect
@Component
public class CacheMonitorAspect {

private final AtomicLong hitCount = new AtomicLong(0);
private final AtomicLong missCount = new AtomicLong(0);

@Around("@annotation(Cacheable)")
public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
Object result = pjp.proceed();

if (result != null) {
hitCount.incrementAndGet();
} else {
missCount.incrementAndGet();
}

// 计算缓存命中率
long total = hitCount.get() + missCount.get();
double hitRate = total > 0 ? (double) hitCount.get() / total : 0;

// 命中率低于80%告警
if (hitRate < 0.8) {
log.warn("缓存命中率过低: {}", hitRate);
}

return result;
}
}

5.2 Prometheus指标暴露

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
public class CacheMetrics {

private final Counter cacheHits = Counter.build()
.name("cache_hits_total")
.help("Total cache hits")
.register();

private final Counter cacheMisses = Counter.build()
.name("cache_misses_total")
.help("Total cache misses")
.register();

public void recordHit() {
cacheHits.inc();
}

public void recordMiss() {
cacheMisses.inc();
}
}

六、总结

问题 原因 解决方案
缓存穿透 查询不存在的数据 布隆过滤器、缓存空值、接口限流
缓存击穿 热点key过期 互斥锁、逻辑过期
缓存雪崩 大量key同时过期 过期时间打散、多级缓存、限流降级

最佳实践:

  1. 永远不要完全依赖缓存
  2. 设计合理的降级方案
  3. 监控缓存命中率
  4. 压测验证方案有效性

Redis缓存优化是一个系统工程,需要结合业务场景选择合适的方案。希望本文能帮助你更好地应对生产环境中的缓存问题。