加锁解决缓存击穿问题
前言
最近在项目中用到了Redis缓存,然后如果是在高并发的场景下,如果我去查询一条数据,在缓存中是没有的,但是在数据库中是有数据的,那么在高并发的前提下,它会一下子把所有的请求打到数据库,这就是缓存击穿的一个经典问题了。
业务逻辑
Controller
@ResponseBody
@GetMapping("index/json/catalog.json")
public Map<String, List<Catelog2Vo>> getCatlogJson(){
return categoryService.getCatalogJson();
}
业务逻辑了就是这样,现在我要查询所有的分类菜单,我把分类菜单的数据放到了redis中,如果redis中没有数据,则会查询数据库,这就是问题的由来了,如果这个时候我们的缓存中没有分类菜单的数据,假如这时候有100万个请求过来,所有的请求都会打到数据库,那我们该怎么解决呢???
Service
/**
* 使用redis缓存, 获取所有分类的json数据
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJson(){
//给缓存中存放json字符串, 取的时候拿到json字符串进行逆转,逆转为对象类型 【序列化与发序列化】
ValueOperations<String, String> ops = redisTemplate.opsForValue();
//从缓存中获取分类菜单
String catalogJson = ops.get("catalogJson");
//如果缓存中没有分类菜单
if (StringUtils.isEmpty(catalogJson)) {
//将从db获取的分类菜单存到缓存(json)
System.out.println("缓存不命中...查询数据库...");
Map<String, List<Catelog2Vo>> catalog = getCatalogJsonForDB();
ops.set("catalogJson", JSON.toJSONString(catalog), 1 , TimeUnit.DAYS);
return catalog;
}
System.out.println("缓存命中....直接返回...");
//否则【缓存中有分类菜单】 -> 直接返回
//把json字符串逆转为对象类型
return JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {} );
}
/**
* 从数据库中获取所有的分类
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJsonForDB() {
String catalogJson = redisTemplate.opsForValue().get("catalogJson");
if (!StringUtils.isEmpty(catalogJson)) {
return JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {} );
}
System.out.println("查询了数据库");
//查询所有的一级分类
// List<CategoryEntity> level1 = getLevel1Catagories();
// 性能优化:将数据库的多次查询变为一次
List<CategoryEntity> selectList = this.baseMapper.selectList(null);
//1、查出所有分类
//1、1)查出所有一级分类
List<CategoryEntity> level1 = getParentCid(selectList, 0L);
Map<String, List<Catelog2Vo>> collect = level1.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//1、每一个的一级分类,查到这个一级分类的二级分类
List<CategoryEntity> categoryEntities = getParentCid(selectList, v.getCatId());
//2、封装上面的结果
List<Catelog2Vo> catalogs2Vos = null;
if (categoryEntities != null) {
catalogs2Vos = categoryEntities.stream().map(l2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(
v.getCatId().toString(),
null,
l2.getCatId().toString(),
l2.getName()
);
//1. 找当前二级分类的三级分类封装成vo
List<CategoryEntity> level3Catelog = getParentCid(selectList, l2.getCatId());
if (level3Catelog != null) {
List<Catelog2Vo.Category3Vo> category3Vos = level3Catelog.stream().map(l3 -> {
Catelog2Vo.Category3Vo category3Vo = new Catelog2Vo.Category3Vo(
l2.getCatId().toString(),
l3.getCatId().toString(),
l3.getName()
);
return category3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(category3Vos);
}
//2. 封装成指定格式
return catelog2Vo;
}).collect(Collectors.toList());
}
return catalogs2Vos;
}));
return collect;
}
解决办法-加锁
这里我们可以用synchronized(this)加锁,但是这是不推荐的,但还是记录一下吧。
我们这里给数据库查询的这个方法getCatalogJsonForDB里面加锁,当有100万个请求时,它会锁住所有的线程。但是仅仅是加了synchronized(this)是没有实际作用的,假如前一个请求操作数据库后,所有的线程将被锁住,当前一个请求操作数据库完毕后,后面的请求还是会继续进行锁竞争进行操作数据库,那这样是肯定与我们的初衷不符的,我们想要的是如果缓存中有(前一个线程已经操作了数据库,后面的所有线程无需继续操作数据库),那么直接返回。
我们需要在同步代码块里面再次进行对缓存中是否有数据进行判断,如果前面的线程操作后缓存中有数据了,后面的线程无需在操作数据库。
/**
* 使用redis缓存, 获取所有分类的json数据
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJson(){
//给缓存中存放json字符串, 取的时候拿到json字符串进行逆转,逆转为对象类型 【序列化与发序列化】
ValueOperations<String, String> ops = redisTemplate.opsForValue();
//从缓存中获取分类菜单
String catalogJson = ops.get("catalogJson");
//如果缓存中没有分类菜单
if (StringUtils.isEmpty(catalogJson)) {
//将从db获取的分类菜单存到缓存(json)
System.out.println("缓存不命中...查询数据库...");
Map<String, List<Catelog2Vo>> catalog = getCatalogJsonForDB();
ops.set("catalogJson", JSON.toJSONString(catalog), 1 , TimeUnit.DAYS);
return catalog;
}
System.out.println("缓存命中....直接返回...");
//否则【缓存中有分类菜单】 -> 直接返回
//把json字符串逆转为对象类型
return JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {} );
}
/**
* 从数据库中获取所有的分类
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJsonForDB() {
//只要是同一把锁, 就能锁住这个锁的所有线程
//sysnchronized(this) , SpringBoot的所有的组件在容器中都是单例的
//TODO 本地锁: synchronized, JUC包(Lock) ,在分布式情况下,想要锁住所有,必须使用分布式锁
synchronized (this) {
String catalogJson = redisTemplate.opsForValue().get("catalogJson");
//我们现在要做的就是 第一次锁完之后,后面应该去确认一下缓存, 如果没有才需要继续查询
if (!StringUtils.isEmpty(catalogJson)) {
return JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {} );
}
System.out.println("查询了数据库");
//查询所有的一级分类
// List<CategoryEntity> level1 = getLevel1Catagories();
// 性能优化:将数据库的多次查询变为一次
List<CategoryEntity> selectList = this.baseMapper.selectList(null);
//1、查出所有分类
//1、1)查出所有一级分类
List<CategoryEntity> level1 = getParentCid(selectList, 0L);
Map<String, List<Catelog2Vo>> collect = level1.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//1、每一个的一级分类,查到这个一级分类的二级分类
List<CategoryEntity> categoryEntities = getParentCid(selectList, v.getCatId());
//2、封装上面的结果
List<Catelog2Vo> catalogs2Vos = null;
if (categoryEntities != null) {
catalogs2Vos = categoryEntities.stream().map(l2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(
v.getCatId().toString(),
null,
l2.getCatId().toString(),
l2.getName()
);
//1. 找当前二级分类的三级分类封装成vo
List<CategoryEntity> level3Catelog = getParentCid(selectList, l2.getCatId());
if (level3Catelog != null) {
List<Catelog2Vo.Category3Vo> category3Vos = level3Catelog.stream().map(l3 -> {
Catelog2Vo.Category3Vo category3Vo = new Catelog2Vo.Category3Vo(
l2.getCatId().toString(),
l3.getCatId().toString(),
l3.getName()
);
return category3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(category3Vos);
}
//2. 封装成指定格式
return catelog2Vo;
}).collect(Collectors.toList());
}
return catalogs2Vos;
}));
return collect;
}
}
这里我们用apifox
模拟一下压力测试, 直接同时创建20个线程访问同一个接口,看操作了几次数据库。可以看到我么20次并发的访问了这个接口还是访问了3次数据库这是为什么呢?
这里导致查询了3次数据库的原因是锁的时序问题。
举个例子:
时序问题就是在线程1查询出结果,并且释放锁之后,将结果放入缓存之前,线程2获取到锁,又重新去查询了数据库,就会导致重复的数据库查询。
举个例子:
线程1查数据库后放到缓存中,这个放缓存的操作需要与网络进行交互、线程池、Redis建立连接等一系列耗时操作导致线程2没有反应过来
误判了缓存中没有数据又去查了一遍数据库
解决办法
就是把放入缓存这一操作和前面的两个操作放到一起,做原子操作,不要分开。
最终代码
/**
* 使用redis缓存, 获取所有分类的json数据
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJson(){
//给缓存中存放json字符串, 取的时候拿到json字符串进行逆转,逆转为对象类型 【序列化与发序列化】
//从缓存中获取分类菜单
String catalogJson = redisTemplate.opsForValue().get("catalogJson");
//如果缓存中没有分类菜单
if (StringUtils.isEmpty(catalogJson)) {
//将从db获取的分类菜单存到缓存(json)
System.out.println("缓存不命中...查询数据库...");
Map<String, List<Catelog2Vo>> catalog = getCatalogJsonForDB();
return catalog;
}
System.out.println("缓存命中....直接返回...");
//否则【缓存中有分类菜单】 -> 直接返回
//把json字符串逆转为对象类型
return JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {} );
}
/**
* 从数据库中获取所有的分类
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJsonForDB() {
//只要是同一把锁, 就能锁住这个锁的所有线程
//sysnchronized(this) , SpringBoot的所有的组件在容器中都是单例的
//TODO 本地锁: synchronized, JUC包(Lock) ,在分布式情况下,想要锁住所有,必须使用分布式锁
synchronized (this) {
String catalogJson = redisTemplate.opsForValue().get("catalogJson");
//我们现在要做的就是 第一次锁完之后,后面应该去确认一下缓存, 如果没有才需要继续查询
if (!StringUtils.isEmpty(catalogJson)) {
return JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {} );
}
System.out.println("查询了数据库");
//查询所有的一级分类
// List<CategoryEntity> level1 = getLevel1Catagories();
// 性能优化:将数据库的多次查询变为一次
List<CategoryEntity> selectList = this.baseMapper.selectList(null);
//1、查出所有分类
//1、1)查出所有一级分类
List<CategoryEntity> level1 = getParentCid(selectList, 0L);
Map<String, List<Catelog2Vo>> collect = level1.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//1、每一个的一级分类,查到这个一级分类的二级分类
List<CategoryEntity> categoryEntities = getParentCid(selectList, v.getCatId());
//2、封装上面的结果
List<Catelog2Vo> catalogs2Vos = null;
if (categoryEntities != null) {
catalogs2Vos = categoryEntities.stream().map(l2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(
v.getCatId().toString(),
null,
l2.getCatId().toString(),
l2.getName()
);
//1. 找当前二级分类的三级分类封装成vo
List<CategoryEntity> level3Catelog = getParentCid(selectList, l2.getCatId());
if (level3Catelog != null) {
List<Catelog2Vo.Category3Vo> category3Vos = level3Catelog.stream().map(l3 -> {
Catelog2Vo.Category3Vo category3Vo = new Catelog2Vo.Category3Vo(
l2.getCatId().toString(),
l3.getCatId().toString(),
l3.getName()
);
return category3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(category3Vos);
}
//2. 封装成指定格式
return catelog2Vo;
}).collect(Collectors.toList());
}
return catalogs2Vos;
}));
redisTemplate.opsForValue().set("catalogJson", JSON.toJSONString(collect), 1 , TimeUnit.DAYS);
return collect;
}
}
还是20个并发请求, 可以看到最后只操作了一次数据库