缓存击穿
也叫做热点key问题,就是一个高并发访问并且重建业务较为复杂的key突然失效,无数的请求访问会在瞬间给数据库带来巨大的压力
解决策略
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | 没有额外的内存消耗 保证数据一致性 实现简单 | 线程需要等待,性能受到影响 可能有死锁的风险 |
逻辑过期 | 线程无需等待,性能较好 | 不保证一致性 有额外的内存消耗 实现复杂 |
下面以访问用户分页信息为例,分别加上采取互斥锁和逻辑过期策略
互斥锁
代码逻辑
同一时刻只允许一个线程查询数据库,请求成功后将数据存入redis
其余线程则一直等待,直到缓存中存在数据
PS: 可以利用redis的setnx方法(只能在第一次成功设置值,以后设置将会失败),模拟锁的获取
// 互斥锁解决缓存击穿
public Result queryWithMutex(int page, int pageSize, String name){
String mykey = EMPLOYEE_PAGE_PREFIX + "page" + page + "_" + "pageSize" + pageSize + "_" + "name"+ name;
Page pageList = null;
//从缓存中查询是否存在
Object o = redisUtils.get(mykey);
//缓存中没有page信息
if(o==null){
String lockKey = EMPLOYEE_LOCK_PREFIX+ "page"+ page + "_" + "pageSize" + pageSize + "_" + "name"+ name;
try {
// 1.获取互斥锁
boolean b = tryLock(lockKey);
int count = 1;
// 2. 判断是否读取成功
while(!b){
// 3. 失败,则休眠重试
Thread.sleep(100);
log.info("已经有线程占有锁,休眠后再次查询,这是第{}次休眠",count++);
b = tryLock(lockKey);
if(b){
o = redisUtils.get(mykey);
log.info("休眠后发现缓存有值了,请求到redis");
pageList = JSON.parseObject(o.toString(), Page.class);
unlock(lockKey);
return Result.success(pageList);
}
}
log.info("我是第{}个进入线程,直接查询数据库",number++);
pageList = queryPageAndSave(page,pageSize,name,mykey);
}catch(InterruptedException e){
throw new RuntimeException();
}finally{
// 释放互斥锁
unlock(lockKey);
}
}
else{
log.info("直接请求redis的");
pageList = JSON.parseObject(o.toString(), Page.class);
}
return Result.success(pageList);
}
情景模拟
此时redis没有缓存,将会有100个线程在1S内同时访问,下图为部分控制台输出
只有一个线程能够访问数据库,其余线程均会休眠。可以根据任务完成的速度,设置线程的休眠时间。
逻辑过期
代码逻辑
保存时候额外添加带有逻辑过期时间的字段,使用了逻辑过期替代TTL。这样redis中永远都有值,虽然这会导致用户访问到旧的数据。
// 保存带有逻辑过期时间的字段
public Page saveEmployeeWithLogicExpire(int page, int pageSize, String name,Long expireSeconds) {
log.info("开始查询数据库信息");
// 查询店铺信息
Page queryPage = queryPage(page, pageSize, name);
// 封装逻辑过期时间
RedisData data = new RedisData();
data.setData(queryPage);
data.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//写入到redis缓存
String key = EMPLOYEE_PAGE_PREFIX + "page" + page + "_" + "pageSize" + pageSize + "_" + "name"+ name;
String value =JSON.toJSONString(data);
redisUtils.set(key,value);
return queryPage;
}
下面代码的逻辑是:当缓存中不存在所求信息,或者逻辑过期时间已到,需要去数据库查询数据更新到redis中。此时,第一个进入方法的线程负责查询更新操作,其余线程会从已有的redis缓存中取出。若缓存中没有,即高并发且第一次访问时候,还会产生缓存击穿问题。
//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// 逻辑过期解决缓存击穿
public Result queryWithLoginExpire(int page, int pageSize, String name){
String mykey = EMPLOYEE_PAGE_PREFIX + "page" + page + "_" + "pageSize" + pageSize + "_" + "name"+ name;
Page pageList = null;
//从缓存中查询是否存在
Object o = redisUtils.get(mykey);
// 缓存中存在
if(o!=null ){
//命中,先把json反序列化为对象
RedisData redisData = JSON.parseObject(o.toString(), RedisData.class);
// 获取分页和逻辑过期时间
Page page1 = JSON.parseObject(redisData.getData().toString(), Page.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
log.info("逻辑时间还没到,直接从redis中返回员工信息");
//未过期,直接返回员工信息
return Result.success(page1);
}
// 已过期,需要缓存重建
String key = EMPLOYEE_LOCK_PREFIX + "page" + page + "_" + "pageSize" + pageSize + "_" + "name"+ name;
boolean b = tryLock(key);
// 更新缓存信息
if(b) {
CACHE_REBUILD_EXECUTOR.submit(()->{
log.info("逻辑时间已过,更新员工信息");
// 重建缓存
Page page2 = this.saveEmployeeWithLogicExpire(page, pageSize, name,EMPLOYEE_LOCK_EXPIRE);
// 释放锁
unlock(key);
return Result.success(page2);
});
}
// 其余线程返回旧的信息
log.info("已经有线程在更新,返回旧的数据");
return Result.success(page1);
}
else {
//缓存中不存在,查询数据库并且存入缓存
log.info("redis中不存在,查询数据库信息");
Page page1 = saveEmployeeWithLogicExpire(page, pageSize, name, EMPLOYEE_LOCK_EXPIRE);
return Result.success(page1);
}
}
情景模拟
初始状态
此时redis缓存为空,有100个线程在1秒内访问分页内容
以下展示部分控制台信息,初始化高并发访问仍会出现缓存击穿
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7f3757c3] was not registered for synchronization because synchronization is not active
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1b925a02] was not registered for synchronization because synchronization is not active
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@53bc6b0b] was not registered for synchronization because synchronization is not active
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1602ed6a] was not registered for synchronization because synchronization is not active
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@24f7d7f2] was not registered for synchronization because synchronization is not active
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@3364860a] was not registered for synchronization because synchronization is not active
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@457a807f] was not registered for synchronization because synchronization is not active
2023-06-08 00:28:12.161 INFO 52980 --- [io-8081-exec-43] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2023-06-08 00:28:12.655 INFO 52980 --- [io-8081-exec-43] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
JDBC Connection [HikariProxyConnection@2106806061 wrapping com.mysql.cj.jdbc.ConnectionImpl@7e216c0] will not be managed by Spring
JDBC Connection [HikariProxyConnection@1338578974 wrapping com.mysql.cj.jdbc.ConnectionImpl@727d514d] will not be managed by Spring
==> Preparing: SELECT COUNT(*) AS total FROM employee
==> Preparing: SELECT COUNT(*) AS total FROM employee
JDBC Connection [HikariProxyConnection@461634391 wrapping com.mysql.cj.jdbc.ConnectionImpl@145b03f7] will not be managed by Spring
==> Preparing: SELECT COUNT(*) AS total FROM employee
==> Parameters:
==> Parameters:
==> Parameters:
JDBC Connection [HikariProxyConnection@1007102482 wrapping com.mysql.cj.jdbc.ConnectionImpl@109dd549] will not be managed by Spring
==> Preparing: SELECT COUNT(*) AS total FROM employee
==> Parameters:
JDBC Connection [HikariProxyConnection@411223316 wrapping com.mysql.cj.jdbc.ConnectionImpl@527d23ab] will not be managed by Spring
==> Preparing: SELECT COUNT(*) AS total FROM employee
==> Parameters:
JDBC Connection [HikariProxyConnection@639844861 wrapping com.mysql.cj.jdbc.ConnectionImpl@9928553] will not be managed by Spring
==> Preparing: SELECT COUNT(*) AS total FROM employee
==> Parameters:
JDBC Connection [HikariProxyConnection@240348441 wrapping com.mysql.cj.jdbc.ConnectionImpl@3e0ff29f] will not be managed by Spring
==> Preparing: SELECT COUNT(*) AS total FROM employee
==> Parameters:
JDBC Connection [HikariProxyConnection@377573666 wrapping com.mysql.cj.jdbc.ConnectionImpl@68286884] will not be managed by Spring
==> Preparing: SELECT COUNT(*) AS total FROM employee
==> Parameters: