黑马点评缓存部分:
缓存的标准操作方式就是查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis
最初缓存版本
java">//最初源代码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;}//不存在 查数据库 MybatisPlusShop shop = getById(id);//数据库不存在 返回错误if (shop == null) {//将空值写入redisstringRedisTemplate.opsForValue().set(SHOP_ID + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}//数据库中存在 写入redisstringRedisTemplate.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规律
-
做好数据的基础格式校验
-
加强用户权限校验
-
做好热点参数的限流
java">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) {// 将空值写入redisstringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);// 返回错误信息return null;}// 6.存在,写入redisthis.set(key, r, time, unit);return r;}
缓存雪崩
热点Key问题
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
缓存雪崩的解决方案:互斥锁
代码:
java">//互斥锁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) {//将空值写入redisstringRedisTemplate.opsForValue().set(SHOP_ID + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}//数据库中存在 写入redisstringRedisTemplate.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
在获取锁之前,先检查一次缓存(第一次检查)。如果缓存已经命中,直接返回数据,无需获取锁。这样可以避免大量线程同时竞争锁,减少锁的开销。
在获取锁之后、查询数据库之前,再次检查缓存(第二次检查)。如果缓存已经命中,直接返回数据,无需查询数据库。这样可以确保在等待锁的过程中,其他线程没有已经重建缓存,从而避免重复操作。(上述代码没写,逻辑过期的写了)
第二次检查防止数据更新,即使锁了,其他线程或进程仍然可以 通过其他途径更新缓存,而不受当前锁的限制
缓存雪崩的解决方案:逻辑过期
java"> 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对象 需要,但实际上是一个JSONObjectShop 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) {//!!!做DoubleCheckString 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));//写入RedisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));}
实现逻辑过期,需要创建一个逻辑过期数据类 记录过期时间的一个类
java">@Data
public class RedisData {private LocalDateTime expireTime;private Object data;
}
逻辑:
在返回数据的时候 先将json反序列化成RedisData对象 再将RedisData 中的data转换成shop,真正需要的对象
然后先判断数据是否过期 没过期返回
过期了需要进行缓存重建
重建还是先拿到锁,再做DoubleCheck 如果不需要继续重建,返回对象
如果确实需要重建 这里采用的是异步执行
1、使用线程池 CACHE_REBUILD_EXECUTOR
提交一个异步任务。
2、在异步任务中,调用 saveShop2Redis
方法重建缓存。
3、无论缓存重建是否成功,最终都会释放锁。
最后返回数据