智能协同云图库第六章学习笔记

观看指南

适合人群

  • 大佬不必往下看了
  • 新手小白对于对象存储不熟悉,看文档比较吃力想要理清整体逻辑
  • 回顾复习,看官方教程部分细节看不明白。看视频又觉得费时间的同学

看完这篇你能收获到什么?

  • 补充在Redis在Springboot中的应用,不止会用更看到其中细节
  • 生活化的语言快速构建知识拼图
  • 部分扩展点带练(其实写的不好,看看思路得了哈哈)
  • 融合场景深刻理解面试题,拷打面试官做准备

一、图片查询优化

StringRedisTemplate 是什么?

由Spring Data Redis 提供的,Spring Boot 自动为你配置并注入的一个 Redis 操作模板类,专门用来处理 Redis 中 字符串(String)类型数据的操作

说白了就是保姆嘛!

原本煮饭的过程,你就需要以下步骤

  • 洗米
  • 加水
  • 测量水位(太有生活了)
  • 下锅
  • 开火
  • 完成煮饭

现在雇佣一个保姆,简化煮饭过程嘛。无需担心这个饭是怎么煮的,吃就行了

  • 下达需求
  • 保姆煮饭
  • 完成煮饭

当我们操作Redis的数据类型为String 类,可采用 opsForValue()接口方法

Java
复制代码
ValueOperations<String, String> valueOps = stringRedisTemplate.opsForValue();

使用这个 API,你不需要关心底层 Redis 命令的拼接,直接通过 Java 方法调用即可。

如果你还想操作其他类型(如 List、Hash、Set、ZSet),Spring 也都提供了类似的操作接口:

Redis 类型接口方法Java 接口
StringopsForValue()ValueOperations
HashopsForHash()HashOperations
ListopsForList()ListOperations
SetopsForSet()SetOperations
ZSetopsForZSet()ZSetOperations

缓存key的构建与使用

序列化与反序列化 - 点我查看底层原理

说白了就是拼装乐高积木,当你组装了一个乐高机器人。你主打的就是拼装体验,拼装完(反序列化)就7天无理由退货嘛,想要装回原盒子里面就必须拆掉(序列化)才装的下嘛。细品奥!

img

构建-序列化

Java
复制代码
// //构建缓存 key String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest); String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes()); String redisKey = "yupicture:listPictureVOByPage:" + hashKey;

目的就是构建一个标记方便后续查询 -(插眼用的),那用什么来做标记比较合适呢?

  • 图片名称
  • 图片规格
  • 用户ID

这些似乎都不太合适,思考在查询业务的时候发送了什么?那能不能就用请求的参数构建缓存key?当然可以!

那么问题来了,这部分请求参数能直接用嘛?

Java
复制代码
@EqualsAndHashCode(callSuper = true) @Data public class PictureQueryRequest extends PageRequest implements Serializable { /** * id */ private Long id; /** * 图片名称 */ private String name; -------------/省略源代码内容/------------------ /** * 简介 */ private String introduction; /** * 分类 */ private String category; private static final long serialVersionUID = 1L; }

这些参数在Java中不都对象嘛,浓缩成一段文字即可。这里就用到序列化

Java
复制代码
// 将查询对象转为 JSON 字符串(等价于“序列化成文本”) String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);

但是这么一大段感觉有点占用地方,压缩一下

Java
复制代码
// 对 JSON 串做 MD5 摘要(生成唯一标识) String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());

此外,由于使用分布式缓存,可能由多个项目和业务共享,因此需要在 key 的开头拼接前缀进行隔离

Java
复制代码
// 拼接最终 Redis Key String redisKey = "yupicture:listPictureVOByPage:" + hashKey;

最终构建的 key 格式是:

JavaScript
复制代码
yupicture:listPictureVOByPage:<MD5值>

这个代码片段 确实是在用“手动****序列化 + 哈希摘要”的方式,构建一个唯一的 Redis 缓存 key。本质上是用一个唯一字符串表达一个复杂的查询对象。

反序列化-使用

javascript
复制代码
// 从 Redis 缓存中查询 ValueOperations<String, String> valueOps = stringRedisTemplate.opsForValue(); String cachedValue = valueOps.get(redisKey); if (cachedValue != null) { // 如果缓存命中,返回结果 Page<PictureVO> cachedPage = JSONUtil.toBean(cachedValue, Page.class); return ResultUtils.success(cachedPage); }

