当前位置: 首页 > article >正文

Redis——缓存击穿

文章目录

  • 1. 问题介绍
    • 1.1 定义
    • 1.2 举例
  • 2. 解决方案
    • 2.1 方案一:互斥锁
      • 2.1.1 做法
      • 2.1.2 流程
      • 2.1.3 示例代码
      • 2.1.4 优点
      • 2.1.5 缺点
      • 2.1.6 适用场景
    • 2.2 方案二:逻辑过期
      • 2.2.1 做法
      • 2.2.2 流程
      • 2.2.3 示例代码
      • 2.2.4 优点
      • 2.2.5 缺点
      • 2.2.6 适用场景
    • 2.3 方案三:限流
  • 3. 总结


1. 问题介绍

1.1 定义

缓存击穿:在一个热点缓存过期后的短时间内 (数据还未缓存到 Redis 中之前),如果有大量 并发请求 查询这个缓存,则这些请求会直接访问 MySQL 数据库,导致 MySQL 压力过大,从而宕机。

也可以把缓存击穿理解为 大量并发请求查询过期热点缓存,直接冲击 MySQL,导致其宕机

1.2 举例

对于请求 goods/10001,如果它的缓存过期了,并且从查询数据库到将其缓存到 Redis 共需 30ms,则在这 30ms 中,每个 goods/10001 请求都会查询数据库,假如有大量的请求,则会导致 MySQL 宕机。

2. 解决方案

从定义和举例中可以看出,缓存击穿的核心问题在于 热点缓存过期导致超多线程同时查询 MySQL,所以有两种解决的思路:

  • 防止多线程查询同一个数据。
  • 不让热点缓存过期。

2.1 方案一:互斥锁

2.1.1 做法

在查询 MySQL 时,针对这个数据加互斥锁,所有查询此数据的线程都需要等待互斥锁释放,然后再次查询缓存,此时缓存中如果有需要的数据,则直接返回,如果没有,则继续查询 MySQL 数据库。

2.1.2 流程

实际上这种思想和单例模式的 双重检查 很像,都是先查看值是否为 null,如果为 null,则加锁,获取锁之后再查看值是否为 null,如果为 null,才进行初始化;否则返回不为 null 的值即可。

双重检查的流程图如下所示:

Created with Raphaël 2.3.0 开始 值是否为 null? 加互斥锁 值是否为 null? 初始化值 返回值 结束 yes no yes no

本做法的流程图如下所示:

Created with Raphaël 2.3.0 开始 缓存是否为 null? 加分布式互斥锁 缓存是否为 null? 查询 MySQL + 缓存值 返回值 结束 yes no yes no

2.1.3 示例代码

注:在代码中,设置分布式互斥锁的过期时间为 5s,这个时间对于大多数查询请求都是足够的,但对于某些比较复杂的多表查询,可能会导致锁过期,这属于分布式锁的续期问题,之后会详细讲解,现在暂时使用它。

public Goods get(long goodsId) {
    // 获取缓存对应的键
    String key = "goods:" + goodsId;

    // 如果缓存中有对应的数据,则直接返回
    Goods goods = (Goods) redisTemplate.opsForValue().get(key);
    if (goods != null) {
        return goods;
    }

    // 获取互斥锁,如果获取互斥锁失败,则休眠一段时间后重试
    String lockKey = "goods:lock:" + goodsId;
    if (Boolean.FALSE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS))) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return get(goodsId); // 递归调用
    }

    try {
        // 获取到锁后,再次检查缓存,防止其他线程在获取锁期间将数据写入缓存
        goods = (Goods) redisTemplate.opsForValue().get(key);
        if (goods != null) {
            return goods;
        }

        // 从数据库中查询,如果数据库中有数据,则缓存查询到的数据
        goods = goodsMapper.getById(goodsId);
        if (goods != null) {
            redisTemplate.opsForValue().set(key, goods, 3, TimeUnit.MINUTES);
        }
    } finally {
        // 释放互斥锁
        redisTemplate.delete(lockKey);
    }

    // 返回查询到的对象
    return goods;
}

