【智能协同云图库】智能协同云图库第五弹:基于 Redis + Caffeine + 腾讯云图片服务对图片进行查询、上传、加载、存储优化和基于分布式 Session 实现登录态保持

发布于:2025-08-06 ⋅ 阅读:(31) ⋅ 点赞:(0)

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


图片优化技术


在云图库项目上线之前,还有很大的优化空间。本节博客中,会分享近 10 种主流的图片优化技术,包括:

  • 图片查询优化:分布式缓存、本地缓存、多级缓存
  • 图片上传优化:压缩、秒传、分片上传、断点续传
  • 图片加载优化:懒加载、缩略图、CDN 加速、浏览器缓存
  • 图片存储优化:降频存储(冷热数据分离)、清理策略

一、图片查询优化


缓存


对于经常访问的数据,每次都从数据库(硬盘)中获取是比较慢的,可以利用性能更高的存储来提高系统响应速度,俗称缓存。合

理使用缓存可以显著降低数据库的压力、提高系统性能。

那么,什么样的数据适合缓存呢?一般情况下就是 4 个字 “读多写少”,要频繁查询的、不怎么修改的。具体来说:

  1. 高频访问的数据:如系统首页、热门推荐内容等。
  2. 计算成本较高的数据:如复杂查询结果、大量数据的统计结果。
  3. 允许短时间延迟的数据:如不需要实时更新的排行榜、图片列表等。

在我们的项目中,主页是用户高频访问的内容,调用的获取图片列表的接口也是高频访问的。

而且即使数据更新存在一定延迟,也不会对用户体验造成明显影响,因此非常适合缓存。


Redis 分布式缓存


分布式缓存 是指将缓存数据分布存储在 多台服务器 上,以便在高并发场景下提供更高的吞吐量和更好的容错性。

Redis 是实现分布式缓存的主流方案,也是后端开发必学的技能。主要是由于它具有下面几个优势:

  • 高性能:基于内存操作,访问速度极快。单节点 Redis 的读写 QPS 可达 10w 次每秒!
  • 丰富的数据结构:支持字符串、列表、集合、哈希、位图等,适用于各种数据结构存储。
  • 分布式支持:可以通过 Redis Cluster 构建高可用、高性能的分布式缓存,还提供哨兵集群机制提升可用性、提供分片集群机制提高可扩展性。

缓存设计


需要缓存首页的图片列表数据,也就是对 listPictureVOByPage 接口进行缓存。

首先按照缓存 3 要素 “key、value、过期时间” 进行设计。


(1) 缓存 key 设计

由于接口支持传入不同的查询条件,对应的数据不同,因此需要将查询条件作为缓存 key 的一部分。

可以将查询条件对象转换为 JSON 字符串,但这个 JSON 会比较长,可以利用哈希算法(如 MD5)来压缩 key。

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

yupicture:listPictureVOByPage:${查询条件key}

(2) 缓存 value 设计

缓存从数据库中查到的 Page 分页对象,存储为什么格式呢?这里有 2 种选择:

  • 为了可读性,可以转换为 JSON 结构的字符串。
  • 为了压缩空间,可以存为二进制等其他结构。

但是对应的 Redis 数据结构都是 string


(3) 缓存过期时间设置

必须设置缓存过期时间! 根据实际业务场景和缓存空间的大小、数据的一致性的要求设置,合适即可。

此处由于查询条件较多,而且考虑到图片会持续更新,设置为 5 ~ 60 分钟 即可。


如何操作Redis?


Java 中有非常多的 Redis 操作库,比如 Jedis、Lettuce 等。

为了便于和 Spring 项目集成,Spring 还提供了 Spring Data Redis 作为操作 Redis 的更高层抽象(默认使用 Lettuce 作为底层客户端)。

由于我们的项目使用 Spring Boot,也推荐使用 Spring Data Redis,开发成本更低。它的使用也非常简单,我们直接上手项目实战


后端开发


(1) 引入 Maven 依赖,使用 Spring Boot Starter 快速整合 Redis:

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

下载 redis

Redis-x64-5.0.14.msi_免费高速下载

提取码:vmty,一路 next 即可;

连接 redis

下载好 redis 客户端后,可以直接通过 idea 连接 redis,如果没有 IDEA 中数据源没有 redis 选项,可能是因为 IDEA 为社区版或者版本较老的原因,可以使用 redis 可视化工具来代替用 IDEA 操作 redis:

image-20250715114420071


(2) 在 application.yml 中添加 Redis 配置:

spring:
  # Redis 配置
  redis:
    database: 0       # 指定使用的 redis 库, redis 共有 16 个库
    host: 127.0.0.1
    port: 6379
    timeout: 5000     # 超时时间, 在超时时间内连接失败, 回报错

(3) 编写 JUnit 单元测试文件,测试使用 StringRedisTemplate 完成对 Redis 的基础操作(增删改查):

image-20250715115116320

@SpringBootTest
public class RedisStringTest {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    public void testRedisStringOperations() {
        // 获取操作对象
        ValueOperations<String, String> valueOps = stringRedisTemplate.opsForValue();

        // Key 和 Value
        String key = "testKey";
        String value = "testValue";

        // 1. 测试新增或更新操作
        valueOps.set(key, value);
        String storedValue = valueOps.get(key);
        assertEquals(value, storedValue, "存储的值与预期不一致");

        // 2. 测试修改操作
        String updatedValue = "updatedValue";
        valueOps.set(key, updatedValue);
        storedValue = valueOps.get(key);
        assertEquals(updatedValue, storedValue, "更新后的值与预期不一致");

        // 3. 测试查询操作
        storedValue = valueOps.get(key);
        assertNotNull(storedValue, "查询的值为空");
        assertEquals(updatedValue, storedValue, "查询的值与预期不一致");

        // 4. 测试删除操作
        stringRedisTemplate.delete(key);
        storedValue = valueOps.get(key);
        assertNull(storedValue, "删除后的值不为空");
    }
}

运行单元测试方法:

image-20250715140542217

那么我们就通过 JUnit 单元测试文件,测试了使用 StringRedisTemplate 对 Redis 进行的基础操作(增删改查)


(4)新写一个使用缓存的分页查询图片列表的接口:

image-20250715133818485

注入 Redis 操作对象:通过 @Resource 注解,将 StringRedisTemplate 对象注入到 Spring 容器中,使其可以在当前类中被使用。

image-20250715134310596

  • StringRedisTemplate 的作用:
    • StringRedisTemplate 是 Spring 提供的用于操作 Redis 的模板类,专门用于处理字符串类型的键值对。
    • 它封装了 Redis 的操作逻辑,简化了代码的编写,同时提供了线程安全的操作方式。
  • 场景:
    • 当需要在 Spring 应用中操作 Redis 数据库时,通过注入 StringRedisTemplate,可以方便地进行字符串类型的键值对的读写操作。

在查询数据库前先查询缓存,如果已有数据则直接返回缓存,如果没有数据则查询数据库,并且将结果设置到缓存中。

代码如下:

/**
 * 分页获取图片列表(封装类, 这个接口的区别在于带上缓存 listPictureVOByPageWithCache, 但是前端依旧是使用没有缓存的接口 listPictureVOByPage)
 */
@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, value, 过期时间

    // 将查询请求对象转为 JSON 格式
    String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);

    // 将查询条件 JSON 转为字节数组, 再将字节数组转为 md5, 得到的结果作为缓存的哈希 key
    String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());

    // 根据方案设计, redisKey = 项目名 + 接口名(去掉 WithCache) + 哈希 key 拼接
    String redisKey = String.format("yupicture:listPictureVOByPage:%s", hashKey);

    // 操作 redis, 从缓存中查询拿到 value
    ValueOperations<String, String> opsedForValue = stringRedisTemplate.opsForValue();

    // 如果有缓存, 直接作为接口响应返回前端
    String cachedValue = opsedForValue.get(redisKey);
    if(cachedValue != null){
        // 如果缓存命中, 将缓存结果反序列化为 JSON 对象
        Page<PictureVO> cachedPage = JSONUtil.toBean(cachedValue, Page.class);
        // Page.class Page 可以不带泛型, 泛型只是为了方便我们编码时使用的
        return ResultUtils.success(cachedPage);
    }

    // 查询缓存的结果为空, 接下来进行查询数据库操作
    Page<Picture> picturePage = pictureService.page(new Page<>(current, size),
            pictureService.getQueryWrapper(pictureQueryRequest));

    // 对数据库查询结果进行封装
    Page<PictureVO> pictureVOPage = pictureService.getPictureVOPage(picturePage, request);

    // 将查询数据库得到的结果, 存入 redis 缓存中, 下次就可以直接通过查询缓存得到结果
    // 将数据库封装结果进行 JSON 序列化, 作为存入缓存的 value
    String cacheValue = JSONUtil.toJsonStr(pictureVOPage);

    // 设置缓存的过期时间, 5~10 minute 过期
    int  cachedExpireTime = 300 + RandomUtil.randomInt(0, 300);
    // 设置缓存过期时间为一个区间, 是为了解决缓存雪崩的问题

    // 将 key , value, 过期时间, 时间单位(s) 存入缓存
    opsedForValue.set(redisKey, cacheValue, cachedExpireTime, TimeUnit.SECONDS);

    // 对查询结果进行脱敏后, 统一封装返回
    return ResultUtils.success(pictureVOPage);
}

缓存过期时间区间设置目的

  • 在缓存系统中,缓存雪崩是一个常见的问题。

    • 缓存雪崩是指大量缓存的 key 在同一时间集中失效,导致接下来的所有请求都直接打到数据库上。
    • 由于数据库需要处理大量原本由缓存承担的请求,可能会因压力过大而崩溃。
  • 为了避免缓存雪崩问题,需要采取一些措施。

    • 其中一个有效的方法是避免让缓存在同一时间集中过期
    • 具体做法是将缓存的过期时间设置为一个时间区间,而不是一个固定的值。
    • 通过引入时间区间,可以增加缓存过期时间的随机性,使不同缓存的过期时间尽量分散,从而避免大量缓存同时失效的情况。

测试带有缓存的分页查询接口


可以通过 Swagger 测试接口返回结果是否正常,并且对比和之前查数据库的性能提升。

  • 未使用缓存:平均响应时间:image-20250715150008366
  • 使用缓存:性能显著提升!image-20250715150129019

细心的同学会发现,为什么接口返回的大小不一样呢?

image-20250715151243271

这是因为缓存的过程中我们将JSON 字符串和 Java 对象进行了转换,使得一些为 null 的字段被过滤掉了。


Caffeine 本地缓存


当应用需要频繁访问某些数据时,可以将这些数据缓存到应用的内存中(比如 JVM 中)。下次访问时,直接从内存读取,而不需要经过网络或其他存储系统。

相比于分布式缓存,本地缓存的速度更快,但是无法在多个服务器间共享数据、而且不方便扩容。因此,本地缓存的应用场景一般是:

  • 数据访问量有限的小型数据集
  • 不需要服务器间共享数据的单机应用
  • 高频、低延迟的访问场景(如用户临时会话信息、短期热点数据)

对于 Java 项目,Caffeine 是主流的本地缓存技术,拥有极高的性能和丰富的功能。比如可以精确控制缓存数量和大小、支持缓存过期、支持多种缓存淘汰策略、支持异步操作、线程安全等。

💡 建议,由于本地缓存不需要引入额外的中间件,成本更低。因此如果只是要提升数据访问性能,优先考虑本地缓存而不是分布式缓存。


缓存设计


本地缓存的设计和分布式缓存基本一致,不再赘述。但有 2 个区别:

  1. 本地缓存需要自己创建初始化缓存结构(可以简单理解为要自己 new 一个 HashMap)。
  2. 由于本地缓存本身就是服务器隔离的,而且占用服务器的内存,key 可以更精简一些,不用再添加项目前缀。

后端开发


(1)引入 Caffeine 的 Maven 依赖


注意:如果要引入 3.x 版本的 Caffeine,Java 版本必须 >= 11!

如果不想升级 JDK,也可以改为引入 2.x 版本。

 <!-- 本地缓存 Caffeine -->
 <dependency>
     <groupId>com.github.ben-manes.caffeine</groupId>
     <artifactId>caffeine</artifactId>
     <version>3.1.8</version>
 </dependency>

(2)构造本地缓存,设置缓存容量和过期时间:

image-20250715154506981

private final Cache<String, String> LOCAL_CACHE = 
    Caffeine.newBuilder()
            .initialCapacity(1024) // 初始容量
            .maximumSize(10000L)   // 最大容量
            .expireAfterWrite(5L, TimeUnit.MINUTES) // 缓存 5 分钟后移除
            .build();

(3)参考之前使用分布式缓存的代码,修改为使用本地缓存。


image-20250715154711649

在查询数据库前先查询本地缓存,如果已有数据则直接返回缓存:

// 构建缓存 key
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String cacheKey = "listPictureVOByPage:" + hashKey;

// 从本地缓存中查询
String cachedValue = LOCAL_CACHE.getIfPresent(cacheKey);
if (cachedValue != null) {
    // 如果缓存命中,返回结果
    Page<PictureVO> cachedPage = JSONUtil.toBean(cachedValue, Page.class);
    return ResultUtils.success(cachedPage);
}

如果没有数据则查询数据库,并且将结果设置到本地缓存中:

// 查询数据库
Page<Picture> picturePage = pictureService.page(new Page<>(current, size),
    pictureService.getQueryWrapper(pictureQueryRequest));

// 获取封装类
Page<PictureVO> pictureVOPage = pictureService.getPictureVOPage(picturePage, request);

// 存入本地缓存
String cacheValue = JSONUtil.toJsonStr(pictureVOPage);
LOCAL_CACHE.put(cacheKey, cacheValue);

image-20250715161630460


性能测试与多级缓存优化


性能测试

可以通过 Swagger 测试一下返回结果是否正常,并且对比和之前查数据库、查 Redis 的性能提升。

  • 有缓存:最快可达 12ms,性能又进一步提升了 1 倍左右,相比数据库提升了好几倍!
  • 当前环境:目前我们数据库和 Redis 都是在本地的,本来访问就比较快。如果使用远程数据库或 Redis,性能的提升会更为明显。

扩展思考

我们发现,使用本地缓存和分布式缓存的流程基本是一致的。那么思考一下,如果你想灵活地切换使用本地缓存或分布式缓存,应该怎么实现呢?

答案:策略模式或者模板方法模式利用一个变量来灵活切换使用分布式缓存/本地缓存)。


多级缓存


多级缓存 是指结合本地缓存和分布式缓存的优点,在同一业务场景下构建两级缓存系统,这样可以兼顾本地缓存的高性能、以及分布式缓存的数据一致性和可靠性。


多级缓存的工作流程

  1. 第一级(Caffeine 本地缓存):优先从本地缓存中读取数据。如果命中,则直接返回。
  2. 第二级(Redis 分布式缓存):如果本地缓存未命中,则查询 Redis 分布式缓存。如果 Redis 命中,则返回数据并更新本地缓存。
  3. 数据库查询:如果 Redis 也未命中,则查询数据库,并将结果写入 Redis 和本地缓存。

流程图:

image-20250715161834075

多级缓存还有一个优势,就是提升了系统的容错性。即使 Redis 出现故障,本地缓存仍可提供服务,减少对数据库的直接依赖。


后端开发


image-20250715154711649


(1) 优先从本地缓存中读取数据。如果命中,则直接返回。

// 构建缓存 key
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String cacheKey = "yupicture:listPictureVOByPage:" + hashKey;

// 1. 查询本地缓存(Caffeine)
String cachedValue = LOCAL_CACHE.getIfPresent(cacheKey);
if (cachedValue != null) {
    Page<PictureVO> cachedPage = JSONUtil.toBean(cachedValue, Page.class);
    return ResultUtils.success(cachedPage);
}

(2) 如果本地缓存未命中,则查询 Redis 分布式缓存。如果 Redis 命中,则返回数据并更新本地缓存。

// 2. 查询分布式缓存(Redis)
ValueOperations<String, String> valueOps = stringRedisTemplate.opsForValue();
cachedValue = valueOps.get(cacheKey);
if (cachedValue != null) {
    // 如果命中 Redis,存入本地缓存并返回
    LOCAL_CACHE.put(cacheKey, cachedValue);
    Page<PictureVO> cachedPage = JSONUtil.toBean(cachedValue, Page.class);
    return ResultUtils.success(cachedPage);
}

(3) 如果 Redis 也未命中,则查询数据库,并将结果写入 Redis 和本地缓存。

// 3. 查询数据库
Page<Picture> picturePage = pictureService.page(new Page<>(current, size),
    pictureService.getQueryWrapper(pictureQueryRequest));
Page<PictureVO> pictureVOPage = pictureService.getPictureVOPage(picturePage, request);

// 4. 更新缓存
String cacheValue = JSONUtil.toJsonStr(pictureVOPage);

// 更新本地缓存
LOCAL_CACHE.put(cacheKey, cacheValue);

// 更新 Redis 缓存,设置过期时间为 5 分钟
valueOps.set(cacheKey, cacheValue, 5, TimeUnit.MINUTES);

image-20250715165031850


扩展


1. 手动刷新缓存


在某些情况下,数据更新较为频繁,但自动刷新缓存机制可能存在延迟,可以通过手动刷新来解决。比如:

  • 提供一个刷新缓存的接口,仅管理员可调用。
  • 提供管理后台,支持管理员手动刷新指定缓存。

2. 解决缓存常见问题


使用缓存时,一般要注意下面几个问题:

  1. 缓存击穿:某些 热点数据 在缓存过期后,大量请求直接打到数据库。

    • 解决方案:设置热点数据的超长过期时间,或使用互斥锁(如 Redisson)控制缓存刷新。
  2. 缓存穿透:用户频繁请求不存在的数据,导致大量的请求直接触发数据库查询。

    • 解决方案:对无效查询结果也进行缓存(如设置空值缓存),或者使用布隆过滤器。
  3. 缓存雪崩:大量缓存同时过期,导致请求打到数据库,系统崩溃。

    • 解决方案:设置不同缓存的过期时间,避免同时过期;或者使用多级缓存,减少对数据库的依赖。

💡 《如何解决缓存中的常见问题》是一道经典的八股文,感兴趣的同学可以在 面试鸭 上阅读学习:Redis 中的缓存击穿、缓存穿透和缓存雪崩是什么? - 面试鸭 - 程序员求职面试刷题神器


3. 自动识别热点图片缓存


可以采用热 key 探测技术,实时对图片的访问量进行统计,并自动将热点图片添加到内存缓存,以应对大量高频的访问。

智能面试刷题平台项目中对该技术有详细的讲解,有时间可以学习下。


4. 查询优化


可以参考 MySQL 数据库的性能优化方法有哪些?,比如获取图片列表时只查询(select)必要的字段,返回给前端时也只返回必要的字段等。


5. 代码优化


如果操作缓存的逻辑更复杂,可以单独抽象 CacheManager 统一管理缓存相关操作。


二、图片上传优化


图片压缩


对于图库网站来说,图片压缩是图片优化中最基本且最重要的操作,能够显著减少图片文件的大小,从而降低带宽使用和流量消耗,大幅降低成本的同时,提高图片加载速度。
有哪些压缩图片的方法呢?

  1. 将图片格式转换为体积更小的格式,比如 WebP 或其他现代格式。
  2. 对图片质量进行压缩。
  3. 缩小图片尺寸。

当然,对于图片网站来说,我们希望尽可能不要影响图片的质量,因此更推荐第 1 种方法。那么将图片压缩成什么格式?如何对图片进行压缩呢?


1. 图片压缩格式


在格式上,有 2 种选择:

  1. WebP

    • 由 Google 开发的现代图片格式,支持有损和无损压缩。相比传统格式:
      • 比 PNG 文件小约 26%。
      • 比 JPEG 文件小约 25%-34%。
      • 支持透明背景(Alpha 通道)。
    • 兼容性:大部分主流浏览器(如 Chrome、Edge、Firefox 等)均已支持 WebP。
  2. AVIF

    • 基于 AV1 视频编码技术的图片格式,压缩率更高。
      • 比 WebP 的文件大小更小,画质更优。
      • 支持透明背景和高动态范围(HDR)。

image-20250717162106298

虽然 AVIF 看起来更牛,但目前其兼容性没有 WebP 要好。为了保证图片在不同浏览器都能正常加载,建议选择 WebP 格式。


2. 图片压缩方案


跟解析图片的操作一样,可以使用本地的图像处理类库自行操作,也可以利用第三方云服务完成。

因为我们图片已经上传到了腾讯云 COS 对象存储服务,可以直接利用数据万象服务。

通过配置图片处理规则,在图片上传的同时自动进行压缩处理,减少开发成本。


如何利用数据万象对图片进行压缩呢?官方提供了文档:数据万象 通过图片压缩实现业务降本增效_腾讯云

主要有 2 种压缩方式:

  1. 访问图片时实时压缩
  2. 上传图片时实时压缩,参考文档

image-20250717162312559


其实还有第三种方式,也可以对已上传的图片进行压缩处理,参考文档

image-20250717162741389


对于我们的需求,要将图片格式转化为 WebP,可以参考官方文档,在上传文件时,传入 Rules 规则。

使用 HTTP API 调用时,传入处理规则参数;

image-20250717164647417


如果使用 SDK,就需要构造图片处理规则对象,参考文档

image-20250717161825371


3. 后端开发


为了实现方便,我们此处仅对文件格式进行转化,不进行质量变换之类的其他处理。


(1)修改 CosManager 上传图片的方法

在调用批量抓取图片接口时,抓取到的图片 url 是没有后缀的,会导致存储桶中的图片无法正确解析:

image-20250717163719538


(2) 将图片后缀转为 webp

接下来,我们需要在对象存储代码中,增加一个图片处理的规则,也就是将图片后缀转为 webp,并且使用数据万象将图片格式转为 webp

image-20250717164707274


代码如下:

image-20250717163226949

更新的代码 4~8

/**
 * 复制一份上传图片的代码, 并添加对图片的信息进行解析和校验的逻辑
 *
 * @param key
 * @param file
 * @return
 */
public PutObjectResult putPictureObject(String key, File file) {
    PutObjectRequest putObjectRequest = new PutObjectRequest(
            cosClientConfig.getBucket(), key, file
    );
    // 1. 定义一个图片处理规则 (获取图片的基本信息, 也被视作为一种对图片的处理)
    PicOperations picOperations = new PicOperations();

    // 2. 设置是否需要返回原图信息, 1 表示返回
    picOperations.setIsPicInfo(1);

    // 4. 新增图片处理规则, 图片压缩(转为 webp)
    String webpKey = FileUtil.mainName(key) + ".webp";
    // 使用 Hutool 工具类获取图片 url 的主名称, 再拼接上后缀


    // 5. 定义图片处理规则列表, 方便后续定义多个规则对图片进行不同的处理
    List<PicOperations.Rule> rules = new ArrayList<>();

    // 6. 根据官方文档, 构造图片操作的规则(新增请求参数)

//        Pic-Operations:
//        {
//            "is_pic_info": 1,
//                "rules": [{
//                    "fileid": "exampleobject",
//                    "rule": "imageMogr2/format/<Format>"
//        }]
//        }

    // compressRule 表示压缩规则
    PicOperations.Rule compressRule = new PicOperations.Rule();
    compressRule.setFileId(webpKey);
    // "rule": "imageMogr2/format/<Format>" 中 <Format> 表示需要拼接的内容
    compressRule.setRule("imageMogr2/format/webp");

    // 关键细节: PicOperations.Rule 有三个参数: fileid、rule、bucket, 官方文档只设置了两个
    // 但是必须三个参数都设置, 否则会报错, 也就是要指定 compressRule 对哪一个存储通生效 
    compressRule.setBucket(cosClientConfig.getBucket());

    // 7. 将 compressRule 设置到 rules 列表中
    rules.add(compressRule);

    // 8. 将 rules 列表存入 PicOperations 对象中
    picOperations.setRules(rules);

    // 3. 构造处理参数: 将图片处理规则设置到上传图片请求中
    putObjectRequest.setPicOperations(picOperations);

    return cosClient.putObject(putObjectRequest);
}

思考:

通过新增图片处理规则,实现了上传图片时,对图片格式进行转换;

那我们怎么得到图片转换后的格式呢,怎么得到转换格式后的 webp 图片的信息呢?

我们需要修改 PictureUploadTemplate 上传图片的方法,从处理结果中获取到转换格式后的 webp 图片的信息;


(3)修改 PictureUploadTemplate 上传图片的方法

从图片处理结果中获取到缩略图,并设置到返回结果中:

image-20250717170533245

更新代码 9~13

/**
 * @param inputSource      输入源
 * @param uploadPathPrefix 上传文件的路径前缀
 *                         由于这个方法是通用的上传图片文件的方法, 因此我们使用上传路径前缀, 而不是具体路径
 *                         具体的路径, 可解析上传文件的具体信息
 * @return 上传图片后解析出的结果
 */
public UploadPictureResult uploadPicture(Object inputSource, String uploadPathPrefix) {
    // 1. 校验图片
    validPicture(inputSource);

    // 2.图片上传地址
    String uuid = RandomUtil.randomString(16);

    String originalFilename = getOriginalFilename(inputSource);

    String uploadFilename = String.format("%s_%s.%s",
            DateUtil.formatDate(new Date()), uuid, FileUtil.getSuffix(originalFilename));

    String uploadPath = String.format("/%s/%s", uploadPathPrefix, uploadFilename);

    File file = null;
    try {
        // 3. 创建临时文件
        file = File.createTempFile(uploadPath, null);

        // 4. 处理文件来源
        processFile(inputSource, file);

        // 5. 上传文件到对象存储
        PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file);

        // 6. 获取图片信息对象
        ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo();

        // 9. 获取到图片处理结果(从上传图片的结果中, 取出图片处理的结果)
        ProcessResults processResults = putObjectResult.getCiUploadResult().getProcessResults();

        // 10. 从图片处理结果中, 获取所有格式处理后的图片(所有被处理后的图片的列表)
        List<CIObject> objectList = processResults.getObjectList();

        // 11. 列表为空, 表示可能处理失败了, 或者没有写处理规则
        if(CollUtil.isNotEmpty((objectList))){
            // 12. 列表不为空, 因为当前只写了一个 rule, 所以 objectList 只有一个值
            CIObject compressCiObject = objectList.get(0);
            // compressCiObject 表示压缩只后的结果, 获取压缩之后的图片信息

            // 13. 原来是对原图进行封装返回结果, 现在是要对压缩结果进行封装返回结果
            return buildResult(originalFilename, compressCiObject);
        }

        // 7. 对原图进行封装返回结果
        return buildResult(originalFilename, file, uploadPath, imageInfo);

    } catch (Exception e) {
        log.error("图片上传到对象存储失败 ", e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
    } finally {
        // 8. 清理临时文件
        deleteTempFile(file);
    }
}

(4)编写新的封装返回结果方法

从压缩图中获取图片信息:

/**
 * 14. 新写一个封装返回结果的方法, 针对压缩后的结果进行封装
 * @param originalFilename  原始文件名
 * @param compressCiObject  压缩后的对象
 * @return
 */
private UploadPictureResult buildResult(String originalFilename, CIObject compressCiObject) {
    // 对原有的封装返回结果的方法代码进行修改
    // 从压缩结果中获取宽高
    int picWidth = compressCiObject.getWidth();
    int picHeight = compressCiObject.getHeight();
    double picScale = NumberUtil.round(picWidth * 1.0 / picHeight, 2).doubleValue();
    UploadPictureResult uploadPictureResult = new UploadPictureResult();
    // cosManage 将图片后缀 .jpg 等修改为 .webp, 新路径为 compressCiObject.getKey()
    uploadPictureResult.setUrl(cosClientConfig.getHost() + "/" + compressCiObject.getKey());
    //  ("/" 不知道会不会影响, 视频中带有这里也先补上)
    uploadPictureResult.setPicName(FileUtil.mainName(originalFilename));
    // 从压缩结果中获取文件大小
    uploadPictureResult.setPicSize(compressCiObject.getSize().longValue());
    uploadPictureResult.setPicWidth(picWidth);
    uploadPictureResult.setPicHeight(picHeight);
    uploadPictureResult.setPicScale(picScale);
    // 从压缩结果中取图片格式
    uploadPictureResult.setPicFormat(compressCiObject.getFormat());

    return uploadPictureResult;
}

4. 接口测试


测试上传图片并查看对象存储中的资源大小:

image-20250717175055096


发现压缩效果显著。而且压缩图和原图同名,便于查找原图。

image-20250717175247511

前端也能正常获取到压缩后的图片信息。


这里的原理是先上传原图,保存成功后再对图片进行压缩处理,生成了一个 webp 的压缩图,并且后端数据库只会保存 webp 压缩图:

image-20250717175603152


扩展


1. 增加对原图的处理


目前每次上传图片实际上会保存原图和压缩图 2 个图片,原图占用的空间还是比较大的。如果想进一步优化,可以:

  • 删除原图,只保留缩略图;
  • 或者在数据库中保存原图的地址,用作备份。

2. 尝试更大比例的压缩


比如使用质量变换 来处理图片。

image-20250717175951503


扩展知识 - 文件秒传


由于文件秒传对于图片上传场景的作用有限,仅作为扩展知识学习即可,不必在本项目中实现。

文件秒传 是一种基于文件的唯一标识(如 MD5、SHA-256)对文件内容进行快速校验,避免重复上传的方法,在大型文件传输场景下非常重要。可以提高性能、节约带宽和存储资源。

大家可能用过 XX 网盘软件,如果重复上传相同的文件 2 次,你会发现第二次的上传速度贼快!


文件秒传的实现方案

流程如下:

  1. 客户端生成文件唯一标识

    • 上传前,通过客户端计算文件的哈希值(如 MD5、SHA-256),生成文件的唯一指纹。
  2. 服务端校验文件指纹

    • 后端接收到文件指纹后,在存储中查询是否已存在相同文件。

    • 若存在相同文件,则直接返回文件的存储路径。

    • 若不存在相同文件,则接收并存储新文件,同时记录其指纹信息。