特别注意这一段

JavaScript
复制代码
Page<PictureVO> cachedPage = JSONUtil.toBean(cachedValue, Page.class);

把 Redis 中获取到的 JSON 字符串 cachedValue,反序列化成 Java 对象(这里是 Page<PictureVO>),这样Java就能继续操作这些对象返回给前端。

  • 查询时读取缓存 String cachedValue = valueOps.get(redisKey); 读取字符串
  • 反序列化 JSONUtil.toBean(cachedValue, Page.class); 还原对象

面试题:你是如何在项目中使用Redis作为缓存的呢?

在图片查询场景中,我引入了Redis缓存大幅度降低了查询响应时间从50ms 平均降至20-30ms。同时降低对象存储的成本。

利用查询图片的请求参数,通过序列化+md5压缩再加入业务名称前缀构建成key

实际查询流程为:

JavaScript
复制代码
查询Redis是否有图片缓存 -> 是 -> 返回结果 | | | 否 -> 查询数据库 -> 返回结果 | | | 回种缓存

本地缓存与多级缓存

  1. 本地缓存Caffine 需要手动构建下HashMap 存储kv,其他核心流程基本一致看原文即可
  2. 多级缓存,就是分布式缓存 + 本地缓存结合。看看原文中的流程图就可明白

扩展点

1.手动刷新缓存

核心流程如下所示

yaml
复制代码
[前端管理后台] ——> [Controller: refreshPictureListCache()] | v 构建缓存 key(queryParam 序列化 + MD5) | v 查询数据库(pictureService.page) | v 封装结果为 PictureVO Page(getPictureVOPage) | v 将结果转为 JSON(JSONUtil.toJsonStr) | v 写入 Redis 缓存(valueOps.set(..., 随机过期时间)) | v 写入本地缓存(LOCAL_CACHE.put(...)) | v 返回封装结果(ResultUtils.success(...))

抽象出构建缓存key方法小优化

这里博主没有统一抽象成CacheManage,以后会优化

  1. PictureService中编写方法
JavaScript
复制代码
/** * 构建缓存key * @return {@link String } */ String buildCacheKey(PictureQueryRequest pictureQueryRequest);
  1. PictureServiceImpl实现该方法
