一、商户查询缓存要实现的内容
1、添加Redis缓存
2、缓存更新策略
3、缓存穿透
4、缓存雪崩
5、缓存击穿
6、缓存工具封装
二、根据id查询商品时添加Redis缓存
2.1 根据id查询商铺缓存流程
2.2 代码实现
@Override
public Result queryById(Long id) {
// 1、从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY+id);
// 2、判断是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3、存在 直接返回
//将json字符串反序列化成java对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4、不存在,根据id查询数据库
Shop shop = getById(id);
if(shop==null){
// 5、不存在:返回错误
return Result.fail("店铺不存在");
}
// 6、存在,写入redis缓存
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop));
// 7、返回
return Result.ok(shop);
}
2.3 知识点
1、java对象与json对象的转换 ---- 利用 hutool 中的 JSONUtil 工具类
//将json字符串反序列化成java对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
//将java对象序列化成json对象
JSONUtil.toJsonStr(shop)
三、缓存更新策略
缓存更新策略有三种:内存淘汰、超时剔除、主动更新。
1、内存淘汰: 不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据。下次查询时更新缓存。
一致性 :差 维护成本:无
2、超时剔除:给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存。
一致性 :一般 维护成本:低
3、主动更新:编写业务逻辑,在修改数据库的同时,更新缓存。
一致性:好 维护成本:高
业务场景:
低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
3.1 主动更新策略
3.1.1 常用策略
由缓存的调用者,在更新数据库的同时更新缓存。
3.1.2 操作缓存和数据库时有三个问题需要考虑
1、删除缓存还是更新缓存?
(1)更新缓存:每次更新数据库都更新缓存,无效写操作较多
(2)删除缓存:更新数据库时让缓存失效,查询时再更新缓存(常用)
2、如何保证缓存与数据库的操作的同时成功或失败?
(1)单体系统,将缓存与数据库操作放在一个事务
(2)分布式系统,利用TCC等分布式事务方案
3、先操作缓存还是先操作数据库
(1)先删除缓存,再操作数据库
(2)先操作数据库,再删除缓存(常用)
3.2 缓存更新策略的最佳实践方案
3.3 给查询的缓存添加超时剔除和主动更新的策略
1、需求:
① 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写到缓存,并设置超时时间。
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
② 根据id修改店铺时,先修改数据库,再删除缓存。
@Override
@Transactional
public Result updateById1(Shop shop) {
if(shop.getId() == null){
return Result.fail("店铺id不能为空");
}
// 1、更新数据库
updateById(shop);
// 2、删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+shop.getId());
return Result.ok();
}
3.4 知识点
1、删除Redis中的缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+shop.getId());
四、缓存穿透
4.1 缓存穿透定义 和 解决方案
1、解决缓存空对象的缺点的方式:每次新增数据时,主动的将数据插入到redis缓存中,覆盖之前的null。
2、布隆过滤器中存放的是一个个字节,并非真正的数据。
4.2 利用 缓存空对象 解决缓存穿透
@Override
public Result queryById(Long id) {
// 1、从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY+id);
// 2、判断是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3、存在 且 shopJson不为空(即redis中存入的不是空值)
//返回店铺信息
//将json字符串反序列化成java对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// redis中有key 但是对应的value为空
if(shopJson == null){
// 返回一个错误信息
return Result.fail("店铺不存在");
}
// 4、不存在,根据id查询数据库
Shop shop = getById(id);
if(shop==null){
// 5、不存在:将空值写入redis中
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
// 6、存在,写入redis缓存
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7、返回
return Result.ok(shop);
}
4.3 总结
4.4 知识点
1、判断String类型是否为空:StrUtil.isNotBlank(shopJson)。当shopJson为null、""、" "是均返回false。
五、缓存雪崩
5.1 缓存雪崩定义 和 解决方案
六、缓存击穿
6.1 缓存击穿定义 和 解决方案
6.2 互斥锁 和 逻辑过期 比较
6.3 互斥锁 和 逻辑过期 优缺点
6.4 基于互斥锁方式解决缓存击穿问题
1、需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题
2、注意:这里的互斥锁不是我们平常用的synolize锁或者lock锁:这种锁,我们拿到了可以执行代码,没有拿到则继续等待。但我们这个,我们拿到锁和拿不到锁的逻辑是需要我们自己定义的。
3、自定义互斥锁:利用redis的setnx属性。
获取锁
// 获取锁
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
//注意:不要直接将 Boolean 的值返回,因为这样返回它会进行拆箱,在这个拆箱的过程中,可能会出现空指针
return BooleanUtil.isTrue(flag);
}
释放锁
// 释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
利用互斥锁解决缓存击穿
// 利用 互斥锁解决 缓存击穿问题
public Shop queryWithMutex(Long id){
// 1、从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY+id);
// 2、判断是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3、存在 且 shopJson不为空(即redis中存入的不是空值)
//返回店铺信息
//将json字符串反序列化成java对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// redis中有key 但是对应的value为空
if(shopJson == null){
// 返回一个错误信息
return null;
}
// 4、实现缓存重建
// 4.1、获取互斥锁
String lockKey = LOCK_SHOP_KEY + 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);
if(shop==null){
// 5、不存在:将空值写入redis中
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
// 6、存在,写入redis缓存
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unLock(lockKey);
}
// 7、返回
return shop;
}
6.5 基于逻辑过期方式解决缓存击穿问题
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
1、将数据和逻辑过期时间保存到redis中
private 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(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
}
2、编写测试类测试saveShop2Redis()方法是否可行
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private ShopServiceImpl shopService;
@Test
void testSaveShop(){
shopService.saveShop2Redis(1L,10L);
}
}
3、编写逻辑过期代码
// 创建线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// 利用 逻辑过期 解决 缓存击穿问题
public Shop queryWithLogicalExpire(Long id){
// 1、从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);
// 2、判断是否存在 -- 缓存是否命中
if(StrUtil.isBlank(shopJson)){
// 未命中,直接返回null
return null;
}
// 3、命中 -- 需要判断逻辑时间是否过期
// 现将json反序列化为对象,获得里面的过期时间
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
//获取商铺信息
JSONObject data = (JSONObject)redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//判断逻辑时间是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 3.1、未过期,直接返回商品信息
return shop;
}
// 3.2、 过期、需要缓存重建
// 4、缓存重建
// 4.1、获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
// 4.2、判断是否获取锁成功
boolean isLock = tryLock(lockKey);
// 4.2、判断是否获取成功
if(isLock){
//TODO 4.3、成功 开启独立线程,实现缓存重建 --- 利用线程池
CACHE_REBUILD_EXECUTOR.submit(()->{
//缓存重建
try {
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException();
} finally {
//释放锁
unLock(lockKey);
}
});
}
// 4.4、返回过期的商铺信息
return shop;
}
6.6 知识点
1、 redis的setnx属性。对于相同的key,value只能保存一次,之后不能修改。可以用它来设计自定义锁
2、对于返回值boolean类型的数据返回时,不要直接返回Boolean(其封装类),因为这样返回它会进行拆箱,在这个拆箱的过程中,可能会出现空指针。如tryLock()方法。
3、idea生成try-catch快捷键:alt+ctrl+l
4、lamdom表达式
5、 创建线程池,并在线程池中开启独立线程,实现缓存重建。
CACHE_REBUILD_EXECUTOR.submit(()->{
//缓存重建
try {
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException();
} finally {
//释放锁
unLock(lockKey);
}
});
七、缓存工具封装
7.1 说明
目前我们暂时只封装4种方法:
1、将任意对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间。
2、将任意对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题。
3、根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题。
4、根据指定的key查询缓存,并反序列化为指定类型,利用逻辑过期的方式解决缓存击穿问题。
package com.hmdp.utils;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.entity.Shop;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static com.hmdp.utils.RedisConstants.CACHE_NULL_TTL;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;
import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 将任意对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间。
*
* **/
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
/**
* 将任意对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题。
*
* **/
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)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题。
* @param keyPrefix redis中每个key的前缀
* @param type
* */
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
// 1、从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2、判断是否存在
if(StrUtil.isNotBlank(json)){
// 3、存在 且 json不为空(即redis中存入的不是空值)
//将json字符串反序列化成java对象
return JSONUtil.toBean(json, type);
}
// redis中有key 但是对应的value为空
if(json == null){
// 返回一个错误信息
return null;
}
// 4、不存在,根据id查询数据库
R r = dbFallback.apply(id);
if(r==null){
// 5、不存在:将空值写入redis中
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
// 6、存在,写入redis缓存
this.set(key,r,time,unit);
// 7、返回
return r;
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,利用逻辑过期的方式解决缓存击穿问题
* @param keyPrefix redis中每个key的前缀
* @param type
*
* @param Function<ID,R> 中的ID表示参数、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 key = keyPrefix + id;
// 1、从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);
// 2、判断是否存在 -- 缓存是否命中
if(StrUtil.isBlank(json)){
// 未命中,直接返回null
return null;
}
// 3、命中 -- 需要判断逻辑时间是否过期
// 现将json反序列化为对象,获得里面的过期时间
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
//获取商铺信息
JSONObject data = (JSONObject)redisData.getData();
R r = JSONUtil.toBean(data, type);
LocalDateTime expireTime = redisData.getExpireTime();
//判断逻辑时间是否过期
if(expireTime.isAfter(LocalDateTime.now())){
// 3.1、未过期,直接返回商品信息
return r;
}
// 3.2、 过期、需要缓存重建
// 4、缓存重建
// 4.1、获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
// 4.2、判断是否获取锁成功
boolean isLock = tryLock(lockKey);
// 4.2、判断是否获取成功
if(isLock){
//TODO 4.3、成功 开启独立线程,实现缓存重建 --- 利用线程池
CACHE_REBUILD_EXECUTOR.submit(()->{
//缓存重建
try {
// 先查数据库
R r1 = dbFallback.apply(id);
// 写入redis
this.setWithLogicalExpire(key,r1,time,unit);
} catch (Exception e) {
throw new RuntimeException();
} finally {
//释放锁
unLock(lockKey);
}
});
}
// 4.4、返回过期的商铺信息
return r;
}
// 获取锁
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
//注意:不要直接将 Boolean 的值返回,因为这样返回它会进行拆箱,在这个拆箱的过程中,可能会出现空指针
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
}
调用工具类
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 利用 缓存空对象解决 缓存穿透问题
// Shop shop = queryWithPassThrough(id);
//Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY,id,Shop.class,id2->getById(id2),CACHE_SHOP_TTL,TimeUnit.MINUTES);
// 利用互斥锁方式解决缓存击穿
// Shop shop = queryWithMutex(id);
// 利用逻辑过期方式解决缓存击穿
// Shop shop = queryWithLogicalExpire(id);
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY,id,Shop.class,id2->getById(id2),CACHE_SHOP_TTL,TimeUnit.MINUTES);
// 7、返回
return Result.ok(shop);
}
7.2 知识类
1、泛型 :查询时由于返回值的类型和ID的类型是不确定的,因此返回值的类型需要使用泛型。【<R,ID> R】【ID id,Class<R> type】
2、函数式编程 : 我们封装工具类的时候,涉及到了数据库查询(不同的业务,查询自己的数据库),故在工具类中我们并不知道数据库如何进行查询的,因此利用参数由调用者告诉我们如何查。查数据库是一个函数,因此传入一个函数。【Function<ID,R> dbFallback】
3、lamden表达式 :