2.1.4 优点

  • 实现简单:实际上这种 加锁防止多次操作 的思想在单例模式中就能学到,实现起来也相对简单,之后要讲的分布式锁续期也可以通过框架来实现。
  • 一致性强:在查询 MySQL 时,由于加了分布式互斥锁,所以全局只有一个线程去查询 MySQL,其它线程都在等待这个线程查询到的结果,不会返回之前过期的数据(这是相对 方案二 而言的)。

2.1.5 缺点

  • 性能开销大:锁的获取和释放有一定的性能损耗,在高并发环境下,大量线程竞争锁会导致线程的上下文切换频繁,增加系统的开销。
  • 响应时间长:当有大量请求同时发现缓存过期并尝试获取分布式互斥锁时,这些请求会被阻塞,直到锁被释放,从而导致响应时间增加。

在示例代码中,锁竞争失败后先休眠了 100ms,减少了锁之间的竞争,从而减少性能开销,但是会导致响应时间变长。

2.1.6 适用场景

加互斥锁的方案适合 要求数据一致性强、可容忍响应时间长 的场景,比如银行的转账业务,需要返回数据库中最新的数据。

2.2 方案二:逻辑过期

2.2.1 做法

给缓存加上一个 expire 字段,代表它的逻辑过期时间,无需设置缓存的实际过期时间。在处理查询请求时,获取到缓存先不着急返回,而是查看这个缓存是否逻辑过期,如果逻辑过期,则获取互斥锁,如果获取成功,则让别的线程去查询 MySQL、缓存新数据 和 释放互斥锁,这个线程先返回旧数据;如果获取失败,则返回旧数据;如果没有逻辑过期,直接返回缓存数据即可。

2.2.2 流程

Created with Raphaël 2.3.0 缓存是否为 null? 查询 MySQL + 缓存数据 返回新数据 缓存是否逻辑过期? 获取分布式互斥锁 是否获取到锁? 开启一个新线程,查询 MySQL + 缓存新数据 + 释放锁 返回旧数据 yes no yes no yes no

2.2.3 示例代码

注:本示例代码中使用 new Thread() 开启了一个新线程,在生产中不要这样做,而是使用线程池,避免频繁创建线程浪费时间。除此之外,由于使用到了分布式互斥锁,所以也会存在续期问题。

public class Goods { // 商品类
    private long goodsId;
    private long expire;

    public long getGoodsId() { return goodsId; }
    public long getExpire() { return expire; }
    public void setGoodsId(long goodsId) { this.goodsId = goodsId; }
    public void setExpire(long expire) { this.expire = expire; }
}
public Goods get(long goodsId) {
    // 获取缓存对应的键
    String key = "goods:" + goodsId;

    // 如果缓存中没有对应的数据,则查询 MySQL
    Goods goods = (Goods) redisTemplate.opsForValue().get(key);
    if (goods == null) {
        // 如果数据库中有数据,则缓存查询到的数据
        goods = goodsMapper.getById(goodsId);
        if (goods != null) {
            goods.setExpire(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(3)); // 设置 3min 的逻辑过期时间
            redisTemplate.opsForValue().set(key, goods);
        }
        // 返回新数据
        return goods;
    }

    // 如果缓存中有数据,则判断数据是否过期,如果没有过期,则直接返回
    long expire = goods.getExpire();
    if (expire > System.currentTimeMillis()) {
        return goods;
    }

    // 获取互斥锁,如果获取互斥锁失败,说明已经有线程去更新数据了,本线程直接返回旧数据即可
    String lockKey = "goods:lock:" + goodsId;
    if (Boolean.FALSE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS))) {
        return goods;
    }

    // 开启一个新线程去更新数据
    new Thread(() -> {
        try {
            // 查询 MySQL
            Goods newGoods = goodsMapper.getById(goodsId);
            if (newGoods != null) {
                // 更新缓存数据
                newGoods.setExpire(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(3)); // 设置 3min 的逻辑过期时间
                redisTemplate.opsForValue().set(key, newGoods);
            }
        } finally {
            // 释放互斥锁
            redisTemplate.delete(lockKey);
        }
    }).start();

    // 本线程先返回旧数据
    return goods;
}