注意:客户端和服务端是相对的概念。因为现在我们要把文件上传到对象存储服务器,我们的后端此时就是“客户端”,对象存储服务器才是 “服务端”。

image-20250717181955132

对于我们的项目,给图片表增加 md5 字段用于存储文件指纹,上传图片前增加类似的逻辑判断即可:

// 计算文件指纹
String md5 = SecureUtil.md5(file);

// 从数据库中查询已有的文件
List<Picture> pictureList = pictureService.lambdaQuery()
    .eq(Picture::getMd5, md5)
    .list();

// 文件已存在,秒传
if (CollUtil.isNotEmpty(pictureList)) {
    // 直接复用已有文件的信息,不必重复上传文件
    Picture existPicture = pictureList.get(0);
} else {
    // 文件不存在,实际上传逻辑
}

实际使用中的限制

我们目前的项目其实不适合使用文件秒传。

一方面是对于图片场景,文件比较小、重复文件也相对较少,秒传的优化效果有限;

另外一方面是本项目使用腾讯云 COS 的对象存储,只能通过唯一地址去取文件,无法完全自定义文件的存储结构、也不支持文件快捷方式的概念,因此秒传的文件地址必须使用和原文件相同的对象路径,可能导致其他的问题(比如用户 A 上传的图片地址等同于用户 B 上传的地址)。


扩展知识 - 分片上传和断点续传


对于大文件,还可以开启分片上传断点续传,不需要自己开发,直接使用 对象存储的 SDK就能完成。

实现原理,利用腾讯云对象存储的 SDK 实现分块上传

  • 如果将文件进行分块上传,就需要设置上传阈值上传分块大小,并且在客户端和服务器同时记录一个上传文件的进度

  • 分 5 块,就记录进度 0/5,如果上传文件过程被中断,如客户端的网络断了,再恢复后重新上传该文件,就继续上传剩余进度的分块即可;

  • 上传结束后,服务端校验文件拼接起来是不是一个完整的文件

  • 虽然这里没有实现,但是可以拿这个原理和面试官吹水

image-20250717182659103


三、图片加载优化


图片加载优化的目的是提升页面加载速度、减少带宽消耗,并改善用户体验。本节鱼皮将从 缩略图、懒加载、CDN 加速、浏览器缓存 这 4 个方面进行全面优化。


缩略图


系统目前的问题:首页直接加载原图,原图文件通常比缩略图大数倍甚至数十倍,不仅导致加载时间长,还会造成大量流量浪费。

解决方案:上传图片时,同时生成一份较小尺寸的缩略图。用户浏览图片列表时加载缩略图,只有在进入详情页或下载时才加载原图。


1. 实现方案


生成缩略图的方法和前面讲的「图片压缩」一致,可以使用本地图像处理类库,也可以利用第三方云服务完成。

此处我们依然选择 数据万象服务,参考Java SDK 文档使用 SDK 来构造图片处理规则对象

image-20250718155020662


具体的图片缩放参数可 对象存储 缩放_参考文档

image-20250718154736366


缩放文档列举了多种缩放规则,我们采用如下缩放规则:

image-20250718155300540


2. 后端开发


(1)数据表 picture 新增缩略图字段:

image-20250718155748754

ALTER TABLE picture
    -- 添加新列
    ADD COLUMN thumbnailUrl varchar(512) NULL COMMENT '缩略图 url';

(2)PictureMapper.xml 新增缩略图字段:

<result property="thumbnailUrl" column="thumbnailUrl" jdbcType="VARCHAR"/>
<!-- ... -->
<sql id="Base_Column_List">
    id,url,thumbnailUrl,name,
    introduction,category,tags,
    picSize,picWidth,picHeight,
    picScale,picFormat,userId,
    createTime,editTime,updateTime,
    isDelete
</sql>

(3)数据模型新增缩略图字段,包括 Picture 类、PictureVO 类、UploadPictureResult 类:

/**
 * 缩略图 url
 */
private String thumbnailUrl;

image-20250718161841990


(4)缩略图处理


首先明确我们使用的缩放规则,设置最大宽高后,对图片进行等比缩小。且如果缩略图的宽高大于原图,则不会处理。

image-20250718162202756

修改 CosManager 的上传图片方法,补充对缩略图的处理:

更新代码 9-12

/**
 * 复制一份上传图片的代码, 并添加对图片的信息进行解析和校验的逻辑
 *
 * @param key
 * @param file
 * @return
 */
public PutObjectResult putPictureObject(String key, File file) {
    PutObjectRequest putObjectRequest = new PutObjectRequest(
            cosClientConfig.getBucket(), key, file
    );
    // 1. 定义一个图片处理规则 (获取图片的基本信息, 也被视作为一种对图片的处理)
    PicOperations picOperations = new PicOperations();

    // 2. 设置是否需要返回原图信息, 1 表示返回
    picOperations.setIsPicInfo(1);

    // 4. 新增图片处理规则, 图片压缩(转为 webp)
    String webpKey = FileUtil.mainName(key) + ".webp";
    // 使用 Hutool 工具类获取图片 url 的主名称, 再拼接上后缀


    // 5. 定义图片处理规则列表, 方便后续定义多个规则对图片进行不同的处理
    List<PicOperations.Rule> rules = new ArrayList<>();

    // 6. 根据官方文档, 构造图片操作的规则(新增请求参数)

    // compressRule 表示压缩规则
    PicOperations.Rule compressRule = new PicOperations.Rule();
    compressRule.setFileId(webpKey);
    // "rule": "imageMogr2/format/<Format>" 中 <Format> 表示需要拼接的内容
    compressRule.setRule("imageMogr2/format/webp");

    // 关键细节: PicOperations.Rule 有三个参数: fileid、rule、bucket, 官方文档只设置了两个
    // 但是必须三个参数都设置, 否则会报错, 也就是要对哪一个存储通生效 compressRule
    compressRule.setBucket(cosClientConfig.getBucket());

    // 7. 将 compressRule 设置到 rules 列表中
    rules.add(compressRule);

    // 8. 将 rules 列表存入 PicOperations 对象中
    picOperations.setRules(rules);

    // 9. 缩略图处理
    PicOperations.Rule thumbnailRule = new PicOperations.Rule();
    thumbnailRule.setBucket(cosClientConfig.getBucket());
    // 10. 缩放规则: /thumbnail/<Width>x<Height>>
    thumbnailRule.setRule(String.format("imageMogr2/thumbnail/%sx%s>", 128,128));
    // %sx%s -> <Width>x<Height>, 还要加一个 >

    // 11. 拼接缩略图的路径 , 不能和原图一样, 否则会将原图覆盖
    String thumbnailKey = FileUtil.mainName(key) + "/thumbnail." + FileUtil.getSuffix(key);
    // 约定原图压缩之后的名称, 也是缩略图的上传路径
    // 取出原图的 mainName + /thumbnail + 原图路径key后缀
    // 这样原图是什么格式, 缩略图就是什么格式, 区分压缩图的 fileId: webpKey

    // 12. 将拼接得到的缩略图的路径, 作为缩略规则的 fileId 参数
    thumbnailRule.setFileId(thumbnailKey);

    rules.add(thumbnailRule);

    // 3. 构造处理参数: 将图片处理规则设置到上传图片请求中
    putObjectRequest.setPicOperations(picOperations);

    return cosClient.putObject(putObjectRequest);
}

修改 PictureUploadTemplate 的上传图片方法,获取到缩略图:

image-20250718164441508

更新代码 12~14

