总览:
1、添加商查询redis缓存:
ShopServiceImpl.java:
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private RedisUtil redisUtil;
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 从Redis中获取数据
String shopJson = (String) redisUtil.get(key);
// 判断redis是否有数据
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 从数据库中获取数据
Shop shop = this.getById(id);
// 判断数据库是否有数据
if (shop == null) {
return Result.fail("店铺不存在"); }
// 将数据存入Redis
redisUtil.set(key, JSONUtil.toJsonStr(shop));
return Result.ok(shop); }
}
2、给店铺类型查询业务添加缓存
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Autowired
private RedisUtil redisUtil;
@Override
public Result queryLsit() {
if (redisUtil.get("typeList")!=null){
return Result.ok(redisUtil.get("typeList"));
}
LambdaQueryWrapper<ShopType> wrapper = new LambdaQueryWrapper<>();
wrapper.orderByAsc(ShopType::getSort);
List<ShopType> typeList = this.list(wrapper);
if (typeList == null) {
return Result.fail("查询失败");
}
redisUtil.set("typeList",typeList);
return Result.ok(typeList);
}
}
3、缓存更新策略
先操作数据库,再删除缓存发生错误概率较低,因为缓存速度快
4、实现商铺缓存与数据库相一致(商铺更新操作)
采用先操作数据库,后删除redis缓存:
public Result updateShopInfo(Shop shop) {
Long id = shop.getId(); if (id == null) {
return Result.fail("店铺id不能为空"); }
String key = CACHE_SHOP_KEY + id;
// 更新数据库
this.updateById(shop);
// 删除Redis中的数据
redisUtil.del(key);
return Result.ok();}
5、缓存穿透解决方案(缓存空对象、布隆过滤)(通过解决查询商铺问题)
**缓存空对象思路分析:**当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了
**布隆过滤:**布隆过滤器
其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,
1、采用缓存空对象
修改shopserviceImpl.java:
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private RedisUtil redisUtil;
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 从Redis中获取数据
String shopJson = (String) redisUtil.get(key);
// 判断redis是否有数据
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 缓存穿透 判断是否是空对象
if (shopJson!=null){
return Result.fail("店铺不存在2");
}
// 从数据库中获取数据
Shop shop = this.getById(id);
// 判断数据库是否有数据
if (shop == null) {
// 利用缓存空对象,防止缓存穿透
redisUtil.set(key,"",60*2);
return Result.fail("店铺不存在");
}
// 将数据存入Redis
redisUtil.set(key, JSONUtil.toJsonStr(shop),60*30);
return Result.ok(shop);
}
总结:什么叫缓存穿透&&解决措施
缓存雪崩
提高redis高可用:使用redis集群,借助redis哨兵机制
缓存击穿:
解决方案一、使用锁来解决:
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
解决方案二、逻辑过期方案
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
进行对比
都是解决缓存重建周期内并发的问题
**互斥锁方案:(保证一致性)**由于保证了互斥性(线程等待),所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
**逻辑过期方案:(保证可用性)** 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦(需要维护逻辑过期时间)
7、基于互斥锁解决缓存击穿问题:(查询商铺问题)
采用自定义互斥锁:
本项目采用redis实现 : setnx... del...
封装之前缓存击穿的解决方法函数:
/**
* 缓存穿透解决函数
* @param id
* @return
*/
public Shop dealCachePass(Long id) {
String key = CACHE_SHOP_KEY + id;
// 从Redis中获取数据
String shopJson = (String) redisUtil.get(key);
// 判断redis是否有数据
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 缓存穿透 判断是否是空对象
if (shopJson != null) {
return null;
}
// 从数据库中获取数据
Shop shop = this.getById(id);
// 判断数据库是否有数据
if (shop == null) {
// 利用缓存空对象,防止缓存穿透
redisUtil.set(key, "", 60 * 2);
return null;
}
// 将数据存入Redis
redisUtil.set(key, JSONUtil.toJsonStr(shop), 60 * 30);
return shop;
}
封装取得互斥锁和释放互斥锁方法:
/**
* 获取互斥锁
* @param lockKey
* @return
*/
public boolean getMutexLock(String lockKey) {
Boolean flag = redisUtil.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放互斥锁
* @param lockKey
*/
public void unlockMutexLock(String lockKey) {
redisUtil.del(lockKey);
}
创建缓存击穿解决函数:
/**
* 缓存击穿解决函数
* @param id
* @return
*/
public Shop dealCacheBreak(Long id) {
String key = CACHE_SHOP_KEY + id;
// 从Redis中获取数据
String shopJson = (String) redisUtil.get(key);
// 判断redis是否有数据
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 缓存穿透 判断是否是空对象
if (shopJson != null) {
return null;
}
// 4.缓存重建
// 4.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean mutexLock = getMutexLock(lockKey);
// 4.2 判断是否获取到锁
// 获取不到锁,等待50ms,再次获取
if (!mutexLock) {
Thread.sleep(50);
// 递归调用自身
return dealCacheBreak(id);
}
// 4.3 获取到锁,再次检测redis缓存是否存在,做DoubleCheck检查redis缓存
// 判断redis是否有数据
if (StrUtil.isNotBlank(shopJson)) {
shop = JSONUtil.toBean(shopJson, Shop.class);
// 释放锁
unlockMutexLock(lockKey);
return shop;
}
// 再从数据库中获取数据
shop = this.getById(id);
// 模拟线上查库重建缓存延迟
Thread.sleep(200);
// 判断数据库是否有数据
if (shop == null) {
// 利用缓存空对象,防止缓存穿透
redisUtil.set(key, "", 60 * 2);
return null;
}
// 将数据存入Redis
redisUtil.set(key, JSONUtil.toJsonStr(shop), 60 * 30);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 4.4 释放锁
unlockMutexLock(lockKey);
}
return shop;
}
修改查询商铺queryById():
// 解决缓存击穿
Shop shop = dealCacheBreak(id);
if (shop == null) {
return Result.fail("商铺不存在");
}
return Result.ok(shop);
}
验证:
使用Apache JMeter:
参照全网最全最细的jmeter接口测试教程以及接口测试流程详解 - 知乎 (zhihu.com)
创建线程组,指定线程数和Ramp-Up,表示在5s内启动1000个线程:
创建Http请求并配置:
查看结果:
1000个请求都已成功返回信息
Ide控制台也只显示一条sql,表明仅有一个线程会查库,其余线程在互斥锁取得后进行doublecheck时拿到了Redis中的缓存
Redis也已上库:
至此,热点key问题的互斥锁(使用redis-string-setnx方式)解决方式完成
8、基于逻辑过期方式解决缓存击穿问题
编写savetoRedis方法:
实现热点key初始化和后续再刷新redis数据方法:
// 保存到redis缓存(适用缓存击穿逻辑过期方式)
public void savetoRedis(Long id, Long seconds) throws InterruptedException {
// 查询商铺数据
Shop shop = this.getById(id);
Thread.sleep(200);
// 逻辑封装
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(seconds));
String key = CACHE_SHOP_KEY + id;
// 写入Redis中
redisUtil.set(key, JSONUtil.toJsonStr(redisData));
}
}
编写完后直接在test方调用savetoRedis(),实现redis初始化。
编写dealCahceBerak2方法,实现逻辑过期时间方式处理缓存击穿:
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); //获取容量10线程池
注意:仍需要使用DoubleCheck在获取到互斥锁之后判断缓存时间是否过期,避免由于使用多线程造成重复rebuild cache
public Shop dealCacheBreak2(Long id) {
String key = CACHE_SHOP_KEY + id;
// 从Redis中获取数据
String shopJson = (String) redisUtil.get(key);
// 判断redis有无数据
// redis无数据,返回空对象
if (StrUtil.isBlank(shopJson)) {
return null;
}
// redis有数据,判断逻辑时间是否过期
RedisData redisDate = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisDate.getData(), Shop.class);
LocalDateTime expireTime = redisDate.getExpireTime();
// 判断时间是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 时间未过期
return shop;
}
// 时间已过期,异步重建缓存
// 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean mutexLock = getMutexLock(lockKey);
// 获取到锁成功,开启异步线程重建缓存
if (mutexLock) {
// Doublecheck检查redis是否过期
// 判断时间是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 时间未过期
return shop;
}
// 开启独立线程(使用线程池)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.savetoRedis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlockMutexLock(lockKey);
}
});
}
// 获取锁失败
return shop;
}
改写queryById方法:
public Result queryById(Long id) {
// 解决缓存穿透
// Shop shop = dealCachePass(id);
// 解决缓存击穿-互斥锁方式
// Shop shop = dealCacheBreak(id);
// 解决缓存击穿-逻辑过期时间方式
Shop shop = dealCacheBreak2(id);
if (shop == null) {
return Result.fail("商铺不存在");
}
return Result.ok(shop);
}
验证:
我们已经调用了savetoRedis()实现了redis初始化操作
name:1085-Tea
修改数据库数据,验证一致性以及多线程并发访问:
name:8080-Tea
JMter设值100线程在一秒内执行完:
执行查询,观察到:
第一个线程返回初始化数据,name:1085-Tea,
此时由于获取到互斥锁,且逻辑时间过期,开始查库话费200ms,故往后大概20个线程(每个线程花费10ms)均返回name:1085-Tea
直到大概22个线程返回:name:8080-Tea
Ide仅执行一次sql:
至此,验证完毕,实现了两库数据一致性和多线程并发访问仅执行一次sql查询,完成热点Key问题逻辑过期时间解决方式
9、封装stringRedisTemplate方法实现缓存工具类:
StringRedisUtil,java:
@Component
@SuppressWarnings("all")
public class StringRedisUtil {
@Autowired
public StringRedisTemplate stringRedisTemplate;
@Autowired public RedisUtil redisUtil;
//* 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
public void set(String key, Object value, Long time, TimeUnit timeUnit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit);
}
//* 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
public void setWithLogicExpire(String key, Object value, Long time, TimeUnit timeUnit) {
// 使用RedisData类来封装value和time
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));
// 写入redis redisUtil.set(key, JSONUtil.toJsonStr(redisData));
}
//* 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
/**
* 缓存穿透解决函数(缓存空对象)
* @param prefix
* @param id
* @param type
* @param function
* @param time
* @param timeUnit
* @param <R>
* @param <ID>
* @return
*/
public <R, ID> R dealCachePass(String prefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit timeUnit) {
String key = prefix + id;
// 从Redis中获取数据
String json = stringRedisTemplate.opsForValue().get(key);
// 判断redis是否有数据
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
// 缓存穿透 判断是否是空值
if (json != null) {
return null;
}
// 从数据库中获取数据
R r = function.apply(id);
// 判断数据库是否有数据
if (r == null) {
// 利用缓存空对象,防止缓存穿透
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 将数据存入Redis
this.set(key, r, time, timeUnit);
return r;
}
//* 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public <R, ID> R dealCacheBreakLogic(String prefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit timeUnit) {
String key = prefix + id;
// 从Redis中获取数据
String json = (String) redisUtil.get(key);
// 判断redis有无数据
// redis无数据,返回空对象
if (StrUtil.isBlank(json)) {
return null;
}
// redis有数据,判断逻辑时间是否过期
RedisData redisDate = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisDate.getData(), type);
LocalDateTime expireTime = redisDate.getExpireTime();
// 判断时间是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 时间未过期
return r;
}
// 时间已过期,异步重建缓存
// 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean mutexLock = getMutexLock(lockKey);
// 获取到锁成功,开启异步线程重建缓存
if (mutexLock) {
// Doublecheck检查redis是否过期
// 判断时间是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 时间未过期
return r;
}
// 开启独立线程(使用线程池)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 从数据库中获取数据
R newr=function.apply(id);
this.setWithLogicExpire(key,newr,time,timeUnit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlockMutexLock(lockKey);
}
});
}
// 获取锁失败
return r;
}
/**
* 获取互斥锁
*
* @param lockKey
* @return
*/
public boolean getMutexLock(String lockKey) {
Boolean flag = redisUtil.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
BooleanUtil.isTrue(flag);
}
/**
* 释放互斥锁
*
* @param lockKey
*/
public void unlockMutexLock(String lockKey) {
redisUtil.del(lockKey); }
}
难点在于封装时泛型以及具体形参的选择
本工具另外使用了RedisUtil类,可以参照如下:(26条消息) 【免费】RedisUtil方法封装类和RedisConfig配置类资源-CSDN文库
验证方法同上文一致。
总结:
**内存淘汰:**redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
**超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
**主动更新:**我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题