目录
一、什么是缓存
二、添加Redis缓存操作
三、缓存更新策略
缓存的更新策略
编辑
业务场景
主动更新策略
案例
四、缓存穿透
1、是什么
2、解决方案
(1)缓存空对象
(2)布隆过滤器
(3)其他策略
3、实践
五、缓存雪崩
1、是什么
2、解决方案
六、缓存击穿
1、是什么
2、解决方案
编辑
互斥锁
逻辑过期
3、实践-互斥锁
用什么锁
实现代码
4、实践-逻辑删除
七、缓存工具封装
一、什么是缓存
缓存就是数据交换的缓冲区,是临时存储数据的地方,一般读写性能较高。
缓存的作用:降低后端负载,提高读写效率,降低响应时间
缓存的成本:数据一致性成本、代码维护成本、运维成本
二、添加Redis缓存操作
三、缓存更新策略
缓存的更新策略
业务场景
- 低一致性:使用内存淘汰机制,例如店铺类型查询的缓存
- 高一致性:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询的缓存
主动更新策略
操作缓存和数据库时要考虑3个问题:
1、删除缓存还是更新缓存?
更新缓存:每次更新数据库都更新缓存,无效写操作很多但是一直没查就很亏
删除操作:更新数据库的时删除缓存,下次查询才有(推荐)
2、如何保证缓存与数据库操作同时成功或失败?
单体系统:将缓存和数据库操作放到一个事务里面
分布式系统:利用tcc等分布式事务方案
3、先操作缓存还是先操作数据库?(重点)(面试题)
先删除缓存:a在删除完缓存后,正在改数据库,这时候来了线程查询数据库,然后更新到缓存,这时候如果a更新完数据库,缓存和数据库就是不一致了
先改数据库:如果缓存过期失效了,a在去数据库查的过程中,b改了数据库,然后更新缓存,这时候a查到数据再更新缓存。缓存和数据库就不一致了。但是这种情况发送的概率远远小于前者。
因为后面的情况发生概率小,要满足很多条件,所以推荐用后者。推荐先改数据库再改缓存。
案例
我们要加上事务注解,保证他们同时成功或同时失败,删之前也要判断一下看id是不是空
四、缓存穿透
1、是什么
缓存穿透是指客户端请求的数据在缓存和数据库都没有,这样缓存永远不会生效,这些请求都会打到数据库。
2、解决方案
(1)缓存空对象
第一次他随便乱打个没有的id来查询缓存和数据库,我们就会返回null,然后把null也存到缓存中。
这样再他下一次带这个id来请求的时候,就会从缓存中拿到null。
优点:简单实现,维护方便
缺点:
额外的内存消耗,缓存了很多没用的数据(可以通过设置时间来缓解)
可能造成短期的不一致。因为是随便乱打的id发的请求,我们帮这个id存缓存了,以后要是真的有这个id了,到时候拿这个id来查也是null(也是控制缓存的时间来缓解,也可以当我们新增数据的时候,主动插入缓存替换掉null)
(2)布隆过滤器
在请求到缓存前加层布隆过滤器,如果这个数据存不存在,直接拒绝,不给继续请求。
布隆过滤器是怎么知道在不在呢?
是通过hash算法算出哈希值,将这些哈希值换成2进制位保存到布隆过滤器,判断数据是否存在的时候就判断对应的位置是0还是1。这种统计不是百分百准确
不存在的时候百分百不存在,说存在不一定存在,可以起到一定过滤作用。还是有一定击穿风险
优点:内存占用少,没有存多余的key
缺点:实现复杂,存在误判的可能。
(3)其他策略
- 做好数据的基础格式校验
- 增强id复杂度,避免被猜测id的规律
- 加强用户权限校验
- 做好热点参数的限流
3、实践
以缓存null值为例,在查询的接口做更改:
当我们查到数据不存在的时候,将这个id作为key,value为空值存储到redis中。
在判断完商品是否存在后加上判断查出来的是否为空值,为空就返回不存在
五、缓存雪崩
1、是什么
缓存雪崩是指同一时段大量的缓存key同时失效或redis服务宕机,导致大量请求打崩数据库
2、解决方案
给不同的key的TTL(过期时间)添加随机值(可以设置成30分钟到40分钟之间的随机数)
利用Redis集群提高服务器的可用性(哨兵机制)
给缓存业务添加降级限流策略(比如当服务器出现问题的时候,拒绝服务,牺牲部分服务来保证安全)
给业务添加多级缓存(nginx、redis、jvm都可以添加缓存,最后才到数据库)
六、缓存击穿
1、是什么
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的Key突然失效了,无数请求访问会瞬间给数据库带来巨大冲击。
高并发访问可以理解为做活动的商品,同一时间有无数的请求来访问这个商品。
缓存重建比较复杂就是缓存可能会过期失效,失效的时候重新添加到缓存的时间很久(可能业务非常复杂,要多表查询运算才能得到的结果)
2、解决方案
互斥锁
如果查到缓存中没有,就加锁查数据库冲击,写入缓存之后再释放锁。这个期间内,所有访问的请求都没有拿到锁只能等待重试,直到缓存更新完毕读取缓存。
优点:没有额外的内存消耗、保证缓存和数据库一致性,实现简单
缺点:线程需要等待,性能受影响用户体验不好。可能有死锁的风险
逻辑过期
设置缓存过期时间的时候,不是真正的设置,而是设置在value里面,如果查询缓存发现逻辑删除时间过期了,就new一个新的线程加锁来查询数据库更新缓存,更新完毕后才释放锁,自己返回旧数据。这个期间内其他线程都没有拿到锁,都返回旧数据,直到更新完毕
优点:线程不用等待,性能好。
缺点:不保证一致性,有额外内存消耗,实现复杂。
3、实践-互斥锁
用什么锁
我们平时用synchronized和lock锁的时候,如果没有拿到锁就要等待,而我们这次是没有拿到锁和拿到锁都有不同的操作要执行。所有用不了这两个
我们可以用redis里面String数据类型的setnx命令来设置锁(当key不存在的时候才可以赋值,存在就不能赋值了)
加锁:setnx lock 1
释放锁:del lock
为了避免死锁,我们通常还会设置上有效期
加锁和释放锁的方法:
实现代码
最后开启JMeter做测试,模拟1000个线程发请求,发现能扛住200qps
4、实践-逻辑删除
要用逻辑删除肯定要多个属性过期时间,我们就可以采用这种方式多增加一个类,这个类有过期时间属性,然后多一个object类,存放要存入redis的数据,这样就不用在原来的类上改动
编写添加到redis的方法
这个plusSeconds就是设置过期时间,用户参数传进来
重写查询的方法
七、缓存工具封装
我们发现缓存的操作还是挺复杂的,我们将来使用的时候不可能每次都这样重新写一遍。 我们可以将这些解决方案封装成工具。
我们封装四个方法,1和3可以是平时添加缓存用的,2和4一般是缓存热点数据用防止击穿问题
1、封装存储方法:将任意java对象序列化为json并存储在string类型的key中,并且设置ttl过期时间
2、封装存储方法(逻辑过期):将任意java对象序列化为json并存储在string类型的key中,并且设置逻辑过期时间,用于处理缓存击穿问题
3、查询缓存(空值解决击穿):根据指定key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透
4、查询缓存(逻辑删除解决穿透):根据指定key查询缓存,并反序列化为指定类型,需要用逻辑过期解决缓存击穿
工具类:
@Slf4j
@Component
public class CacheClient {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 向缓存中添加 key
* */
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 设置逻辑过期时间
* */
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 缓存穿透
* */
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、根据 Id 查询 Redis
String json = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 2、判断 shopJSON 是否为空
if (StrUtil.isNotBlank(json)) {
// 3、存在,直接返回
R r = JSONUtil.toBean(json, type);
return r;
}
// 增加对空字符串的判断
if(json != null){
return null;
}
// 4、不存在,查询数据库
R r = dbFallback.apply(id);
// 5、不存在,返回错误
if(r == null){
// 店铺不存在时,缓存空值
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6、存在,写入 Redis
this.set(key, r, time, unit);
return r;
}
public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id,Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
// 1、根据 Id 查询 Redis
String json = stringRedisTemplate.opsForValue().get(key);
// 2、判断 shopJSON 是否为空
if (StrUtil.isBlank(json)) {
// 3、存在,直接返回
return null;
}
// 命中,需要先把 json 反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
// 判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
return r;
}
// 已过期,需要缓存重建
// 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// 开辟独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 缓存重建
R r1 = dbFallback.apply(id);
this.setWithLogicalExpire(key, r1, time, unit);
}catch (Exception e) {
throw new RuntimeException(e);
} finally {
unLock(lockKey);
}
});
}
// 6、存在,写入 Redis
this.set(key, r, time, unit);
return r;
}
// 获取锁
private boolean tryLock(String key){
Boolean isTrue = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(isTrue);
}
// 释放锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
}
调用的service
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private CacheClient cacheClient;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Override
public Result queryById(Long id) {
// 解决缓存穿透
// Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
// Shop shop = queryWithMetux(id);
// 使用逻辑过期时间解决缓存击穿问题
// Shop shop = queryWithLogicalExpire(id);
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
if(shop == null){
return Result.fail("店铺不存在!");
}
// 7、返回
return Result.ok(shop);
}
public Shop queryWithLogicalExpire(Long id){
// 1、根据 Id 查询 Redis
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);
// 2、判断 shopJSON 是否为空
if (StrUtil.isBlank(shopJson)) {
// 3、存在,直接返回
return null;
}
// 命中,需要先把 json 反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
// 判断是否过期
if(expireTime.isAfter(LocalDateTime.now())){
return shop;
}
// 已过期,需要缓存重建
// 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// 开辟独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 缓存重建
this.saveShop2Redis(id, 20L);
}catch (Exception e) {
throw new RuntimeException(e);
} finally {
unLock(lockKey);
}
});
}
// 6、存在,写入 Redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return shop;
}
}