如何解决Redis缓存穿透
本篇将带大家了解如何在不同的业务场景下防范Redis缓存穿透,以查询商品业务场景为例子,分别使用缓存null和布隆过滤器的方法来防范缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样请求到达缓存永远不会命中,并且查询数据库的结果也为null
,不会写入缓存,从而使得这些请求都会打到数据库上,在并发量非常高的场景下,大量的请求打到数据库上很有可能会压垮数据库
常规情况下的缓存机制
通常客户端对服务端发出热点数据的查询请求时,会先请求到缓存上,若缓存命中则直接返回数据,若未命中则回去查询数据库,如果在数据库中查到了数据则会返回客户端,并且将数据写入缓存,以便下次查询时,请求可以直接从缓存中获取数据,从而减轻数据库的压力
那么此时就会产生缓存穿透的问题,以一个根据ID
查询商品的业务为例:
/*** 根据id查询商品* @param id* @return*/@Overridepublic Result queryById(Long id) {// 1.查询redis缓存String jsonItem = redisTemplate.opsForValue().get("item:" + id);// key// 2.命中直接返回if (StrUtil.isNotBlank(jsonItem)) {Item item = JSONUtil.toBean(jsonItem, Item.class);return Result.ok(item);}// 3.未命中查询数据库Item item = getById(id);// 4.数据库中没有返回错误if (item == null) {return Result.fail("商品不存在!");}// 5.写入redisredisTemplate.opsForValue() // 设置过期时间30min.set("item:" + id, JSONUtil.toJsonStr(item), 30, TimeUnit.MINUTES);// 6.返回数据return Result.ok(item);}
这个案例严格执行了上面的缓存机制,执行步骤为:
- 查询
redis
缓存 - 命中则直接返回数据
- 未命中则查询数据库
- 数据库中无数据则返回错误
- 写入
redis
缓存 - 返回数据
此时若是客户端发来一个完全不存在的id的查询请求时,业务逻辑会在第4步时直接返回错误信息,如果有人恶意大量编造虚假ID
来对数据库发动大规模的请求攻击时,我们的服务是我无法处理这种情况的,所以为了数据库的安全性考虑,我们必须要解决缓存穿透问题
常规的解决方案有两种:
-
缓存空对象:当查询到一个不存在的数据时,我们也将空值写入缓存,使得下次请求可以命中缓存
- 优点:简单粗暴
- 缺点:容易造成数据短暂不一致、内存消耗增大
-
布隆过滤:在请求到达缓存之前先利用布隆过滤算法判断对象是否存在,若不存在则会直接拒绝请求
- 优点:内存消耗较少
- 缺点:实现复杂,存在误判
缓存空对象
当缓存未命中并且查询数据库后无数据,此时也将空值写入缓存中,在下次查询时,可以直接命中缓存并返回,但是在命中缓存后,要判断是否命中的是空值,如果是控制则返回错误信息,防止将缓存的空对象当作正常的业务对象
内存消耗问题:如果伪造ID
发起大规模请求攻击,那么攻击过后缓存中会有许多垃圾数据,也就是我们假如的空对象数据,大大占用了缓存内存,所以我们可以为空对象的缓存添加一个较短的过期时间TTL
/*** 根据id查询商品* @param id* @return*/@Overridepublic Result queryById(Long id) {// 1.查询redisString jsonItem = redisTemplate.opsForValue().get("item:" + id);// 2.命中直接返回if (StrUtil.isNotBlank(jsonItem)) {Item item = JSONUtil.toBean(jsonItem, Item.class);return Result.ok(item);}// 判断是否命中为空值if (jsonItem != null){// 返回错误return Result.fail("商品不存在");}// 3.未命中查询数据库Item item = getById(id);// 4.数据库中没有返回错误if (item == null) {// 将空值写入缓存redisTemplate.opsForValue().set("item:" + id, "", 2, TimeUnit.MINUTES); // 两分钟过期时间// 返回错误信息return Result.fail("商品不存在");}// 5.写入redisredisTemplate.opsForValue().set("item:" + id, JSONUtil.toJsonStr(item), 30, TimeUnit.MINUTES);// 6.返回数据return Result.ok(item);}
布隆过滤
这是一种空间效率极高的概率型数据结构。它可以用来判断一个元素是否在一个集合中。在缓存系统中,可以将数据库中所有可能存在的数据的键(如商品 ID)放入布隆过滤器中。当一个请求到来时,先通过布隆过滤器进行检查,如果布隆过滤器判断该数据不存在,那么就直接返回,不会再去数据库查询;如果布隆过滤器判断可能存在,再去缓存和数据库中查询。布隆过滤器存在一定的误判率,但可以通过调整参数来降低误判率
那么布隆过滤器又是怎么知道某个值是否存在呢?
底层原理:底层有一个二进制数组,初始时所有位都被设置为 0,有多个哈希函数,其作用是将输入的元素映射到数组中的位置,一个元素进行多次哈希后,将数据不同位置的值设置为1,此时请求一个元素时,就可以使用该元素多次哈希的值在数组中查询是否所有位置全部为1,若不全部为1则证明该元素一定不存在,从而拒绝请求,但是如果所有位置都为1也不一定证明其一定存在,因为可能存在哈希碰撞的情况
实现:
1.引入依赖
在pom.xml文件中添加 Guava 的依赖,用于实现布隆过滤器:
<dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>31.1-jre</version>
</dependency>
2.创建布隆过滤器实例
可以创建一个工具类或者在合适的配置类中初始化布隆过滤器,示例如下:
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;import java.nio.charset.StandardCharsets;@Configuration
public class BloomFilterConfig {private static final int EXPECTED_INSERTIONS = 10000; // 预期插入的元素数量,预估下数据库中商品ID的数量规模private static final double FPP = 0.01; // 误判率,可根据需求调整@Beanpublic BloomFilter<Long> ItemIdBloomFilter() {return BloomFilter.create(Funnels.longFunnel(StandardCharsets.UTF_8), EXPECTED_INSERTIONS, FPP);}
}
3.初始化布隆过滤器数据
需要在合适的时机(比如项目启动后,从数据库加载完初始数据时等)把数据库中已有的店铺 ID 添加到布隆过滤器中,示例代码可以放在一个启动后执行的方法中(比如实现ApplicationRunner接口等方式)
import com.google.common.hash.BloomFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;import java.util.List;@Component
public class BloomFilterInitializer implements ApplicationRunner {@Autowiredprivate ItemService itemService; // 假设你的业务层接口是这个名字,根据实际调整@Autowiredprivate BloomFilter<Long> ItemIdBloomFilter;@Overridepublic void run(ApplicationArguments args) throws Exception {List<Long> allItemIds = itemService.getAllItemIds(); // 需在ItemService中定义获取所有商品ID的方法for (Long id : allItemIds) {ItemIdBloomFilter.put(id);}}
}
4.在查询方法中使用布隆过滤器进行判断
修改原来的queryById方法,在查询 Redis 之前先通过布隆过滤器判断商品ID 是否可能存在:
@Autowiredprivate BloomFilter<Long> itemIdBloomFilter;/*** 根据id查询商品** @param id* @return*/@Overridepublic Result queryById(Long id) {// 先通过布隆过滤器判断if (!itemIdBloomFilter.mightContain(id)) {return Result.fail("商品不存在");}// 1.查询redisString jsonItem = redisTemplate.opsForValue().get("item:" + id);// 2.命中直接返回if (StrUtil.isNotBlank(jsonItem)) {Item item = JSONUtil.toBean(jsonItem, Item.class);return Result.ok(item);}// 3.未命中查询数据库Item item = getById(id);// 4.数据库中没有返回错误if (item == null) {return Result.fail("商品不存在");}// 5.写入redisredisTemplate.opsForValue().set("item:" + id, JSONUtil.toJsonStr(item), 30, TimeUnit.MINUTES);// 6.返回数据return Result.ok(item);}
以上两种方法都是被动防范缓存穿透的方法,除此之外还有一些方法可以主动防止缓存穿透:
- 增强
ID
的复杂度,增大伪造ID
的困难程度 - 做好数据格式校验,校验
ID
是否遵循特定规则 - 做好热点数据限流
- 加强用户权限校验