缓存设计
缓存对象:需要缓存首先的图片列表数据,也就是对 listPictureVOByPage 接口进行缓存。
缓存三要素:“key、value、过期时间”。
1)缓存 key 设计
由于接口支持传入不同的查询条件,对应的数据不同,因此需要将查询条件作为缓存 key 的一部分。可以将查询条件对象转换为 JSON 字符串,但这个 JSON 会比较长,可以利用哈斯算法(md5) 来压缩 key。此外由于使用分布式缓存,可能由多个项目和业务共享,因此需要在 key 的开头拼接前缀进行隔离。设计出的 key 如下:
picture:listPictureVOByPage:${查询条件key}
2)缓存 value 设计
缓存从数据库中查到的 Page 分页对象,存储为什么格式呢?这里有2中选择:
- 为了可读性,可以转换为 JSON 结构的字符串 。例:{"id":"1","name":"pgs"}
- 为了压缩空间,可以存为二进制等其他结构
但是对应的 Redis 数据结构都是 string.
3)缓存过期的时间设置
必须设置缓存过期时间! 根据实际业务场景和缓存空间的大小、数据的一致性的要求设置,合适即可,此处由于查询条件较多、而且考虑到图片会持续更新,设置为 5 ~ 60 分钟即可。
如何操作 Redis?
Java 中有很多的 Redis 操作库,比如 Jedis、Lettuce 等。为了便于和 Spring 项目集成,Spring还提供了 Spring Data Redis 作为操作 Redis 的更高层抽象(默认使用 Lettuce 作为底层客户端)。由于我们的项目使用 Spring Boot,也推荐使用 Spring Data Redis,开发成本更低。
Caffeine 本地缓存
当应用需要频繁访问某些数据时,可以将这些缓存存到应用内存中(比如 JVM中);下次访问时,直接从内存读取,而不需要经过网络或其他存储系统。
相比于分布式缓存,本地缓存的速度更快,但是无法在多个服务器间共享数据、而且不方便扩容。
所以本地缓存的应用场景一般是:
- 数据访问量有限的小型数据集
- 不需要服务器间共享数据的单机应用
- 高频、低延迟的访问场景(如用户临时会话信息、短期热点数据)。
对于 Java 项目,Caffeine 是主流的本地缓存技术,拥有极高的性能和丰富的功能。比如可以精确控制缓存数量和大小、支持缓存过期、支持多种缓存淘汰策略、支持异步操作、线程安全等。
多级缓存
多级缓存是指结合本地缓存和分布式缓存的优点,在同一业务场景下构建两级缓存系统,这样可以兼顾本地缓存的高性能、以及分布式缓存的数据一致性和可靠性。
多级缓存的工作流程:
- 第一级(Caffeine 本地缓存):优先从本地缓存中读取数据。如果命中,则直接返回。
- 第二级(Redis 分布式缓存):如果本地缓存未命中,则查询 Redis 分布式缓存。如果 Redis 命中,则返回数据并更新本地缓存。
- 数据库查询:如果 Redis 也没有命中,则查询数据库,并将结果写入 Redis 和本地缓存。
多级缓存还有一个优势,就是提升了系统的容错性。即使 Redis 出现故障,本地缓存仍可提供服务,减少对数据库的直接依赖。
后端开发
1)引入 Maven 依赖,使用 Spring Boot Stater 快速整合 Redis,引入Caffeine:
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 本地缓存 Caffeine -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
2)在 application.yml 中添加 Redis 配置:
spring:
redis:
database: 0
host: 127.0.0.1
port: 6379
timeout: 5000
3)新写一个使用缓存的分页查询图片列表的接口。在查询数据库前先查询缓存,如果已有数据则直接返回缓存,如果没有数据则查询数据库,并且将结果设置到缓存中。
构造本地缓存,设置缓存容量和过期时间:
private final Cache<String, String> LOCAL_CACHE =
Caffeine.newBuilder().initialCapacity(1024)
.maximumSize(10000L)
// 缓存 5 分钟移除
.expireAfterWrite(5L, TimeUnit.MINUTES)
.build();
@PostMapping("/list/page/vo/cache")
public BaseResponse<Page<PictureVO>> listPictureVOByPageWithCache(@RequestBody PictureQueryRequest pictureQueryRequest,
HttpServletRequest request){
long current = pictureQueryRequest.getCurrent();
long size = pictureQueryRequest.getPageSize();
// 缓存限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
//普通用户只能看到已过审的数据
pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());
//构建缓存 key
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String cacheKey = String.format("pgspicture:listPictureVOByPage:%s", hashKey);
//从 Redis 缓存中查询
//1.先从本地缓存中查询
String cacheValue = LOCAL_CACHE.getIfPresent(cacheKey);
if(cacheValue != null){
//如果缓存命中,返回结果
Page<PictureVO> cachePage = JSONUtil.toBean(cacheValue, Page.class);
return ResultUtils.success(cachePage);
}
//2.本地缓存未命中,查询Redis 分布式缓存
ValueOperations<String,String> valueOps = stringRedisTemplate.opsForValue();
String cachedValue = valueOps.get(cacheKey);
if(cachedValue != null){
//如果缓存命中,更新本地缓存,返回结果
LOCAL_CACHE.put(cacheKey,cachedValue);
Page<PictureVO> cachePage = JSONUtil.toBean(cachedValue, Page.class);
return ResultUtils.success(cachePage);
}
//3.查询数据库
Page<Picture> picturePage = pictureService.page(new Page<>(current, size),
pictureService.getQueryWrapper(pictureQueryRequest));
//获取封装类
Page<PictureVO> pictureVOPage = pictureService.getPictureVOPage(picturePage, request);
//4.更新缓存
//存入 Redis 缓存
LOCAL_CACHE.put(cacheKey,cacheValue);
cacheValue = JSONUtil.toJsonStr(pictureVOPage);
// 5 - 10 分钟随机过期,防止雪崩
int cacheExpireTime = 300 + RandomUtil.randomInt(0,300);
valueOps.set(cacheKey,cacheValue,cacheExpireTime, TimeUnit.SECONDS);
//返回结果
return ResultUtils.success(pictureVOPage);
}
测试:
没有缓存
有缓存
有缓存的情况下明显访问速度快了十来倍。