redis缓存击穿和缓存穿透的封装
(来源黑马redis)
一、首先是互斥锁
bash
//拿到锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);//相当于setnx
return BooleanUtil.isTrue(flag);//判断是否成功,因为直接返回可能会导致拆箱
}
//释放锁
private void unlock(String key){
stringRedisTemplate.delete(key);
}
解释:
tryLock 方法
这个方法尝试获取一个分布式锁,使用 Redis 的 setIfAbsent 方法来实现。
1.方法签名: private boolean tryLock(String key)
key: 锁的键名,用于在 Redis 中标识这个锁。
返回值: 如果成功获取锁,则返回 true;否则返回 false。
2.方法内部逻辑:
使用 stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS) 尝试在 Redis 中设置一个键值对。
key: 锁的键名。
"1": 锁的值,这里只是一个占位符,表示该键已被锁定。
10, TimeUnit.SECONDS: 设置锁的过期时间为 10 秒。这是为了防止死锁,即某个进程获取了锁但未能正确释放,导致其他进程无法获取锁。
setIfAbsent 方法相当于 Redis 的 SETNX 命令,它会在键不存在时设置键值对,并返回 true;如果键已存在,则不做任何操作并返回 false。
BooleanUtil.isTrue(flag) 用于判断 setIfAbsent 的返回值。这里进行了安全的布尔值判断,避免了自动拆箱可能引发的 NullPointerException。
unlock 方法
这个方法用于释放之前获取的分布式锁。
方法签名: private void unlock(String key)
key: 需要释放的锁的键名。
方法内部逻辑:
使用 stringRedisTemplate.delete(key) 来删除 Redis 中的锁键。这相当于释放了锁,使得其他进程可以尝试获取该锁。
这个方法没有返回值,因为它只是简单地执行删除操作。
注意事项:
在实际的生产环境中,你可能需要处理更多的边界情况和异常,例如网络错误、Redis 服务器故障等。
为了防止误删其他进程的锁,你可能需要在删除前验证锁的值是否与你设置的值相匹配。
在高并发的场景下,你可能需要考虑使用更复杂的锁机制,例如 RedLock 算法,以提高锁的可靠性和安全性。
在某些情况下,你可能需要处理锁续期的问题,特别是当锁的持有时间可能超过你最初设置的过期时间时。这可以通过定时任务或后台线程来实现。
二、封装为工具类
bash
@Slf4j
@Component
public class CaCheClient {
private StringRedisTemplate stringRedisTemplate;//注入,操作redis。
/**
* 构造函数,用于初始化StringRedisTemplate。
*/
public CaCheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 向Redis中设置键值对,并指定过期时间。
*
* @param key 键
* @param value 值
* @param time 过期时间
* @param unit 时间单位
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value) , time, unit);
}
/**
* 使用逻辑过期方式向Redis中设置键值对。
*
* @param key 键
* @param value 值
* @param time 过期时间
* @param 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));
}
/**
* 使用互斥锁实现缓存穿透处理的逻辑。
*
* @param keyPreFix 键前缀
* @param id 唯一标识符
* @param type 返回对象的类型
* @param dbFallback 当缓存不存在时,从数据库获取数据的函数(因为有参数有返回值)
* @param time 缓存过期时间
* @param unit 时间单位
* @param <R> 返回对象的类型
* @param <ID> 唯一标识符的类型
* @return 返回查询到的对象
*/
public <R,ID> R queryWithPassThrough(
String keyPreFix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit){//我们不知道返回什么类型,所以定义泛型<R> R,
String key =keyPreFix + id; //定义一个key,包装id和一个字段名
//1.从redis中查询商铺缓存
String Json = stringRedisTemplate.opsForValue().get(key);//存的是一个对象可以用哈希,也可以用String,这里用hash演示
//2.判断是否存在
if (StrUtil.isNotBlank(Json)) {//isNotBlank只有里面有字符的时候才是true,null和空或者/n都为空false
//3.存在,直接返回
//把JSON对象转化为shop对象
return JSONUtil.toBean(Json, type);
}
//判断命中的是否是空值
if (Json != null) {
//返回一个错误信息
return null;
}//防止缓存穿透:缓存穿透是指恶意请求或者不存在的数据请求导致大量的查询直接访问数据库,而绕过了缓存层。在这段代码中,如果 Json 不为空(即缓存中存在值),但其实际内容为null,则这可能是一个早前缓存的结果,数据库中确实没有对应数据。在这种情况下,直接返回 null,避免继续查询数据库,从而节省资源。
//4.不存在,根据id查询数据库
// R r = getById(id);//因为我们这里,需要去查询一个有参有返回值的函数,所以我们在上面定义Function(难点)
R r = dbFallback.apply(id);//Function<ID,R> dbFallback,这里传入id,返回R
//5.不存在,返回错误
if (r == null) {
//将空值,写入redis
stringRedisTemplate.opsForValue().set(key,"null",2L, TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.存在,把数据写入redis,
this.set(key, r, time, unit);
//7.然后返回。
return r;
}
/**
* 创建一个线程池
*/
private static final ExecutorService CACHE_BUILDER_EXECUTOR = Executors.newFixedThreadPool(10);//获得十个线程
/**
* 使用逻辑过期解决缓存击穿问题的查询方法。
*
* @param keyPreFix 键前缀
* @param id 唯一标识符
* @param type 返回对象的类型
* @param dbFallback 当缓存不存在或过期时,从数据库获取数据的函数
* @param time 缓存过期时间
* @param unit 时间单位
* @param <R> 返回对象的类型
* @param <ID> 唯一标识符的类型
* @return 返回查询到的对象
*/
public <R,ID> R queryWithLogicalExpire(
String keyPreFix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit){
String key =keyPreFix + id; //定义一个key,包装id和一个字段名
//1.从redis中查询商铺缓存
String Json = stringRedisTemplate.opsForValue().get(key);//存的是一个对象可以用哈希,也可以用String,这里用hash演示
//2.判断是否存在
if (StrUtil.isBlank(Json)) {//isNotBlank只有里面有字符的时候才是true,null和空或者/n都为空false
//3.存在,直接返回
// 如果缓存中的值为空(包括 null、空字符串或者只包含空白字符),则直接返回 null。
// 这是为了避免缓存穿透,即使缓存中有值但实际上没有有效数据时,也不去访问数据库,而是直接返回空结果。
//把JSON对象转化为shop对象
return null;
}
//RedisData里面有两个参数: private LocalDateTime expireTime;private Object data;data用来存储数据
//4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(Json, RedisData.class);
Object data = redisData.getData();
R r = JSONUtil.toBean((JSONObject) data,type);
LocalDateTime expireTime = redisData.getExpireTime();
//5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {//意思是,过期时间(expireTime.).是不是在当前(LocalDateTime.now())时间之后(.isAfter)
//5.1 未过期,直接返回店铺信息
return r;
}
//5.2 过期了,需要缓存重建
//6 缓存重建
//6.1获取互斥锁
String lockKey = "lock:shop:" + id; //定义一个key,包装id和一个字段名
boolean isLock = tryLock(lockKey);
//6.2判断是否获取锁成功
if (isLock){
//6.3 成功,开启独立线程,实现缓存重建
CACHE_BUILDER_EXECUTOR.submit(() -> {
try {
//重建缓存
//1.
R r1 = dbFallback.apply(id);//apply(id) 方法: dbFallback.apply(id) 调用了函数式接口 dbFallback 的 apply 方法,传入了参数 id,这个方法的作用是根据 id 从数据库中获取数据并返回。
//2.写入redis
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
//释放锁
unlock(lockKey);
}
});
}
return r;
}
//拿到锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);//相当于setnx
return BooleanUtil.isTrue(flag);//判断是否成功,因为直接返回可能会导致拆箱
}
//释放锁
private void unlock(String key){
stringRedisTemplate.delete(key);
}
}
三、调用
bash
Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private CaCheClient caCheClient;
@Override
public Result queryById(Long id) {//首先,根据id在redis中查询店铺缓存
//缓存穿透,访问不存在的id来测试
// Shop shop = caCheClient
// .queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
//用互斥锁解决缓存击穿
// Shop shop = queryWithMutex(id);
//用逻辑过期解决缓存击穿,用jmt快速访问测试
Shop shop =caCheClient
.queryWithLogicalExpire(CACHE_SHOP_KEY,id,Shop.class,this::getById, 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在");
}
//7.然后返回。
return Result.ok(shop);
}
四、数据预热
bash
/**
* 数据的预热(就是在很多活动开始前,会提前导入数据,方便访问)
* @param id
* @param expireSeconds
*/
public void saveShop2Redis(Long id,Long expireSeconds){
//我们传入的这两个数据,一个是用来查询的,一个是用来设置过期时间的,都是自己定义的
//1.查询店铺数据
Shop shop =getById(id);//用过Mp来查询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));
//通过string类型写入
}
五、缓存更新的CacheAside方案
CacheAside:缓存调用者在更新数据库的同时完成对缓存的更新,先操作数据库,后缓存
这里就有面试题了,
(使用事务保证数据库与缓存的操作原子性)
bash
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
//1.更新数据库
updateById(shop);
//2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
//3.返回结果
return Result.ok();
}