/**
 * @param inputSource      输入源
 * @param uploadPathPrefix 上传文件的路径前缀
 *                         由于这个方法是通用的上传图片文件的方法, 因此我们使用上传路径前缀, 而不是具体路径
 *                         具体的路径, 可解析上传文件的具体信息
 * @return 上传图片后解析出的结果
 */
public UploadPictureResult uploadPicture(Object inputSource, String uploadPathPrefix) {
    // 1. 校验图片
    validPicture(inputSource);

    // 2.图片上传地址
    String uuid = RandomUtil.randomString(16);

    String originalFilename = getOriginalFilename(inputSource);

    String uploadFilename = String.format("%s_%s.%s",
            DateUtil.formatDate(new Date()), uuid, FileUtil.getSuffix(originalFilename));

    String uploadPath = String.format("/%s/%s", uploadPathPrefix, uploadFilename);

    File file = null;
    try {
        // 3. 创建临时文件
        file = File.createTempFile(uploadPath, null);

        // 4. 处理文件来源
        processFile(inputSource, file);

        // 5. 上传文件到对象存储
        PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file);

        // 6. 获取图片信息对象
        ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo();

        // 9. 获取到图片处理结果(从上传图片的结果中, 取出图片处理的结果)
        ProcessResults processResults = putObjectResult.getCiUploadResult().getProcessResults();

        // 10. 从图片处理结果中, 获取所有格式处理后的图片(所有被处理后的图片的列表)
        List<CIObject> objectList = processResults.getObjectList();

        // 11. 列表为空, 表示可能处理失败了, 或者没有写处理规则
        if(CollUtil.isNotEmpty((objectList))){
            // 12. 获取压缩之后的结果
            CIObject compressCiObject = objectList.get(0);

            // 13. 获取缩略之后的结果
            CIObject thumbnailCiObject = objectList.get(1);

            // 14. 封装压缩图, 缩略图的返回结果
            return buildResult(originalFilename, compressCiObject, thumbnailCiObject);
        }

        // 7. 对原图进行封装返回结果
        return buildResult(originalFilename, file, uploadPath, imageInfo);

    } catch (Exception e) {
        log.error("图片上传到对象存储失败 ", e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
    } finally {
        // 8. 清理临时文件
        deleteTempFile(file);
    }
}

修改 PictureUploadTemplate 封装返回结果的方法,将缩略图路径也设置到返回结果中:

更新代码:增加 thumbnailCiObject 参数, 将缩略图路径也设置到返回结果中(注释 8)

/**
 * 新写一个封装返回结果的方法, 针对压缩后的结果进行封装
 * @param originalFilename  原始文件名
 * @param compressCiObject  压缩后的对象
 * @param thumbnailCiObject  缩略后后的对象
 * @return
 */
private UploadPictureResult buildResult(String originalFilename, CIObject compressCiObject, CIObject thumbnailCiObject) {
    // 1. 对原有的封装返回结果的方法代码进行修改
    // 2. 从压缩结果中获取宽高
    int picWidth = compressCiObject.getWidth();
    int picHeight = compressCiObject.getHeight();
    double picScale = NumberUtil.round(picWidth * 1.0 / picHeight, 2).doubleValue();
    UploadPictureResult uploadPictureResult = new UploadPictureResult();
    // 3. 设置压缩后的原图地址
    uploadPictureResult.setUrl(cosClientConfig.getHost() + "/" + compressCiObject.getKey());
    // cosManage 将图片后缀 .jpg 等修改为 .webp, 新路径为 compressCiObject.getKey()

    //  4. ("/" 不知道会不会影响, 视频中带有这里也先补上)
    uploadPictureResult.setPicName(FileUtil.mainName(originalFilename));
    // 5. 从压缩结果中获取文件大小
    uploadPictureResult.setPicSize(compressCiObject.getSize().longValue());
    uploadPictureResult.setPicWidth(picWidth);
    uploadPictureResult.setPicHeight(picHeight);
    uploadPictureResult.setPicScale(picScale);
    // 6. 从压缩结果中取图片格式
    uploadPictureResult.setPicFormat(compressCiObject.getFormat());

    // 8. 设置缩略图地址
    uploadPictureResult.setThumbnailUrl(cosClientConfig.getHost() + "/" + thumbnailCiObject.getKey());

    // 7. 返回可访问的地址
    return uploadPictureResult;
}

当前代码只是解析得到了上传图片得到的结果对象 uploadPictureResult ,因为注释 8 新增了 thumbnailUrl 属性的赋值;

我们需要同步在上传图片方法中,将 thumbnailUrl 属性的值存到数据库中,也就是对 Picture 实体类 thumbnailUrl 赋值。


需要同步修改 PictureService 的上传图片方法,补充设置缩略图字段:

image-20250718165916632

更新代码 15

@Override
public PictureVO uploadPicture(Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) {

    // 1. 校验参数, 用户未登录, 抛出没有权限的异常
    ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);

    // 2. 判断是新增图片, 还是更新图片, 所以先判断图片是否存在
    Long pictureId = null;
    if (pictureUploadRequest != null) {
        // 3. 如果传入的请求不为空, 才获取请求中的图片 ID
        pictureId = pictureUploadRequest.getId();
    }

    // 4. 图片 ID 不为空, 查数据库中是否有对应的图片 ID
    // 新增条件 pictureId > 0, 仅当有 id (id >0)才检查
    // todo
    if (pictureId != null && pictureId > 0) {

        Picture oldPicture = this.getById(pictureId);
        ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");

        // 修改 2: 仅本人和管理员可以编辑图片
        // Long 类型包装类最好也用 equals 判断
        if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
        }
    }

    // 7. 定义上传文件的前缀 public/登录用户 ID
    String uploadPathPrefix = String.format("public/%s", loginUser.getId());
    // 根据用户划分前缀, 当前的图片文件上传到公共图库, 因此前缀定义为 public

    // 8. 上传图片, 上传图片 API 需要的参数(原始文件 + 文件前缀), 获取上传文件结果对象
    PictureUploadTemplate pictureUploadTemplate = filePictureUpload;
    if (inputSource instanceof String) {
        pictureUploadTemplate = urlPictureUpload;
    }

    UploadPictureResult uploadPictureResult = pictureUploadTemplate.uploadPicture(inputSource, uploadPathPrefix);
    // UploadPictureResult uploadPictureResult = fileManager.uploadPicture(multipartFile, uploadPathPrefix);

    // 9. 构造要入库的图片信息(样板代码)
    Picture picture = new Picture();
    picture.setUrl(uploadPictureResult.getUrl());
    
    // 15. 从上传结果中获取缩略图 url, 并设置到数据库中
    picture.setThumbnailUrl(uploadPictureResult.getThumbnailUrl());
    
    String picName = uploadPictureResult.getPicName();
    if(pictureUploadRequest!=null && StrUtil.isNotBlank(pictureUploadRequest.getPicName())){
        // 图片更新请求不为空, 并且图片更新请求中的图片名称属性不为空, 以更新请求的图片名称, 代替图片解析结果的名称
        // pictureUploadRequest 的 PicName 属性是允许用户传递的
        picName = pictureUploadRequest.getPicName();
    }
    picture.setName(picName);
    picture.setPicSize(uploadPictureResult.getPicSize());
    picture.setPicWidth(uploadPictureResult.getPicWidth());
    picture.setPicHeight(uploadPictureResult.getPicHeight());
    picture.setPicScale(uploadPictureResult.getPicScale());
    picture.setPicFormat(uploadPictureResult.getPicFormat());
    picture.setUserId(loginUser.getId());

    this.fillReviewParams(picture, loginUser);
    // 10. 操作数据库, 如果 pictureId 不为空, 表示更新图片, 否则为新增图片
    if (pictureId != null) {
        // 11. 如果是更新, 需要补充 id 和编辑时间
        picture.setId(pictureId);
        picture.setEditTime(new Date());
    }

    // 12. 利用 MyBatis 框架的 API,根据实体对象 picture 是否存在 ID 值, 来决定是执行插入操作还是更新操作
    boolean result = this.saveOrUpdate(picture);

    // 13. result 返回 false, 表示数据库不存在该图片, 不能调用图片上传(更新)接口
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片上传失败, 数据库操作失败");

    // 14. 对数据进行脱敏, 并返回
    return PictureVO.objToVo(picture);
}

