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 之前会进入finally
return 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 之前会进入finally
return 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(); // 先转 JSONObject
T 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);
}
});