【Redis实战】Redis用于缓存的常见问题及解决方案的实现

0.演示项目搭建

演示环境

  • OS:Windows 10

  • JDK:1.8.0_301

  • IDE:IDEA_2021.1.2

  • MySQL:5.7.35

  • Redis:6.26

  • SpringBoot:2.6.6

下面我们从零搭建一个基于 SpringBoot 脚手架的服务,并针对于数据库中的题目表进行开发查询接口。

引入项目依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>1.2.12</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.1</version>
    </dependency>

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-generator</artifactId>
        <version>3.5.1</version>
    </dependency>

    <!--MP代码生成器模板引擎-->
    <dependency>
        <groupId>org.apache.velocity</groupId>
        <artifactId>velocity-engine-core</artifactId>
        <version>2.3</version>
    </dependency>

    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.4</version>
    </dependency>
</dependencies>

 

 

配置项目文件

# 服务端口
server:
  port: 8889

# 服务名称
spring:
  application:
    name: RedisDemo
  # 数据源配置
  datasource:
    druid:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/onlineexams?characterEncoding=utf-8&useSSL=false&serverTimezone=GMT
      username: root
      password: 3105501510

  # Redis配置
  redis:
    host: 192.168.149.145
    port: 6379
    password: 3105501510
    lettuce:
      pool:
        max-active: 10
        max-idle: 10
        min-idle: 1
        time-between-eviction-runs: 10s

  # Jackson配置
  jackson:
    default-property-inclusion: non_null  # JSON处理时忽略非空字段

# MP配置
mybatis-plus:
  global-config:
    db-config:
      # 表前缀
      table-prefix: tb_
      # 主键自增
      id-type: auto
  # 打印SQL语句
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

# 日志配置
logging:
  level:
    cn.imyjs: debug
  pattern:
    dateformat: mm:ss.SSS

 

 

数据库表设计

