Redis的缓存穿透、缓存雪崩、缓存击穿问题及有效解决方案

目录

一、缓存穿透

1.简介

2.解决方案

3.修改前的代码

4.修改过后的代码

二、缓存雪崩

1.简介

2.解决方案

三、缓存击穿

1.简介

2.解决方案

3.用代码来实现互斥锁来解决缓存击穿

4.用代码来实现逻辑过期解决缓存击穿

四、缓存穿透和缓存击穿的区别


一、缓存穿透

1.简介

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

也就是有请求一请求到后端就一定会打到数据库(redis和MySQL都没有该数据),打一次不要紧,但要是有无数个请求同时打过来,那数据库大概率会崩溃,所以预防缓存穿透问题还是十分重要的。

2.解决方案

目前常见的解决方案有返回缓存空对象和布隆过滤两种方法,区别如下:

  • 缓存空对象

    • 优点:实现简单,维护方便

    • 缺点:

      • 额外的内存消耗

      • 可能造成短期的不一致

  • 布隆过滤

    • 优点:内存占用较少,没有多余key

    • 缺点:

      • 实现复杂

      • 存在误判可能

缓存空对象的解决思路如下:

当一个请求直接打到数据库的时候,后端直接在Redis中返回个空对象,之后多次相同的请求都会止步于Redis,通俗易懂的话来说,要打打Redis,别打MySQL。

布隆过滤的解决思路如下:

布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,

假设布隆过滤器判断这个数据不存在,则直接返回

这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突

3.修改前的代码

我们采用返回空对象的方法来缓解缓存穿透问题,修改前的代码如下:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    /**
     * 根据id查询店铺信息
     * @param id
     * @return
     */
    @Override
    public Result queryById(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        //1.从redis中查询商铺缓存信息
        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) {
            //6、没有,返回错误
            return Result.fail("店铺不存在");
        }
        //7、有数据,返回,并将数据写入redis
        String jsonStr = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set(key,jsonStr);
        //8、返回结果
        return Result.ok(shop);
    }
}

这段代码的详细分析在以下文章可查看:给查询业务添加Redis缓存

4.修改过后的代码

@Override
    public Result queryById(Long id) {
        String key = RedisConstants.CACHE_SHOP_KEY + id;
        //1.从redis中查询商铺缓存信息
        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) {
            //6、没有,返回空字符串给redis------------------------------->修改后
            stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
            return Result.fail("店铺不存在");
        }
        //7、有数据,返回,并将数据写入redis
        String jsonStr = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set(key,jsonStr,30L, TimeUnit.MINUTES);
        //8、返回结果
        return Result.ok(shop);
    }

二、缓存雪崩

1.简介

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

2.解决方案

  • 给不同的Key的TTL添加随机值

  • 利用Redis集群提高服务的可用性

  • 给缓存业务添加降级限流策略

  • 给业务添加多级缓存

三、缓存击穿

1.简介

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

2.解决方案

常见的解决方案有两种:

  • 互斥锁

  • 逻辑过期

互斥锁的解决思路就是在Redis进行缓存重建时,拿到一个互斥锁,其他请求拿不到这个锁就是乖乖等待锁的释放。

逻辑过期的解决思路如下:

在存入redis的value中增加一个字段,该字段为过期时间加上x分钟,通过计算就知道这个数据是否逻辑上过期,事实上没过期一直存在redis中。

在redis进行缓存重建的时候,会另开一个线程进行重建并拿到互斥锁,其他线程拿不到数据想要缓存重建时也拿不到锁,那就直接返回旧数据。

互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响

逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦

3.用代码来实现互斥锁来解决缓存击穿

其中这个互斥锁跟我们平时用的锁不太一样,不是synchronized等那些锁,这个互斥锁是我们自己编写的锁,其核心是利用redis中的setnx指令来实现这个互斥锁。(共享+先到先到+不会被其他线程修改)

获取锁和释放锁的代码如下:

private boolean tryLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}

private void unlock(String key) {
    stringRedisTemplate.delete(key);
}

Service代码如下:

