一、前言
上一节我们实现了登录校验的功能,说明这个项目正式地入门了,那么随之而来的redis第二个核心用法,就是去操作缓存,这个的应用很多,比如我们对于商户的查询,如果从缓存中拿出来是比数据库快得多的,所以按照苍穹外卖的手段,我们可以将增删改查都增加一个环节:对redis的操作环节,接下来我将实际操作,并且引出两个可能出现的安全问题。
二、商户缓存
查询的整体流程如下:
1.查询缓存,检查是否存在缓存,有缓存就直接查缓存。
2.如果没有缓存,就去查数据库。
3.将数据库中的数据添加进缓存。
这个功能还是非常好实现的,也不存在理解困难,所以我直接给出代码:
查询:
java
private final StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
Shop shop = queryWithPassThrough(id);
if(shop==null){
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
public Shop queryWithPassThrough(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
//1.从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在(商铺是否有缓存)
if (StrUtil.isNotBlank(shopJson))/*shopJson != null && !shopJson.isEmpty()*///这样也可以,更加直观
{//(这里的判断条件是:如果缓存查询到有不为空(null || "")的字符串,直接返回)
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
//6.返回
return shop;
}
更新:
java
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
//1.更新数据库
updateById(shop);
//2.删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
return null;
}
三、商户类型缓存
这里是我自己实现的,也很简单,直接看代码吧。
java
@Service
@RequiredArgsConstructor
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
private final ShopTypeMapper shopTypeMapper;
private final StringRedisTemplate stringRedisTemplate;
@Override
public List<ShopType> queryTypeList() {
String json = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_TYPE_KEY);
if (StrUtil.isNotBlank(json)) {
List<ShopType> shopTypeList = JSONUtil.toList(json, ShopType.class);
return shopTypeList;
}
List<ShopType> shopTypes = shopTypeMapper
.selectList(new QueryWrapper<ShopType>()
.orderByAsc("sort"));
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_TYPE_KEY,JSONUtil.toJsonStr(shopTypes));
return shopTypes;
}
}
四、缓存穿透
以下所有问题我们都以查询商户为例:
首先给出一个情景来解释缓存穿透,如果浏览器发出一个请求,这个请求在数据库和缓存中都没有(比如我想查id=-1的商户信息),那么首先我会去查缓存,那缓存中当然没有了,所以接着就会去查数据库,数据库发现也没有,最后我们就会返回一个 "商户不存在" 回去。
这里有人会觉得没问题啊,毕竟已经返回不存在的信息回去了,但是试想一下,如果是在高并发的情况下呢?比如我有10000个请求同时请求这个不存在的信息(恶意请求),那我必然会去查10000次数据库,这对于数据库的压力是很大的,甚至可能会有崩溃的风险。
于是这就是第一个安全问题------缓存穿透。
那么怎么去解决这个问题呢?从本质来看,是否只要不去查数据库就可以避免这个问题了,那我们就可以有两个方案:
1.存入这个不存在的缓存,但是给它数据设置成空,这样的话,只要再请求这个不存在的信息,就会查到值为空的缓存,然后返回,自然也就不会再去调用数据库查询了。
2.布隆过滤器,在请求进入服务器之前拦截检验是否是合理请求。
由于第二个方案比较复杂,这里我们先实现第一个方案:存入空缓存。
java
@Service
@RequiredArgsConstructor
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
private final StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);
if(shop==null){
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
public Shop queryWithPassThrough(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
//1.从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在(商铺是否有缓存)
if (StrUtil.isNotBlank(shopJson))/*shopJson != null && !shopJson.isEmpty()*///这样也可以,更加直观
{//(这里的判断条件是:如果缓存查询到有不为空(null || "")的字符串,直接返回)
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//由于上个判断筛选了,所以能到这里的查询结果只有两种:1.null 2.""
//判断命中的是否是空值(如果不是null,那就是空字符串了,而空字符串就说明一定是缓存穿透,就不能再查数据库了)
if (shopJson != null) {
//返回错误信息
return null;
}
//而如果是null,,就是未命中就有两种情况:
// (1)只是数据库没有上传到缓存,那就去上传
// (2)是缓存穿透,也就是数据库查不到,这里就需要存入空值
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if (shop == null) {
//将空值存入redis
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7.返回
return shop;
}
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
//1.更新数据库
updateById(shop);
//2.删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + id);
return null;
}
}
这里我的注释写得非常非常详细,因为这个过程还是不太好理解的,但是本质还是通过多重判断来给请求定性,只要定性了就好处理了。
这里的 "定性" 我可以定为三种:
1.合理请求,有缓存
2.合理请求,没缓存
3.不合理请求,没缓存
自然也就有对应的解决方法:
1.直接查询缓存
2.查询数据库,然后添加缓存
3.存入空缓存
五、缓存击穿
同样的,给出一个情景,redis有一个热点"key",经常会被查到,非常常用,这就意味这这个key将长时间经历来自高并发的压力。
当我想改变这个key对应的值,正常来讲我们就会先改变数据库,然后删除缓存,最后重建缓存 。确实,在正常情况下是不会出现问题的,但是这是一个超高并发的热点 "key" ,**在缓存被删除了但是还没有被重建的阶段,**我们的请求将会直接去查数据库,在超高并发的情况下,数据库又有被查爆的风险了。
好了,这就是第二个安全问题------缓存击穿
解决缓存击穿的方法有两种:
1.互斥锁
2.逻辑过期
1.互斥锁
我们先来看第一种**:互斥锁**
首先还是看本质,本质上是要解决数据库的崩溃问题: 这是因为太多人查询了,而且和缓存穿透不同,这些都是合理的请求啊,我们不能把别人的合理请求给拦截了呀,那么既然无法拦截,那么就从如何重建来思考解决方案,我们重建缓存是不是只需要一条请求就够了,那我们就只放一条请求去查数据库然后重建缓存,其他的请求就先等着,等那一条请求完成使命(重建完成)了,这些请求就只需要去查缓存就可以拿到数据了,这样是不是就只查询了一次数据库了呢?
想法很好,但是如何实现?我们注意到这是高并发情况,并且存在排队等待的情景,那就可以想到多线程中类似的情景,当时我们是通过上锁解决的,所以这里我们也可以通过上锁来解决。
这里的上锁其实就是去redis中添加一个键,我们要求所有的请求在想去查数据库之前先停下来,看看是不是已经有人去尝试重建缓存了,如果有人已经在走重建缓存的流程了,那就停下来等待它重建完成,如果没人走这个流程,那我们自己就去重建缓存,当然,当我们去重建缓存时,其他的请求就都排队等着吧。
基本流程已经确定了,接下来有个小问题,排队等待的形式是什么?这里我们可以使用递归来不断重试,我们可以间隔50ms就查一次缓存试试,看看别人重建是否完成。
于是代码如下:
java
@Service
@RequiredArgsConstructor
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
private final StringRedisTemplate stringRedisTemplate;
@Override
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 = RedisConstants.CACHE_SHOP_KEY + id;
//1.从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在(商铺是否有缓存)
if (StrUtil.isNotBlank(shopJson))/*shopJson != null && !shopJson.isEmpty()*///这样也可以,更加直观
{//(这里的判断条件是:如果缓存查询到有不为空(null || "")的字符串,直接返回)
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//由于上个判断筛选了,所以能到这里的查询结果只有两种:1.null 2.""
//判断命中的是否是空值(如果不是null,那就是空字符串了,而空字符串就说明一定是缓存穿透,就不能再查数据库了)
if (shopJson != null) {
//返回错误信息
return null;
}
//而如果是空null,就是未命中,这里有两种情况:
// (1)只是数据库没有上传到缓存,那就去上传
// (2)是缓存穿透,也就是数据库查不到,这里就需要存入空值
//4.实现缓存重建
//4.1 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//4.2 判断是否获取成功
if (!isLock) {
//4.3 失败,则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4上锁后二次确认缓存是否存在
String shopJsonDouble = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJsonDouble)) {
return JSONUtil.toBean(shopJsonDouble, Shop.class);
}
//4.5 到这里就表示确实还是需要重建缓存(别的线程没有重建),根据id查询数据库
shop = getById(id);
//模拟延迟(测试)
//Thread.sleep(200);
//5.不存在,返回错误
if (shop == null) {
//将空值存入redis
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unlock(lockKey);
}
//8.返回
return shop;
}
/**
* 上锁
*
* @param key
* @return
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);//避免拆包问题
}
/**
* 关锁
*
* @param key
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
这里注意了,缓存穿透和缓存击穿是两个概念!!!这是两个安全问题,所以目前要想同时解决这两个问题,一要多重判断定性,二要重建上锁。(当然,后续可能还有更先进的方案,但是两个问题无论如何都必须用两个方案解决)
2.逻辑过期
逻辑过期和互斥锁是两种不同的方式,但是他们都是解决缓存击穿的解决方案,所以情景与刚刚的互斥锁是一样的,但是这里先总结一下他们的效果差别:
互斥锁的效果是:一个请求重建缓存,其余请求等待。
逻辑过期的效果是:一个分出一个线程重建缓存,其余请求拿已经过期了的数据。
这里来讲讲逻辑过期的本质:其实就是给缓存一个逻辑过期时间,每次请求的时候看看过期没有,过期了就从数据库更新一次。
对于热点商户,基本上一直会有请求,所以可以看作每20s自动更新一次,而对于冷门商户,由于很久才会有一次请求,所以很久都不会更新,这样的好处是不会占用资源,并且如果真的要更新,只需要请求一次就直接更新了(因为间隔时间远大于逻辑过期时间了,也就是早就过期了)。
因为这个方案的步骤很繁琐,所以有必要理清步骤:
0. 准备工作:
(1)准备一个类来存储逻辑过期时间和数据(其实就是扩展原有的Shop类,相当于多添加一个成员变量------过期时间,但是这个类是普适的,无论哪个类来了都能这样扩展)。
(2)创建一个线程池便于后续重建缓存时使用,保证重建时不会阻塞。
1. 预热缓存,手动先存入热点key到缓存去,这里是的缓存在数据上看是永久存在的。
2. 由于预热了缓存,所以就不存在未命中的情况了,所以我们直接可以拿到缓存(无论逻辑上是否过期)。
3. 判断这个缓存逻辑上是否过期,没有过期就直接返回店铺信息。
4.如果过期了就要重建缓存了(注意,这里有两个线程,一个是本方法的线程,一个是线程池的线程,在拿锁的过程中,本方法的线程只负责开启线程池线程任务,让线程池中的一个独立的线程来重建,然后本线程会继续走下去,拿到过期的缓存,无论线程池的线程是否走完):上锁,查数据库,存缓存,放锁。
基本流程:
java
public Shop queryWithLogicalExpire(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
//1.从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在(商铺是否有缓存)
if (StrUtil.isBlank(shopJson)) {
//3.不存在,直接返回空
return null;
}
//4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
//5.1未过期,直接返回店铺信息
return shop;
}
//5.2 已过期,需要缓存重建
//6.缓存重建
//6.1 获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
//6.2 判断是否获取锁成功
boolean isLock = tryLock(lockKey);
if (isLock) {
//6.3 成功,开启独立线程实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//重建
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
}
//6.4 失败,返回过期的商铺信息
return shop;
}
创建线程池:
java
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
重建缓存:
java
public void saveShop2Redis(Long id, Long expireSecond) throws InterruptedException {
//1.查询店铺数据
Shop shop = getById(id);
Thread.sleep(200);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSecond));
//3.写入redis
stringRedisTemplate
.opsForValue()
.set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
扩展过期时间:
java
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
最后看看互斥锁和逻辑过期的优缺点:
互斥锁:数据一致性极强,但是由于有等待时间,用户体验不太好,可能会卡,所以可以作为逻辑过期的兜底方案。
逻辑过期:数据一致性比较差,至少20s才更新一次,但是用户体验感挺好的,拿的之前的数据,所以不会卡。