关于Redis的击穿穿透与雪崩

知识点助记

就比如你开了一个商店,Redis就像放在门外面的小货架,那上面摆放着最畅销的商品。

但是有这么三种情况会造成商店崩溃:

  1. 缓存击穿:有一个很热门的商品正好过期了,这个时候涌入了一大批购买这个商品的顾客,所有人都涌向了你的小商店去买这一个物品,造成系统瘫痪。
  2. 缓存穿透:有一个调皮的小孩子,他一直管你要你商店里没有的东西,这导致店员每次都要去找这个不存在的商品。大量这样的请求会让店员累坏。
  3. 缓存雪崩:货架上很多畅销的产品在同一时间过期,这个时候来了很多顾客来买这些商品,导致大量请求瞬间砸向商店,造成系统瘫痪。

解决办法

缓存击穿

核心思路就是防止多个请求同时去数据库查询同一个失效的热点key

  1. 对于极热点key设置永不过期。通过后台任务或者消息队列在数据更新时主动刷新缓存。
  2. 互斥锁:当第一个请求发现缓存失效时,他并不直接去查数据库,而是先获取一个分布式锁。获取锁成功的请求才有资格去查询数据表并
    重建缓存。其他请求则等待,缓存重建完后直接从Redis中获取数据。
// 以下是伪代码

public String getData(String key){
// 从缓存中获取
String data = redis.get(key);
if (data == null){ // 缓存失效
// 尝试获取分布式锁
if (tryLock(key)){
try{
// 再次验证缓存是否有数据
data = redis.get(key);
if (data == null) {
// 从数据库中获取数据
data = dataMapper.getData(key);

// 写入Redis
redis.set(key, 36000, data);
}
} finally {
// 释放锁
unlock(key);
}
} else {
// 未获取到锁请求,缓存正在重建
Thread.sleep(50);
return getData(key); // 重试
}
}
return data;
}

缓存穿透

核心思路是即使数据不存在,也要在缓存中留下一个标记,为数据库挡住恶意无效请求。

  1. 缓存空对象:如果从数据库中查询数据不存在,我们仍然要向Redis中写入一个空值(如null,””),并设置一个较短的过期时间(3-5min)。
    这样缓存空对象,就可以防止缓存穿透。较短的过期也可以及时在Redis中释放无效key
  2. 布隆过滤器:在访问缓存和数据库之前先访问布隆过滤器。
    布隆过滤器的原理就是告诉你某个key一定不存在或者可能存在
    在系统启动的时候,将所有可能存在的key预先加载到布隆过滤器,当请求过来的时候先用布隆过滤器判断。
    如果返回不存在,则直接返回空。如果可能存在,那就继续查询缓存和数据库。

缓存雪崩

核心思路是避免大量热点Key在同一时间过期。

  1. 设置随机过期时间:我们可以在基础值上添加一个随机值。

关于布隆过滤器的拓展

布隆过滤器告诉你这个key存在,那只是可能存在。但是他说这个key不存在那就一定没有。

其实布隆过滤器的底层实现就是一个超大的位数组(Bit Array) + 多个独立的哈希函数。
位数组所有位置默认是0.哈希函数将任意输入的key映射到位数组的某个位置。

它可以用极小的空间,极快的查询效率(时间复杂度O(1))简单判断key是否存在。

具体实现

布隆过滤器的操作只有

  • 添加(Add)
    将要添加的元素item分别通过k个哈希函数计算,得到k个哈希值
    将这k个哈希值对位数组长度m取模,得到k个位置索引。
    将位数组这k个位置的值设置为1
// 伪代码
public void add(String item) {
for(int i = 0; i < k; i++) {
int index = hashFunction[i].hash(item) % m;
bitArray.set(index, true);
}
}
  • 查询(Contains)
    将要查询的元素item同样通过k个哈希函数计算并取模,得到k个位置索引。
    如果所有位置都是1,则返回true(可能存在),否则返回false(一定不存在)。
// 伪代码
public boolean contains(String item){
for(int i = 0; i < k; i++) {
int index = hashFunction[i].hash(item) % m;
if(!bitArray.get(index)) { // 如果有一个0
return false; // 那这个key一定不存在
}
}
return true; // 可能存在 再进行下一步查找
}

主要参数

  • m 位数组长度。m越大误判率越低,但空间消耗越大
  • k 哈希函数数量。k越大误判率越低,但计算开销大,且位数组更快被填满(增加误判)
  • n 预期元素数量。就是你预计要存多少个元素
  • p 期望误判率。可以容忍误判率多少

公式:
最优哈希函数数量:k = ln(2) * (m / n)

误判率:p = (1 - e^(-k * n / m))^k

Java集成Redission实现布隆过滤器

  1. 引入pom依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.6</version>
</dependency>
  1. 配置Redission客户端
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;

@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + host + ":" + port);
return Redisson.create(config);
}
}
  1. 初始化布隆过滤器
@Service
public class BloomFilterService {
@Resource
private RedissonClient redissonClient;
@Resource
private DataService dataService;

@PostConstruct // 标记一个方法在依赖注入完后自动调用
public void initBloomFilter() {
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("userFilter");
bloomFilter.tryInit(1000000L, 0.01); // 预期数据量100万,误判率1%
List<String> ids = dataService.getAllIds();
ids.forEach(bloomFilter::add);
}
}
  1. 业务逻辑中使用布隆过滤器
public User getUserById(String id) {
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("userFilter");
// 步骤1:检查布隆过滤器
if (!bloomFilter.contains(id)) {
return null; // 直接拦截不存在的数据
}
// 步骤2:查询缓存
User user = redisTemplate.opsForValue().get("user:" + id);
if (user != null) {
return user;
}
// 步骤3:查询数据库
user = userRepository.findById(id);
if (user != null) {
redisTemplate.opsForValue().set("user:" + id, user, 30, TimeUnit.MINUTES);
} else {
// 缓存空值,防止重复查询数据库
redisTemplate.opsForValue().set("user:" + id, NullValue.DEFAULT, 5, TimeUnit.MINUTES);
}
return user;
}