一、缓存雪崩
缓存雪崩是指当缓存中的大量数据在同一时间失效,导致大量请求直接访问数据库,从而引起数据库的压力过大,严重影响系统的性能。解决缓存雪崩的常用方法有三种。
解决方案:
1、缓存数据的随机过期时间
一种解决缓存雪崩的方法是在缓存数据的过期时间上增加随机因素,从而避免大量数据在同一时间失效的情况。在SpringBoot中,我们可以通过设置Redis缓存的过期时间和一个随机值来实现这个解决方案。例如
@Override
public List<User> getUserList() {
List<User> userList = redisTemplate.opsForValue().get("userList");
if (userList == null) {
// 如果Redis中不存在,则查询数据库
userList = userDao.getUserList();
if (userList != null && userList.size() > 0) {
// 将数据缓存到Redis中,并增加随机的过期时间
int random = new Random().nextInt(600) + 600;
redisTemplate.opsForValue().set("userList", userList, random, TimeUnit.SECONDS);
}
}
return userList;
}
在上面的代码中,我们先在缓存中查询数据,如果不存在,则去数据库中查询,并将数据缓存到Redis中,并增加随机的过期时间。这样即使大量数据在同一时间失效,也不会全部直接访问数据库,从而避免了缓存雪崩的问题。
2、预热缓存
另外一种解决缓存雪崩的方法是在系统启动时预热缓存,将系统中的热点数据提前加载到缓存中,从而避免了大量请求同时访问数据库的情况。在SpringBoot中,我们可以通过编写一个启动时执行的方法,来实现预热缓存的解决方案。例如:
@Component
public class CacheInit implements CommandLineRunner {
@Autowired
private UserDao userDao;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public void run(String... args) throws Exception {
List<User> userList = userDao.getUserList();
if (userList != null && userList.size() > 0) {
// 将数据缓存到Redis中,并设置过期时间为1小时
for (User user : userList) {
redisTemplate.opsForValue().set(user.getId(), user, 1, TimeUnit.HOURS);
}
}
}
}
在上面的代码中,我们在系统启动时执行run方法,在该方法中先去数据库中查询热点数据,然后将数据缓存到Redis中,并设置过期时间为1小时。这样即使缓存中的数据在同一时间失效,也能够保证系统中的热点数据始终被缓存,从而避免了缓存雪崩的问题。
3、使用分布式锁
最后一种解决缓存雪崩的方法是使用分布式锁,从而避免大量请求同时访问数据库的情况。在SpringBoot中,我们可以通过Redisson来实现分布式锁的解决方案。例如:
@Override
public List<User> getUserList() {
List<User> userList = redisTemplate.opsForValue().get("userList");
if (userList == null) {
// 如果Redis中不存在,则尝试获取分布式锁
RLock lock = redissonClient.getLock("userListLock");
try {
// 尝试加锁,并设置锁的过期时间为5秒
boolean success = lock.tryLock(5, TimeUnit.SECONDS);
if (success) {
// 如果获取到了锁,则查询数据库并将数据缓存到Redis中
userList = userDao.getUserList();
if (userList != null && userList.size() > 0) {
redisTemplate.opsForValue().set("userList", userList, 1, TimeUnit.HOURS);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放锁
lock.unlock
}
} return userList;
}
在上面的代码中,我们先在缓存中查询数据,如果不存在,则尝试获取分布式锁,如果获取到了锁,则查询数据库并将数据缓存到Redis中。如果没有获取到锁,则等待一段时间再尝试获取锁,这样即使大量请求同时访问系统,也能够保证只有一个请求去查询数据库并缓存数据,从而避免了缓存雪崩的问题。
二、缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无 数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
- 互斥锁
- 逻辑过期
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的, 此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的 时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到 数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行 数据库代码,对数据库访问压力过大
1、互斥锁
如果未命中缓存,先获取互斥锁,获取锁之后要再次检查缓存,如果还是未命中进行缓存重建,这样当其他线程来的时候就会获取锁失败,这时我们让这个线程休眠一会,重新查询缓存,如果命中就返回嘛,如果没命中再次尝试获取锁,假设这次获取锁成功了,还是再次检查缓存,如果未命中重建缓存。
优点:可保证数据高一致性
缺点:性能低,可能发生死锁
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人 去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休 眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
2、逻辑过期
为缓存key设置逻辑过期时间(就是加一个字段),假设线程1查询缓存,未命中直接返回,命中判断是否过期发现,没过期也好说直接返回数据就行,已过期,就会尝试获取锁,然后此刻开启新的线程进行缓存重建,线程1返回旧数据,其他线程获取锁失败都返回旧数据。
优点:性能高
缺点:数据可能不一致,实现复杂
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续 通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1 去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据 的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访 问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把 重建数据构建完后,其他线程才能走返回正确的数据。
两者对比:
解决方案 | 优点 | 缺点 |
互斥锁 | 没有额外的内存消耗 保证一致性 实现简单 | 线程需要等待,性能受影响 可能有死锁风险 |
逻辑过期 | 线程无需等待,性能较好 | 不保证一致性 有额外内存消耗 实现复杂 |
三、缓存穿透
缓存穿透是指查询一个不存在的数据,由于缓存中没有数据,请求会直接穿透到数据库中,从而引起数据库的压力过大,严重影响系统的性能。解决缓存穿透的常用方法有两种。
1、布隆过滤器
布隆过滤器是一种高效的数据结构,可以判断一个元素是否存在于一个集合中,同时也可以减轻数据库的压力。在使用布隆过滤器的时候,首先将所有的数据hash到一个位图中,如果查询的数据在位图中不存在,那么直接返回不存在,从而避免了对数据库的查询操作。
优点:内存占用较少,没有多余key
缺点:实现复杂,存在误判可能
在SpringBoot中,我们可以使用Guava提供的布隆过滤器实现缓存穿透的解决方案。例如:
@Bean
public BloomFilter bloomFilter() {
return BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 100000, 0.001);
}
@Override
public User getUserById(String id) {
// 先从布隆过滤器中查询是否存在
if (!bloomFilter.mightContain(id)) {
return null;
}
// 如果存在,则查询Redis中的缓存数据
User user = redisTemplate.opsForValue().get(id);
if (user == null) {
// 如果Redis中不存在,则查询数据库
user = userDao.getUserById(id);
if (user != null) {
// 将数据缓存到Redis中
redisTemplate.opsForValue().set(id, user);
} else {
// 如果数据库中也不存在,则将该id加入到布隆过滤器中
bloomFilter.put(id);
}
}
return user;
}
在上面的代码中,首先通过布隆过滤器判断请求的数据是否存在于集合中,如果不存在,则直接返回null,从而避免了对数据库的查询操作。
2、空对象存储
另外一种解决缓存穿透的方法是采用空对象缓存的方式,即当查询的数据不存在时,将一个空对象缓存到Redis中。这样下次查询同样不存在的数据时,就可以直接从Redis中获取到一个空对象,从而避免了对数据库的查询操作。
优点:实现简单,方便维护
缺点:占内存,可能造成短期数据不一致
在SpringBoot中,我们可以通过设置Redis缓存的过期时间来实现空对象缓存的解决方案。例如:
@Override
public User getUserById(String id) {
User user = redisTemplate.opsForValue().get(id);
if (user == null) {
// 如果Redis中不存在,则查询数据库
user = userDao.getUserById(id);
if (user != null) {
// 将数据缓存到Redis中
redisTemplate.opsForValue().set(id, user);
} else {
// 如果数据库中也不存在,则将一个空对象缓存到Redis中,设置过期时间防止缓存雪崩
redisTemplate.opsForValue().set(id, new User(), 5, TimeUnit.MINUTES);
}
}
return user;
}
在上面的代码中,当查询的数据不存在时,我们将一个空对象缓存到Redis中,并设置了5分钟的过期时间。这样即使缓存中的数据被清空了,也不会引起数据库的压力过大,从而避免了缓存穿透。