JAVA面试题分享三百五十七:SpringBoot多级缓存解决方案

目录

前言

1. 什么是多级缓存

2. Caffeine快速入门

2.1. 项目准备

2.2. 使用

2.3. 驱逐策略

3. 一级缓存

3.1. Caffeine配置

3.2. 实现缓存逻辑

3.3. 测试

4. 二级缓存

4.1. Redis配置

4.2. 缓存注解

4.3. 测试

5. 一级缓存更新的问题

6. 分布式场景下的问题

6.1. 问题分析

6.2. 问题解决

6.3. 测试


前言

SpringBoot实现项目更删改查后,会有新的问题需要解决,就是并发大的问题,一般而言,解决查询并发大的问题,常见的手段是为查询接口增加缓存,从而可以减轻持久层的压力。 按照我们以往的经验,在查询接口中增加Redis缓存即可,将查询的结果数据存储到Redis中,执行查询时首先从Redis中命中,如果命中直接返回即可,没有命中查询Mysql,将解决写入到Redis中。 这样就解决问题了吗?其实并不是,试想一下,如果Redis宕机了或者是Redis中的数据大范围的失效,这样大量的并发压力就会进入持久层,会对持久层有较大的影响,甚至可能直接崩溃。 如何解决该问题呢,可以通过多级缓存的解决方案来进行解决。

1. 什么是多级缓存

图片

由上图可以看出,在用户的一次请求中,可以设置多个缓存以提升查询的性能,能够快速响应。

  • 浏览器的本地缓存

  • 使用Nginx作为反向代理的架构时,可以启用Nginx的本地缓存,对于代理数据进行缓存

  • 如果Nginx的本地缓存未命中,可以在Nginx中编写Lua脚本从Redis中命中数据

  • 如果Redis依然没有命中的话,请求就会进入到Tomcat,也就是执行我们写的程序,在程序中可以设置进程级的缓存,如果命中直接返回即可。

  • 如果进程级的缓存依然没有命中的话,请求才会进入到持久层查询数据。

以上就是多级缓存的基本的设计思路,其核心思想就是让每一个请求节点尽可能的进行缓存操作。

🚨说明,这里我们实现二级缓存,分别是:JVM进程缓存和Redis缓存。

2. Caffeine快速入门

Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库,也就是可以通过Caffeine实现进程级的缓存。Spring内部的缓存使用的就是Caffeine。

Caffeine的性能非常强悍,下图是官方给出的性能对比:

图片

2.1. 项目准备

完整项目结构如下

图片

  • 创建spring-boot-cache-demo项目,并在pom.xml中添加依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <artifactId>spring-boot-starter-parent</artifactId>
        <groupId>org.springframework.boot</groupId>
        <version>2.7.15</version>
    </parent>

    <groupId>com.zbbmeta</groupId>
    <artifactId>spring-boot-cache-demo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!--jvm进程缓存-->
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
        </dependency>

        <!-- Spring Boot Starter for Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>


<!--        test-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.30</version>
        </dependency>

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

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

    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

  • 创建项目需要使用到的表

create database backend_db;
use backend_db;

create table tb_tutorial
(
    id      bigint auto_increment comment '主键ID'
        primary key,
    title   varchar(40)    comment '标题',
    description    varchar(30)    comment '描述',
    published     tinyint        comment '1 表示发布 0 表示未发布'
);

  • 根据MybatisX生成tb_tutorial对应实体类、Mapper、Service

图片

图片

  • com.zbbmeta.controller包下创建TutorialController

package com.zbbmeta.controller;


import cn.hutool.core.util.StrUtil;
import com.github.benmanes.caffeine.cache.Cache;
import com.zbbmeta.entity.Tutorial;
import com.zbbmeta.service.TutorialService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.*;


import java.util.Objects;

/**
 * @author springboot葵花宝典
 * @description: TODO
 */
@RestController
@RequestMapping("/api")
public class TutorialController {

    @Autowired
    TutorialService tutorialService;