(5)测试效果


上传大图片时,缩略图的效果显著,体积直接减小百倍!但有个比较坑的情况,如果上传的图片本身就比较小,缩略图反而比压缩图更大,还不如不缩略!

我们可以优化 CosManager 图片上传的逻辑,仅对 > 20 KB 的图片生成缩略图:

image-20250718181904303

// 缩略图处理,仅对 > 20 KB 的图片生成缩略图
if (file.length() > 2 * 1024) {
    PicOperations.Rule thumbnailRule = new PicOperations.Rule();
    thumbnailRule.setBucket(cosClientConfig.getBucket());
    String thumbnailKey = FileUtil.mainName(key) + "_thumbnail." + 		FileUtil.getSuffix(key);
    thumbnailRule.setFileId(thumbnailKey);
    // 缩放规则 /thumbnail/<Width>x<Height>>(如果大于原图宽高,则不处理)
    thumbnailRule.setRule(String.format("imageMogr2/thumbnail/%sx%s>", 128, 128));
    rules.add(thumbnailRule);
}

修改 PictureUploadTemplate 的逻辑,如果没有生成缩略图,则缩略图等于压缩图:

image-20250718182145531

if (CollUtil.isNotEmpty(objectList)) {
    CIObject compressedCiObject = objectList.get(0);
    // 缩略图默认等于压缩图
    CIObject thumbnailCiObject = compressedCiObject;
    // 有生成缩略图,才得到缩略图
    if (objectList.size() > 1) {
        thumbnailCiObject = objectList.get(1);
    }
    // 封装压缩图返回结果
    return buildResult(originFilename, compressedCiObject, thumbnailCiObject);
}

CDN 加速


1. 什么是 CDN?


CDN(内容分发网络) 是通过将图片文件分发到全球各地的节点,用户访问时从离自己最近的节点获取资源的技术,常用于文件资源或后端动态请求的网络加速,也能大幅分摊源站的压力、支持更多请求同时访问,是性能提升的利器。

image-20250719103725206


腾讯云 CDN 产品文档中提供的 CDN 原理图:

image-20250719103752034

💡 如果你想了解一些云服务的介绍、架构和最佳实践,多去看大公司云服务的产品文档,就能学到很多知识。

CDN 请求的核心过程:

  • 图片文件由 源站(如 COS 对象存储、或者服务器) 上传至 CDN 服务进行缓存。
  • 当用户请求图片时,CDN 会根据用户的地理位置,返回离用户 最近的 CDN 节点缓存的图片资源。
  • 未命中缓存的图片将从源站获取,并缓存在 CDN 节点,供后续用户访问,俗称 回源。

💡 回源的具体解释:在内容分发网络中,回源是指用户通过浏览器发送请求时,响应该请求的是源站点的服务器,而不是各节点上的缓存服务器。一般情况下,当 CDN 节点上的缓存服务器没有缓存响应的内容,或者响应的内容在源站点服务器上被修改,就会回源站去获取。


2. CDN 的优势


有同学会问了:COS 对象存储 不也是存储图片的服务么?CDN 内容分发网络 有啥独特的优势啊?

从这两个服务的名称中,我们就能明显感受到区别了,COS 更倾向于 “存储”,CDN 更倾向于 “网络请求”。

所以如果文件存储容量较大、但是访问频率较低,用对象存储性价比更高;但如果资源访问频率高、流量消耗大,还需要对访问进行加速、减少源站压力,就要使用 CDN 了。

CDN 的流量和请求单价通常低于对象存储,而且更加安全,可以保护源站地址不被泄露。


3. 如何使用CDN?


一般情况下,如果你要对外提供文件(图片)访问 / 下载服务,建议结合 COS 和 CDN。
比如对于本项目,COS 作为源站,负责存储图片文件;CDN 负责提供文件访问服务,以及缓存、安全性的设置。也就是说,使用 CDN 之后,我们数据库中存储的图片地址就不再是 COS 的地址,而是 CDN 的 URL。

如何开通和使用 CDN 服务?建议阅读 官方的产品文档、还有 CDN 结合 COS 的文档,写得很贴心。

💡 CDN 还提供自动图片优化功能,感兴趣的同学可以 看文档 了解下。

但是,注意 CDN 是个付费产品,按量计费,所以使用时有一些注意事项。俗话说得好:“乱用 CDN,钱包两行泪!”大家一定要认真看:

  1. 缓存策略:为静态资源(如图片、CSS、JS)设置长期缓存时间,可以减少回源的次数和消耗。
  2. 防盗链:配置 Referer 防盗链保护资源,比如仅允许自己的域名可以加载图片。image-20250719133127237
  3. IP 限制:根据需要配置 IP 黑白名单,限制不必要的访问。 image-20250719133154436
  4. HTTPS 配置:配置有效的 SSL 证书,启用 HTTPS 传输,提高请求的安全性。
  5. 监控告警这点尤为重要!一定要给 CDN 配置监控告警,比如设置一段时间内最多消耗的流量,超出时会自动发短信告警,避免费用超额;或者限制单个 IP 的请求频率,防止突发流量影响服务。 image-20250719133213725
  6. CDN 节点选择:国内业务选择覆盖中国大陆的节点就足够了,非必要的话,不要开通全球 CDN 节点,容易遭受海外攻击。
  7. 访问日志:开启访问日志,分析用户行为和流量来源,这个能力更适合业务访问量较大的场景。

浏览器缓存


通过设置 HTTP 头信息(如 Cache-Control),可以让用户的浏览器将资源缓存在本地。在用户再次访问同样的资源时,直接从本地缓存加载资源,而无需再次请求服务器。

所有缓存在使用时的注意事项基本都是类似的:

  1. 设置合理的缓存时间。常用的几种设置参数是:

    • 静态资源使用长期缓存,比如:Cache-Control: public, max-age=31536000 表示缓存一年,适合存储图片等静态资源。
    • 动态内容使用验证缓存,比如:Cache-Control: private, no-cache 表示缓存可被客户端存储,但每次使用前需要与服务器验证有效性。适合会动态变化内容的页面,比如用户个人中心。
    • 敏感内容禁用缓存,比如:Cache-Control: no-store 表示不允许任何形式的缓存,适合安全性较高的场景,比如登录页面、支付页面。
  2. 要能够及时更新缓存。可以给图片的名称添加 “版本号”(如文件名中包含 hash 值),这样哪怕上传相同的图片,由于版本号不同,得到的图片地址也不同,下次访问时就会重新加载。

对于我们的项目,图片资源是非常适合长期缓存在浏览器本地的,也已经通过给文件名添加日期和随机数防止了重复。

由于图片是从对象存储云服务加载的,如果需要使用缓存,可以接入 CDN 服务,直接在云服务的控制台配置缓存,参考文档image-20250719133315860


如果触发了浏览器本地缓存,在 F12 控制台中能够看到图片瞬间加载成功。

image-20250719133330657


四、图片存储优化


数据沉降与清理策略


数据沉降


