目录
五、实战篇-商户查询缓存
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.不存在直接返回 404return 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查询商铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
@Overridepublic 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.不存在直接返回 404return 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@Transactionalpublic 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.不存在直接返回 404return 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字符串中setBoolean 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);//不存在直接返回 404return 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下载和安装
参考
JMeter下载和安装_仰望_1的博客-CSDN博客
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 {
@Resourceprivate 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.不存在直接返回 404return 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
@Overridepublic 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);}
具体代码
redis实战篇-hmdp-短信登录-商铺缓存: 存放黑马点评中redis进行短信登录、商铺查询的代码 ,包括前端后后端