防止缓存击穿、缓存穿透和缓存雪崩

使用Redis缓存防止缓存击穿、缓存穿透和缓存雪崩

在高并发系统中,缓存击穿、缓存穿透和缓存雪崩是三种常见的缓存问题。本文将介绍如何使用Redis、分布式锁和布隆过滤器有效解决这些问题,并且会通过Java代码详细说明实现的思路和原因。


1. 背景

缓存穿透:指的是大量请求缓存中不存在且数据库中也不存在的数据,导致大量请求直接打到数据库上,形成数据库压力。

缓存击穿:指的是某个热点数据在高并发时失效,大量请求同时穿透缓存,导致数据库负载瞬间激增。

缓存雪崩:指的是缓存集中过期或宕机,导致短时间内大量请求打到数据库,压垮后端服务。

为了应对这三种情况,本文介绍了三种方法:

  1. 缓存穿透:使用空值缓存和布隆过滤器。
  2. 缓存击穿:使用分布式锁保证只有一个线程访问数据库并重建缓存。
  3. 缓存雪崩:合理设置缓存过期时间,防止大规模缓存同时失效。

2. 缓存穿透处理

我们首先来看queryWithPassThrough方法,它处理的是缓存穿透问题。

public <R, P> R queryWithPassThrough(String key, Class<R> clazz, Function<P, R> bdCallback, P params, Duration duration) {
    // 1. 从缓存中查询数据
    String dataJsonStr = stringRedisTemplate.opsForValue().get(key);
    
    // 2. 如果缓存中存在数据,直接返回
    if (StrUtil.isNotBlank(dataJsonStr)) {
        return JSONUtil.toBean(dataJsonStr, clazz);
    }
    
    // 3. 如果缓存存在的是空字符串,表示数据库中也没有该数据,直接返回null,防止缓存穿透
    if ("".equals(dataJsonStr)) {
        return null;
    }
    
    // 4. 缓存没有命中,调用数据库查询
    R data = bdCallback.apply(params);
    
    // 5. 如果数据库中没有数据,缓存空字符串以防止缓存穿透,并设置较短的过期时间
    if (data == null) {
        stringRedisTemplate.opsForValue().set(key, "", Duration.ofSeconds(20));
        return null;
    }
    
    // 6. 数据库查询有结果,将结果缓存并设置过期时间
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(data), duration);
    return data;
}
解析:
  1. 缓存查询:首先从Redis缓存中查询数据,如果有数据,直接返回。
  2. 缓存空值机制:如果缓存中存储的是空字符串(即以前数据库查询也没有该数据),防止缓存穿透(同一个不存在的请求反复击穿缓存,查询数据库)。
  3. 查询数据库:当缓存没有命中且没有空值时,查询数据库并缓存结果。如果数据库中没有数据,缓存空值并设置短期过期时间(例如20秒)。
为什么要缓存空值?

防止缓存穿透。如果没有缓存空值,对于不存在的数据的查询会反复击穿缓存,导致数据库压力过大。


3. 缓存击穿处理

缓存击穿问题通常发生在热点数据过期时,同时有大量请求到达数据库。为了解决这个问题,我们使用分布式锁来保证在缓存失效时,只有一个线程能够访问数据库并更新缓存。

private <R, P> R queryWithMutex(String key, Class<R> clazz, Duration expireSeconds, Function<P, R> bdCallback, P params) {
    // 1. 从缓存查询数据
    String dataJson = stringRedisTemplate.opsForValue().get(key);
    
    // 2. 如果缓存中有数据,直接返回
    if (StrUtil.isNotBlank(dataJson)) {
        return JSONUtil.toBean(dataJson, clazz);
    }
    
    // 3. 如果缓存中是空值,返回null,防止穿透
    if ("".equals(dataJson)) {
        return null;
    }
    
    // 4. 缓存没有命中,尝试加锁
    String lock = "lock:" + key;
    try {
        while (!tryLock(lock)) {
            // 如果获取不到锁,等待并重复检查缓存
            Thread.sleep(50L);
            dataJson = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(dataJson)) {
                return JSONUtil.toBean(dataJson, clazz);
            }
            if ("".equals(dataJson)) {
                return null;
            }
        }
        
        // 5. 加锁后再次检查缓存,防止其他线程已经重建缓存
        dataJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(dataJson)) {
            return JSONUtil.toBean(dataJson, clazz);
        }
        if ("".equals(dataJson)) {
            return null;
        }
        
        // 6. 如果缓存没有数据,查询数据库
        R apply = bdCallback.apply(params);
        if (apply == null) {
            // 如果数据库没有数据,缓存空值
            stringRedisTemplate.opsForValue().set(key, "", Duration.ofMinutes(2));
            return null;
        }
        
        // 7. 将数据库数据存入缓存
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(apply), expireSeconds);
        return apply;
    } catch (Exception e) {
        throw new RuntimeException(e);
    } finally {
        // 8. 释放锁
        releaseLock(lock);
    }
}
解析:
  1. 缓存查询:如果缓存中有数据,直接返回,避免击穿。
  2. 加锁机制:如果缓存没有数据,尝试获取锁。如果获取不到锁,说明其他线程正在重建缓存,此时线程等待并轮询缓存,直到锁释放或缓存更新。
  3. 缓存重建:拿到锁的线程查询数据库,并将结果存入缓存。
  4. 锁的释放:确保在缓存重建完成后释放锁,避免死锁。
