黑马点评缓存部分:
缓存的标准操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis
最初缓存版本
//最初源代码
public Shop queryWithPassThrough(Long id) {
//从redis中查商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(SHOP_ID + id);
//判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
//存在 直接返回
return null;
}
//判断是不是空
if (shopJson != null) {
//返回错误信息
return null;
}
//不存在 查数据库 MybatisPlus
Shop shop = getById(id);
//数据库不存在 返回错误
if (shop == null) {
//将空值写入redis
stringRedisTemplate.opsForValue().set(SHOP_ID + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//数据库中存在 写入redis
stringRedisTemplate.opsForValue().set(SHOP_ID + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//返回
return null;
}
解决数据不一致的办法:双写
由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在, 几种方案
Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案
Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理
Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致
如果采用第一个方案,那么假设我们每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
我们应当是先操作数据库,再删除缓存,原因在于,如果你选择先删除缓存再操作数据库,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
缓存穿透问题:缓存空对象
指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
缓存穿透的解决方案有哪些?
缓存null值
布隆过滤
增强id的复杂度,避免被猜测id规律
做好数据的基础格式校验
加强用户权限校验
做好热点参数的限流
这里使用缓存null值来完成缓存穿透
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.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
缓存雪崩
热点Key问题
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
给不同的Key的TTL添加随机值
利用Redis集群提高服务的可用性
给缓存业务添加降级限流策略
给业务添加多级缓存
缓存雪崩的解决方案:互斥锁
代码:
//互斥锁
public Shop queryWithMutex(Long id) {
//1.从redis中查商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(SHOP_ID + id);
//判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//存在 直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断是不是空
if (shopJson != null) {
//返回错误信息
return null;
}
//实现缓存重建
// 获取互斥锁
String lockKey = SHOP_LOCK_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 判断是否成功
if (!isLock) {
//失败->休眠并重试
Thread.sleep(50);
queryWithMutex(id);
}
//成功->查询数据库信息
shop = getById(id);
//模拟延迟
//Thread.sleep(200);
//数据库不存在 返回错误
if (shop == null) {
//将空值写入redis
stringRedisTemplate.opsForValue().set(SHOP_ID + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//数据库中存在 写入redis
stringRedisTemplate.opsForValue().set(SHOP_ID + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//释放互斥锁
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unLock(lockKey);
}
//返回
return shop;
}
// 获取锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
//返回的啥时候拆箱 可能出现空指针
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
互斥锁的大致逻辑:
缓存查询+缓存重建
先从 Redis 中查询缓存数据。
如果缓存命中(数据存在),直接返回缓存数据。
如果缓存未命中(数据不存在),尝试获取分布式锁,防止多个线程同时重建缓存。
如果没有得到锁,休眠 重试
如果得到了锁 查询数据库信息
数据库信息不存在 将空值写入redis
数据库中存在 写入redis
最后无论如何释放锁 返回数据
DoubleCheck: 为了避免多个线程同时重建缓存,从而减少不必要的数据库查询和缓存写入操作
在这里需要加入这个doubleCheck
在获取锁之前,先检查一次缓存(第一次检查)。如果缓存已经命中,直接返回数据,无需获取锁。这样可以避免大量线程同时竞争锁,减少锁的开销。
在获取锁之后、查询数据库之前,再次检查缓存(第二次检查)。如果缓存已经命中,直接返回数据,无需查询数据库。这样可以确保在等待锁的过程中,其他线程没有已经重建缓存,从而避免重复操作。(上述代码没写,逻辑过期的写了)
第二次检查防止数据更新,即使锁了,其他线程或进程仍然可以 通过其他途径更新缓存,而不受当前锁的限制
缓存雪崩的解决方案:逻辑过期
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//热点key 逻辑
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
String json = stringRedisTemplate.opsForValue().get(key);
//从redis中查商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(SHOP_ID + id);
//判断是否存在
if (StrUtil.isBlank(shopJson)) {
//存在 直接返回
return null;
}
//命中 需要把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
//redisData.getData()是一个Object对象 需要,但实际上是一个JSONObject
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//判断过期
if (expireTime.isAfter(LocalDateTime.now())) {
//没过期
return shop;
}
//过期了需要缓存重建
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
//判断是否获取锁成功
if (isLock) {
//!!!做DoubleCheck
String jsonAgain = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(jsonAgain)) {
RedisData redisDataAgain = JSONUtil.toBean(jsonAgain, RedisData.class);
LocalDateTime expireTimeAgain = redisDataAgain.getExpireTime();
if (expireTimeAgain.isAfter(LocalDateTime.now())) {
return JSONUtil.toBean((JSONObject) redisDataAgain.getData(), Shop.class);
}
}
// 缓存仍需重建,异步执行
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unLock(lockKey);
}
});
}
return shop;
}
//缓存重建
public void saveShop2Redis(Long id, Long expireSeconds) {
//查询店铺信息
Shop shop = getById(id);
//封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
实现逻辑过期,需要创建一个逻辑过期数据类 记录过期时间的一个类
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
逻辑:
和前面一样,先从redis中查缓存 存在就返回数据
在返回数据的时候 先将json反序列化成RedisData对象 再将RedisData 中的data转换成shop,真正需要的对象
然后先判断数据是否过期 没过期返回
过期了需要进行缓存重建
重建还是先拿到锁,再做DoubleCheck 如果不需要继续重建,返回对象
如果确实需要重建 这里采用的是异步执行
1、使用线程池 CACHE_REBUILD_EXECUTOR
提交一个异步任务。
2、在异步任务中,调用 saveShop2Redis
方法重建缓存。
3、无论缓存重建是否成功,最终都会释放锁。
最后返回数据
值得注意的是,在执行之前需要对缓存数据进行预热 将数据加载到缓存中。