    /**
     * 根据ID查询Tutorial
     * @param id
     * @return
     */
    @GetMapping("/tutorials/{id}")
    public Tutorial getTutorialById(@PathVariable("id") long id) {
        Tutorial tutorial1 = this.tutorialService.getById(id);
        return tutorial1DTO;
    }

    /**
     * 创建Tutorial
     * @param tutorial
     * @return
     */
    @PostMapping("/tutorials")
    public Tutorial createOrUpdateTutorial(@RequestBody Tutorial tutorial) {
            boolean save = tutorialService.save(tutorial);
            return tutorial;
    }

}

2.2. 使用

导入依赖:

<!--jvm进程缓存-->
<dependency>
  <groupId>com.github.ben-manes.caffeine</groupId>
  <artifactId>caffeine</artifactId>
</dependency>

基本使用: 在项目的 src/test/java 目录下,创建com.zbbmeta包,在包下创建CaffeineTest测试类

package com.zbbmeta;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

/**
 * @author springboot葵花宝典
 * @description: TODO
 */

@SpringBootTest
public class CaffeineTest {

    @Test
    public void testCaffeine() {
        // 创建缓存对象
        Cache<String, Object> cache = Caffeine.newBuilder()
                .initialCapacity(10) //缓存初始容量
                .maximumSize(100) //缓存最大容量
                .build();

        //将数据存储缓存中
        cache.put("key1", 123);

        // 从缓存中命中数据
        // 参数一:缓存的key
        // 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是在未命中时执行
        // 优先根据key查询进程缓存,如果未命中,则执行参数二的Lambda表达式,执行完成后会将结果写入到缓存中
        Object value1 = cache.get("key1", key -> 456);
        System.out.println(value1); //123

        Object value2 = cache.get("key2", key -> 456);
        System.out.println(value2); //456
    }
}


2.3. 驱逐策略

Caffeine缓存的一种,肯定需要有缓存的清除策略,不然的话内存总会有耗尽的时候。 Caffeine提供了三种缓存驱逐策略:

  • 基于容量:设置缓存的数量上限

// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
    .maximumSize(1) // 设置缓存大小上限为 1,当缓存超出这个容量的时候,会使用Window TinyLfu策略来删除缓存。
    .build();
  • 基于时间:设置缓存的有效时间

// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
    // 设置缓存有效期为 10 秒,从最后一次写入开始计时 
    .expireAfterWrite(Duration.ofSeconds(10)) 
    .build();
  • 基于引用:设置缓存为软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用。

🚨注意:在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。

3. 一级缓存

下面我们通过增加Caffeine实现一级缓存,主要是在 com.zbbmeta.controller.TutorialController 中实现缓存逻辑。

3.1. Caffeine配置

  • application.yml中配置Caffeine

caffeine:
  init: 100
  max: 10000
  • com.zbbmeta.config包下创建CaffeineConfig,实现Caffeine缓存配置

package com.zbbmeta.config;

/**
 * @author springboot葵花宝典
 * @description: Caffeine缓存配置
 */

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.zbbmeta.entity.Tutorial;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;


@Configuration
public class CaffeineConfig {

    @Value("${caffeine.init}")
    private Integer init;
    @Value("${caffeine.max}")
    private Integer max;

    @Bean
    public Cache<String, Tutorial> transportInfoCache() {
        // 创建缓存对象
        return Caffeine.newBuilder()
                .initialCapacity(init) //缓存初始容量
                .maximumSize(max)//缓存最大容量
                .build();
    }

}

3.2. 实现缓存逻辑

com.zbbmeta.controller.TutorialController中进行数据的命中,如果命中直接返回,没有命中查询Mysql。

    @Autowired
    Cache<String, Tutorial> transportInfoCache;
    
    /**
     * 根据ID查询Tutorial
     * @param id
     * @return
     */
    @GetMapping("/tutorials/{id}")
    public Tutorial getTutorialById(@PathVariable("id") long id) {
        // 从缓存中命中数据
        // 参数一:缓存的key
        // 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是在未命中时执行
        // 优先根据key查询进程缓存,如果未命中,则执行参数二的Lambda表达式,执行完成后会将结果写入到缓存中
        Tutorial tutorial1DTO = this.transportInfoCache.get(StrUtil.toString(id), s -> {
            Tutorial tutorial1 = this.tutorialService.getById(id);
            return tutorial1;
        });
        return tutorial1DTO;
    }