为什么需要加锁?

防止缓存击穿时,多个线程同时查询数据库,导致数据库压力激增。通过分布式锁,保证只有一个线程可以查询数据库并更新缓存。


4. 逻辑过期处理

为了解决缓存雪崩问题,可以通过逻辑过期方式处理热点数据的过期。即使数据过期了,系统也能继续返回旧数据,同时后台线程异步更新缓存。

private <R, P> R queryWithLogicalExpire(String key, Class<R> clazz, Duration expireSeconds, Function<P, R> bdCallback, P params) {
    // 1. 从缓存查询数据
    String dataJson = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isBlank(dataJson)) {
        return null;
    }
    
    // 2. 反序列化数据
    RedisData data = JSONUtil.toBean(dataJson, RedisData.class);
    R r = JSONUtil.toBean((JSONObject) data.getData(), clazz);
    
    // 3. 检查数据是否过期,如果没有过期,直接返回数据
    if (data.getExpireTime().isAfter(LocalDateTime.now())) {
        return r;
    }
    
    // 4. 如果数据已过期,尝试加锁,进行缓存重建
    String lock = "lock:" + key;
    if (tryLock(lock)) {
        try {
            dataJson = stringRedisTemplate.opsForValue().get(key);
            data = JSONUtil.toBean(dataJson, RedisData.class);
            if (data.getExpireTime().isAfter(LocalDateTime.now())) {
                return JSONUtil.toBean((JSONObject) data.getData(), clazz);
            }
            
            // 5. 异步重建缓存
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    R newData = bdCallback.apply(params);
                    RedisData redisData = new RedisData(newData, LocalDateTime.now().plusSeconds(expireSeconds.getSeconds()));
                    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    releaseLock(lock);
                }
            });
        } finally {
            releaseLock(lock);
        }
    }
    
    // 6. 返回过期数据,保证系统可用性
    return r;
}
解析:
  1. 缓存逻辑过期:数据存入缓存时,同时存储逻辑过期时间。即使数据过期了,系统可以继续返回旧数据,防止雪崩。
  2. 后台异步更新:通过线程池,异步执行缓存重建,防止阻塞用户请求。
  3. 双重检查:在获取锁后,再次检查缓存

,避免重复重建缓存。

为什么需要异步更新?

即使数据过期,也可以返回旧数据,保证服务可用性。同时在后台异步更新缓存,减少对前台服务的影响。


5. 分布式锁

为确保只有一个线程能重建缓存,我们使用了Redis的setIfAbsent方法实现分布式锁,并且为锁设置了过期时间,防止死锁。

private boolean tryLock(String key) {
    // 尝试获取锁,并设置锁的过期时间,防止死锁
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(10L));
    return Boolean.TRUE.equals(flag);
}

private void releaseLock(String key) {
    // 释放锁
    stringRedisTemplate.delete(key);
}
解析:
  • 获取锁:通过setIfAbsent,确保只有一个线程能获取锁,并设置锁过期时间,防止死锁。
  • 释放锁:任务完成后,释放锁,允许其他线程继续操作。
为什么要设置锁的过期时间?

防止由于异常情况(例如服务器宕机)导致锁无法释放,产生死锁问题。


6. 总结

通过使用Redis缓存、分布式锁和逻辑过期策略,我们可以有效解决缓存穿透缓存击穿缓存雪崩问题。通过这套方案,我们能够在保证系统高可用的同时,减少数据库压力,并提高服务的性能和稳定性。

关键点包括:

  • 缓存空值机制:防止缓存穿透。
  • 分布式锁:防止缓存击穿时多个线程同时访问数据库。

这套方案对于处理高并发场景下的缓存问题是非常有效的。希望这篇文章能帮助大家更好地理解和应对缓存问题。

缓存击穿缓存穿透缓存雪崩是与 Redis 缓存相关的常见问题。这些问题主要出现在缓存系统无法有效地处理某些请求或者在高并发情况下。 1. 缓存击穿:指的是一个热点数据突然失效,而此时有大量并发请求同一份失效的数据,导致这些请求穿透缓存,直接访问数据库,从而导致数据库压力过大。为了避免缓存击穿,可以在缓存失效的时候,设置短暂的锁来阻止其他请求直接访问数据库,并在锁过期后重新加载缓存。 2. 缓存穿透:指的是查询一个不存在的数据,而此类请求会直接绕过缓存,直接访问数据库。这样的请求会导致大量无效的数据库查询,造成数据库压力过大。为了避免缓存穿透,可以在查询结果为空时,也将空值保存到缓存中,并设置一个较短的过期时间。 3. 缓存雪崩:指的是大规模缓存失效,导致所有请求都直接访问数据库。这种情况通常是由于缓存服务器故障、过期时间设置不当或者缓存数据集中过度等原因引起的。为了避免缓存雪崩,可以设置缓存的过期时间时加上一个随机值,使缓存失效时间分散开来;或者使用多级缓存,将请求分散到不同的缓存服务器上。 以上是对于缓存击穿缓存穿透缓存雪崩的简要解释。在实际应用中,还可以结合具体的业务场景和实际需求采取一些其他的措施来防止这些问题的发生。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值