CREATE TABLE `tb_subject` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `class_id` bigint(20) NOT NULL COMMENT '类别ID',
    `name` varchar(30) DEFAULT NULL COMMENT '科目名称',
    `subject_img` varchar(255) DEFAULT NULL COMMENT '科目封面',
    `paper_count` int(11) DEFAULT NULL COMMENT '试卷数量',
    `description` varchar(255) DEFAULT NULL COMMENT '科目描述',
    `enable` tinyint(1) DEFAULT '0' COMMENT '是否启用 0:启用 1:禁用',
    `create_time` datetime DEFAULT NULL COMMENT '添加时间',
    `modify_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
    `deleted` tinyint(1) DEFAULT '0' COMMENT '逻辑删除 0:未删除 1:已删除',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;


INSERT INTO `tb_subject` VALUES (1, 2, '四级数据库', 'http://localhost/admin/files/download/80856d677e0f4eb995cfb679547889bb.jpg', 0, '测试修改删除Redis cache', 0, '2022-08-27 01:21:51', '2022-08-27 01:21:51', 0);
INSERT INTO `tb_subject` VALUES (2, 2, '三级网络技术', 'http://localhost/admin/files/download/ac82678810f64853a1793692d8f8fa9d.jpg', 0, 'NCRE:三级网络技术', 0, '2022-07-04 08:58:09', '2022-07-05 18:27:07', 0);
INSERT INTO `tb_subject` VALUES (3, 2, '二级C语言', 'http://localhost/admin/files/download/271d0cb60b9d46b79729829453a82e85.jpg', 0, 'NCRE:二级C语言', 0, '2022-07-04 08:58:54', '2022-07-05 18:27:08', 0);
INSERT INTO `tb_subject` VALUES (4, 2, '二级MySQL', 'http://localhost/admin/files/download/e6e394a69ae9471cb3a0fdeff59cfc92.jpg', 0, 'NCRE:二级MySQL', 0, '2022-07-04 09:03:21', '2022-07-05 18:27:11', 0);
INSERT INTO `tb_subject` VALUES (5, 2, '二级JAVA', 'http://localhost/admin/files/download/5dcf911862504dfebd9508f69d38d339.jpg', 0, 'NCRE:二级JAVA(基础题库)', 0, '2022-07-04 09:04:31', '2022-07-05 18:27:09', 0);
INSERT INTO `tb_subject` VALUES (6, 2, '三级数据库技术', 'http://localhost/admin/files/download/ce4b4b94f2de4459b94a393a11cc1f8c.png', 20, 'NCRE:三级数据库技术(基础题库)', 0, '2022-07-05 10:51:06', '2022-07-05 10:51:06', 0);
INSERT INTO `tb_subject` VALUES (7, 0, '测试添加 删除 Redis cache', 'http://localhost/admin/files/download/88107873ca8f4049a2f27709220d2227.jpg', 0, '测试修改删除Redis cache', 0, '2022-08-27 01:23:14', '2022-08-27 01:23:14', 0);

 

 

生成三层代码

public class MpGenerator {
    public static void main(String[] args) {
        generate();
    }

    private static void generate() {
        //1、配置数据源
        FastAutoGenerator.create("jdbc:mysql://localhost:3306/onlineexams?serverTimezone=GMT%2b8", "root", "3105501510")
                //2、全局配置
                .globalConfig(builder -> {
                    builder.author("YJS") // 设置作者名
                            .outputDir(System.getProperty("user.dir") + "/src/main/java")   // 设置输出路径:项目的 java 目录下
                            .commentDate("yyyy-MM-dd hh:mm:ss")   // 注释日期
                            .dateType(DateType.TIME_PACK)   // 定义生成的实体类中日期的类型 TIME_PACK=LocalDateTime;ONLY_DATE=Date;
                            .fileOverride()   //覆盖之前的文件
                            // .enableSwagger()   //开启 swagger 模式
                            .disableOpenDir();   //禁止打开输出目录,默认打开
                })
                //3、包配置
                .packageConfig(builder -> {
                    builder.parent("cn.imyjs") // 设置父包名
                            //.moduleName("mp")   //设置模块包名
                            .entity("beans")   //pojo 实体类包名
                            .service("service") //Service 包名
                            .serviceImpl("service.Impl") // ***ServiceImpl 包名
                            .mapper("mapper")   //Mapper 包名
                            .xml("mapper")  //Mapper XML 包名
                            .controller("controller") //Controller 包名
                            .other("utils") //自定义文件包名
                            .pathInfo(Collections.singletonMap(OutputFile.mapperXml, System.getProperty("user.dir")+"/src/main/resources/mapper"));   //配置 mapper.xml 路径信息:项目的 resources 目录下
                })
                //4、策略配置
                .strategyConfig(builder -> {
                    builder.addInclude("tb_subject") // 设置需要生成的数据表名
                            .addTablePrefix("tb_") // 设置过滤表前缀

                            //4.1、Mapper策略配置
                            .mapperBuilder()
                            .superClass(BaseMapper.class)   //设置父类
                            .formatMapperFileName("%sMapper")   //格式化 mapper 文件名称
                            .enableMapperAnnotation()       //开启 @Mapper 注解
                            .formatXmlFileName("%sXml") //格式化 Xml 文件名称
                            // 4.2、service 策略配置
                            .serviceBuilder()
                            .formatServiceFileName("%sService") //格式化 service 接口文件名称,%s进行匹配表名,如 UserService
                            .formatServiceImplFileName("%sServiceImpl") //格式化 service 实现类文件名称,%s进行匹配表名,如 UserServiceImpl

                            //4.3、实体类策略配置
                            .entityBuilder()
                            .enableLombok() //开启 Lombok
                            .disableSerialVersionUID()  //不实现 Serializable 接口,不生产 SerialVersionUID
                            .logicDeleteColumnName("deleted")   //逻辑删除字段名
                            .naming(NamingStrategy.underline_to_camel)  //数据库表映射到实体的命名策略:下划线转驼峰命
                            .columnNaming(NamingStrategy.underline_to_camel)    //数据库表字段映射到实体的命名策略:下划线转驼峰命
                            .addTableFills(
                                    new Column("create_time", FieldFill.INSERT),
                                    new Column("modify_time", FieldFill.INSERT_UPDATE)
                            )   //添加表字段填充,"create_time"字段自动填充为插入时间,"modify_time"字段自动填充为插入修改时间
                            .enableTableFieldAnnotation()       // 开启生成实体时生成字段注解

                            //4.4、Controller策略配置
                            .controllerBuilder()
                            .formatFileName("%sController") //格式化 Controller 类文件名称,%s进行匹配表名,如 UserController
                            .enableRestStyle();  //开启生成 @RestController 控制器
                })
                //5、模板
                .templateEngine(new VelocityTemplateEngine())
                /*
                    .templateEngine(new FreemarkerTemplateEngine())
                    .templateEngine(new BeetlTemplateEngine())
                */
                //6、执行
                .execute();
    }
}

 

 

统一返回结果

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    private Boolean success;
    private String errorMsg;
    private Object data;
    private Long total;

    public static Result ok(){
        return new Result(true, null, null, null);
    }
    public static Result ok(Object data){
        return new Result(true, null, data, null);
    }
    public static Result ok(List<?> data, Long total){
        return new Result(true, null, data, total);
    }
    public static Result fail(String errorMsg){
        return new Result(false, errorMsg, null, null);
    }
}

 

 

查询科目服务

// SubjectController.java
@RestController
@RequestMapping("/subject")
public class SubjectController {

    @Autowired
    private SubjectService subjectService;

    @GetMapping("/find/{id}")
    public Result getSubjectById(@PathVariable Long id){
        return subjectService.getSubjectById(id);
    }
}

// SubjectServiceImpl.java
@Service
public class SubjectServiceImpl extends ServiceImpl<SubjectMapper, Subject> implements SubjectService {
    @Override
    public Result getSubjectById(Long id) {
        Subject subject = getById(id);
        return Objects.isNull(subject) ? Result.fail("查询ID无效") : Result.ok(subject);
    }
}

 

 

测试查询科目

总结

到这里,我们就完成了一个基于SpringBoot框架搭建起来的最基础的一个服务,并且只是提供了一个接口,也就是对于题目数据的查询接口,不过目前针对这个查询我们并没有设置缓存,也就是说每发一次请求就会去查询一次数据库。下面逐步演示使用缓存以及常见的缓存问题和对应的解决方案的演示。

1.缓存基本使用

缓存概念

什么是缓存?

缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据。一般从数据库中获取,存储于本地。

  • 本地缓存:指的是在应用中的缓存组件,其最大的优点是应用和缓存是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等,在单应用不需要集群支持或者集群情况下各节点无需互相通知的场景下使用本地缓存较合适;同时,它的缺点也是应为缓存跟应用程序耦合,多个应用程序无法直接的共享缓存,各应用或集群的各节点都需要维护自己的单独缓存,对内存是一种浪费。

  • 分布式缓存:指的是与应用分离的缓存组件或服务,其最大的优点是自身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存。

为什么要用缓存?

之所以要使用缓存,就是为了提高系统性能进而提升用户体验以及应对更多的用户请求。直接操作缓存能够承受的数据请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据添加到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高的系统整体的并发。缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力。缓存主要是可以降低对数据库的访问压力,缓存数据大部分位于内存中,进而降低IO成本,加快数据的访问速度

比如 CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。再比如操作系统在 页表方案 基础之上引入了快表来加速虚拟地址到物理地址的转换。我们可以把块表理解为一种特殊的高速缓冲存储器(Cache)。

使用示例

下面演示给科目查询接口添加最基本缓存的使用,只需要修改 SubjectServiceImpl.java中的 getSubjectById服务实现即可。

// 注入Redis
@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result getSubjectById(Long id) {
    // 拼接用于Redis缓存的key 例如:【cache:subject:1】
    String key = CACHE_SUBJECT_KEY  + id;
    // 首先查询缓存
    String subjectJson = stringRedisTemplate.opsForValue().get(key);

    // 如果缓存命中,直接返回数据
    if (StrUtil.isNotBlank(subjectJson)){  // 不为 null 空字符串 “”
        return Result.ok(JSONUtil.toBean(subjectJson, Subject.class));
    }

    // 如果缓存未命中,则去查询数据库
    Subject subject = getById(id);

    // 如果查询数据库为空 则直接返回失败信息
    if (Objects.isNull(subject)){
        return Result.fail("科目数据查询失败!");
    }

    // 数据库查询成功,将查询到的数据添加到缓存
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(subject));

    // 返回数据
    return Result.ok(subject);
}

 

 

通过测试,发现这次连续发送相同的请求,在缓存有效期内只需查询一次数据库,大大的降低了数据库的压力。但是,当我们向 Redis 插入太多数据,此时就可能会导致缓存中的数据过多,再者如果数据库中的数据进行了修改,而缓存依然存在并且没有进行更新,那么此时的请求得到的将会是旧的数据,这就是数据库和缓存的一致性问题。

问题分析

由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在。

怎么解决呢?有如下几种方案:

  • Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案

  • Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理

  • Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

综合考虑使用方案一,但是方案一调用者如何处理呢?操作缓存和数据库时有三个问题需要考虑:

  • 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多

    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存

如果采用更新缓存,那么假设我们每次操作数据库后,都去更新缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来。
  • 先操作缓存还是先操作数据库?

    • 先删除缓存,再操作数据库

    • 先操作数据库,再删除缓存

我们应当是先操作数据库,再删除缓存,原因在于,如果你选择先删除缓存,再操作数据库,在两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他查询数据库并写入缓存,当他写入缓存后,线程1再执行更新数据库动作时,实际上写入缓存的依旧是旧的数据。
  • 如何保证缓存与数据库的操作的同时成功或失败?

    • 单体系统,将缓存与数据库操作放在一个事务

    • 分布式系统,利用TCC等分布式事务方案

问题解决

修改业务逻辑,满足下面的需求:

  • 根据id查询科目时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间

  • 根据id修改科目时,先修改数据库,再删除缓存

1.修改getSubjectById(Long id)方法中,添加数据到缓存时设置自动过期时间:

// 数据库查询成功,将查询到的数据添加到缓存
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(subject),CACHE_SUBJECT_TTL, TimeUnit.MINUTES);

 

 

2.新增修改科目接口,并在服务类方法中,设置更新数据库后删除缓存

@Override
@Transactional
public Result updateSubject(Subject subject) {
    Long id = subject.getId();

    if (Objects.isNull(id)){
        return Result.fail("更新科目的ID异常!");
    }
    boolean update = updateById(subject);
    if (!update){
        return Result.fail("更新科目异常!");
    }
    String key = CACHE_SUBJECT_KEY  + id;
    stringRedisTemplate.delete(key);

    return Result.ok("更新成功!");
}

 

 

注意:需要保证更新数据库成功后的删除缓存操作在同一个事务中。

总结

在以上使用缓存的基本操作中,我们需要注意的是添加到缓冲中的KEY值的设计,通常可以定义一个常量类,专门用于管理缓存中的key值以及TTL过期时间等。

public class RedisConstants {
    public static final String CACHE_SUBJECT_KEY = "cache:subject:";
    public static final Long CACHE_SUBJECT_TTL = 30L;
}

 

 

除此之外,在上面的代码,我们并没有考虑各种各种的问题,下面我们就来分析下特殊情况下的缓存带来的异常问题。

2.缓存穿透及解决方案

基本概念

当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。这和缓存击穿有根本的区别,区别在于缓存穿透的情况是传进来的key在Redis中是不存在的

如果有恶意攻击者不断请求系统中不存在的数据,会导致短时间大量请求落在数据库上,造成数据库压力过大,甚至击垮数据库系统。

缓存穿透 :当我们客户端发送的大量请求访问数据时,会先请求 Redis ,但是此时 Redis 中并没有该缓存数据,此时就会去访问到数据库,但是由于数据库中也没有数据,就造成了无法构建该数据的缓存,这样缓存永远不会生效,此时我们就称发生了缓存穿透。

问题演示

解决方案

  • 把无效的Key存进Redis中、返回空对象。如果Redis查不到数据,数据库也查不到,我们把这个Key值保存进Redis,设置value="",当下次再通过这个Key查询时就不需要再查询数据库。为了避免存储过多空对象,通常会给空对象设置一个过期时间。这种处理方式肯定是有问题的:如果有大量的key穿透,缓存空对象会占用宝贵的内存空间;空对象的key设置了过期时间,在这段时间可能会存在缓存和持久层数据不一致的场景。

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

    • 缺点:额外的内存消耗、可能造成短期的不一致

  • 使用布隆过滤器(推荐)。布隆过滤器专门用来检测集合中是否存在特定的元素。布隆过滤器的作用是某个 key 不存在,那么就一定不存在,它说某个 key 存在,那么很大可能是存在(存在一定的误判率)。于是我们可以在缓存之前再加一层布隆过滤器,在查询的时候先去布隆过滤器查询 key 是否存在,如果不存在就直接返回。

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

    • 缺点:实现复杂、存在误判可能

方案分析

缓存空对象思路分析:简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到 Redis 中去,这样,下次用户过来访问这个不存在的数据,那么在 Redis 中也能找到这个数据就不会进入到数据库了。

布隆过滤思路分析:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问 Redis ,哪怕此时 Redis 中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到 Redis 中,假设布隆过滤器判断这个数据不存在,则直接返回。这种方式优点在于节约内存空间,存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突。

编码解决

在这里使用缓存空对象方案进行解决缓存穿透的问题。

在原来的逻辑中,我们如果发现这个数据在MySQL中不存在,直接就返回错误信息了,这样是会存在缓存穿透问题的。应该是如果这个数据不存在,我们不会返回错误信息,还是会把这个数据写入到Redis中,并且将value设置为空值,当再次发起查询时,我们如果发现命中之后,判断这个value是否是空值,如果是空值,则是之前写入的数据,证明是缓存穿透数据,如果不是,则直接返回数据。代码如下:

/**
 * 使用缓存空值解决缓存穿透问题
 * @param id 查询科目ID
 * @return Result
 */
private Result CachePenetration(Long id){
    // 拼接用于Redis缓存的key 例如:【cache:subject:1】
    String key = CACHE_SUBJECT_KEY  + id;

    // 首先查询缓存
    String subjectJson = stringRedisTemplate.opsForValue().get(key);

    // 解决缓存穿透:如果缓存命中,并不会直接返回数据
    //             首先判断缓存是否为空值,如果是则返回错误信息,不为空值则返回缓存数据
    if (Objects.nonNull(subjectJson)){  // 不为 null  == 缓存命中
        if (StrUtil.isBlank(subjectJson)){  // 空字符串 “”  == 缓存穿透
            return Result.fail("查询ID有误!");
        }
        return Result.ok(JSONUtil.toBean(subjectJson, Subject.class));
    }

    // 如果缓存未命中,则去查询数据库
    Subject subject = getById(id);

    // 如果查询数据库为空 添加空值作为缓存
    if (Objects.isNull(subject)){
        stringRedisTemplate.opsForValue().set(key, "",CACHE_NULL_TTL, TimeUnit.MINUTES);
        return Result.fail("科目数据查询失败!");
    }

    // 数据库查询成功,将查询到的数据添加到缓存
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(subject),CACHE_SUBJECT_TTL, TimeUnit.MINUTES);

    // 返回数据
    return Result.ok(subject);

}

 

总结

缓存穿透产生的原因是什么?

用户请求的数据在缓存中和数据库中都不存在,导致无法构建起缓存,不断发起这样的请求,给数据库带来巨大压力。

缓存穿透的解决方案有哪些?

  • 缓存null值

  • 布隆过滤

  • 增强id的复杂度,避免被猜测id规律

  • 做好数据的基础格式校验

  • 加强用户权限校验

  • 做好热点参数的限流

3.缓存击穿及解决方案

基本概念

缓存击穿的意思是缓存中没有数据,而数据库中有数据。出现这一情况的原因一般是缓存到期。并且在其这个时候用户访问量很大,导致读缓存没有读到,都去访问数据库,造成数据库压力大。

一个并发访问量比较大的key在某个时间过期,导致所有的请求直接打在数据库上。

问题分析

正常情况下,假设线程1在查询缓存未命中之后,本来应该去查询数据库,然后再把这个数据重新加载到 Redis 缓存中,此时只要线程1走完这个过程,其他线程就都能从缓存中加载这些数据。

但是假设在线程1还没有来得及查询数据库时,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,接着同一时间去访问数据库,同时的去执行数据库代码,导致了对数据库访问压力过大。

解决方案

常见的解决方案有

  • 设置热点数据永远不过期。

  • 分级缓存

  • 逻辑过期

  • 互斥锁

方案分析

使用互斥锁解决

因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。

假设现在线程1过来访问,他查询缓存没有命中,但是此时他去请求锁资源并且获得到了锁的资源,那么线程1就会允许去执行查询数据库的逻辑,假设现在线程2过来,线程2同样没有命中缓存,但在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据。

如果缓存失效的情况,只有拿到锁才可以查询数据库,降低了在同一时刻打在数据库上的请求,防止数据库打死。当然这样会导致系统的性能变差。

使用逻辑过期解决

我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。

我们把过期时间设置在 Redis 的 value 中,注意:这个过期时间并不会直接作用于 Redis ,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个线程去进行以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

两种方案对比

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

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

编码解决

互斥锁方案

核心思路就是利用Redis的setnx方法来表示获取锁,该方法含义是Redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true, 如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程

1.首先去创建一个锁的接口类:

 

public interface ILock {
    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功; false代表获取锁失败
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}

 

 

2.然后去实现接口类:

public class SimpleRedisLock implements ILock{
    private final String LOCKKEY;
    private final StringRedisTemplate STRING_REDIS_TEMPLATE;

    public SimpleRedisLock(String LOCKKEY, StringRedisTemplate STRING_REDIS_TEMPLATE) {
        this.LOCKKEY = LOCKKEY;
        this.STRING_REDIS_TEMPLATE = STRING_REDIS_TEMPLATE;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        Boolean isLock = STRING_REDIS_TEMPLATE.opsForValue().setIfAbsent(LOCKKEY, "1", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(isLock);
    }

    @Override
    public void unlock() {
        STRING_REDIS_TEMPLATE.delete(LOCKKEY);
    }
}

 

 

3.操作代码:

/**
 * 使用缓存互斥锁解决缓存击穿问题
 * @param id 查询科目ID
 * @return Result
 */
private Result CacheBreakdownByLock(Long id){
    // 拼接用于Redis缓存的key 例如:【cache:subject:1】
    String key = CACHE_SUBJECT_KEY  + id;

    // 首先查询缓存
    String subjectJson = stringRedisTemplate.opsForValue().get(key);

    // 解决缓存穿透:如果缓存命中,并不会直接返回数据
    //             首先判断缓存是否为空值,如果是则返回错误信息,不为空值则返回缓存数据
    if (Objects.nonNull(subjectJson)){  // 不为 null  == 缓存命中
        if (StrUtil.isBlank(subjectJson)){  // 空字符串 “”  == 缓存穿透
            return Result.fail("查询ID有误!");
        }
        return Result.ok(JSONUtil.toBean(subjectJson, Subject.class));
    }

    // 如果缓存未命中,则去查询数据库
    // 使用互斥锁去解决缓存击穿问题
    String lockKey = SUBJECT_LOCK_KEY + id;
    SimpleRedisLock simpleRedisLock = new SimpleRedisLock(lockKey, stringRedisTemplate);
    Subject subject = null;
    try {
        // 尝试获取锁
        boolean isLock = simpleRedisLock.tryLock(SUBJECT_LOCK_TTL);
        if (!isLock){
            // 获取锁失败
            Thread.sleep(50);
            this.CacheBreakdownByLock(id);
        }
        // 获取锁成功,再次检查缓存 double check
        subjectJson = stringRedisTemplate.opsForValue().get(key);
        if (Objects.nonNull(subjectJson)){
            if (StrUtil.isBlank(subjectJson)){
                return Result.fail("查询ID有误!");
            }
            return Result.ok(JSONUtil.toBean(subjectJson, Subject.class));
        }
        // 查询数据库
        subject = getById(id);
        System.out.println(Thread.currentThread().getId()+ "查询数据库成功!");
        // 如果查询数据库为空 添加空值作为缓存
        if (Objects.isNull(subject)){
            stringRedisTemplate.opsForValue().set(key, "",CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("科目数据查询失败!");
        }
        // 数据库查询成功,将查询到的数据添加到缓存
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(subject),CACHE_SUBJECT_TTL, TimeUnit.MINUTES);
    } catch (Exception e) {
        e.printStackTrace();
    } finally{
        simpleRedisLock.unlock();
    }
    // 返回数据
    return Result.ok(subject);
}

 

 

逻辑过期方案

核心思路是当用户开始查询 Reids 时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁

1.新建一个实体类,用于封装实体数据和逻辑过期时间。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class RedisData {
    private LocalDateTime localDateTime;
    private Object data;
}

 

 

2.在SubjectServiceImpl 新增此方法,利用单元测试进行缓存预热

/**
 * 通过逻辑过期方式解决缓存击穿方案时 进行缓存预热
 * @param id 预热id
 * @param expireSeconds 逻辑过期时间 TTL
 */
public void saveCache2Redis(Long id, Long expireSeconds){
    Subject subject = getById(id);
    if (Objects.isNull(subject)){
        return;
    }
    RedisData redisData = new RedisData();
    redisData.setLocalDateTime(LocalDateTime.now().plusSeconds(expireSeconds));
    redisData.setData(subject);
    stringRedisTemplate.opsForValue().set(CACHE_SUBJECT_KEY + id, JSONUtil.toJsonStr(redisData));
}

3.测试类执行以下代码进行缓存预热

@Resource
private SubjectService subjectService;

@Test
public void testSaveCache2Redis(){
    subjectService.saveCache2Redis(1L, 20L);
}

4.使用逻辑过期方案解决缓存击穿的核心代码

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

/**
 * 使用逻辑过期方案解决缓存击穿问题
 * @param id 查询科目ID
 * @return Result
 */
private Result CacheBreakdownByLogic(Long id){
    // 拼接用于Redis缓存的key 例如:【cache:subject:1】
    String key = CACHE_SUBJECT_KEY  + id;

    // 首先尝试从缓存中获取数据
    String subjectJson = stringRedisTemplate.opsForValue().get(key);

    // 为 null 或者 空值  == 缓存未命中
    if (StrUtil.isBlank(subjectJson)){
        //  如果缓存未命中,表明请求的不是预设的热点数据 直接返回错误信息
        return Result.fail("查询ID有误!");
    }

    // 缓存命中,需要先把json反序列化为对象
    RedisData redisData = JSONUtil.toBean(subjectJson, RedisData.class);
    Subject cacheSubject = JSONUtil.toBean((JSONObject) redisData.getData(), Subject.class);
    LocalDateTime cacheTime = redisData.getLocalDateTime();

    // 判断缓存是否过期
    if (cacheTime.isAfter(LocalDateTime.now())) {
        // 未过期,直接返回信息
        return Result.ok(cacheSubject);
    }

    // 已过期,需要缓存重建
    // 获取互斥锁
    SimpleRedisLock simpleRedisLock = new SimpleRedisLock(SUBJECT_LOCK_KEY + id, stringRedisTemplate);
    // // 判断是否获取锁成功
    boolean isLock = simpleRedisLock.tryLock(SUBJECT_LOCK_TTL);
    if (isLock) {
        // 获取锁成功
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                //重建缓存
                this.saveCache2Redis(id, 20L);
                System.out.println(Thread.currentThread().getId() + "线程重建缓存成功!");
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                simpleRedisLock.unlock();
            }
        });
    }
    // 返回过期数据
    return Result.ok(cacheSubject);
}

测试结果:

再测试执行之前,手动模拟修改数据库数据。

通过Jmeter模拟一百个线程发送请求,会发现前几个请求所得到的是旧数据,也就是缓存和数据库不一致!

注意:由于并发的不确定性,缓存和数据库有时会全部一致!

总结

4.缓存雪崩及解决方案

基本概念

当某一个时刻出现大规模的缓存失效的情况,那么就会导致大量的请求直接打在数据库上面,导致数据库压力巨大,如果在高并发的情况下,可能瞬间就会导致数据库宕机。这时候如果运维马上又重启数据库,马上又会有新的流量把数据库打死。这就是缓存雪崩。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。造成缓存雪崩的关键在于在同一时间大规模的key失效

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

解决方案

  • 提高缓存可用性

    • 集群部署:通过集群来提升缓存的可用性,提高Redis的容灾性,可以利用Redis本身的Redis Cluster或者第三方集群方案如Codis等。

    • 多级缓存:设置多级缓存,第一级缓存失效的基础上,访问二级缓存,每一级缓存的失效时间都不同。

  • 过期时间

    • 均匀过期:为了避免大量的缓存在同一时间过期,在原有的失效时间上加上一个随机值,比如1-5分钟随机。这样就避免了因为采用相同的过期时间导致的缓存雪崩。

    • 热点数据永不过期。

  • 熔断降级

    • 服务熔断:当流量到达一定的阈值时,就直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上。至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。

    • 服务降级:当出现大量缓存失效,而且处在高并发高负荷的情况下,在业务系统内部暂时舍弃对一些非核心的接口和数据的请求,而直接返回一个提前准备好的 fallback(退路)错误处理信息。

  • 数据库:提高数据库的容灾能力,可以使用分库分表,读写分离的策略。

编码采用均匀过期解决方案,就是在原有的失效时间上加上一个随机值,比如1-5分钟随机,代码比较简单,不再演示。

5.封装 Redis 工具类

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:

  • 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间

  • 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题

  • 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题

  • 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用互斥锁解决缓存击穿问题

  • 方法5:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

/**
 * @Classname CacheClient
 * @Description Redis解决缓存击穿、缓存穿透工具类
 * @Date 2022/10/23 21:08
 * @Created by YJS
 * @WebSite www.imyjs.cn
 */
@Component
public class CacheClient {
    private final StringRedisTemplate stringRedisTemplate;

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
     * @param key Redis缓存中的 key
     * @param value Redis缓存中的 value
     * @param time TTL过期时间
     * @param unit 时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
     * @param key Redis缓存中的 key
     * @param value Redis缓存中的 value
     * @param time 逻辑过期时间 注意:不是Redis缓存中的 key 过期时间
     * @param unit 时间单位
     */
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        // 设置逻辑过期
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // 写入Redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     * 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
     * @param keyPrefix Redis缓存中KEY的前缀 例如:【cache:shop:】
     * @param id 查询ID
     * @param type 查询类型 .class
     * @param dbFallback 回调函数
     * @param time 缓存数据 TTL过期时间
     * @param unit 时间单位
     * @param <R> 泛型:查询的类型
     * @param <ID> 泛型:目标类ID的类型
     * @return 查询的类型的对象实例
     */
    public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = keyPrefix + id;
        // 1.从redis查询缓存
        String cacheJson = stringRedisTemplate.opsForValue().get(key);

        // 2. 缓存命中
        if (cacheJson != null){
            // 缓存为空值则返回错误信息
            if (StrUtil.isBlank(cacheJson)){
                return null;
            }
            // 缓存不为空值则直接返回数据信息
            return JSONUtil.toBean(cacheJson, type);
        }


        // 3.缓存未命中,根据id查询数据库
        R r = dbFallback.apply(id);
        // 4.数据库不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 5.存在,将数据写入redis
        this.set(key, r, time, unit);
        // 返回数据
        return r;
    }


    /**
     * 根据指定的key查询缓存,并反序列化为指定类型,需要利用互斥锁解决缓存击穿问题
     * @param keyPrefix  Redis缓存中KEY的前缀 例如:【cache:shop:】
     * @param id 查询ID
     * @param type 查询类型 .class
     * @param dbFallback 回调函数
     * @param time 缓存数据 TTL过期时间
     * @param unit 时间单位
     * @param <R> 泛型:查询的类型
     * @param <ID> 泛型:目标类ID的类型
     * @return 查询的类型的对象实例
     */
    public <R, ID> R queryWithMutex(String keyPrefix,String lockKeyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        // 拼装用于存放Redis的KEY
        String key = keyPrefix + id;

        // 首先尝试从缓存中获取数据
        String cacheJson = stringRedisTemplate.opsForValue().get(key);

        // 解决缓存穿透:如果缓存命中,不直接返回数据
        //             首先判断缓存是否为空值,如果缓存为空则返回错误信息,缓存不为空则直接返回店铺信息
        if (cacheJson != null){
            // 缓存命中
            if (StrUtil.isBlank(cacheJson)){ // 如果缓存是空字符串 “”
                // 缓存为空值则返回错误信息
                return null;
            }
            // 缓存不为空则直接返回店铺信息
            return JSONUtil.toBean(cacheJson, type);
        }

        // 缓存未命中,则去查询数据库
        // 实现缓存重构 获取互斥锁
        String lockKey = lockKeyPrefix + id;
        R r = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 判断是否获取成功
            if (!isLock) {
                // 获取锁失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, lockKeyPrefix, id, type, dbFallback, time, unit);
            }
            // 获取锁成功,根据id查询数据库
            r = dbFallback.apply(id);
            System.out.println(Thread.currentThread().getId() + "查询数据库成功!");
            // 数据库中不存在,返回错误
            if (r == null) {
                // 将空值写入redis
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 数据库中存在,则写入redis
            this.set(key, r, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            // 释放锁
            unlock(lockKey);
        }
        // 返回
        return r;
    }


    /**
     * 根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
     * @param keyPrefix  Redis缓存中KEY的前缀 例如:【cache:shop:】
     * @param id 查询ID
     * @param type 查询类型 .class
     * @param dbFallback 回调函数
     * @param time 缓存数据 TTL过期时间
     * @param unit 时间单位
     * @param <R> 泛型:查询的类型
     * @param <ID> 泛型:目标类ID的类型
     * @return 查询的类型的对象实例
     */
    public <R, ID> R queryWithLogicalExpire(String keyPrefix, String lockKeyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        // 拼装用于存放Redis的KEY
        String key = keyPrefix + id;

        // 首先尝试从缓存中获取数据
        String cacheJson = stringRedisTemplate.opsForValue().get(key);

        if (StrUtil.isBlank(cacheJson)){  // 空字符串 "" 以及null 都为true
            // 如果缓存未命中,表明请求的不是预设的热点数据 直接返回错误信息
            return null;
        }
        // 缓存命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(cacheJson, RedisData.class);
        R bean = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();

        // 判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())){
            // 未过期,直接返回店铺信息
            return bean;
        }

        // 已过期,需要缓存重建
        // 获取互斥锁
        String lockKey = lockKeyPrefix + id;
        boolean isLock = tryLock(lockKey);
        // 判断是否获取锁成功
        if (isLock){
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try{
                    // 获取锁成功,根据id查询数据库
                    R r = dbFallback.apply(id);
                    // 重建缓存
                    this.setWithLogicalExpire(key,r,time,unit);
                    System.out.println(Thread.currentThread().getId() + "获得了锁,查询数据库成功!");
                }catch (Exception e){
                    throw new RuntimeException(e);
                }finally {
                    unlock(lockKey);
                }
            });
        }
        // 返回过期的信息
        return bean;
    }

    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);
    }
}

 

6.补充知识

缓存击穿、穿透、雪崩三者有什么区别?

发生缓存击穿问题的关键是:缓存中的某个热点数据过期

发生缓存穿透问题的关键是:数据既不在缓存中,也不在数据库中,无法构建缓存数据

发生缓存雪崩问题的关键是:大量缓存数据在同一时间过期

什么是缓存预热?

缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统,这样就可以避免在用户请求的时候,先查询数据库,然后再将数据回写到缓存。

如果不进行预热, 那么 Redis 初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。

缓存预热的操作方法

  • 数据量不大的时候,工程启动的时候进行加载缓存动作;

  • 数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;

  • 数据量太大的时候,优先保证热点数据进行提前加载到缓存。

什么是缓存降级?

缓存降级是指缓存失效或缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或访问服务的内存数据。

在项目实战中通常会将部分热点数据缓存到服务的内存中,这样一旦缓存出现异常,可以直接使用服务的内存数据,从而避免数据库遭受巨大压力。

降级一般是有损的操作,所以尽量减少降级对于业务的影响程度。

微信关注

编程那点事儿

编程那点事儿

本站为非盈利性站点,所有资源、文章等仅供学习参考,并不贩卖软件且不存在任何商业目的及用途,如果您访问和下载某文件,表示您同意只将此文件用于参考、学习而非其他用途。
本站所发布的一切软件资源、文章内容、页面内容可能整理来自于互联网,在此郑重声明本站仅限用于学习和研究目的;并告知用户不得将上述内容用于商业或者非法用途,否则一切后果请用户自负。
如果本站相关内容有侵犯到您的合法权益,请仔细阅读本站公布的投诉指引页相关内容联系我,依法依规进行处理!
作者:理想
链接:https://www.imyjs.cn/archives/1156
THE END
二维码
【Redis实战】Redis用于缓存的常见问题及解决方案的实现
0.演示项目搭建 演示环境 OS:Windows 10 JDK:……
<<上一篇
下一篇>>
文章目录
关闭
目 录