一,缓存概述
缓存就是数据交换的缓冲区(称作Cache)是临时存储数据的地方,一般读写性能较高。
- 缓存的作用:
- 降低后端负载
- 提高读写效率,降低响应时间
- 缓存的成本:
- 数据一致性成本
- 代码维护成本
- 运维成本
二,添加Redis缓存
代码实现:
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String key="cache:shop:" + id;
//1. 从redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//存在直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//3. 不存在,根据id查询数据库
Shop shop = getById(id);
if(shop == null){
//不存在,返回error
return Result.fail("商铺不存在!");
}
//4.存在,写入redis,并添加超时时间
stringRedisTemplate.opsForValue()
.set(key,JSONUtil.toJsonStr(shop),30, TimeUnit.MINUTES);
//5.返回数据
return Result.ok(shop);
}
三,缓存更新策略
主动更新策略
操作缓存和数据库有三个问题需要考虑:
-
删除缓存还是更新缓存
- 更新缓存:每次更新数据库都更新缓存,无效的写操作较多(X)
- 删除缓存:每次更新数据库都让缓存失效,查询时再更新缓存**(√)**
-
如何保证缓存与数据库的操作同时成功或失败
- 单体系统:将缓存与数据库操作放到一个事务
- 分布式系统:利用TCC等分布式事务方案
-
先操作缓存还是数据库
-
先删缓存,再操作数据库: 线程1刚删除完缓存的间隙,线程2就查询数据库并再次写入缓存,然后线程1再更新数据库,会导致数据不一致。
-
先操作数据库,再删缓存: 只有满足:
- 多个个线程并行执行
- 线程1查询时,缓存刚好失效
- 写入缓存的间隙其他线程完成更新数据库和删除缓存操作
这种概率很小,因为缓存的操作是远小于数据库的操作的,即使发生了这种 情况,我们只需要给缓存加上存在时间即可解决。
-
我们在写的时候,针对数据库的删除,更新操作我们先操作数据库,然后删除缓存,在查询的时候给缓存设置一个超时时间即可。需要注意对缓存和数据库的操作需要加上事务注解。
四,缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
4.1 缓存空对象
优点:实现简单,维护方便
缺点:
- 额外的内存消耗
- 可能造成短期的不一致
4.2 布隆过滤
优点:内存占用小,没有多余的key
缺点:
- 实现复杂
- 存在误判的可能
4.3 其他
- 增加id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
4.4 缓存空对象代码实现
public Result queryById(Long id) {
String key="cache:shop:" + id;
//1. 从redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//存在直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断命中的是否是空值
if(shopJson != null){
return Result.fail("店铺信息不存在!");
}
//3. 不存在,根据id查询数据库
Shop shop = getById(id);
if(shop == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",2, TimeUnit.MINUTES);
//不存在,返回error
return Result.fail("商铺不存在!");
}
//4.存在,写入redis,并添加超时时间
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),30, TimeUnit.MINUTES);
//5.返回数据
return Result.ok(shop);
}
五,缓存雪崩
缓存雪崩:同一段时间大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
六,缓存击穿
缓存击穿问题也叫热点Key问题,就是被一个高并发访问并且缓存重建业务比较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
解决方法:
6.1 互斥锁解决缓存击穿问题
互斥锁
- 优点:
- 没有额外内存消耗
- 保证一致性
- 实现简单
- 缺点:
- 线程需要等待,性能受影响
- 可能有死锁的风险
代码实现:
@Autowired
private StringRedisTemplate stringRedisTemplate;
public Result queryById(Long id) {
Shop shop=queryWithMutex(id);
if(shop == null){
return Result.fail("店铺数据不存在!");
}
return Result.ok(shop);
}
public Shop queryWithMutex(Long id) {
String key="cache:shop:" + id;
String lockKey="lock:shop"+id;
//1. 从redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//存在直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的是否是空值
if(shopJson != null){
return null;
}
//3. 没有命中,进行缓存重建
Shop shop=null;
try{
boolean isLock = tryLock(lockKey);
if(!isLock){
//没有获取到锁,等待
Thread.sleep(10);
return queryWithMutex(id);
}
//获取到了锁
//根据id查询数据
shop = getById(id);
//将数据写入redis
if(shop == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",2, TimeUnit.MINUTES);
//不存在,返回error
return null;
}
//4.存在,写入redis,并添加超时时间
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),30, TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
//解锁
unLock(lockKey);
}
return shop;
}
//获取锁
private boolean tryLock(String key){
//即 setnx ""cache:shop:"+id "1" 来设置一个锁,且ttl为 10秒
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//用BooleanUtil是为了防止包装类的null
return BooleanUtil.isTrue(flag);
}
//释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
6.2 逻辑过期解决缓存击穿问题
逻辑过期
- 优点:
- 线程无需等待,性能较好
- 缺点:
- 不保证一致性
- 有额外内存消耗
- 实现复杂
热点key一般会提前写入到redis里面,如果查不到说明这个key不是热点。
代码实现:
-
添加一个类,用来添加额外的逻辑过期时间字段
@Data public class RedisData { private LocalDateTime expireTime; private Object data; } -
编写一个保存逻辑过期时间key的方法
@Autowired private StringRedisTemplate stringRedisTemplate; @Override public Result queryById(Long id) { Shop shop=queryWithMutex(id); if(shop == null){ return Result.fail("店铺数据不存在!"); } return Result.ok(shop); } public void saveShopToRedis(Long id,Long expireSeconds){ //1.查询店铺数据 Shop shop = getById(id); //2.封装逻辑过期时间 RedisData redisData = new RedisData(); redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); redisData.setData(shop); //3.写入redis stringRedisTemplate.opsForValue().set("cache:shop"+id,JSONUtil.toJsonStr(redisData)); } -
逻辑过期代码:
public Shop queryWithLogicalExpire(Long id){ String key="cache:shop:" + id; String lockKey="lock:shop"+id; //1. 从redis查询缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); //2.判断是否存在 if(StrUtil.isBlank(shopJson)){ //不存在直接返回null return null; } //3. 命中,需要先把json反序列化为对象 RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); Shop shop = (Shop) redisData.getData(); LocalDateTime expireTime = redisData.getExpireTime(); //4. 判断是否过期 if(expireTime.isAfter(LocalDateTime.now())){ //未过期,直接返回店铺信息 return shop; } //5.已过期,进行缓存重建 //5.1获取互斥锁 boolean isLock = tryLock(lockKey); //5.2 判断是否获取互斥锁成功 if(!isLock){ //成功,开启独立线程,实现缓存重建 //正确应该开线程池,我懒得开知道什么意思就行了 new Thread(()->{ try{ saveShopToRedis(id,30L); }catch (Exception e){ throw new RuntimeException(e); }finally { //解锁 unLock(lockKey); } }).start(); } //失败,返回过期信息 return shop; }