public Shop queryWithMutex(Long id)  {
        String key = CACHE_SHOP_KEY + id;
        // 1、从redis中查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get("key");
        // 2、判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //判断命中的值是否是空值
        if (shopJson != null) {
            //返回一个错误信息
            return null;
        }
        // 4.实现缓存重构
        //4.1 获取互斥锁
        String lockKey = "lock:shop:" + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2 判断否获取成功
            if(!isLock){
                //4.3 失败,则休眠重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            //4.4 成功,根据id查询数据库
             shop = getById(id);
            // 5.不存在,返回错误
            if(shop == null){
                 //将空值写入redis
                stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
                //返回错误信息
                return null;
            }
            //6.写入redis
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);

        }catch (Exception e){
            throw new RuntimeException(e);
        }
        finally {
            //7.释放互斥锁
            unlock(lockKey);
        }
        return shop;
    }

4.用代码来实现逻辑过期解决缓存击穿

流程图如下:

具体代码如下:

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
    String key = CACHE_SHOP_KEY + id;
    // 1.从redis查询商铺缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    // 2.判断是否存在
    if (StrUtil.isBlank(json)) {
        // 3.存在,直接返回
        return null;
    }
    // 4.命中,需要先把json反序列化为对象
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 5.判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())) {
        // 5.1.未过期,直接返回店铺信息
        return shop;
    }
    // 5.2.已过期,需要缓存重建
    // 6.缓存重建
    // 6.1.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    boolean isLock = tryLock(lockKey);
    // 6.2.判断是否获取锁成功
    if (isLock){
        CACHE_REBUILD_EXECUTOR.submit( ()->{

            try{
                //重建缓存
                this.saveShop2Redis(id,20L);
            }catch (Exception e){
                throw new RuntimeException(e);
            }finally {
                unlock(lockKey);
            }
        });
    }
    // 6.4.返回过期的商铺信息
    return shop;
}

四、缓存穿透和缓存击穿的区别

缓存穿透和缓存击穿之间的区别,可以说是既有质的区别也有量的区别:

  1. 质的区别

    • 数据存在性不同:缓存穿透是查询一个数据库中不存在的数据,而缓存击穿是查询一个数据库中确实存在且非常热门的数据。
    • 触发原因不同:缓存穿透是由于查询的数据在数据库中不存在,导致缓存中也没有对应的记录;缓存击穿则是由于缓存中的数据到期,而此时有大量请求需要访问这个数据。
  2. 量的区别

    • 请求量不同:缓存穿透可能涉及到的请求量相对较小,因为通常不会有很多请求同时查询一个不存在的数据;而缓存击穿则可能涉及到大量的请求,因为热门数据的访问量通常很大。
    • 影响范围不同:缓存穿透的影响范围可能较小,因为它只涉及到查询不存在的数据;而缓存击穿的影响范围可能较大,因为它涉及到的是热门数据,可能会对数据库造成较大压力。

总的来说,缓存穿透和缓存击穿在触发原因、数据存在性、请求量和影响范围等方面都存在明显的区别,这些区别既包括了质的不同,也包括了量的不同。因此,在设计缓存策略时,需要针对这两种情况分别采取不同的解决方案。