2.2.4 优点

  • 性能开销小:当查询到旧数据时,全局只有一个线程查询 MySQL,不会有线程之间频繁切换的问题。但如果旧数据很多,则 针对每个旧数据都使用新线程查询 会占用较多的 CPU 资源。
  • 响应时间短:由于查询到旧数据直接返回,所以响应时间会很短。

2.2.5 缺点

  • 实现复杂:相对于方案一,本方案需要使用线程池,并且还要给对象添加一个 expire 字段表示逻辑过期,增加了实现的复杂度。
  • 一致性弱:在另一个线程更新缓存的时间段内,只能获取到旧数据。但更新完毕后,就可以获取到新数据了。从而保证了数据的 最终一致性
  • 缓存数据太多:由于没有给缓存设置物理过期时间,所以缓存永不过期,如果缓存太多数据,则根据 Redis 配置的缓存淘汰策略不同,会造成不同程度的影响(之后会讲)。

2.2.6 适用场景

逻辑过期的方案适合 要求响应时间短、数据只需要最终一致 的场景,比如视频的详情查询,暂时查询不到最新的点赞量、浏览量也无所谓。

2.3 方案三:限流

限流对于缓存击穿问题仍是一种不错的解决方案,加载控制器层。如果前两种方案都无法实现,则可以采取这种方案。

3. 总结

缓存击穿指的是热点数据过期后,短时间内大量查询请求穿透 Redis,直接冲击 MySQL,导致其宕机。

解决方案主要有两种:

  • 互斥锁:在缓存过期后,全局只允许一个线程查询 MySQL。适用于数据强一致性的场景。
  • 逻辑过期:无需给缓存设置物理过期时间,而给缓存设置逻辑过期时间,数据过期后先返回旧数据,使用另一个线程(这个线程也是全局唯一的,使用分布式互斥锁保证)查询并缓存新数据。适用于响应时间短的场景。
  • 此外,限流也可以作为一种保底方案处理缓存击穿问题。

http://www.kler.cn/a/452523.html

相关文章:

  • 【3DGS文献阅读】Splatter Image: Ultra-Fast Single-View 3D Reconstruction
  • 第十七届山东省职业院校技能大赛 中职组“网络安全”赛项任务书正式赛题
  • 将Minio设置为Django的默认Storage(django-storages)
  • openwrt 负载均衡方法 openwrt负载均衡本地源接口
  • langchain使用FewShotPromptTemplate出现KeyError的解决方案
  • 【Linux】ChatGLM-4-9B模型之All Tools
  • NetLimiter使用教程,并掌握其基本的网络管理和流量控制能力
  • 聊一聊 C#线程池 的线程动态注入 (下)
  • Flutter项目兼容鸿蒙Next系统
  • 外包干了27天,技术退步明显。。。。。
  • MyBatis-Plus分页拦截器,源码的重构(重构total总数的计算逻辑)
  • UDP传输层通信协议详解
  • 33 Opencv ShiTomasi角点检测
  • 获取 jakarta.servlet.http.HttpServletRequest请求IP
  • 【stm32can】
  • C# Winform打开和预览PDF,方法一:调用CefSharp包,内嵌浏览器
  • EMS(energy managment system)从0到1
  • 软考架构师笔记-计算机系统组成-1
  • 10. zynq应用开发--camke编译
  • 【每日学点鸿蒙知识】Charles抓包、lock文件处理、WebView组件、NFC相关、CallMethod失败等
  • Spring源码_05_IOC容器启动细节
  • Oracle 备份与恢复 (Docker部署版)
  • 单机服务和微服务
  • 模型的量化(Quantization)
  • 一篇梳理清楚JavaScript ES6中的Promise
  • [WASAPI]音频API:从Qt MultipleMedia走到WASAPI,相似与不同