3.3. 测试

未命中场景:使用PostMan访问地址http://localhost:8989/api/tutorials/1736743535144022017

图片

结果如下:

图片

命中之后,在此查询

图片

响应结果:

图片

4. 二级缓存

二级缓存通过Redis的存储实现,这里我们使用Spring Cache进行缓存数据的存储和读取。

4.1. Redis配置

Spring Cache默认是采用jdk的对象序列化方式,这种方式比较占用空间而且性能差,所以往往会将值以json的方式存储,此时就需要对RedisCacheManager进行自定义的配置。

com.zbbmeta.config包下创建RedisConfig类配置redis

/**
 * Redis相关的配置
 */
@Configuration
public class RedisConfig {

    /**
     * 存储的默认有效期时间,单位:小时
     */
    @Value("${redis.ttl:1}")
    private Integer redisTtl;

    @Bean
    public RedisCacheManager redisCacheManager(RedisTemplate redisTemplate) {
        // 默认配置
        RedisCacheConfiguration defaultCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                // 设置key的序列化方式为字符串
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                // 设置value的序列化方式为json格式
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
//                .disableCachingNullValues() // 不缓存null
                .entryTtl(Duration.ofHours(redisTtl));  // 默认缓存数据保存1小时

        // 构redis缓存管理器
        RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisTemplate.getConnectionFactory())
                .cacheDefaults(defaultCacheConfiguration)
                .transactionAware() // 只在事务成功提交后才会进行缓存的put/evict操作
                .build();
        return redisCacheManager;
    }
}

4.2. 缓存注解

接下来需要在Service中增加SpringCache的注解,确保数据可以保存、更新数据到Redis。

    @GetMapping("/tutorials/{id}")
    @Cacheable(value = "tutorial-info", key = "#p0") //新增缓存数据
    public Tutorial getTutorialById(@PathVariable("id") long id) {
      //省略
    }


    /**
     * 创建或者更新Tutorial
     * @param tutorial
     * @return
     */
    @CachePut(value = "tutorial-info", key = "#tutorial.id")
    @PostMapping("/tutorials")
    public Tutorial createOrUpdateTutorial(@RequestBody Tutorial tutorial) {

      //省略
    }

4.3. 测试

重启服务,进行功能测试,发现数据可以正常写入到Redis中,并且查询时二级缓存已经生效。

图片

到这里,已经完成了一级和二级缓存的逻辑。

5. 一级缓存更新的问题

更新Tutorial时,只是更新了Redis中的数据,并没有更新Caffeine中的数据,需要在更新数据时将Caffeine中相应的数据删除。 具体实现如下:

    @Autowired
    Cache<String, Tutorial> transportInfoCache;
    
  /**
     * 创建或者更新Tutorial
     * @param tutorial
     * @return
     */
    @CachePut(value = "tutorial-info", key = "#tutorial.id")
    @PostMapping("/tutorials")
    public Tutorial createOrUpdateTutorial(@RequestBody Tutorial tutorial) {

            if( tutorial.getId()!=0){
                Tutorial tutorial1 = tutorialService.getById(tutorial.getId());
                if (Objects.nonNull(tutorial1)) {
                    tutorial1.setId(tutorial1.getId());
                    tutorial1.setTitle(tutorial.getTitle());
                    tutorial1.setDescription(tutorial.getDescription());
                    tutorial1.setPublished(tutorial.getPublished());
                    tutorialService.updateById(tutorial1);
                }
            }else {
               tutorialService.save(tutorial);
            }
            //清除缓存中的数据
            this.transportInfoCache.invalidate(StrUtil.toString(tutorial.getId()));
            return tutorial;


    }

这样的话就可以删除Caffeine中的数据,也就意味着下次查询时会从二级缓存中查询到数据,再存储到Caffeine中。

6. 分布式场景下的问题

6.1. 问题分析

通过前面的解决,视乎可以完成一级、二级缓存中数据的同步,如果在单节点项目中是没有问题的,但是,在分布式场景下是有问题的,看下图:

