目录
五、实战篇-商户查询缓存
5.1 什么是缓存
5.2 添加Redis缓存
1、不添加redis时,数据查询的作用模型:
2、添加redis时,数据查询的作用模型:
3、业务流程图:编辑
4、代码实现
5、练习题
5.3 缓存更新策略
1、主动更新
2.Cache Aside Pattern(旁路缓存模式)
3、总结
4、给查询商铺的缓存添加超时剔除和主动更新的策略
5.4 缓存穿透
1、解决方案
2、解决商铺查询时,缓存穿透问题
3、总结
5.5 缓存雪崩
5.6 缓存击穿
1、解决方案
2、基于互斥锁方式解决缓存击穿问题
3、基于逻辑过期方式解决缓存击穿问题
4、JMeter下载和安装
5.7 缓存工具封装
五、实战篇-商户查询缓存
5.1 什么是缓存
缓存就是数据交换的缓冲区(称作Cache ),是存储数据的临时地方,一般读写性能较高
缓存的作用:
- 降低后端负载 ---直接访问缓存,返回数据
- 提高读写效率,降低响应时间---基于内存存储
缓存的成本:
- 数据一致性成本
- 代码维护成本----解决一致性问题代码复杂
- 运维成本-- 要保证高可用搭建集群
5.2 添加Redis缓存
1、不添加redis时,数据查询的作用模型:
2、添加redis时,数据查询的作用模型:
redis命中直接返回数据,未命中数据库查询返回数据,并且将数据缓存到redis中
3、业务流程图:
4、代码实现
public Result selectShopInfoById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.判断redis中是否存在该id的数据
String str = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(str)) {
//2.存在 直接返回数据
return Result.ok(JSONUtil.toBean(str,Shop.class));
}
//3.不存在 查询数据库是否存在
Shop shop = baseMapper.selectById(id);
if (StringUtils.isEmpty(shop)) {
//4.不存在直接返回 404
return Result.fail("店铺不存在");
}
//5.存在将数据存储在redis,然后返回
String shopJsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key,shopJsonStr);
return Result.ok(shop);
}
5、练习题
给店铺类型业务添加缓存
public Result queryOrderByAscList() {
//1.判断redis是否存在 商铺类型的缓存
Long size = stringRedisTemplate.opsForList().size(CACHE_SHOP_TYPE_KEY);
if (size > 0){
//2.存在 直接取出返回
List<String> range = stringRedisTemplate.opsForList().range(CACHE_SHOP_TYPE_KEY, 0, size);
List<ShopType> shopTypes = range.stream().map(item -> {
return JSONUtil.toBean(item,ShopType.class);
}).collect(Collectors.toList());
//.sorted(Comparator.comparing(ShopType::getSort).reversed())
return Result.ok(shopTypes);
}
//3.不存在,查询数据库
QueryWrapper<ShopType> queryWrapper = new QueryWrapper<>();
queryWrapper.orderByAsc("sort");
List<ShopType> shopTypes = baseMapper.selectList(queryWrapper);
if (shopTypes.size() <= 0){
//4.数据库不存在 直接返回错误
return Result.fail("数据不存在");
}
//5.数据库存在,将数据存储在缓存中然后返回
List<String> collect = shopTypes.stream().map(item -> {
return JSONUtil.toJsonStr(item);
}).collect(Collectors.toList());
stringRedisTemplate.opsForList().rightPushAll(CACHE_SHOP_TYPE_KEY,collect);
//设置过期时间是1天
stringRedisTemplate.expire(CACHE_SHOP_TYPE_KEY,CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
return Result.ok(shopTypes);
}
5.3 缓存更新策略
内存淘汰 | 超时剔除 | 主动更新 | |
说明 | 不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存 | 给缓存数据添加TTL过期时间,到期后自动删除缓存。下次查询时更新缓存 | 编写业务逻辑,在修改数据的同时,更新缓存 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
业务场景:
- 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
- 高一致性需求:主动更新,并以超时时间作为兜底方案。例如店铺详情查询的缓存
1、主动更新
- Cache Aside Pattern 有缓存的调用缓存,在更新数据库的同时更新缓存
- Read/Write Through Pattern 缓存和数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无序关心缓存一致性问题
- Write Behind Caching Pattern 调用者只操作缓存,由其他线程异步的将缓存数据持久化到数据库,保证最终一致
Redis的主动更新有三种常见的方案,包括: Cache Aside Pattern(旁路缓存模式):应用程序先从缓存中获取数据,如果缓存中不存在要访问的数据,则从数据库获取,再将数据写入缓存中。 优点:高效性能,减少数据库访问次数和负载,适合于对数据实时性要求不高的应用。 缺点:存在缓存和数据库数据不一致的问题,当读写并发量大时,可能会出现脏数据。 Read/Write Through Pattern(读写穿透模式):数据缓存和数据库相连,应用程序从缓存中获取数据,如缓存中没有相应数据,会通过缓存访问层查找数据。该层在未命中数据后,查询数据库。若命中则返回数据,并同步写入缓存中;否则返回空值或默认值。 优点:保证缓存、数据库数据一致性,并且在缓存失效的情况下,也可以避免因读操作而引起的数据库压力过大,同时也可以防止缓存数据与数据库之间的数据不一致。 缺点:每次访问数据都必须通过缓存去访问数据库,增加了结构的复杂性并降低了系统的效率。 Write Behind Caching Pattern(写回缓存模式):在进行写操作时,不直接将数据写入到数据库中,而是先将数据写入缓存中,待缓存达到一定条件后再批量同步到数据库中。 优点:提高了写操作的性能,并且降低了数据库负载,可以适用于写入比较频繁但读取全量较少的应用场景,同时也减少了与数据库的交互次数和延迟。 缺点:由于只在达到缓存阈值之后才进行同步,因此可能会存在缓存中未及时更新的数据,从而引起数据不一致性问题,同时当缓存重新启动时还需要从磁盘上读取数据进行恢复,增加了复杂度。 需要针对具体应用场景选择合适的主动更新方案,并结合Redis中提供的其他功能一起使用。
这里比较常用的:Cache Aside Pattern(旁路缓存模式)
2.Cache Aside Pattern(旁路缓存模式)
操作缓存和数据库有三个问题需要考虑:
- 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失败,查询时再更新缓存(比较符合)
- 如何保障缓存与数据库的操作同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
- 先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库(不推荐,更新数据库时间长,出现概率很大)第一个线程删除缓存后,在更新数据库的时候,还没更新成功的时候, 第二个线程访问了,发现缓存没有,查询数据库的数据,这是数据库的数据的旧的,将旧的数据更新到缓存中出现了不一致性
可以使用延时双删的策略,即先删除缓存,在更新数据库,然后休眠500毫秒在删除缓存,但是因为第二次延时时间,不确定性很大,一般不推荐使用
- 先操作数据库,再删除缓存(推荐,相较于上一种出现概率很低)因为某种原因,缓存找中数据没了,线程1访问的时候发现没有缓存,查询数据库得到旧数据,要进行写入缓存操作时 线程2进行了更新数据库,删除缓存,然后线程1更新了缓存为旧数据
3、总结
缓存更新策略的最佳实践方案:
- 低一致性需求:使用Redis自带的内存淘汰机制
- 高一致性需求:主动更新,并以超时剔除作为兜底方案
- 读操作 Cache Aside Pattern(旁路缓存模式):
- 缓存未命则直接返回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作:
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
4、给查询商铺的缓存添加超时剔除和主动更新的策略
修改ShopController中的业务逻辑,满足下面的需求:
- 根据id查询商铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
@Override
public Result selectShopInfoById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.判断redis中是否存在该id的数据
String str = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(str)) {
//2.存在 直接返回数据
return Result.ok(JSONUtil.toBean(str,Shop.class));
}
//3.不存在 查询数据库是否存在
Shop shop = baseMapper.selectById(id);
if (StringUtils.isEmpty(shop)) {
//4.不存在直接返回 404
return Result.fail("店铺不存在");
}
//5.存在将数据存储在redis,然后返回
String shopJsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key,shopJsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
- 根据id修改店铺时,先修改数据库,再删除缓存
@Override
@Transactional
public Result updateShopById(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
//修改数据库
baseMapper.updateById(shop);
//删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY+shop.getId());
return Result.ok();
}
5.4 缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
1、解决方案
常见的解决方案有两种:
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗 ---设置ttl过期时间
- 可能造成短期的不一致 ----插入数据的时候,更新缓存将null的覆盖
- 布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:
- 实现复杂
- 存在误判可能---布隆过滤器是居于hash算法,存在哈希碰撞问题
判断不存在的肯定不存在,判断存在的时候,可能不存在
2、解决商铺查询时,缓存穿透问题
public Result selectShopInfoById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.判断redis中是否存在该id的数据
String str = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(str)) {
//2.存在 直接返回数据
return Result.ok(JSONUtil.toBean(str,Shop.class));
}
//上面判断后 执行到这句的时候,只能是null或者空字符串
if (str != null) {
return Result.fail("店铺不存在");
}
//3.不存在 查询数据库是否存在
Shop shop = baseMapper.selectById(id);
if (StringUtils.isEmpty(shop)) {
//将null存入到redis中
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//4.不存在直接返回 404
return Result.fail("店铺不存在");
}
//5.存在将数据存储在redis,然后返回
String shopJsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key,shopJsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
3、总结
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存和数据库汇总都不存在,不断发起这样的请求给数据库带来巨大压力
缓存穿透的解决方案有那些?
- 缓存null值
- 布隆过滤器
- 增强id的复杂度,避免被猜测id规律,然后做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
5.5 缓存雪崩
缓存雪崩是指同一时段大量的缓存key同时失效或者Redis服务五宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
5.6 缓存击穿
缓存击穿问题也叫作热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大冲击。
1、解决方案
互斥锁
逻辑过期
比较
解决方案 | 优点 | 缺点 |
互斥锁 | 没有额外的内存消耗 保证了一致性 实现简单 | 线程需要等待,性能受影响 可能有死锁的情况 |
逻辑过期 | 线程无序等待,性能好 | 不保证一致性 存在内存消耗 实现复杂 |
2、基于互斥锁方式解决缓存击穿问题
需求:根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题
private Result cacheShopWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
Shop shop = null;
//1.判断redis中是否存在该id的数据
String str = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(str)) {
//2.存在 直接返回数据
return Result.ok(JSONUtil.toBean(str,Shop.class));
}
//上面判断后 执行到这句的时候,只能是null或者空字符串
if (str != null) {
return Result.fail("店铺不存在");
}
String lockKey = LOCK_SHOP_KEY + id;
try {
//3.不存在 先尝试获取互斥锁 利用redis中string字符串中set
Boolean flagBoolean = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
boolean flag = BooleanUtil.isTrue(flagBoolean);
//4.获取锁失败
if (!flag) {
//获取锁失败休眠一会
Thread.sleep(100);
//然后进行重试 ---递归
return selectShopInfoById(id);
}
//5.如果获取锁成功 查询数据库
shop = baseMapper.selectById(id);
//模拟重建延迟
Thread.sleep(200);
//如果数据库中没有数据
if (StringUtils.isEmpty(shop)) {
//将null存入到redis中
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//不存在直接返回 404
return Result.fail("店铺不存在");
}
//.存在将数据存储在redis,然后返回
String shopJsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key,shopJsonStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
//6.释放锁
stringRedisTemplate.delete(lockKey);
}
return Result.ok(shop);
}
3、基于逻辑过期方式解决缓存击穿问题
需求:根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
//弄一个线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//逻辑过期
private Result cacheShopWithLogicTTL(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.判断redis中是否存在该id的数据
String str = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(str)) {
//2.不存在 直接返回空
return Result.fail("商铺信息为空");
}
//3.存在 判断缓存是否过期 逻辑时间
RedisData redisData = JSONUtil.toBean(str, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
JSONObject data = (JSONObject)redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
if (expireTime.isAfter(LocalDateTime.now())) {
//4.未过期 直接返回商铺信息
return Result.ok(shop);
}
//5.过期了尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
boolean flag = BooleanUtil.isTrue(aBoolean);
if (!flag) {
//6.如果未获取到锁 直接返回旧数据
return Result.ok(shop);
}
//7.成功获取到锁 开启一个独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
//重构缓存
try {
saveShopToRedis(id,30L);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
stringRedisTemplate.delete(lockKey);
}
//释放锁
});
//8.返回旧的数据
return Result.ok(shop);
}
4、JMeter下载和安装
1.下载
2.解压
3.设置环境变量
4.path中设置
5.启动
双击打开bin中的jemter.bat
就自动启动了
6.设置中文
7.进行配置
8、输入参数,测试
5.7 缓存工具封装
基于StingRedisTemplate封装一个缓存工具类,满足下列需求:
方法1:将任意Java对象序列化为json并存储在String类型的key中,并且可设置TTL过期时间
方法2:将任意Java对象序列化为json并存储在String类型的key中,并在可以设计逻辑过期时间,用于处理缓存击穿问题
方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
封装类:CacheClient
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 lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
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.*;
/**
* @packageName: com.hmdp.utils
* @author: winter
* @date: 2023/4/25 8:55
* @version: 1.0
* @email 1660420659@qq.com
* @description: 封装Redis工具类
*/
@Slf4j
@Component
public class CacheClient {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 将任意Java对象序列化为json并存储在String类型的key中,
* 并且可设置TTL过期时间
* @param key key
* @param obj 存储对象
* @param timeTTL 过期时间
* @param timeUnit 单位
*/
public void set(String key, Object obj, Long timeTTL, TimeUnit timeUnit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(obj),timeTTL,timeUnit);
}
/**
* 将任意Java对象序列化为json并存储在String类型的key中,
* 并在可以设计逻辑过期时间,用于处理缓存击穿问题
* @param key key
* @param obj 存储对象
* @param timeTTL 过期时间
* @param timeUnit 单位
*/
public void setWithLogicalExpire(String key,Object obj,Long timeTTL, TimeUnit timeUnit) {
RedisData redisData = new RedisData();
redisData.setData(obj);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(timeTTL)));
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
/**
* 通过key获取字符串
* @param key
* @return
*/
public String get(String key) {
String str = stringRedisTemplate.opsForValue().get(key);
return str;
}
/**
* 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
* @param key key值
* @param id id值
* @param tClass 类型
* @param function 手写方法
* @param timeTTL 过期时间
* @param timeUnit 时间单位
* @param <T> 对象类型
* @param <ID> id类型
* @return 对象
*/
public <T,ID> T getWithPassThrough(String key,ID id, Class<T> tClass, Function<ID,T> function,Long timeTTL, TimeUnit timeUnit) {
//1.判断redis中是否存在该id的数据
String str = get(key);
if (StrUtil.isNotBlank(str)) {
//2.存在 直接返回数据
return JSONUtil.toBean(str,tClass);
}
//上面判断后 执行到这句的时候,只能是null或者空字符串
if (str != null) {
return null;
}
//3.不存在 查询数据库是否存在
T shop = function.apply(id);
if (StringUtils.isEmpty(shop)) {
//将null存入到redis中
set(key,"",CACHE_NULL_TTL,timeUnit);
//4.不存在直接返回 404
return null;
}
//5.存在将数据存储在redis,然后返回
set(key,shop,timeTTL,timeUnit);
return shop;
}
//弄一个线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
* @param key key
* @param id id
* @param tClass RedisDate中存储对象类型
* @param function 方法
* @param timeTTL 过期时间
* @param timeUnit 过期类型
* @param <T> 对象类型
* @param <ID> id类型
* @return 对象
*/
public <T,ID> T getWithLogicalExpire(String key,ID id, Class<T> tClass, Function<ID,T> function,Long timeTTL, TimeUnit timeUnit) {
//1.判断redis中是否存在该id的数据
String str = get(key);
if (StrUtil.isBlank(str)) {
//2.不存在 直接返回空
return null;
}
//3.存在 判断缓存是否过期 逻辑时间
RedisData redisData = JSONUtil.toBean(str, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
JSONObject data = (JSONObject)redisData.getData();
T shop = JSONUtil.toBean(data, tClass);
if (expireTime.isAfter(LocalDateTime.now())) {
//4.未过期 直接返回商铺信息
return shop;
}
//5.过期了尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
boolean flag = BooleanUtil.isTrue(aBoolean);
if (!flag) {
//6.如果未获取到锁 直接返回旧数据
return shop;
}
//7.成功获取到锁 开启一个独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
//重构缓存
try {
//查询店铺数据
T tshop = function.apply(id);
//模拟
Thread.sleep(200);
//封装逻辑过期时间
setWithLogicalExpire(key,tshop,timeTTL,timeUnit);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
stringRedisTemplate.delete(lockKey);
}
//释放锁
});
//8.返回旧的数据
return shop;
}
}
测试:ShopServiceImpl
@Override
public Result selectShopInfoById(Long id) throws InterruptedException {
//1.缓存穿透 存储null值解决方案
// return cacheShopWithPassThrough(id);
//2.缓存击穿 --互斥锁解决方案
// return cacheShopWithMutex(id);
//3.缓存击穿 ---逻辑过期解决方案
// return cacheShopWithLogicTTL(id);
//4.使用封装类中解决缓存穿透 存储null值办法
// Shop shop = cacheClient.getWithPassThrough(CACHE_SHOP_KEY + id, id, Shop.class
// ,this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// return Result.ok(shop);
//5.使用封装类中解决缓存击穿 逻辑过期方式
//为了测试 将逻辑过期时间设置短一点
Shop shop = cacheClient.getWithLogicalExpire(CACHE_SHOP_KEY + id, id, Shop.class
, this::getById, 10L, TimeUnit.SECONDS);
return Result.ok(shop);
}