黑马点评2——商户查询缓存(P37店铺类型查询业务添加缓存练习题答案)redis缓存、更新、穿透、雪崩、击穿、工具封装
文章目录
- 什么是缓存?
- 添加Redis缓存
- 店铺类型查询业务添加缓存练习题
- 缓存更新策略
- 给查询商铺的缓存添加超时剔除和主动更新的策略
- 缓存穿透
- 缓存空对象
- 布隆过滤
- 缓存雪崩
- 解决方案
- 缓存击穿
- 解决方案
- 基于互斥锁方式解决缓存击穿问题
- 基于逻辑过期的方式解决缓存击穿问题
- 缓存工具封装
什么是缓存?
缓存也要考虑成本的问题,不是随便用的
添加Redis缓存
@Override
public Result queryById(Long id) {
String redisKey = RedisConstants.CACHE_SHOP_KEY + id;
// 1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(redisKey);
// 2. 判读是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 不存在,根据id查询数据库
Shop shop = getById(id);
// 5. 不存在,写入redis
if(shop == null){
return Result.fail("店铺不存在!");
}
// 6. 存在,写入redis
stringRedisTemplate.opsForValue().set(redisKey,JSONUtil.toJsonStr(shop));
// 7. 返回
return Result.ok(shop);
}
店铺类型查询业务添加缓存练习题
@Override
public Result queryTypeList() {
// 1. 从redis查询店铺类别缓存
List<String> shopTypeRedisKey = stringRedisTemplate.opsForList().range(RedisConstants.CACHE_SHOP_TYPE_KEY,0,-1);
// 2. 判断是否命中缓存
if(!CollectionUtils.isEmpty(shopTypeRedisKey)){
// 3. 存在,直接返回,即是命中缓存
// 使用stream流将json集合转为
List<ShopType> shopTypeList = shopTypeRedisKey.stream()
.map(item -> JSONUtil.toBean(item, ShopType.class))
.sorted(Comparator.comparingInt(ShopType::getSort))
.collect(Collectors.toList());
// 返回缓存数据
return Result.ok(shopTypeList);
}
// 4. 不存在,查询数据库
List<ShopType> shopTypes = query().orderByAsc("sort").list();
// 判断数据库中是否有数据
if(CollectionUtils.isEmpty(shopTypes)){
// 不存在则缓存一个空集合,解决缓存穿透
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_TYPE_KEY, Collections.emptyList().toString(),RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("商品分类信息为空");
}
// 5. 数据存在,先写入redis,再返回
// 使用stream流将bean集合转为json集合
List<String> shopTypeCache = shopTypes.stream()
.sorted(Comparator.comparingInt(ShopType::getSort))
.map(item -> JSONUtil.toJsonStr(item))
.collect(Collectors.toList());
stringRedisTemplate.opsForList().rightPushAll(RedisConstants.CACHE_SHOP_TYPE_KEY,shopTypeCache);
stringRedisTemplate.expire(RedisConstants.CACHE_SHOP_TYPE_KEY,RedisConstants.CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
// 6. 返回(按类别升序排序)
return Result.ok(shopTypes);
}
缓存更新策略
操作缓存和数据库的顺序,不论谁先进行都可能会有线程安全的问题
但方案二的发生可能性更小,所以更优
总结:
给查询商铺的缓存添加超时剔除和主动更新的策略
查询店铺:
@Override
public Result queryById(Long id) {
String redisKey = RedisConstants.CACHE_SHOP_KEY + id;
// 1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(redisKey);
// 2. 判读是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 不存在,根据id查询数据库
Shop shop = getById(id);
// 5. 不存在,返回错误
if(shop == null){
return Result.fail("店铺不存在!");
}
// 6. 存在,写入redis
stringRedisTemplate.opsForValue().set(redisKey,JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7. 返回
return Result.ok(shop);
}
修改店铺:
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺id不能为空");
}
// 更新数据库,在删除缓存
updateById(shop);
// 删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
return Result.ok();
}
缓存穿透
客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库
缓存空对象
可以设置一个TTL,解决内存消耗问题
可能存在短期不一致的问题,控制TTL的时间,可以一定程度的缓解这个问题。
布隆过滤
客户端个redis之间,在加一层过滤——布隆过滤器——哈希算法二进制位保存数据
布隆过滤器说如果不存在一定是不存在,但存在不一定是100% 的
先看一下之前查询商铺信息的业务流程
物品们采用方案一应该把空数据写入redis
缓存雪崩
解决方案
- 给不同的key的TTL添加随机值——针对问题一
- 利用redis集群提高服务的可用性——针对问题二
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
解决方案
互斥锁和逻辑过期
基于互斥锁方式解决缓存击穿问题
获取锁:
- redis的setnx指令可以在key不存在的时候写,存在的时候不能写,就类似于互斥
释放锁:
- 删掉就行了
设置锁的时候要设置有效期,避免因为某种原因锁得不到释放
@Override
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if(shop == null){
return Result.fail("店铺不存在!");
}
// 7. 返回
return Result.ok(shop);
}
/**
* 解决缓存击穿(互斥锁)的写法
* @param id
* @return
*/
public Shop queryWithMutex(Long id){
String redisKey = RedisConstants.CACHE_SHOP_KEY + id;
// 1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(redisKey);
// 2. 判读是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3. 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 命中的是否是空值
if(shopJson != null){
// 返回一个错误信息
return null;
}
//4. 开始实现缓存重建
// 4.1 获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try{
boolean isLock = tryLock(lockKey);
// 4.2 判断是否获取成功
if(!isLock){
// 4.3 如果失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 4.4 如果成功,根据id查询数据库
shop = getById(id);
// 模拟重建的延时——测试的时候打开
// Thread.sleep(200);
// 5. 不存在,返回错误
if(shop == null){
// 将空值写入redis——解决缓存穿透
stringRedisTemplate.opsForValue().set(redisKey,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6. 存在,写入redis
stringRedisTemplate.opsForValue().set(redisKey,JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e){
throw new RuntimeException(e);
}finally {
// 释放互斥锁
unLock(lockKey);
}
// 7. 返回
return shop;
}
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag); // 因为flag是封装类,而要求的返回值是基本数据类型,在返回的时候就会进行自动的拆箱,拆箱的时候会出现空指针
}
private void unLock(String key){
stringRedisTemplate.delete(key);
}
基于逻辑过期的方式解决缓存击穿问题
有个小问题,我们想要给存入redis的数据添加过期时间,但是我们的Shop实体类中又没有过期时间这个字段怎么办呢?
我们去给这个Shop实体添加过期时间字段可行吗?可行,但是对代码有侵入性,而且这个字段除了这里其他地方都用不到。
那怎么办?
我们可以声明一个RedisData的实体类,里面有一个过期时间的属性,让Shop继承这个实体类,Shop也就有了过期时间的属性了,但还是有一点点不好,还是需要修改源代码,需要修改Shop,有一定的侵入性,虽然也蛮好的。
还有一种方案:在RedisData中在声明一个Object的字段,把想要存储的数据放到Object中。
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
实际的项目肯定会有管理系统在后台点击,把热点数据提前缓存进redis,我们这里用一个单元测试完成这个功能。
先写一个缓存进redis的方法
public void saveShop2Redis(Long id, Long expireSeconds){
// 1. 查询店铺数据
Shop shop = getById(id);
// 2. 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
///3.写入redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
在编写一个单元测试
@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
private ShopServiceImpl shopService;
@Test
void testSaveShop() {
shopService.saveShop2Redis(1L, 10L);
}
}
下面我们完成基于逻辑过期的方式解决缓存击穿的商铺查询的代码
// 使用线程池来开辟新线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 解决缓存击穿(逻辑过期)的写法
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id){
String redisKey = RedisConstants.CACHE_SHOP_KEY + id;
// 1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(redisKey);
// 2. 判读是否存在
if(StrUtil.isBlank(shopJson)){
// 3. 不存在,直接返回
return null;
}
// 4. 命中需要判断过期时间,需要先把json反序列化位对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject jsonData = (JSONObject) redisData.getData(); // 如果不强转就是一个Object,但本质上是JSONObject,所以先转成JSONObject
Shop shop = JSONUtil.toBean(jsonData, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. 判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 5.1 未过期,直接返回店铺信息
return shop;
}
// 5.2 已过期,需要缓存重建
// 6. 缓存重建
// 6.1 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2 判断是否获取锁成功
if(isLock){
//6.3成功 开启独立线程实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
// 重建缓存
this.saveShop2Redis(id,20L);
}catch (Exception e){
} finally {
// 释放锁
unLock(lockKey);
}
});
}
// 6.4 返回过期的商铺信息
return shop;
}
缓存工具封装
把封装的代码放到CacheClient这个类中,并添加@Component注解,把这个bean交给Spring管理,封装的工具类如下:
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
// 用构造器注入
public CacheClient(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate = stringRedisTemplate;
}
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String redisKey = keyPrefix + id;
// 1. 从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(redisKey);
// 2. 判读是否存在
if(StrUtil.isNotBlank(json)){
// 3. 存在,直接返回
return JSONUtil.toBean(json, type);
}
// 命中的是否是空值
if(json != null){
// 返回一个错误信息
return null;
}
// 4. 不存在,根据id查询数据库——我们哪知道去查哪个数据库,只能调用者告诉我们,——函数式编程
R r = dbFallback.apply(id);
// 5. 不存在,返回错误
if(r == null){
// 将空值写入redis——解决缓存穿透
stringRedisTemplate.opsForValue().set(redisKey,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6. 存在,写入redis
this.set(redisKey, r, time, unit);
// 7. 返回
return r;
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public <R,ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String redisKey = keyPrefix + id;
// 1. 从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(redisKey);
// 2. 判读是否存在
if(StrUtil.isBlank(json)){
// 3. 不存在,直接返回
return null;
}
// 4. 命中需要判断过期时间,需要先把json反序列化位对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
JSONObject jsonData = (JSONObject) redisData.getData(); // 如果不强转就是一个Object,但本质上是JSONObject,所以先转成JSONObject
R r = JSONUtil.toBean(jsonData, type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5. 判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 5.1 未过期,直接返回店铺信息
return r;
}
// 5.2 已过期,需要缓存重建
// 6. 缓存重建
// 6.1 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2 判断是否获取锁成功
if(isLock){
//6.3成功 开启独立线程实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
// 重建缓存
// 先查数据库
R r1 = dbFallback.apply(id);
// 写入redis
this.setWithLogicalExpire(redisKey, r1, time, unit);
}catch (Exception e){
} finally {
// 释放锁
unLock(lockKey);
}
});
}
// 6.4 返回过期的商铺信息
return r;
}
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag); // 因为flag是封装类,而要求的返回值是基本数据类型,在返回的时候就会进行自动的拆箱,拆箱的时候会出现空指针
}
private void unLock(String key){
stringRedisTemplate.delete(key);
}
}
封装这个工具类,有很多的技巧要总结:
- 传递的参数和返回的数据类型要泛型
- 函数式编程:在封装queryWithPassThrough的时候,里面在redis查询不存在的时候,我们要去查询数据库,那查询数据库的代码,我们泛型传递的参数,调用哪个查询数据库的函数去查询数据库呢?这时要用函数式编程,把要用到的函数通过参数传递过来,有参数有返回值就用
Function<ID, R> dbFallback
,使用的时候直接R r = dbFallback.apply(id);
即可,调用这个工具方法的时候把具体的查询函数作为参数传进去。
那这些工具类在调用的时候又该怎么调用呢?
@Override
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = cacheClient.queryWithPassThrough(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期解决缓存击穿问题
Shop shop = cacheClient.queryWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
if(shop == null){
return Result.fail("店铺不存在!");
}
// 7. 返回
return Result.ok(shop);
}
那我们的缓存击穿想测试的话,还是得先用单元测试的方法,先往redis中写入点热点数据,现在就可以改进我们的单元测试代码
@SpringBootTest
class HmDianPingApplicationTests {
@Autowired
private CacheClient cacheClient;
@Test
void testSaveShop() {
Shop shop = shopService.getById(1L);
cacheClient.setWithLogicalExpire(RedisConstants.CACHE_SHOP_KEY + 1L,shop,10L, TimeUnit.SECONDS);
}
}