大部分数据的访问热度会随着存储时间延长逐渐降低,为了严格控制存储成本,需要定期分析业务数据的
访问情况,并动态调整存储类型。

这就涉及到 数据沉降技术,将长时间未访问的数据自动迁移到低频访问存储,从而降低存储成本。就跟我
们平时使用电脑一样,SSD 硬盘很贵,我们一般优先将常用软件放在SSD 目录中,至于一些以前写过的资
料什么的,可以放在机械硬盘或外接硬盘中。


1. 先分析: 通过对象存储提供的清单 / 访问日志分析,或者业务代码中自行统计分析。

2. 再沉降: 可以直接通过对象存储提供的 生命周期 功能自动沉降数据,只需编写沉降规则即可。如下图,将 30 天未修改的文件沉降至低频存储:

image-20250719141950332

  • 低频存储的价格比标准存储低了一些,还可以将 几乎不需要修改和访问 的文件(比如日志文件)移动到归档存储中,存储价格更低,可节约几倍的成本!
  • 不过要注意,虽然低频存储的存储费用更低,但是当你要访问低频存储的资源时,会产生数据取回费用,所以一般只对几乎不访问的资源进行沉降,尽量减少取回费用。

数据沉降冷热数据分离 的概念是比较接近的,冷热数据分离是根据数据的访问热度,将访问频繁的数据(热数据)和访问较少的数据(冷数据)存储在不同的存储层中。

对于我们的项目,很久无人问津的历史图片就可以称为 “冷数据”,可以利用 COS 的生命周期功能在 30 天后自动沉降为低频存储。

当然也可以通过数据库记录图片的访问和下载时间,自行调用 API 批量沉降数据或者转储到其他存储服务。

💡 数据沉降和冷热数据分离的概念又有一些细微的差别

  • 数据沉降 更倾向于关注一个对象的生命周期(一个资源从热到冷),目标更多的是降低存储成本,配置沉降规则后也一般不会调整。
  • 冷热数据分离 更关注整个系统的资源分布(比如热门图片放到性能更高的硬盘中,冷门图片进行归档存储),目标是同时优化性能和节约成本,数据的热度可以实时调整。

清理策略


对于 “重存储” 的系统,数据清理是必要的!通过设置合理的清理策略,可以避免冗余数据占用存储空间,降低成本。这里分享 4 种典型的清理策略:

  1. 立即清理

    • 在删除图片记录时,立即关联删除对象存储中已上传的图片文件,确保数据库记录与存储文件保持一致。
    • 这里还有个小技巧,可以使用异步清理,降低对删除操作性能的影响,并且记录一些日志,避免删除失败的情况。
  2. 手动清理

    • 由管理员手动触发清理任务,可以筛选要清理的数据,按需选择需要清理的文件范围。
  3. 定期清理

    • 通过定时任务自动触发清理操作。
    • 系统预先设置规则(如文件未访问时间超过一定期限)自动清理不需要的数据。
  4. 惰性清理

    • 清理任务不会主动执行,而是等到资源需求增加(存储空间不足)或触发特定操作时才清理
    • 适合存储空间紧张但清理任务优先级较低的场景。

实际开发中,以上几种清理策略可以结合使用。比如 Redis 的内存管理机制结合了定期清理惰性清理策略。

  • 定期清理 通过后台定期扫描一部分键,随机检查并删除已过期的键,从而主动释放内存,减少过期键的堆积。
  • 惰性清理 则是在访问某个键时,检查其是否已过期,如果已过期则立即删除。

这两种策略互为补充:定期清理降低了过期键的占用积累,而惰性清理确保了访问时键的准确性和及时清理,从而在性能和内存使用之间取得平衡。

实现方案

对于我们的项目,由于不像 Redis 一样对空间的限制那么严格,更多的是为了节约成本,所以不需要惰性清理策略,按需运用 “立即清理 + 手动清理 + 定期清理” 即可。


后端开发


(1)CosManager 补充删除对象的方法:

image-20250719170913546

/**
 * 删除对象
 *
 * @param key 文件 key
 */
public void deleteObject(String key) throws CosClientException {
    cosClient.deleteObject(cosClientConfig.getBucket(), key);
}

(2)在 PictureService 中开发图片清理方法

image-20250719171116444

注意,删除图片时,需要先判断该图片地址是否还存在于其他记录里,确认没有才能删除。比如秒传的场景,就有可能多个图片地址指向同一个文件。

此外,还要注意删除对象存储中的文件时传入的是 key(不包含域名的相对路径),而数据库中取到的图片地址是包含域名的,所以删除前要移除域名,从而得到 key。这段在视频教程中没有讲,大家可以自行实现。

@Async
@Override
public void clearPictureFile(Picture oldPicture) {
    // 1. 判断该图片是否被多条记录使用
    String pictureUrl = oldPicture.getUrl();
    // 获取图片 url

    // 2. 以图片 url 构造查询条件, 返回有相同 url 的记录条数
    Long count = this.lambdaQuery()
            .eq(Picture::getUrl, pictureUrl)
            .count();

    // 3. 调用对象存储, 删除存储桶中的图片
    cosManager.deleteObject(pictureUrl);

    // 4. 删除对应缩略图
    String thumbnailUrl = oldPicture.getThumbnailUrl();
    if(StrUtil.isNotBlank(thumbnailUrl)){
        cosManager.deleteObject(thumbnailUrl);
    }
}

上述代码中,使用了 Spring 的 @Async 注解,可以使得方法被异步调用,记得要在启动类上添加 @EnableAsync 注解才会生效。

@SpringBootApplication
@EnableAsync  // 开启异步支持
@MapperScan("com.yupi.yupiturebackend.mapper")
@EnableAspectJAutoProxy(exposeProxy = true)

public class YuPitureBackendApplication {
    public static void main(String[] args) {
        SpringApplication.run(YuPitureBackendApplication.class, args);
    }
}

然后我们可以将 clearPictureFile 方法运用到图片删除接口,图片更新接口等场景。

image-20250719175002258


扩展


  1. 补充更多清理时机:在重新上传图片时,虽然那条图片记录不会删除,但其实之前的图片文件已经作废了,也可以触发清理逻辑。
  2. 实现更多清理策略:比如用 Spring Scheduler 定时任务实现定时清理、编写一个接口供管理员手动清理,作为一种兜底策略。
  3. 优化清理文件的代码:比如要删除多个文件时,使用对象存储的批量删除接口代替 for 循环调用。
  4. 为了清理原图,可以在数据库中保存原图的地址

至此,智能协同云图库项目的第一阶段已经开发优化完成,大家已经可以将这个项目部署上线并写到简历上啦~ 从下一期教程开始,我们将继续升级平台的能力,让它能够满足更多使用需求。


五、登录态自动保持


在进入下期教程前,大家可以运用自己学过的知识,对项目自行做一波优化。比如之前我们每次重启服务器都要重新登陆,既然已经整合了 Redis,不妨使用 Redis 管理 Session,更好地维护登录态


Redis 分布式 Session 操作方式也很简单,1 分钟就能完成。

(1)先在 Maven 中引入 spring-session-data-redis 库:

<!-- Spring Session + Redis -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

(2)修改 application.yml 配置文件,更改 Session 的存储方式和过期时间:

image-20250719180218449

spring:
  # session 配置
  session:
    store-type: redis
    # session 30 天过期
    timeout: 2592000

server:
  port: 8123
  servlet:
    context-path: /api
    # cookie 30 天过期
    session:
      cookie:
        max-age: 2592000

image-20250719182454064

这就搞定了,可以测试下重启服务器后是否还需要重新登陆,并且查看 Redis 中是否有登录相关的 key。


在这里插入图片描述

在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到