黑马点评项目02——商户查询缓存(缓存穿透、缓存雪崩、缓存击穿)以及细节
1.添加redis缓存
StringRedisTemplate 使用的是这个哈,有人可能有疑问,存放的是字符串吗,商铺值应该是个对象才对啊,在细节中解析
代码:
@Override
public Result queryById(Long id) {//查询redis,若存在则转换成对象后返回String key = CACHE_SHOP_KEY + id;String shopJson = stringRedisTemplate.opsForValue().get(key);if (StringUtils.isNotBlank(shopJson)) {Shop shop = JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}//不存在则查询数据库,然后转成以json串存⼊redis后,返回Shop shop = shopMapper.selectById(id);if(shop==null){return Result.fail("店铺不存在");}stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));return Result.ok(shop);
}
2.API 细节解析 json串与对象相互转换
stringRedisTemplate.opsForValue().get(key)
的返回值是一个String,如果查不到,返回null,为了以防万一,HuTool工具判断 StringUtils.isNotBlank(shopJson)
,可以确保是确实是一个商铺。
命中缓存,转换为将String json串转换为对象, Shop shop = JSONUtil.toBean(shopJson, Shop.class);
注意这个API,字符串转化为Shop;
不命中缓存,查数据库返回商铺Shop shop = shopMapper.selectById(id)
,
此时注意了,不能直接把对象放进去,要放进去一个json,也注意这个API。
stringRedisTemplate.opsForValue() .set(key,JSONUtil.toJsonStr(shop))
3.Redis缓存和数据库一致性策略
Cache Aside(旁路缓存)策略(适合读多写少)
注意:写的时候先更新数据库,这样也可能发生不一致问题,只是几率相对较小,一个解决策略就是加上延迟双删
另外,Cache Aside 策略适合读多写少的场景,不适合写多的场景,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。
//先更新数据库,再删除缓存shopMapper.updateById(shop);stringRedisTemplate.delete(CACHE_SHOP_KEY+ id);
4. 缓存穿透
缓存穿透 :是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
4.1 缓存空对象
在原来的逻辑中,我们如果发现这个数据在mysql中不存在,直接就返回404了,这样是会存在缓存穿透问题的。现在,我如果查询到数据库没有这个对象时,我就往Redis存放(id:‘’)空字符串,下次你再访问,给你空字符串,根本过不了isBank()
缺点:
可能存在短时间不一致问题;占用内存
注意:缓存空值要设置较短的过期时间(如 5~10 分钟)
4.2 布隆过滤
直接拦截了,只要数据库中没有,当然可能会存在误判,不过概率较小!!!
4.3 其他方案
5. 缓存雪崩
缓存雪崩:是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。就是缓存Redis废了
6. 缓存击穿(例如:优惠劵信息id)
缓存击穿是指:某个热点 key(访问频率极高)突然失效,大并发请求在同一时间全部打到数据库,短时间内数据库可能被压垮。
6.1 互斥锁
/*模拟加锁*/private boolean tryLock(String key){Boolean b = stringRedisTemplate.opsForValue().setIfAbsent(key, "", 30, TimeUnit.SECONDS);return BooleanUtil.isTrue(b);}private void unlock(String key){stringRedisTemplate.delete(key);}
stringRedisTemplate.opsForValue().setIfAbsent(key, "", 30, TimeUnit.SECONDS)
拿到了锁就返回true
Boolean.TRUE.equals(success)
或者 BooleanUtil.isTrue(success)
来判断
互斥锁逻辑
public Result queryById(Long id) {// 1. 从 Redis 查询缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);// 2. 如果缓存命中,直接返回 必须有实际东西才可以if(StrUtil.isNotBlank(shopJson)){log.info("shopJson缓存中有:{}",shopJson);Shop shop = JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}// 3. 如果缓存中是空字符串或者占位符(说明数据库中查过确实不存在),返回错误if (shopJson != null) {return Result.fail("店铺信息不存在(缓存空值)");}// 4. 缓存未命中,准备查询数据库前先尝试加锁,防止缓存击穿String lockKey = "shop:lock" + id;boolean lock = tryLock(lockKey); // 尝试加锁 true 该线程拿到了锁Shop shop;try {if (lock) {// 5. 获取锁成功,查询数据库shop = getById(id);// 6. 数据库中也不存在,返回错误(此处未缓存空值,依赖布隆拦截)if (shop == null) {// return 之前会进入finallyreturn Result.fail("店铺不存在");}// 7. 查询成功,写入缓存String jsonStr = JSONUtil.toJsonStr(shop);stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, 3L, TimeUnit.MINUTES);} else {// 8. 获取锁失败,稍等后递归重试(等待其他线程完成缓存填充)Thread.sleep(50);// return 之前会进入finallyreturn queryById(id);}} catch (InterruptedException e) {throw new RuntimeException(e);} finally{// 9. 只有拿到锁的线程才释放锁,避免误删其他线程的锁if (lock) {unlock(lockKey);}}// 10. 返回结果return Result.ok(shop);}
6.2 逻辑过期
既然是高并发访问那干脆就直接redis里面一直都不要删除了,再加个逻辑过期时间,过期的话就开个独立线程去更新数据写入redis,在没更新完之前访问到的都是redis里面的旧数据。
具体实现见:逻辑过期解决缓存击穿
我只是讲一下难以实现的技术点:
1、需要封装一个实体类+过期时间一起构成RedisData对象,有两种实现方式。
第一种:泛型
@Data
public class RedisData<T> {private LocalDateTime expireTime;private T data;
}
第二种:Object
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RedisData {private LocalDateTime expireTime;private Object data;
}
第一种就是序列化麻烦一些,不过更规范,api记住
// 1. 查询 Redis 缓存String json = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isBlank(json)) {// 缓存未命中return null;}// 2. 反序列化为带逻辑过期的数据结构RedisData<?> redisData = JSONUtil.toBean(json, RedisData.class);/* JSONObject dataJson = (JSONObject) redisData.getData(); // 先转 JSONObjectT data = dataJson.toBean(type);*/T data = JSONUtil.toBean((JSONObject) redisData.getData(), type);LocalDateTime expireTime = redisData.getExpireTime();
接下来就是判断是否过期了,如果没有过期,直接返回data;如果过期了,尝试获取锁,注意有个细节,如果获取锁后一定要再判断一下是否从缓存中得到是否为空,为空,说明被删掉了,返回之前找的旧data,再判断这时候是不是不过期了,这样就少一次IO,不过期,说明有其他线程刚刚更新过了。
如果确实是过期,交给其他线程重建,
// 缓存重建线程池(用于异步更新缓存)
private static final ExecutorService CACHE_REBUILD_EXECUTOR =Executors.newFixedThreadPool(10);
// 6. 异步线程池重建缓存CACHE_REBUILD_EXECUTOR.submit(() -> {try {// 查询数据库T fresh = dbFallback.apply(id);// 模拟重建缓存Thread.sleep(200);// 重新写入缓存(逻辑过期)this.setWithLogicalExpire(key, fresh, time, unit);} catch (Exception e) {e.printStackTrace();} finally {// 释放锁unlock(lockKey);}});