typescript
复制代码
@Override public String buildCacheKey(PictureQueryRequest pictureQueryRequest) { // 构建缓存key String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest); String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes()); String cacheKey = "sionpicture:listPictureVOByPage:" + hashKey; return cacheKey; }
  1. PictureController 编写接口
java
复制代码
// 手动刷新缓存 @PostMapping("/admin/cache/refreshPictureList") @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) public BaseResponse<Page<PictureVO>> refreshPictureListCache(@RequestBody PictureQueryRequest pictureQueryRequest, HttpServletRequest request) { // 构建缓存key String cacheKey = pictureService.buildCacheKey(pictureQueryRequest); // redis 可操作对象 ValueOperations<String, String> valueOps = stringRedisTemplate.opsForValue(); long current = pictureQueryRequest.getCurrent(); long size = pictureQueryRequest.getPageSize(); ThrowUtils.throwIf(size >= 20, ErrorCode.PARAMS_ERROR, "不允许查询大于20条的数据"); // 查询数据库 Page<Picture> picturePage = pictureService.page(new Page<>(current, size), pictureService.getQueryWrapper(pictureQueryRequest)); //获取封装类 Page<PictureVO> pictureVOPage = pictureService.getPictureVOPage(picturePage, request); // 更新缓存 //更新Redis缓存 String cacheValue = JSONUtil.toJsonStr(pictureVOPage); //5-10分钟随机过期,防止雪崩 int cacheExpireTime = 300 + RandomUtil.randomInt(0, 300); valueOps.set(cacheKey, cacheValue, cacheExpireTime, TimeUnit.SECONDS); //写入本地缓存 LOCAL_CACHE.put(cacheKey, cacheValue); return ResultUtils.success(pictureVOPage); }
  1. listPictureByPageWithCache 优化构建key方法
JavaScript
复制代码
// 普通用户默认只能查看已经过审的数据 pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue()); ----------------不变-------------------------- // 构建缓存key String cacheKey = pictureService.buildCacheKey(pictureQueryRequest); ----------------不变--------------------------

2.整合缓存方法CacheManager

  • 新建Cache 包
  • 在包下新建 CacheManager 抽象出缓存方法进行统一的缓存管理

这里我写的不好,可以再优化,整合分布式缓存和本地缓存的方法

清理热门key统计,也可以用LRU算法等进行优化

java
复制代码
/** * @Author : wick * @Date : 2025/4/23 20:47 */ @Component @Slf4j public class CacheManager { //构造本地缓存,设置缓存容量和过期时间 private final Cache<String, String> LOCAL_CACHE = Caffeine.newBuilder() .initialCapacity(1024) .maximumSize(10000L) //缓存5分钟 .expireAfterWrite(5L, TimeUnit.MINUTES) .build(); @Resource private HotKeyTracker hotKeyTracker; @Autowired private StringRedisTemplate stringRedisTemplate; /** * 构建缓存key * * @param pictureQueryRequest * @return {@link String } */ public String buildCacheKey(PictureQueryRequest pictureQueryRequest) { // 构建缓存key String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest); String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes()); String cacheKey = "sionpicture:listPictureVOByPage:" + hashKey; return cacheKey; } /** * 查询本地缓存 * * @param cacheKey * @return {@link String } */ public String getLocalCache(String cacheKey) { String cachedValue = LOCAL_CACHE.getIfPresent(cacheKey); return cachedValue; } /** * 写入本地缓存 * * @param cacheKey * @param value */ public void putLocalCache(String cacheKey, String value) { if (cacheKey == null || value == null) { log.warn("忽略缓存操作,key 或 value 为 null,key={}, value={}", cacheKey, value); return; } LOCAL_CACHE.put(cacheKey, value); } /** * 获取分布式缓存 * * @param cacheKey * @return {@link String } */ public String getRedisCache(String cacheKey) { ValueOperations<String, String> valueOps = stringRedisTemplate.opsForValue(); valueOps = stringRedisTemplate.opsForValue(); String cachedValue = valueOps.get(cacheKey); return cachedValue; } /** * 写入分布式缓存 * * @param cacheKey * @param cacheValue */ public void setRedisCache(String cacheKey, String cacheValue) { //5-10分钟随机过期,防止雪崩 int cacheExpireTime = 300 + RandomUtil.randomInt(0, 300); ValueOperations<String, String> valueOps = stringRedisTemplate.opsForValue(); valueOps.set(cacheKey, cacheValue, cacheExpireTime, TimeUnit.SECONDS); } /** * 定时缓存热门key,到本地 */ @Scheduled(fixedRate = 60000) public void cacheHotKeys() { List<String> hotKeys = hotKeyTracker.getHotKeys(); ValueOperations<String, String> valueOps = stringRedisTemplate.opsForValue(); for (String key : hotKeys) { String value = valueOps.get(key); if (StrUtil.isNotBlank(value)) { LOCAL_CACHE.put(key, value); // Caffeine 或 ConcurrentMap log.info("热Key已缓存至本地:{}", key); } } hotKeyTracker.clear(); } }
  • 修改 PictureController.listPictureByPageWithCache 中原来的缓存方法
JavaScript
复制代码
// 构建缓存key String cacheKey = cacheManager.buildCacheKey(pictureQueryRequest); // 1.先从本地缓存查询 String cachedValue = cacheManager.getLocalCache(cacheKey);

3.检测热点key,自动写入本地缓存

  • 在Cache 包下新建 HotKeyTracker
java
复制代码
/** * @Author : wick * @Date : 2025/4/23 20:40 */ @Component public class HotKeyTracker { // 定义一个线程安全的Map,用于存储每个键的计数器 private final Map<String, AtomicInteger> counter = new ConcurrentHashMap<>(); // 定义热点阈值,表示1分钟内被认为是热点的最小访问次数 private final int threshold = 50; // 热点阈值(1分钟内) // 记录一次访问,传入的key对应的计数器值加1 public void record(String key) { // 使用computeIfAbsent方法,如果Map中不存在该key,则初始化为新的AtomicInteger对象 counter.computeIfAbsent(key, k -> new AtomicInteger()).incrementAndGet(); } // 获取当前的热点键列表,即访问次数超过阈值的键 public List<String> getHotKeys() { return counter.entrySet().stream() // 过滤出访问次数大于等于阈值的键值对 .filter(entry -> entry.getValue().get() >= threshold) .map(Map.Entry::getKey) .collect(Collectors.toList()); } // 清空计数器Map public void clear() { counter.clear(); } }
  • 如果缓存命中则记录下这个key
javascript
复制代码
// 如果缓存命中,返回结果 if (cachedValue != null) { // 记录访问次数,判断是否为热key hotKeyTracker.record(cacheKey); Page<PictureVO> cachePage = JSONUtil.toBean(cachedValue, Page.class); return ResultUtils.success(cachePage); } // 2.本地缓存未命中, 查询 Redis分布式缓存 cachedValue = cacheManager.getRedisCache(cacheKey); // 如果缓存命中,返回结果 if (cachedValue != null) { // 记录访问次数,判断是否为热key hotKeyTracker.record(cacheKey); // 写入本地缓存中 cacheManager.putLocalCache(cacheKey, cachedValue); Page<PictureVO> cachePage = JSONUtil.toBean(cachedValue, Page.class); return ResultUtils.success(cachePage); }
  • 在启动类上增加 @EnableScheduling开启定时任务
typescript
复制代码
@SpringBootApplication @EnableScheduling @EnableAsync @MapperScan("com.sion.sionpicturebackend.mapper") @EnableAspectJAutoProxy(exposeProxy = true) public class SionPictureBackendApplication { public static void main(String[] args) { SpringApplication.run(SionPictureBackendApplication.class, args); } }
  • 调小一点阈值断点去查看一下是否定时触发热门key
img

二、图片上传优化

图片转换成webp格式

  1. 将图片文件上传至对象存储(如 COS)
  2. 对图片进行处理:压缩并转为 WebP 格式
java
复制代码
public PutObjectResult putPictureObject(String key, File file) { PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key, file); // 对图片进行处理(获取基本信息也被视作为一种处理) PicOperations picOperations = new PicOperations(); // 1 表示返回原图信息 picOperations.setIsPicInfo(1); List<PicOperations.Rule> rules = new ArrayList<>(); // 图片压缩(转成 webp 格式) String webpKey = FileUtil.mainName(key) + ".webp"; PicOperations.Rule compressRule = new PicOperations.Rule(); compressRule.setRule("imageMogr2/format/webp"); compressRule.setBucket(cosClientConfig.getBucket()); compressRule.setFileId(webpKey); rules.add(compressRule); // 构造处理参数 picOperations.setRules(rules); putObjectRequest.setPicOperations(picOperations); return cosClient.putObject(putObjectRequest); }

核心关注点

  • PicOperations: 这是一个处理图片的操作类,用于指定上传图片时需要的处理规则。可理解为Picture Operations - 图片操作
  • setIsPicInfo(1): 这个操作是告诉 COS 上传后返回图片的基本信息(如宽高、格式、大小等)。1表示请求返回原图信息。
  • 创建空的规则列表用于存储不同的图片处理规则,例如添加更多的处理规则(如水印、裁剪等),可以继续扩展 PicOperations 类里的规则
JavaScript
复制代码
List<PicOperations.Rule> rules = new ArrayList<>();
  • PicOperations.Rule Rule 是 PicOperations 类的一个内部类或静态类,用来定义图片处理的规则
  • 把处理操作(即 picOperations)设置到上传请求中。
JavaScript
复制代码
putObjectRequest.setPicOperations(picOperations);

对对象存储不熟悉的同学,这里一些方法可能看的有点吃力

压缩图片

原文中

2)修改 PictureUploadTemplate 上传图片的方法,从图片处理结果中获取到缩略图,并设置到返回结果中: 在这一步实际是 从图片结果获取“压缩图”,继续往下看原文后面如果没有缩略图,压缩图 = 缩略图

java
复制代码
try { // 创建临时文件 file = File.createTempFile(uploadPath, null); // 处理文件来源(本地或 URL) processFile(inputSource, file); // 上传图片到对象存储 PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file); ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo(); ProcessResults processResults = putObjectResult.getCiUploadResult().getProcessResults(); List<CIObject> objectList = processResults.getObjectList(); if (CollUtil.isNotEmpty(objectList)) { CIObject compressedCiObject = objectList.get(0); // 封装压缩图返回结果 return buildResult(originFilename, compressedCiObject); } // 封装原图返回结果 return buildResult(originFilename, file, uploadPath, imageInfo); } catch (Exception e) { log.error("图片上传到对象存储失败", e); throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败"); }

核心关注点

  • CIObject 是腾讯云 COS 图片处理服务(CI,Content Image)中用于描述处理后的图片对象的类

  • 获取图片处理结果

    JavaScript
    复制代码
    ProcessResults processResults = putObjectResult.getCiUploadResult().getProcessResults(); List<CIObject> objectList = processResults.getObjectList();
    • 从上传结果中获取图片处理后的结果。处理后的结果存储在 objectList 中

      1. ProcessResults 包含了图片上传后的所有处理操作结果,比如压缩、缩略图等
      2. objectList 是一个列表,存储了所有处理结果对象(如压缩图、缩略图等)
      3. 现在暂时存放压缩图
  • 检查处理结果并返回压缩图

    JavaScript
    复制代码
    if (CollUtil.isNotEmpty(objectList)) { CIObject compressedCiObject = objectList.get(0); // 封装压缩图返回结果 return buildResult(originFilename, compressedCiObject); }
    • 如果 objectList 非空,说明至少有一个处理结果

    • 获取 objectList 中的第一个处理结果对象(压缩后的图片),并将其传递给 buildResult 方法进行封装。

    • buildResult 方法将返回封装后的压缩图相关信息

面试题

你在项目中做了哪些成本控制呢?为什么会这样做呢?

有的兄弟 ,有的

有段时间up主“影视飓风”说到各家流媒体平台为了节约成本,当某个视频刚发布的时候清晰度是拉满的当上了热门后清晰度反而会下降。我就思考这个平台也应该在某些场景可以降低成本呢?

在首页浏览图片的时候,不需要太清晰能看就行。在图片详情页可以看到更多的细节就好了,如果希望要原图呢也可以开通平台会员进行无损下载。

三、图片加载优化

核心关注点

这部分没有太多要说的,跟上一部分基本一致。关注这里一个细节即可

CosManager.putPictureObject 首先处理压缩图webp , 再到缩略图

JavaScript
复制代码
// 图片压缩(转成 webp 格式) String webpKey = FileUtil.mainName(key) + ".webp"; // 缩略图处理 PicOperations.Rule thumbnailRule = new PicOperations.Rule();

上面说到如果 objectList 非空,说明至少有一个处理结果

JavaScript
复制代码
ProcessResults processResults = putObjectResult.getCiUploadResult().getProcessResults(); List<CIObject> objectList = processResults.getObjectList(); if (CollUtil.isNotEmpty(objectList)) { CIObject compressedCiObject = objectList.get(0); // 🔥🔥🔥看这里 CIObject thumbnailCiObject = objectList.get(1); // 封装压缩图返回结果 return buildResult(originFilename, compressedCiObject, thumbnailCiObject); }

那么objectList.get(1);这个拿到的就是缩略图

扩展点

原图下载

  • 在数据库增加原图字段originUrl保留原始图,管理员与会员可用
  • 增加会员角色枚举,并提供鉴权服务
  • 开通会员流程,兑换码兑换会员资格
  • 前端按钮触发原图URL下载

我先往下做了,后面实现了再更新哈哈

面试题

那你是如何具体实现浏览图片时的成本控制呢?

我通过腾讯云平台的数据万象,用户上传图片后台设置一系列的规则对图片进行处理,如压缩图、缩略图。

cosManager.putPictureObject返回上传图片结果其中

putObjectResult.getCiUploadResult().getProcessResults() 中有一个对象列表processResults.getObjectList();处理完成图片结果将其返回。

图片太小避免反向优化,此时缩略图等于压缩图。如果有缩略图,前端优先返回缩略图 这样原图300kb -> 压缩图 30kb —> 缩略图 3kb 大幅度节省了流量的传输费用

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
Sion
下载 APP