一、什么是缓存雪崩?
缓存雪崩是指缓存服务器中大量的缓存数据在同一时间失效或者同一时间遭受到大量请求的情况,导致请求全部落到了数据库上,引发数据库性能问题,甚至导致数据库宕机。
缓存雪崩解决方案主要有以下几种:
-
缓存数据的过期时间设置随机化:避免大量的缓存数据在同一时间失效,可以将缓存数据的过期时间设置为随机时间,这样可以有效地将缓存数据的失效时间错开,避免同时失效的情况。
-
加入二级缓存:在应用系统中加入一个二级缓存,可以将热点数据缓存在二级缓存中,避免大量请求直接落到数据库上。同时,二级缓存的过期时间应该设置得比一级缓存更长一些,以充分利用缓存数据的有效期。
-
数据预热:在系统上线前,可以提前将一些数据缓存在缓存中,这样可以避免系统上线后大量请求直接落到数据库上。
-
限流降级:在高峰期限制请求量,防止系统被压垮。同时,可以对一些不太重要的请求进行降级处理,如返回默认值或者友好提示,以保证整个系统的可用性。
-
分布式部署:将缓存服务器分布在多台机器上,避免单点故障,提高系统的可用性和容错性。
二、演示一个简单案例
一般情况下,为了防止并发请求打爆数据库,都会加上一层缓存拦截请求,降低数据库压力,但是有时候由于缓存命中率不稳定,导致还有大批量并发请求访问到数据库,造成数据库压力过大从而崩溃。到头来还是数据库崩溃了,那么咋么解决这个一问题呢?这里提供一种简单方法,代码如下:
定义一个 DoubleCacheServiceImpl 业务代码块,先判断缓存中是否存在数据,如果没有查询数据库,查询到数据最后存入到缓存中,如下所示:
@Service
@Slf4j
public class DoubleCacheServiceImpl implements DoubleCacheService{
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private RedisTemplate redisTemplate;
@Override
public Person doubleCache(String key) throws Exception {
Object o = redisTemplate.opsForValue().get(key);
if (Objects.nonNull(o)) {
log.info(">>>>>>从 Redis 缓存中获取结果 person = {}",o);
return (Person) o;
}
Person person = jdbcTemplate.queryForObject("select id, user_name,age from test_user where id=?",new Object[]{
key}, new BeanPropertyRowMapper<>(Person.class));
log.info(">>>>>>从数据库中获取结果 person = {}",person);
redisTemplate.opsForValue().set(key,person);
redisTemplate.expire(key, 30,TimeUnit.SECONDS);
return person;
}
}
在定义 Controller 层,在这里通过 CountDownLatch 模拟 50 个并发量,代码如下:
@RestController
@Slf4j
public class DoubleCacheController {
@Autowired
private DoubleCacheService doubleCacheService;
@RequestMapping("/doubleCache")
public void doubleCache(String key) {
CountDownLatch countDownLatch = new CountDownLatch(COUNT);
for (int i = 0; i < COUNT; i++) {
new Thread(()->{
try {
// 准备 50 个线程
countDownLatch.await();
doubleCacheService.doubleCache(key);
} catch (Exception e) {
log.info(">>>>>>出现异常,msg={}",e.getMessage());
throw new RuntimeException(e);
}
}).start();
countDownLatch.countDown();
}
}
}
结果输出如下:
2023-01-12 18:36:15.057 INFO 65094 --- [ Thread-29] c.g.c.rabbitmq.DoubleCacheServiceImpl : >>>>>>从数据库中获取结果 person = Person(id=31, userName=薛擎熙, age=31)
2023-01-12 18:36:15.057 INFO 65094 --- [ Thread-45] c.g.c.rabbitmq.DoubleCacheServiceImpl : >>>>>>从数据库中获取结果 person = Person(id=31, userName=薛擎熙, age=31)
2023-01-12 18:36:15.057 INFO 65094 --- [ Thread-54] c.g.c.rabbitmq.DoubleCacheServiceImpl : >>>>>>从数据库中获取结果 person = Person(id=31, userName=薛擎熙, age=31)
2023-01-12 18:36:15.057 INFO 65094 --- [ Thread-49] c.g.c.rabbitmq.DoubleCacheServiceImpl : >>>>>>从数据库中获取结果 person = Person(id=31, userName=薛擎熙, age=31)
2023-01-12 18:36:15.059 INFO 65094 --- [ Thread-24] c.g.c.rabbitmq.DoubleCacheServiceImpl : >>>>>>从数据库中获取结果 person = Person(id=31, userName=薛擎熙, age=31)
从 “>>>>>>从数据库中获取结果” 这个显示结果可以看出,50 个请求全部打到了数据库上。50 个请求数据库可以抗住,好现在换成 500 个请求试试,性能差点的机器有可能会提示 Connection Too Many。
上面 DoubleCacheServiceImpl 类中逻辑,由于 50 个线程同时执行,查询 Redis 中没有缓存,所以所有线程都去查询数据库,数据库压力就扛不住了,现在假设让一个线程去查询数据库,其他线程别去,这样数据库压力就下来了。那么如何保证让其中一个线程去,其他线程等待呢?可以用加锁的方式。这里就简单使用同步锁,毕竟同步锁在 JDK1.6 之后性能已被优化。
改造之后的 Service 代码如下:
@Service
@Slf4j
public class DoubleCacheServiceImpl implements DoubleCacheService{
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private RedisTemplate redisTemplate;
@Override
public Person doubleCache(String key) throws Exception {
Person person;
Object o = redisTemplate.opsForValue().get(key);
if (Objects.nonNull(o)) {
log.info(">>>>>>从 Redis 缓存中获取结果 person = {}",o);
return (Person) o;
}
synchronized (DoubleCacheServiceImpl.class) {
Object tempObj = redisTemplate.opsForValue().get(key);
if (Objects.nonNull(tempObj)) {
log.info(">>>>>>从 Redis 缓存中获取结果 person = {}",tempObj);
return (Person) tempObj;
}
person = jdbcTemplate.queryForObject("select id, user_name,age from test_user where id=?",new Object[]{
key}, new BeanPropertyRowMapper<>(Person.class));
log.info(">>>>>>从数据库中获取结果 person = {}",person);
redisTemplate.opsForValue().set(key,person);
redisTemplate.expire(key, 30,TimeUnit.SECONDS);
}
return person;
}
}
在同步锁里面再去查询一次缓存,有点类似 DCL 的意思,如果缓存中有走缓存。只不过这样整体性能确实受到影响。但是降低了数据库压力。