图片

说明:

  • 部署了2个Tutorial服务节点,每个微服务都有自己进程级的一级缓存,都共享同一个Redis作为二级缓存

  • 假设,所有节点的一级和二级缓存都是空的,此时,用户通过节点1查询Tutorial信息,在完成后,节点1的caffeine和Redis中都会有数据

  • 接着,系统通过节点2更新了物流数据,此时节点2中的caffeine和Redis都是更新后的数据

  • 用户还是进行查询动作,依然是通过节点1查询,此时查询到的将是旧的数据,也就是出现了一级缓存与二级缓存之间的数据不一致的问题

6.2. 问题解决

如何解决该问题呢?可以通过消息的方式解决,就是任意一个节点数据更新了数据,发个消息出来,通知其他节点,其他节点接收到消息后,将自己caffeine中相应的数据删除即可。 关于消息的实现,可以采用RabbitMQ,也可以采用Redis的消息订阅发布来实现,在这里为了应用技术的多样化,所以采用Redis的订阅发布来实现。

图片

Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。

图片

当有新消息通过 publish 命令发送给频道 channel1 时, 这个消息就会被发送给订阅它的三个客户端。 Redis的订阅发布功能与传统的消息中间件(如:RabbitMQ)相比,相对轻量一些,针对数据准确和安全性要求没有那么高的场景可以直接使用。

  • com.zbbmeta.config.RedisConfig增加订阅的配置:

  public static final String CHANNEL_TOPIC = "tutorial-info-caffeine";

    /**
     * 配置订阅,用于解决Caffeine一致性的问题
     *
     * @param connectionFactory 链接工厂
     * @param listenerAdapter 消息监听器
     * @return 消息监听容器
     */
    @Bean
    public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
                                                   MessageListenerAdapter listenerAdapter) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listenerAdapter, new ChannelTopic(CHANNEL_TOPIC));
        return container;
    }
  • 编写RedisMessageListener用于监听消息,删除caffeine中的数据。 在com.zbbmeta.listener包下创建RedisMessageListener用于监听

package com.zbbmeta.listener;


import cn.hutool.core.convert.Convert;
import com.github.benmanes.caffeine.cache.Cache;
import com.zbbmeta.entity.Tutorial;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
/**
 * @author springboot葵花宝典
 * @description:  redis消息监听,解决Caffeine一致性的问题
 */
@Component
public class RedisMessageListener extends MessageListenerAdapter {

    @Resource
    private Cache<String, Tutorial> transportInfoCache;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        //获取到消息中的运单id
        String id = Convert.toStr(message);
        //将本jvm中的缓存删除掉
        this.transportInfoCache.invalidate(id);
    }
}

更新数据后发送消息:

    @CachePut(value = "tutorial-info", key = "#tutorial.id")
    @PostMapping("/tutorials")
    public Tutorial createOrUpdateTutorial(@RequestBody Tutorial tutorial) {

            if( tutorial.getId()!=0){
                Tutorial tutorial1 = tutorialService.getById(tutorial.getId());
                if (Objects.nonNull(tutorial1)) {
                    tutorial1.setId(tutorial1.getId());
                    tutorial1.setTitle(tutorial.getTitle());
                    tutorial1.setDescription(tutorial.getDescription());
                    tutorial1.setPublished(tutorial.getPublished());
                    tutorialService.updateById(tutorial1);

                }
            }else {
               tutorialService.save(tutorial);
            }
            //清除缓存中的数据
//            this.transportInfoCache.invalidate(StrUtil.toString(tutorial.getId()));
            //发布订阅消息到redis
            this.stringRedisTemplate.convertAndSend(RedisConfig.CHANNEL_TOPIC, StrUtil.toString(tutorial.getId()));
            return tutorial;


    }

6.3. 测试

测试时,需要启动2个相同的微服务,但是端口不能重复,需要设置不同的端口:

图片

通过测试,发现可以接收到Redis订阅的消息:

图片

图片

最终可以解决多级缓存间的一致性的问题。

 

  • 20
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

之乎者也·

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值