一、核心思路
在原来的逻辑中,我们如果发现这个数据在mysql中不存在,直接就返回404了,这样是会存在缓存穿透问题的
现在的逻辑中:如果这个数据不存在,我们不会返回404 ,还是会把空的数据写入到Redis中,并且将value设置为空,当再次发起查询时,就会命中缓存。但是并不是到这就结束了,既然将空值写到redis了,就会导致我们从redis中命中时,命中的就不一定是商铺信息了,还有可能是空值,因此命中后还需要对结果做判断。
判断这个value是否是null,如果是null,则是之前写入的数据,证明是缓存穿透数据,直接返回null即可;如果不是,则直接返回商品信息。
二、代码实现
RedisConstants.java
设置空值的有效期
public static final Long CACHE_NULL_TTL = 2L;
ShopServiceImpl.java
@Override
public Result queryById(Long id) {
// 1.从redis查询商铺缓存,查询的时候需要选择一个数据结构,应该选哈希还是string呢?这里存的商铺肯定是一个对象,存储一个对象很多同学会说选哈希。
// 没错,选哈希完全可以,因为之前演示的时候都是用哈希来演示,这里就用string来演示。
// 店铺key的选择要确保唯一,现在传店铺,每一个店铺都要有一个唯一的key,店铺的id就是唯一的,因此在这里直接使用id作为key。当然我们需要一个前缀
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.查询数据库不存在,直接返回错误
if (shop == null) {
//\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
// 将空值写入redis,并且有效期不能像真实数据那么长(30分钟)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return Result.fail("店铺不存在!");
//\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
}
// 6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回
return Result.ok(shop);
}
这样一来,下次来查询的时候,如果还是查询这个空值,那么它其实就能够命中了,但是这个命中的不是店铺数据,而是字符串。
我们通过 StrUtil.is
判断的时候,只有字符串中真正有数据的时候才是true,剩下的 null、""、"\t\n(换行)"
,都属于false。
也就意味着只有在有商铺数据的情况下它才是true,""
也是false。
再往下,它就直接去查数据库了,那肯定不行,我们还需要再判断一下是否是空值。命中的如果是空值,那么我们还需要返回一个结果,而不能是直接查数据库。
完整代码如下,命中空值返回错误,命中数据就返回数据,什么都没命中null,就去查询数据库,这样就可以解决缓存穿透的一个问题了。
@Override
public Result queryById(Long id) {
// 1.从redis查询商铺缓存,查询的时候需要选择一个数据结构,应该选哈希还是string呢?这里存的商铺肯定是一个对象,存储一个对象很多同学会说选哈希。
// 没错,选哈希完全可以,因为之前演示的时候都是用哈希来演示,这里就用string来演示。
// 店铺key的选择要确保唯一,现在传店铺,每一个店铺都要有一个唯一的key,店铺的id就是唯一的,因此在这里直接使用id作为key。当然我们需要一个前缀
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return Result.fail("店铺信息不存在!")
}
//\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.查询数据库不存在,直接返回错误
if (shop == null) {
//\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
// 将空值写入redis,并且有效期不能像真实数据那么长(30分钟)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return Result.fail("店铺不存在!");
//\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
}
// 6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回
return Result.ok(shop);
}
三、代码实现
重启代码,当我们查询一个命中的数据,那肯定是没问题的
那我们现在查询一个不存在的数据试一试,例如 0
,返回店铺不存在
返回控制台,可以发现有一个查询店铺的SQL语句的
清空控制台,打开浏览器,再查询一次,再来查看控制台,可以发现没有查询,也就说明我们的请求根本没有到达数据库,也就是没有请求压力,这就是因为我们建立了空值在redis中。
打开redis,可以发现redis中已经存储了两个了,其中 0
就是空,此时就解决了缓存穿透的问题了。
四、总结
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和数据库中都不存在,这样的请求一定会到达我们的数据库。如果不断发起这样的请求,就会给数据库带来巨大压力
缓存穿透的解决方案有哪些?
- 缓存null值(我们采用的方式)
- 布隆过滤(采用一种哈希算法)
上面两种是我们之前提到过的,但是缓存穿透任然不仅仅靠这两种解决,这两种其实属于一种被动的方案:人家已经来穿透你,然后你想办法去弥补。事实上我们也可以主动采取一些措施去解决缓存穿透。
- 增强id的复杂度,避免被猜测id规律,这样一来它就不太容易输入一些自己编的id了。
- 当你id有复杂度,有一定的规律,就可以加强基础的这种参数格式的校验了
- 加强用户权限校验,一些业务并不是任何人都能去访问我,例如有些功能需要先登录,登录后我们还可以对用户做限流,这样的用户访问我们的时候它有一个什么样频率的限制
- 做好热点参数的限流,例如商品访问很多、空值等也可以做限流(SpringCloud的里面的)