SpringBoot实现项目更删改查后,会有新的问题需要解决,就是并发大的问题,一般而言,解决查询并发大的问题,常见的手段是为查询接口增加缓存,从而可以减轻持久层的压力。
按照我们以往的经验,在查询接口中增加Redis缓存即可,将查询的结果数据存储到Redis中,执行查询时首先从Redis中命中,如果命中直接返回即可,没有命中查询Mysql,将解决写入到Redis中。
这样就解决问题了吗?其实并不是,试想一下,如果Redis宕机了或者是Redis中的数据大范围的失效,这样大量的并发压力就会进入持久层,会对持久层有较大的影响,甚至可能直接崩溃。
如何解决该问题呢,可以通过多级缓存的解决方案来进行解决。
1. 什么是多级缓存
![SpringBoot多级缓存解决方案 图片[1]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-1-13.png)
由上图可以看出,在用户的一次请求中,可以设置多个缓存以提升查询的性能,能够快速响应。
- 浏览器的本地缓存
- 使用Nginx作为反向代理的架构时,可以启用Nginx的本地缓存,对于代理数据进行缓存
- 如果Nginx的本地缓存未命中,可以在Nginx中编写Lua脚本从Redis中命中数据
- 如果Redis依然没有命中的话,请求就会进入到Tomcat,也就是执行我们写的程序,在程序中可以设置进程级的缓存,如果命中直接返回即可。
- 如果进程级的缓存依然没有命中的话,请求才会进入到持久层查询数据。
以上就是多级缓存的基本的设计思路,其核心思想就是让每一个请求节点尽可能的进行缓存操作。
🚨说明,这里我们实现二级缓存,分别是:JVM进程缓存和Redis缓存。🚨说明,这里我们实现二级缓存,分别是:JVM进程缓存和Redis缓存。🚨说明,这里我们实现二级缓存,分别是:JVM进程缓存和Redis缓存。
2. Caffeine快速入门
Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库,也就是可以通过Caffeine实现进程级的缓存。Spring内部的缓存使用的就是Caffeine。
Caffeine的性能非常强悍,下图是官方给出的性能对比:
![SpringBoot多级缓存解决方案 图片[2]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-2-13.png)
2.1. 项目准备
完整项目结构如下
![SpringBoot多级缓存解决方案 图片[3]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-3-12.png)
- 创建
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><?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><?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 表示未发布');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 表示未发布' );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
![SpringBoot多级缓存解决方案 图片[4]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-4-13.png)
![SpringBoot多级缓存解决方案 图片[5]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-5-11.png)
- 在
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 {@AutowiredTutorialService 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;}}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; } }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><!--jvm进程缓存--> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency><!--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*/@SpringBootTestpublic class CaffeineTest {@Testpublic 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); //123Object value2 = cache.get("key2", key -> 456);System.out.println(value2); //456}}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 } }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() .maximumSize(1) // 设置缓存大小上限为 1,当缓存超出这个容量的时候,会使用Window TinyLfu策略来删除缓存。 .build();// 创建缓存对象 Cache<String, String> cache = Caffeine.newBuilder() .maximumSize(1) // 设置缓存大小上限为 1,当缓存超出这个容量的时候,会使用Window TinyLfu策略来删除缓存。 .build();
- 基于时间:设置缓存的有效时间
// 创建缓存对象Cache<String, String> cache = Caffeine.newBuilder()// 设置缓存有效期为 10 秒,从最后一次写入开始计时.expireAfterWrite(Duration.ofSeconds(10)).build();// 创建缓存对象 Cache<String, String> cache = Caffeine.newBuilder() // 设置缓存有效期为 10 秒,从最后一次写入开始计时 .expireAfterWrite(Duration.ofSeconds(10)) .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: 100max: 10000caffeine: init: 100 max: 10000caffeine: 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;@Configurationpublic class CaffeineConfig {@Value("${caffeine.init}")private Integer init;@Value("${caffeine.max}")private Integer max;@Beanpublic Cache<String, Tutorial> transportInfoCache() {// 创建缓存对象return Caffeine.newBuilder().initialCapacity(init) //缓存初始容量.maximumSize(max)//缓存最大容量.build();}}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(); } }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。
@AutowiredCache<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;}@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; }@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
![SpringBoot多级缓存解决方案 图片[6]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-6-10.png)
结果如下:
![SpringBoot多级缓存解决方案 图片[7]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-8-9.png)
命中之后,在此查询:
![SpringBoot多级缓存解决方案 图片[8]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-9-9.png)
响应结果:
![SpringBoot多级缓存解决方案 图片[9]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-10-8.png)
4. 二级缓存
二级缓存通过Redis的存储实现,这里我们使用Spring Cache进行缓存数据的存储和读取。
4.1. Redis配置
Spring Cache默认是采用jdk的对象序列化方式,这种方式比较占用空间而且性能差,所以往往会将值以json的方式存储,此时就需要对RedisCacheManager进行自定义的配置。
在com.zbbmeta.config
包下创建RedisConfig
类配置redis
/*** Redis相关的配置*/@Configurationpublic class RedisConfig {/*** 存储的默认有效期时间,单位:小时*/@Value("${redis.ttl:1}")private Integer redisTtl;@Beanpublic 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;}}/** * 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; } }/** * 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) {//省略}@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) { //省略 }@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中,并且查询时二级缓存已经生效。
![SpringBoot多级缓存解决方案 图片[10]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-11-8.png)
到这里,已经完成了一级和二级缓存的逻辑。
5. 一级缓存更新的问题
更新Tutorial
时,只是更新了Redis中的数据,并没有更新Caffeine中的数据,需要在更新数据时将Caffeine中相应的数据删除。 具体实现如下:
@AutowiredCache<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;}@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; }@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. 问题分析
通过前面的解决,视乎可以完成一级、二级缓存中数据的同步,如果在单节点项目中是没有问题的,但是,在分布式场景下是有问题的,看下图:
![SpringBoot多级缓存解决方案 图片[11]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-12-8.png)
说明:
- 部署了2个
Tutorial
服务节点,每个微服务都有自己进程级的一级缓存,都共享同一个Redis作为二级缓存 - 假设,所有节点的一级和二级缓存都是空的,此时,用户通过节点1查询
Tutorial
信息,在完成后,节点1的caffeine和Redis中都会有数据 - 接着,系统通过节点2更新了物流数据,此时节点2中的caffeine和Redis都是更新后的数据
- 用户还是进行查询动作,依然是通过节点1查询,此时查询到的将是旧的数据,也就是出现了一级缓存与二级缓存之间的数据不一致的问题
6.2. 问题解决
如何解决该问题呢?可以通过消息的方式解决,就是任意一个节点数据更新了数据,发个消息出来,通知其他节点,其他节点接收到消息后,将自己caffeine中相应的数据删除即可。 关于消息的实现,可以采用RabbitMQ,也可以采用Redis的消息订阅发布来实现,在这里为了应用技术的多样化,所以采用Redis的订阅发布来实现。
![SpringBoot多级缓存解决方案 图片[12]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-13-8.png)
Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。
![SpringBoot多级缓存解决方案 图片[13]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-14-8.png)
当有新消息通过 publish 命令发送给频道 channel1 时, 这个消息就会被发送给订阅它的三个客户端。
Redis的订阅发布功能与传统的消息中间件(如:RabbitMQ)相比,相对轻量一些,针对数据准确和安全性要求没有那么高的场景可以直接使用。
- 在
com.zbbmeta.config.RedisConfig
增加订阅的配置:
public static final String CHANNEL_TOPIC = "tutorial-info-caffeine";/*** 配置订阅,用于解决Caffeine一致性的问题** @param connectionFactory 链接工厂* @param listenerAdapter 消息监听器* @return 消息监听容器*/@Beanpublic RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,MessageListenerAdapter listenerAdapter) {RedisMessageListenerContainer container = new RedisMessageListenerContainer();container.setConnectionFactory(connectionFactory);container.addMessageListener(listenerAdapter, new ChannelTopic(CHANNEL_TOPIC));return container;}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; }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一致性的问题*/@Componentpublic class RedisMessageListener extends MessageListenerAdapter {@Resourceprivate Cache<String, Tutorial> transportInfoCache;@Overridepublic void onMessage(Message message, byte[] pattern) {//获取到消息中的运单idString id = Convert.toStr(message);//将本jvm中的缓存删除掉this.transportInfoCache.invalidate(id);}}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); } }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()));//发布订阅消息到redisthis.stringRedisTemplate.convertAndSend(RedisConfig.CHANNEL_TOPIC, StrUtil.toString(tutorial.getId()));return tutorial;}@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; }@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个相同的微服务,但是端口不能重复,需要设置不同的端口:
![SpringBoot多级缓存解决方案 图片[14]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-15-5.png)
通过测试,发现可以接收到Redis订阅的消息:
![SpringBoot多级缓存解决方案 图片[15]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-16-5.png)
![SpringBoot多级缓存解决方案 图片[16]-SpringBoot多级缓存解决方案-不念博客](https://www.bunian.cn/wp-content/uploads/2023/12/640-17-5.png)
最终可以解决多级缓存间的一致性的问题。