👨🎓作者简介:一位大四、研0学生,正在努力准备大四暑假的实习
🌌上期文章:Redis:原理速成+项目实战——Redis实战5(互斥锁、逻辑过期解决缓存击穿问题)
📚订阅专栏:Redis:原理速成+项目实战
希望文章对你们有所帮助
如果要看懂这篇文章的代码,请提前了解一下函数式编程,了解一下lambda表达式以及this::getById的写法。
在之前几个文章已经讲解了如何给我们的项目增加Redis缓存,并进行了Redis缓存的最佳实践,并且针对Redis缓存会存在的三个问题(缓存穿透、缓存雪崩、缓存击穿)进行了解决:
Redis缓存最佳实践
解决Redis缓存穿透、雪崩、击穿
互斥锁、逻辑过期解决缓存击穿问题
解决缓存击穿和缓存穿透的代码都还是有点复杂的,但是我们可以发现他们的方法的通用性,因此我们可以对其进行封装,封装成工具类以便于后续的开发。并进行一个简单的总结。
缓存工具封装
我们要基于StringRedisTemplate封装一个缓存工具类,可以满足以下的需求:
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 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.*;
@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){
//用JSONUtil将其序列化为json对象,再以String形式进行存储
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
//set并带上逻辑过期
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
RedisData redisData = new RedisData();
//注入基本的value
redisData.setData(value);
//注入逻辑过期时间,也就是当前时间+time,注意要把时间转换成秒
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
//这里写入Redis的序列是redisData序列化后的
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 解决缓存穿透,注意这里要做成泛型的
* 我们需要的参数是形成key的前缀,id,以及传过来的对象
* 这里的id也同样是泛型,因为没办法保证用户传过来的类型是Int还是Long
* 这里如果不存在的话,我们就需要查询数据库,通用的函数根本不知道从数据库的哪张表进行查询,因此我们需要自行传入
* Function<T, R>表示有参数有返回值的类型,“dbFallback”表示数据库降级逻辑,代表查询Redis失败后要去做的后备方案
*/
public <R, ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
//从Redis中查询序列化的json字符串
String json = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if (StrUtil.isNotBlank(json)) {
//存在,直接返回
return JSONUtil.toBean(json, type);
}
//注意isNotBlank会忽略null,所以还要看命中的是否是null
if (json != null) {
return null;
}
//不存在,根据id查询数据库
R r = dbFallback.apply(id);
//不存在,返回错误
if (r == null){
//存一个null到Redis中
//这种没用的信息,TTL没必要设置太长了,这里我设置成了2min
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
return null;
}
//存在,写入Redis,直接用set方法
this.set(key, r, time, unit);
//返回
return r;
}
//逻辑过期解决缓存击穿问题
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public <ID, R> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type,Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = CACHE_SHOP_KEY + id;
//从Redis中查询商铺缓存,存储对象可以用String或者Hash,这里用String
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if (StrUtil.isBlank(shopJson)) {
//未命中,直接返回
return null;
}
//命中,先把json反序列化成对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();//注意我们获取到了data信息以后,返回的会是一个JSONObject的格式
R r = JSONUtil.toBean(data, type);
LocalDateTime expireTime = redisData.getExpireTime();
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//未过期,直接返回店铺信息
return r;
}
//已过期,缓存重建
//获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;//LOCK_SHOP_KEY="lock:shop:"
boolean isLock = tryLock(lockKey);
//判断是否获取锁成功
if (isLock){
//成功获取锁,开启独立线程来实现缓存重建,用线程池来做
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建缓存
//查询数据库,这里依旧使用函数式编程
R r1 = dbFallback.apply(id);
//写入Redis
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
//释放锁
unlock(lockKey);
}
});
}
//没有成功获取,直接返回过期信息
return r;
}
private boolean tryLock(String key){
//opsForValue里面没有真正的setNx,而是setIfAbsent,表示如果不存在就执行set
//值就随便设定一下,重点是要获取到锁,但是设定了TTL为10s
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
/**
* 如果是直接返回flag,可能会有拆箱操作,造成空指针,需要用BooleanUtil工具类
* 因为Boolean不是基本类型的boolean,是boolean的封装类
*/
return BooleanUtil.isTrue(flag);
}
private void unlock(String key){
stringRedisTemplate.delete(key);
}
}
工具调用方式
我们之前对于店铺信息的查询方式就可以修改成如下形式:
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
//缓存穿透的代码调用
//这里的this::getById其实就是lambda表达式:id2->getById(id2),写高级点
//Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//逻辑过期解决缓存击穿
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在");
}
//返回
return Result.ok(shop);
}
相应的验证方式大家可以借鉴之前的文章,在这里我已经是完成了验证了,证明代码调通,没有发生线程安全问题。
缓存总结
认识缓存
问题 | 答案 |
---|---|
什么是缓存 | 一种具备高效读写能力的数据暂存区域 |
缓存的作用 | 降低后端负载;提高服务读写响应速度 |
缓存的成本 | 开发成本、运维成本、一致性问题 |
我们为商铺查询以及商品类型的查询分别添加了缓存,基本的缓存作用模型很容易理解,需要注意一下相应的String与Hash类型的应用。当然之前的文章中我还用了List类型实现了商品类型查询的缓存。
缓存更新策略
1、三种策略
(1)内存淘汰:Redis自带的内存淘汰机制
(2)过期淘汰:利用expire命令给数据设置过期时间
(3)主动更新:主动完成数据库与缓存的同时更新
2、策略选择
(1)低一致性要求:内存淘汰或过期淘汰
(2)高一致性要求:主动更新为主,过期淘汰兜底
3、主动更新的方案有三种,我们选用的是最常用的Cache Aside Pattern,由缓存的调用者在更新数据库同时更新缓存。
4、Cache Aside Pattern的模式选择
问题 | 选择 |
---|---|
更新缓存还是删除缓存? | 删除缓存 |
先操作数据库还是缓存? | 先操作数据库 |
如何确保数据库与缓存操作的原子性? | 单体系统(事务机制)或分布式系统(分布式事务机制) |
总结:先操作数据库、再删除缓存,并保证原子性。
5、最佳实践:Redis缓存最佳实践
(1)查询
①查缓存
②命中,直接返回
③未命中,查询数据库
④将数据库数据写入缓存
⑤返回结果
(2)修改(确保原子性)
①修改数据库
②删除缓存
缓存穿透
1、产生原因:客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
2、解决方案:
(1)缓存空对象(我们的选择)
(2)布隆过滤
(3)其他:做好数据的基础格式校验、加强用户权限校验;做好热点参数的限流
缓存雪崩
1、产生原因:同一时段有大量的缓存key失效,或者Redis服务宕机,导致大量请求到达数据库,带来的巨大压力。
2、解决方案:
(1)给不同的key的TTL添加随机值
(2)利用Redis集群提高服务可用性
(3)给缓存业务添加降级限流策略
(4)给业务添加多级缓存
缓存击穿
1、产生原因:也叫作热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
2、解决方案:
(1)互斥锁
(2)逻辑过期
上面三个问题的解决:
解决Redis缓存穿透、雪崩、击穿
互斥锁、逻辑过期解决缓存击穿问题