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

前言

最近在优化项目性能,大量使用了Redis做缓存。但缓存也不是万能的,用不好反而会出现各种问题

今天就总结一下Redis常见的三个问题:缓存穿透、缓存击穿、缓存雪崩,以及对应的解决方案

这三个词虽然听起来很像,但其实是三种不同的问题,下面一个一个来说

缓存穿透

什么是缓存穿透

缓存穿透是指查询一个不存在的数据,缓存中没有,数据库中也没有

这样每次请求都会打到数据库,如果有人恶意攻击,一直查询不存在的数据,可能会导致数据库崩溃

解决方案

方案一:缓存空对象

当查询数据库发现数据不存在时,也把空对象缓存起来:

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

@Autowired
UserMapper userMapper;

@Autowired
RedisTemplate<String, Object> redisTemplate;

public User getUserById(Long id) {
String key = "user:" + id;
User user = (User) redisTemplate.opsForValue().get(key);

if (user != null) {
// 如果是空对象,直接返回null
return user.getId() == null ? null : user;
}

// 查询数据库
user = userMapper.selectById(id);

if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
} else {
// 缓存空对象,过期时间短一点
User nullUser = new User();
redisTemplate.opsForValue().set(key, nullUser, 5, TimeUnit.MINUTES);
}

return user;
}
}

这种方式实现简单,但缺点是如果缓存了很多空对象,会占用内存

方案二:布隆过滤器

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

在缓存之前加一层布隆过滤器,如果布隆过滤器说不存在,那就直接返回,不用查数据库了

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
@Configuration
public class BloomFilterConfig {

private BloomFilter<Long> bloomFilter;

@PostConstruct
public void init() {
// 预计插入100万条数据,误判率0.01%
bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1000000,
0.0001
);

// 把所有有效的key放进去
List<Long> allIds = getAllIds();
for (Long id : allIds) {
bloomFilter.put(id);
}
}

public boolean mightContain(Long id) {
return bloomFilter.mightContain(id);
}

private List<Long> getAllIds() {
// 从数据库或者缓存中获取所有有效的id
return Collections.emptyList();
}
}

使用的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class UserService {

@Autowired
BloomFilterConfig bloomFilterConfig;

public User getUserById(Long id) {
// 先判断布隆过滤器
if (!bloomFilterConfig.mightContain(id)) {
return null;
}

// 再查缓存和数据库
// ...
}
}

布隆过滤器的缺点是有一定的误判率,而且删除数据不方便

缓存击穿

什么是缓存击穿

缓存击穿是指某个热点数据过期了,这时候大量请求同时过来,缓存没命中,全部打到数据库

比如一个热点新闻,缓存刚好过期,这时候有上万人同时来访问,数据库瞬间就炸了

解决方案

方案一:设置热点数据永不过期

对于热点数据,可以不设置过期时间,或者设置很长的过期时间:

1
2
3
4
5
6
7
8
9
10
11
12
public User getHotUser(Long id) {
String key = "user:" + id;
User user = (User) redisTemplate.opsForValue().get(key);

if (user == null) {
user = userMapper.selectById(id);
// 热点数据不设置过期时间
redisTemplate.opsForValue().set(key, user);
}

return user;
}

方案二:互斥锁

当缓存失效时,只允许一个线程去查数据库,其他线程等待

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 User getUserWithLock(Long id) {
String key = "user:" + id;
User user = (User) redisTemplate.opsForValue().get(key);

if (user == null) {
String lockKey = "lock:" + key;
try {
// 尝试获取锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

if (Boolean.TRUE.equals(locked)) {
// 获取到锁,查数据库
user = userMapper.selectById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
} else {
// 没获取到锁,等100ms再试
Thread.sleep(100);
return getUserWithLock(id);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}

return user;
}

方案三:逻辑过期

不设置Redis的过期时间,而是在缓存的数据中加一个过期时间字段

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
@Data
public class CacheData<T> {
private T data;
private Long expireTime;
}

public <T> T getWithLogicalExpire(String key, Class<T> type, long expireTime) {
String cacheKey = "cache:" + key;
CacheData<T> cacheData = (CacheData<T>) redisTemplate.opsForValue().get(cacheKey);

if (cacheData == null) {
// 第一次访问,需要初始化
return null;
}

T data = cacheData.getData();

// 判断是否过期
if (System.currentTimeMillis() > cacheData.getExpireTime()) {
// 过期了,但仍然返回旧数据
// 同时异步更新缓存
CompletableFuture.runAsync(() -> {
// 查询数据库并更新缓存
T newData = loadFromDatabase(key);
CacheData<T> newCache = new CacheData<>();
newCache.setData(newData);
newCache.setExpireTime(System.currentTimeMillis() + expireTime);
redisTemplate.opsForValue().set(cacheKey, newCache);
});
}

return data;
}

这种方式的好处是不会有缓存击穿的问题,但缺点是可能会返回过期数据

缓存雪崩

什么是缓存雪崩

缓存雪崩是指大量的缓存在同一时间过期,或者Redis服务挂了,导致所有请求都打到数据库

解决方案

方案一:设置不同的过期时间

给不同的key设置不同的过期时间,避免同时过期:

1
2
3
4
5
6
public void cacheUser(User user) {
String key = "user:" + user.getId();
// 过期时间加一个随机值,比如30分钟 ± 5分钟
long expireTime = 30 + (long) (Math.random() * 10 - 5);
redisTemplate.opsForValue().set(key, user, expireTime, TimeUnit.MINUTES);
}

方案二:缓存预热

系统启动时,提前把热点数据加载到缓存中:

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

@Autowired
UserService userService;

@Autowired
RedisTemplate<String, Object> redisTemplate;

@Override
public void run(ApplicationArguments args) {
// 查询热点数据
List<User> hotUsers = userService.getHotUsers();

// 预加载到缓存
for (User user : hotUsers) {
String key = "user:" + user.getId();
redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
}
}
}

方案三:Redis高可用

搭建Redis集群或者哨兵模式,保证Redis服务的高可用:

1
2
3
4
5
6
7
8
spring:
redis:
sentinel:
master: mymaster
nodes:
- 127.0.0.1:26379
- 127.0.0.1:26380
- 127.0.0.1:26381

方案四:多级缓存

使用本地缓存 + Redis缓存的多级架构:

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

@Autowired
RedisTemplate<String, Object> redisTemplate;

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

public User getUserById(Long id) {
// 先查本地缓存
User user = localCache.getIfPresent(id);
if (user != null) {
return user;
}

// 再查Redis缓存
String key = "user:" + id;
user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
localCache.put(id, user);
return user;
}

// 最后查数据库
user = userMapper.selectById(id);
if (user != null) {
localCache.put(id, user);
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}

return user;
}
}

即使Redis挂了,本地缓存还能顶一段时间

总结

这三个问题虽然听起来很像,但其实是不同场景:

  • 缓存穿透:查询不存在的数据,用布隆过滤器或者缓存空对象
  • 缓存击穿:热点数据过期,用互斥锁或者逻辑过期
  • 缓存雪崩:大量缓存同时过期,用随机过期时间或者多级缓存

实际使用中,往往是多种方案组合使用,根据业务场景选择合适的方案

暂时就先记录这么多