<think> 嗯,用户想了解缓存击穿的概念、原因和解决方案。根据引用内容,缓存击穿是指某个非常热门的key在缓存失效瞬间,大量并发请求直接穿透数据库,可能导致数据库压垮。这与缓存雪崩(大量key同时失效)不同,是针对单个高热点key的失效问题。 用户似乎正在学习缓存相关的技术问题,可能是后端开发工程师或系统架构师。从提问方式看,ta对缓存机制已有基础认知,现在需要深入理解特定场景的故障模式。 关于概念部分需要强调三个关键点:1) 针对热门key 2) 并发量高 3) 缓存失效瞬间。引用[1]和[4]的对比描述很清晰,特别是与雪崩的区分点值得保留。 解决方案方面,引用提到两种核心方法:永不过期和互斥锁。但注意到引用[2]补充了逻辑过期方案的优缺点,这点需要展开说明:虽然减少更新次数是优点,但存在数据不一致风险,且实现复杂。用户作为开发者可能更关心实际选型建议。 技术细节上要特别注意: - 互斥锁的setnx实现需要设置合理的超时时间 - 逻辑过期方案中"缓存为空"的特殊情况处理 - 引用[3]提到的监控指标(命中率、QPS)对生产环境很重要 用户可能隐含的需求是希望获得可落地的方案,所以考虑在回答中加入伪代码示例。虽然引用里没提供代码,但根据系统指令要求技术准确度,准备用代码块展示互斥锁的标准实现流程。 最后考虑设计的问题要覆盖延伸场景: 1) 互斥锁性能影响 - 针对高并发优化 2) 逻辑过期一致性 - 适合对一致性要求不高的场景 3) 组合方案设计 - 生产环境通常需要多级防护 这样既紧扣主题又提供进阶思考方向。 </think>以下是关于缓存击穿的概念、原因及解决方案的详细说明,结合引用内容整理: --- ### **一、缓存击穿的概念** 缓存击穿指 **某个访问量极高的热点 key 在缓存过期瞬间**,大量并发请求直接穿透缓存层,同时涌入数据库查询数据,导致数据库瞬时压力激增甚至崩溃。 ✅ **核心特征**: - 针对单个高热度 key(如秒杀商品、热搜话题) - 高并发请求集中访问同一 key - 缓存失效瞬间触发数据库压力 ⚠️ **与缓存雪崩的区别**: > 缓存雪崩是**大量 key 集中失效**或 Redis 宕机(引用[4]),而击穿仅针对**单个热点 key 失效**(引用[1][4])。 --- ### **二、发生原因** 1. **热点 key 过期**:高访问量 key 到达 TTL(生存时间)后被自动清除。 2. **未预热缓存**:新热点 key 首次请求时缓存未建立。 3. **突发流量**:如明星绯闻、秒杀活动,瞬间请求量远超预期。 --- ### **三、解决方案** #### **方案 1:互斥锁(Mutex Lock)** ```python def get_data(key): data = redis.get(key) if data is None: # 缓存失效 if redis.setnx("lock:" + key, 1, timeout=5): # 尝试获取分布式锁(引用[1][2]) data = db.query(key) # 查数据库 redis.set(key, data, ttl=300) redis.delete("lock:" + key) else: time.sleep(0.1) # 未获锁则短暂等待 return get_data(key) # 重试 return data ``` ✅ **优点**: - 确保仅一个线程重建缓存,避免数据库被打垮 ❌ **缺点**: - 等待线程可能短暂延迟(需设置合理的锁超时时间) #### **方案 2:逻辑过期(逻辑永不过期)** 1. **缓存永不过期**: ```redis redis.set(key, data) # 不设置TTL(引用[1]) ``` 2. **异步更新缓存**: - 后台任务定期更新数据 - 或通过消息队列监听数据变更 ✅ **优点**:彻底避免缓存失效 ❌ **缺点**: - 数据一致性需额外保障(引用[2]) - 占用内存可能增长(需监控淘汰策略) #### **方案 3:热点 key 探测与预热** - **实时监控**:识别高频访问 key(如 Redis 的 `hotkeys` 命令) - **提前续期**:在 key 过期前主动刷新缓存 - **冷启动预热**:活动开始前提前加载数据到缓存 --- ### **四、组合策略建议(引用[3])** 1. **互斥锁 + 逻辑过期**: - 对核心热点 key 使用逻辑过期 - 非核心 key 用互斥锁降低复杂度 2. **监控告警**: - 实时跟踪缓存命中率、数据库 QPS - 突发流量时动态延长热点 key TTL 3. **限流降级**: - 在缓存层设置请求限流(如 Redis Cell) - 降级方案:返回默认值或静态页 --- ### **伪代码示例(互斥锁优化版)** ```python def get_data_with_lock(key): data = redis.get(key) if data: return data lock_key = f"lock:{key}" if redis.setnx(lock_key, 1, ex=3): # 锁超时3秒防死锁 try: data = db.query(key) # 双检锁防止重复查询 redis.set(key, data, ex=60) finally: redis.delete(lock_key) else: time.sleep(0.05) # 降低重试频率 return get_data_with_lock(key) # 递归重试 return data ``` ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值