跳到主要内容
智能协同云图库:Redis+Caffeine 多级缓存与图片全链路优化实战 | 极客日志
Java SaaS java 算法
智能协同云图库:Redis+Caffeine 多级缓存与图片全链路优化实战 本文分享了智能协同云图库的性能优化方案。通过构建 Redis 与 Caffeine 结合的多级缓存体系,显著降低数据库压力并提升查询速度。在图片处理上,利用腾讯云数据万象实现 WebP 格式压缩与缩略图生成,结合 CDN 加速与浏览器缓存策略优化加载体验。此外,还探讨了冷热数据分离的存储清理机制,以及基于 Redis 实现分布式 Session 以维持登录态。整套方案兼顾了性能、成本与用户体验。
NodeJser 发布于 2026/3/26 更新于 2026/4/25 0 浏览图片优化技术
在云图库项目上线之前,还有很大的优化空间。本节分享近 10 种主流的图片优化技术,涵盖查询、上传、加载和存储四个维度。
图片查询优化 :分布式缓存、本地缓存、多级缓存
图片上传优化 :压缩、秒传、分片上传、断点续传
图片加载优化 :懒加载、缩略图、CDN 加速、浏览器缓存
图片存储优化 :降频存储(冷热数据分离)、清理策略
一、图片查询优化
对于经常访问的数据,每次都从数据库获取比较慢,利用性能更高的存储来提高系统响应速度,俗称缓存。合理使用缓存可以显著降低数据库压力、提高系统性能。
什么样的数据适合缓存?一般是'读多写少'。具体来说:
高频访问的数据 :如系统首页、热门推荐内容等。
计算成本较高的数据 :如复杂查询结果、大量数据的统计结果。
允许短时间延迟的数据 :如不需要实时更新的排行榜、图片列表等。
在我们的项目中,主页是用户高频访问的内容,调用的获取图片列表接口也是高频访问的。即使数据更新存在一定延迟,也不会对用户体验造成明显影响,因此非常适合缓存。
Redis 分布式缓存
分布式缓存是指将缓存数据分布存储在多台服务器上,以便在高并发场景下提供更高的吞吐量和更好的容错性。Redis 是实现分布式缓存的主流方案,主要优势如下:
高性能 :基于内存操作,访问速度极快。单节点 Redis 的读写 QPS 可达 10w 次每秒。
丰富的数据结构 :支持字符串、列表、集合、哈希、位图等,适用于各种数据结构存储。
分布式支持 :可以通过 Redis Cluster 构建高可用、高性能的分布式缓存,还提供哨兵集群机制提升可用性、提供分片集群机制提高可扩展性。
缓存设计
需要缓存首页的图片列表数据,也就是对 listPictureVOByPage 接口进行缓存。首先按照缓存三要素'key、value、过期时间'进行设计。
(1) 缓存 key 设计
由于接口支持传入不同的查询条件,对应的数据不同,因此需要将查询条件作为缓存 key 的一部分。可以将查询条件对象转换为 JSON 字符串,但这个 JSON 会比较长,可以利用哈希算法(如 MD5)来压缩 key。此外,由于使用分布式缓存,可能由多个项目和业务共享,因此需要在 key 的开头拼接前缀进行隔离。设计出的 key 如下:
yupicture: listPictureVOByPage:${查询条件 key }
(2) 缓存 value 设计
缓存从数据库中查到的 Page 分页对象,存储为什么格式呢?这里有 2 种选择:
为了可读性,可以转换为 JSON 结构的字符串。
为了压缩空间,可以存为二进制等其他结构。
但是对应的 Redis 数据结构都是 string。
(3) 缓存过期时间设置
必须设置缓存过期时间!根据实际业务场景和缓存空间的大小、数据的一致性的要求设置,合适即可。此处由于查询条件较多,而且考虑到图片会持续更新,设置为 5 ~ 60 分钟即可。
后端开发
Java 中有非常多的 Redis 操作库,比如 Jedis、Lettuce 等。为了便于和 Spring 项目集成,Spring 还提供了 Spring Data Redis 作为操作 Redis 的更高层抽象(默认使用 Lettuce 作为底层客户端)。由于我们的项目使用 Spring Boot,也推荐使用 Spring Data Redis,开发成本更低。
(1) 引入 Maven 依赖
org.springframework.boot
spring-boot-starter-data-redis
<dependency >
<groupId >
</groupId >
<artifactId >
</artifactId >
</dependency >
(2) 在 application.yml 中添加 Redis 配置
spring:
redis:
database: 0
host: 127.0 .0 .1
port: 6379
timeout: 5000
(3) 编写 JUnit 单元测试文件,测试使用 StringRedisTemplate 完成对 Redis 的基础操作(增删改查)
@SpringBootTest
public class RedisStringTest {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
public void testRedisStringOperations () {
ValueOperations<String, String> valueOps = stringRedisTemplate.opsForValue();
String key = "testKey" ;
String value = "testValue" ;
valueOps.set(key, value);
String storedValue = valueOps.get(key);
assertEquals(value, storedValue, "存储的值与预期不一致" );
String updatedValue = "updatedValue" ;
valueOps.set(key, updatedValue);
storedValue = valueOps.get(key);
assertEquals(updatedValue, storedValue, "更新后的值与预期不一致" );
storedValue = valueOps.get(key);
assertNotNull(storedValue, "查询的值为空" );
assertEquals(updatedValue, storedValue, "查询的值与预期不一致" );
stringRedisTemplate.delete(key);
storedValue = valueOps.get(key);
assertNull(storedValue, "删除后的值不为空" );
}
}
注入 Redis 操作对象:通过 @Resource 注解,将 StringRedisTemplate 对象注入到 Spring 容器中,使其可以在当前类中被使用。
StringRedisTemplate 的作用:StringRedisTemplate 是 Spring 提供的用于操作 Redis 的模板类,专门用于处理字符串类型的键值对。它封装了 Redis 的操作逻辑,简化了代码的编写,同时提供了线程安全的操作方式。场景:当需要在 Spring 应用中操作 Redis 数据库时,通过注入 StringRedisTemplate,可以方便地进行字符串类型的键值对的读写操作。
在查询数据库前先查询缓存,如果已有数据则直接返回缓存,如果没有数据则查询数据库,并且将结果设置到缓存中。
@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());
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String redisKey = String.format("yupicture:listPictureVOByPage:%s" , hashKey);
ValueOperations<String, String> opsedForValue = stringRedisTemplate.opsForValue();
String cachedValue = opsedForValue.get(redisKey);
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);
int cachedExpireTime = 300 + RandomUtil.randomInt(0 , 300 );
opsedForValue.set(redisKey, cacheValue, cachedExpireTime, TimeUnit.SECONDS);
return ResultUtils.success(pictureVOPage);
}
缓存过期时间区间设置目的
在缓存系统中,缓存雪崩是一个常见的问题。缓存雪崩是指大量缓存的 key 在同一时间集中失效,导致接下来的所有请求都直接打到数据库上。由于数据库需要处理大量原本由缓存承担的请求,可能会因压力过大而崩溃。为了避免缓存雪崩问题,需要采取一些措施。其中一个有效的方法是 避免让缓存在同一时间集中过期。具体做法是将缓存的过期时间 设置为一个时间区间,而不是一个固定的值。通过引入 时间区间,可以 增加缓存过期时间的随机性,使不同缓存的过期时间尽量分散,从而避免大量缓存同时失效的情况。
测试带有缓存的分页查询接口 可以通过 Swagger 测试接口返回结果是否正常,并且对比和之前查数据库的性能提升。
使用缓存 :性能显著提升!
未使用缓存 :平均响应时间较长。
细心的同学会发现,为什么接口返回的大小不一样呢?这是因为缓存的过程中我们将 JSON 字符串和 Java 对象进行了转换,使得一些为 null 的字段被过滤掉了。
Caffeine 本地缓存 当应用需要频繁访问某些数据时,可以将这些数据缓存到应用的内存中(比如 JVM 中)。下次访问时,直接从内存读取,而不需要经过网络或其他存储系统。
相比于分布式缓存,本地缓存的速度更快,但是无法在多个服务器间共享数据、而且不方便扩容。因此,本地缓存的应用场景一般是:
数据访问量有限的小型数据集
不需要服务器间共享数据的单机应用
高频、低延迟的访问场景(如用户临时会话信息、短期热点数据)
对于 Java 项目,Caffeine 是主流的本地缓存技术,拥有极高的性能和丰富的功能。比如可以精确控制缓存数量和大小、支持缓存过期、支持多种缓存淘汰策略、支持异步操作、线程安全等。
💡 建议 ,由于本地缓存不需要引入额外的中间件,成本更低。因此如果只是要提升数据访问性能,优先考虑本地缓存而不是分布式缓存。
缓存设计 本地缓存的设计和分布式缓存基本一致,不再赘述。但有 2 个区别:
本地缓存需要自己创建初始化缓存结构(可以简单理解为要自己 new 一个 HashMap)。
由于本地缓存本身就是服务器隔离的,而且占用服务器的内存,key 可以更精简一些,不用再添加项目前缀。
后端开发 (1) 引入 Caffeine 的 Maven 依赖
注意:如果要引入 3.x 版本的 Caffeine,Java 版本必须 >= 11!如果不想升级 JDK,也可以改为引入 2.x 版本。
<dependency >
<groupId > com.github.ben-manes.caffeine</groupId >
<artifactId > caffeine</artifactId >
<version > 3.1.8</version >
</dependency >
private final Cache<String, String> LOCAL_CACHE = Caffeine.newBuilder()
.initialCapacity(1024 )
.maximumSize(10000L )
.expireAfterWrite(5L , TimeUnit.MINUTES)
.build();
(3) 参考之前使用分布式缓存的代码,修改为使用本地缓存。
在查询数据库前先查询本地缓存,如果已有数据则直接返回:
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);
性能测试与多级缓存优化
性能测试 可以通过 Swagger 测试一下返回结果是否正常,并且对比和之前查数据库、查 Redis 的性能提升。
有缓存 :最快可达 12ms,性能又进一步提升了 1 倍左右,相比数据库提升了好几倍!
当前环境 :目前我们数据库和 Redis 都是在本地的,本来访问就比较快。如果使用远程数据库或 Redis,性能的提升会更为明显。
扩展思考 我们发现,使用本地缓存和分布式缓存的流程基本是一致的。那么思考一下,如果你想灵活地切换使用本地缓存或分布式缓存,应该怎么实现呢?
答案:策略模式或者模板方法模式(利用一个变量来灵活切换使用分布式缓存/本地缓存)。
多级缓存 多级缓存是指结合本地缓存和分布式缓存的优点,在同一业务场景下构建两级缓存系统,这样可以兼顾本地缓存的高性能、以及分布式缓存的数据一致性和可靠性。
第一级(Caffeine 本地缓存) :优先从本地缓存中读取数据。如果命中,则直接返回。
第二级(Redis 分布式缓存) :如果本地缓存未命中,则查询 Redis 分布式缓存。如果 Redis 命中,则返回数据并更新本地缓存。
数据库查询 :如果 Redis 也未命中,则查询数据库,并将结果写入 Redis 和本地缓存。
多级缓存还有一个优势,就是提升了系统的容错性。即使 Redis 出现故障,本地缓存仍可提供服务,减少对数据库的直接依赖。
后端开发 (1) 优先从本地缓存中读取数据。如果命中,则直接返回。
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest);
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
String cacheKey = "yupicture:listPictureVOByPage:" + hashKey;
String cachedValue = LOCAL_CACHE.getIfPresent(cacheKey);
if (cachedValue != null ) {
Page<PictureVO> cachedPage = JSONUtil.toBean(cachedValue, Page.class);
return ResultUtils.success(cachedPage);
}
(2) 如果本地缓存未命中,则查询 Redis 分布式缓存。如果 Redis 命中,则返回数据并更新本地缓存。
ValueOperations<String, String> valueOps = stringRedisTemplate.opsForValue();
cachedValue = valueOps.get(cacheKey);
if (cachedValue != null ) {
LOCAL_CACHE.put(cacheKey, cachedValue);
Page<PictureVO> cachedPage = JSONUtil.toBean(cachedValue, Page.class);
return ResultUtils.success(cachedPage);
}
(3) 如果 Redis 也未命中,则查询数据库,并将结果写入 Redis 和本地缓存。
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);
valueOps.set(cacheKey, cacheValue, 5 , TimeUnit.MINUTES);
扩展
1. 手动刷新缓存 在某些情况下,数据更新较为频繁,但自动刷新缓存机制可能存在延迟,可以通过手动刷新来解决。比如提供一个刷新缓存的接口,仅管理员可调用;或者提供管理后台,支持管理员手动刷新指定缓存。
2. 解决缓存常见问题
缓存击穿 :某些热点数据在缓存过期后,大量请求直接打到数据库。
解决方案 :设置热点数据的超长过期时间,或使用互斥锁(如 Redisson)控制缓存刷新。
缓存穿透 :用户频繁请求不存在的数据,导致大量的请求直接触发数据库查询。
解决方案 :对无效查询结果也进行缓存(如设置空值缓存),或者使用布隆过滤器。
缓存雪崩 :大量缓存同时过期,导致请求打到数据库,系统崩溃。
解决方案 :设置不同缓存的过期时间,避免同时过期;或者使用多级缓存,减少对数据库的依赖。
3. 自动识别热点图片缓存 可以采用热 key 探测技术,实时对图片的访问量进行统计,并自动将热点图片添加到内存缓存,以应对大量高频的访问。
4. 查询优化 可以参考 MySQL 数据库的性能优化方法有哪些?比如获取图片列表时只查询(select)必要的字段,返回给前端时也只返回必要的字段等。
5. 代码优化 如果操作缓存的逻辑更复杂,可以单独抽象 CacheManager 统一管理缓存相关操作。
二、图片上传优化 对于图库网站来说,图片压缩是图片优化中最基本且最重要的操作,能够显著减少图片文件的大小,从而降低带宽使用和流量消耗,大幅降低成本的同时,提高图片加载速度。
将图片格式转换为体积更小的格式,比如 WebP 或其他现代格式。
对图片质量进行压缩。
缩小图片尺寸。
当然,对于图片网站来说,我们希望尽可能不要影响图片的质量,因此更推荐第 1 种方法。那么将图片压缩成什么格式?如何对图片进行压缩呢?
1. 图片压缩格式
WebP
由 Google 开发的现代图片格式,支持有损和无损压缩。相比传统格式:比 PNG 文件小约 26%,比 JPEG 文件小约 25%-34%,支持透明背景(Alpha 通道)。
兼容性:大部分主流浏览器(如 Chrome、Edge、Firefox 等)均已支持 WebP。
AVIF
基于 AV1 视频编码技术的图片格式,压缩率更高。比 WebP 的文件大小更小,画质更优,支持透明背景和高动态范围(HDR)。
虽然 AVIF 看起来更牛,但目前其兼容性没有 WebP 要好。为了保证图片在不同浏览器都能正常加载,建议选择 WebP 格式。
2. 图片压缩方案 跟解析图片的操作一样,可以使用本地的图像处理类库自行操作,也可以利用第三方云服务完成。因为我们图片已经上传到了腾讯云 COS 对象存储服务,可以直接利用数据万象服务。通过配置图片处理规则,在图片上传的同时自动进行压缩处理,减少开发成本。
其实还有第三种方式,也可以对已上传的图片进行压缩处理。
对于我们的需求,要将图片格式转化为 WebP,可以在上传文件时,传入 Rules 规则。使用 HTTP API 调用时,传入处理规则参数;如果使用 SDK,就需要构造图片处理规则对象。
3. 后端开发 为了实现方便,我们此处仅对文件格式进行转化,不进行质量变换之类的其他处理。
(1)修改 CosManager 上传图片的方法 在调用批量抓取图片接口时,抓取到的图片 url 是没有后缀的,会导致存储桶中的图片无法正确解析。
(2)将图片后缀转为 webp 接下来,我们需要在对象存储代码中,增加一个图片处理的规则,也就是将图片后缀转为 webp,并且使用数据万象将图片格式转为 webp。
public PutObjectResult putPictureObject (String key, File file) {
PutObjectRequest putObjectRequest = new PutObjectRequest (cosClientConfig.getBucket(), key, file);
PicOperations picOperations = new PicOperations ();
picOperations.setIsPicInfo(1 );
String webpKey = FileUtil.mainName(key) + ".webp" ;
List<PicOperations.Rule> rules = new ArrayList <>();
PicOperations.Rule compressRule = new PicOperations .Rule();
compressRule.setFileId(webpKey);
compressRule.setRule("imageMogr2/format/webp" );
compressRule.setBucket(cosClientConfig.getBucket());
rules.add(compressRule);
picOperations.setRules(rules);
putObjectRequest.setPicOperations(picOperations);
return cosClient.putObject(putObjectRequest);
}
思考:通过新增图片处理规则,实现了上传图片时,对图片格式进行转换;那我们怎么得到图片转换后的格式呢,怎么得到转换格式后的 webp 图片的信息呢?我们需要修改 PictureUploadTemplate 上传图片的方法,从处理结果中获取到转换格式后的 webp 图片的信息。
(3)修改 PictureUploadTemplate 上传图片的方法 从图片处理结果中获取到缩略图,并设置到返回结果中:
public UploadPictureResult uploadPicture (Object inputSource, String uploadPathPrefix) {
validPicture(inputSource);
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 {
file = File.createTempFile(uploadPath, null );
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 compressCiObject = objectList.get(0 );
return buildResult(originalFilename, compressCiObject);
}
return buildResult(originalFilename, file, uploadPath, imageInfo);
} catch (Exception e) {
log.error("图片上传到对象存储失败" , e);
throw new BusinessException (ErrorCode.SYSTEM_ERROR, "上传失败" );
} finally {
deleteTempFile(file);
}
}
(4)编写新的封装返回结果方法
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 ();
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. 接口测试 测试上传图片并查看对象存储中的资源大小。发现压缩效果显著。而且压缩图和原图同名,便于查找原图。前端也能正常获取到压缩后的图片信息。
这里的原理是先上传原图,保存成功后再对图片进行压缩处理,生成了一个 webp 的压缩图,并且后端数据库只会保存 webp 压缩图。
扩展
1. 增加对原图的处理 目前每次上传图片实际上会保存原图和压缩图 2 个图片,原图占用的空间还是比较大的。如果想进一步优化,可以删除原图,只保留缩略图;或者在数据库中保存原图的地址,用作备份。
2. 尝试更大比例的压缩
扩展知识 - 文件秒传 由于文件秒传对于图片上传场景的作用有限,仅作为扩展知识学习即可,不必在本项目中实现。
文件秒传是一种基于文件的唯一标识(如 MD5、SHA-256)对文件内容进行快速校验,避免重复上传的方法,在大型文件传输场景下非常重要。可以提高性能、节约带宽和存储资源。
大家可能用过网盘软件,如果重复上传相同的文件 2 次,你会发现第二次的上传速度贼快!
文件秒传的实现方案
客户端生成文件唯一标识
上传前,通过客户端计算文件的哈希值(如 MD5、SHA-256),生成文件的唯一指纹。
服务端校验文件指纹
后端接收到文件指纹后,在存储中查询是否已存在相同文件。
若存在相同文件,则直接返回文件的存储路径。
若不存在相同文件,则接收并存储新文件,同时记录其指纹信息。
注意:客户端和服务端是相对的概念。因为现在我们要把文件上传到对象存储服务器,我们的后端此时就是'客户端',对象存储服务器才是'服务端'。
对于我们的项目,给图片表增加 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,如果上传文件过程被中断,如客户端的网络断了,再恢复后重新上传该文件,就继续上传剩余进度的分块即可;上传结束后,服务端校验文件拼接起来是不是一个完整的文件。虽然这里没有实现,但是可以拿这个原理和面试官吹水。
三、图片加载优化 图片加载优化的目的是提升页面加载速度、减少带宽消耗,并改善用户体验。本节将从缩略图、懒加载、CDN 加速、浏览器缓存这 4 个方面进行全面优化。
缩略图 系统目前的问题:首页直接加载原图,原图文件通常比缩略图大数倍甚至数十倍,不仅导致加载时间长,还会造成大量流量浪费。
解决方案:上传图片时,同时生成一份较小尺寸的缩略图。用户浏览图片列表时加载缩略图,只有在进入详情页或下载时才加载原图。
1. 实现方案 生成缩略图的方法和前面讲的「图片压缩」一致,可以使用本地图像处理类库,也可以利用第三方云服务完成。此处我们依然选择数据万象服务,参考 Java SDK 文档使用 SDK 来构造图片处理规则对象。
具体的图片缩放参数可参考对象存储缩放参考文档。缩放文档列举了多种缩放规则,我们采用如下缩放规则:
2. 后端开发 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 类:
private String thumbnailUrl;
首先明确我们使用的缩放规则,设置最大宽高后,对图片进行等比缩小。且如果缩略图的宽高大于原图,则不会处理。
修改 CosManager 的上传图片方法,补充对缩略图的处理:
public PutObjectResult putPictureObject (String key, File file) {
PutObjectRequest putObjectRequest = new PutObjectRequest (cosClientConfig.getBucket(), key, file);
PicOperations picOperations = new PicOperations ();
picOperations.setIsPicInfo(1 );
String webpKey = FileUtil.mainName(key) + ".webp" ;
List<PicOperations.Rule> rules = new ArrayList <>();
PicOperations.Rule compressRule = new PicOperations .Rule();
compressRule.setFileId(webpKey);
compressRule.setRule("imageMogr2/format/webp" );
compressRule.setBucket(cosClientConfig.getBucket());
rules.add(compressRule);
picOperations.setRules(rules);
PicOperations.Rule thumbnailRule = new PicOperations .Rule();
thumbnailRule.setBucket(cosClientConfig.getBucket());
thumbnailRule.setRule(String.format("imageMogr2/thumbnail/%sx%s>" , 128 , 128 ));
String thumbnailKey = FileUtil.mainName(key) + "/thumbnail." + FileUtil.getSuffix(key);
thumbnailRule.setFileId(thumbnailKey);
rules.add(thumbnailRule);
putObjectRequest.setPicOperations(picOperations);
return cosClient.putObject(putObjectRequest);
}
修改 PictureUploadTemplate 的上传图片方法,获取到缩略图:
public UploadPictureResult uploadPicture (Object inputSource, String uploadPathPrefix) {
validPicture(inputSource);
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 {
file = File.createTempFile(uploadPath, null );
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 compressCiObject = objectList.get(0 );
CIObject thumbnailCiObject = objectList.get(1 );
return buildResult(originalFilename, compressCiObject, thumbnailCiObject);
}
return buildResult(originalFilename, file, uploadPath, imageInfo);
} catch (Exception e) {
log.error("图片上传到对象存储失败" , e);
throw new BusinessException (ErrorCode.SYSTEM_ERROR, "上传失败" );
} finally {
deleteTempFile(file);
}
}
修改 PictureUploadTemplate 封装返回结果的方法,将缩略图路径也设置到返回结果中:
private UploadPictureResult buildResult (String originalFilename, CIObject compressCiObject, CIObject thumbnailCiObject) {
int picWidth = compressCiObject.getWidth();
int picHeight = compressCiObject.getHeight();
double picScale = NumberUtil.round(picWidth * 1.0 / picHeight, 2 ).doubleValue();
UploadPictureResult uploadPictureResult = new UploadPictureResult ();
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());
uploadPictureResult.setThumbnailUrl(cosClientConfig.getHost() + "/" + thumbnailCiObject.getKey());
return uploadPictureResult;
}
当前代码只是解析得到了上传图片得到的结果对象 uploadPictureResult,因为注释 8 新增了 thumbnailUrl 属性的赋值;我们需要同步在上传图片方法中,将 thumbnailUrl 属性的值存到数据库中,也就是对 Picture 实体类 thumbnailUrl 赋值。
需要同步修改 PictureService 的上传图片方法,补充设置缩略图字段:
@Override
public PictureVO uploadPicture (Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) {
ThrowUtils.throwIf(loginUser == null , ErrorCode.NO_AUTH_ERROR);
Long pictureId = null ;
if (pictureUploadRequest != null ) {
pictureId = pictureUploadRequest.getId();
}
if (pictureId != null && pictureId > 0 ) {
Picture oldPicture = this .getById(pictureId);
ThrowUtils.throwIf(oldPicture == null , ErrorCode.NOT_FOUND_ERROR, "图片不存在" );
if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
throw new BusinessException (ErrorCode.NO_AUTH_ERROR);
}
}
String uploadPathPrefix = String.format("public/%s" , loginUser.getId());
PictureUploadTemplate pictureUploadTemplate = filePictureUpload;
if (inputSource instanceof String) {
pictureUploadTemplate = urlPictureUpload;
}
UploadPictureResult uploadPictureResult = pictureUploadTemplate.uploadPicture(inputSource, uploadPathPrefix);
Picture picture = new Picture ();
picture.setUrl(uploadPictureResult.getUrl());
picture.setThumbnailUrl(uploadPictureResult.getThumbnailUrl());
String picName = uploadPictureResult.getPicName();
if (pictureUploadRequest != null && StrUtil.isNotBlank(pictureUploadRequest.getPicName())) {
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);
if (pictureId != null ) {
picture.setId(pictureId);
picture.setEditTime(new Date ());
}
boolean result = this .saveOrUpdate(picture);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片上传失败,数据库操作失败" );
return PictureVO.objToVo(picture);
}
上传大图片时,缩略图的效果显著,体积直接减小百倍!但有个比较坑的情况,如果上传的图片本身就比较小,缩略图反而比压缩图更大,还不如不缩略!我们可以优化 CosManager 图片上传的逻辑,仅对 > 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);
thumbnailRule.setRule(String.format("imageMogr2/thumbnail/%sx%s>" , 128 , 128 ));
rules.add(thumbnailRule);
}
修改 PictureUploadTemplate 的逻辑,如果没有生成缩略图,则缩略图等于压缩图:
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(内容分发网络)是通过将图片文件分发到全球各地的节点,用户访问时从离自己最近的节点获取资源的技术,常用于文件资源或后端动态请求的网络加速,也能大幅分摊源站的压力、支持更多请求同时访问,是性能提升的利器。
腾讯云 CDN 产品文档中提供的 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,钱包两行泪!'大家一定要认真看:
缓存策略 :为静态资源(如图片、CSS、JS)设置长期缓存时间,可以减少回源的次数和消耗。
HTTPS 配置 :配置有效的 SSL 证书,启用 HTTPS 传输,提高请求的安全性。
CDN 节点选择 :国内业务选择覆盖中国大陆的节点就足够了,非必要的话,不要开通全球 CDN 节点,容易遭受海外攻击。
访问日志 :开启访问日志,分析用户行为和流量来源,这个能力更适合业务访问量较大的场景。
监控告警 :这点尤为重要!一定要给 CDN 配置监控告警,比如设置一段时间内最多消耗的流量,超出时会自动发短信告警,避免费用超额;或者限制单个 IP 的请求频率,防止突发流量影响服务。
IP 限制 :根据需要配置 IP 黑白名单,限制不必要的访问。
防盗链 :配置 Referer 防盗链保护资源,比如仅允许自己的域名可以加载图片。
浏览器缓存 通过设置 HTTP 头信息(如 Cache-Control),可以让用户的浏览器将资源缓存在本地。在用户再次访问同样的资源时,直接从本地缓存加载资源,而无需再次请求服务器。
设置合理的缓存时间 。常用的几种设置参数是:
静态资源使用长期缓存,比如:Cache-Control: public, max-age=31536000 表示缓存一年,适合存储图片等静态资源。
动态内容使用验证缓存,比如:Cache-Control: private, no-cache 表示缓存可被客户端存储,但每次使用前需要与服务器验证有效性。适合会动态变化内容的页面,比如用户个人中心。
敏感内容禁用缓存,比如:Cache-Control: no-store 表示不允许任何形式的缓存,适合安全性较高的场景,比如登录页面、支付页面。
要能够及时更新缓存 。可以给图片的名称添加'版本号'(如文件名中包含 hash 值),这样哪怕上传相同的图片,由于版本号不同,得到的图片地址也不同,下次访问时就会重新加载。
对于我们的项目,图片资源是非常适合长期缓存在浏览器本地的,也已经通过给文件名添加日期和随机数防止了重复。由于图片是从对象存储云服务加载的,如果需要使用缓存,可以接入 CDN 服务,直接在云服务的控制台配置缓存,参考文档。
如果触发了浏览器本地缓存,在 F12 控制台中能够看到图片瞬间加载成功。
四、图片存储优化
数据沉降与清理策略
数据沉降 大部分数据的访问热度会随着存储时间延长逐渐降低,为了严格控制存储成本,需要定期分析业务数据的访问情况,并动态调整存储类型。这就涉及到数据沉降技术,将长时间未访问的数据自动迁移到低频访问存储,从而降低存储成本。就跟我们平时使用电脑一样,SSD 硬盘很贵,我们一般优先将常用软件放在 SSD 目录中,至于一些以前写过的资料什么的,可以放在机械硬盘或外接硬盘中。
先分析 :通过对象存储提供的清单/访问日志分析,或者业务代码中自行统计分析。
再沉降 :可以直接通过对象存储提供的 生命周期 功能自动沉降数据,只需编写沉降规则即可。如下图,将 30 天未修改的文件沉降至低频存储:
低频存储的价格比标准存储低了一些,还可以将 几乎不需要修改和访问 的文件(比如日志文件)移动到归档存储中,存储价格更低,可节约几倍的成本!
不过要注意,虽然低频存储的存储费用更低,但是当你要访问低频存储的资源时,会产生数据取回费用,所以一般只对几乎不访问的资源进行沉降,尽量减少取回费用。
数据沉降 和 冷热数据分离 的概念是比较接近的,冷热数据分离是根据数据的访问热度,将访问频繁的数据(热数据)和访问较少的数据(冷数据)存储在不同的存储层中。
对于我们的项目,很久无人问津的历史图片就可以称为'冷数据',可以利用 COS 的生命周期功能在 30 天后自动沉降为低频存储。当然也可以通过数据库记录图片的访问和下载时间,自行调用 API 批量沉降数据或者转储到其他存储服务。
💡 数据沉降和冷热数据分离的概念又有一些细微的差别 :数据沉降 更倾向于关注一个对象的生命周期(一个资源从热到冷),目标更多的是降低存储成本,配置沉降规则后也一般不会调整。冷热数据分离 更关注整个系统的资源分布(比如热门图片放到性能更高的硬盘中,冷门图片进行归档存储),目标是同时优化性能和节约成本,数据的热度可以实时调整。
清理策略 对于'重存储'的系统,数据清理是必要的!通过设置合理的清理策略,可以避免冗余数据占用存储空间,降低成本。这里分享 4 种典型的清理策略:
立即清理
在删除图片记录时,立即关联删除对象存储中已上传的图片文件,确保数据库记录与存储文件保持一致。
这里还有个小技巧,可以使用异步清理,降低对删除操作性能的影响,并且记录一些日志,避免删除失败的情况。
手动清理
由管理员手动触发清理任务,可以筛选要清理的数据,按需选择需要清理的文件范围。
定期清理
通过定时任务自动触发清理操作。
系统预先设置规则(如文件未访问时间超过一定期限)自动清理不需要的数据。
惰性清理
清理任务不会主动执行,而是等到资源需求增加(存储空间不足)或触发特定操作时才清理
适合存储空间紧张但清理任务优先级较低的场景。
实际开发中,以上几种清理策略可以结合使用。比如 Redis 的内存管理机制结合了定期清理和惰性清理策略。
定期清理 通过后台定期扫描一部分键,随机检查并删除已过期的键,从而主动释放内存,减少过期键的堆积。
惰性清理 则是在访问某个键时,检查其是否已过期,如果已过期则立即删除。
这两种策略互为补充:定期清理降低了过期键的占用积累,而惰性清理确保了访问时键的准确性和及时清理,从而在性能和内存使用之间取得平衡。
对于我们的项目,由于不像 Redis 一样对空间的限制那么严格,更多的是为了节约成本,所以不需要惰性清理策略,按需运用'立即清理 + 手动清理 + 定期清理' 即可。
后端开发 (1) CosManager 补充删除对象的方法:
public void deleteObject (String key) throws CosClientException {
cosClient.deleteObject(cosClientConfig.getBucket(), key);
}
(2) 在 PictureService 中开发图片清理方法
注意,删除图片时,需要先判断该图片地址是否还存在于其他记录里,确认没有才能删除。比如秒传的场景,就有可能多个图片地址指向同一个文件。此外,还要注意删除对象存储中的文件时传入的是 key(不包含域名的相对路径),而数据库中取到的图片地址是包含域名的,所以删除前要移除域名,从而得到 key。这段在视频教程中没有讲,大家可以自行实现。
@Async
@Override
public void clearPictureFile (Picture oldPicture) {
String pictureUrl = oldPicture.getUrl();
Long count = this .lambdaQuery().eq(Picture::getUrl, pictureUrl).count();
cosManager.deleteObject(pictureUrl);
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 方法运用到图片删除接口,图片更新接口等场景。
扩展
补充更多清理时机 :在重新上传图片时,虽然那条图片记录不会删除,但其实之前的图片文件已经作废了,也可以触发清理逻辑。
实现更多清理策略 :比如用 Spring Scheduler 定时任务实现定时清理、编写一个接口供管理员手动清理,作为一种兜底策略。
优化清理文件的代码 :比如要删除多个文件时,使用对象存储的批量删除接口代替 for 循环调用。
为了清理原图,可以在数据库中保存原图的地址 。
至此,智能协同云图库项目的第一阶段已经开发优化完成,大家已经可以将这个项目部署上线并写到简历上啦~ 从下一期教程开始,我们将继续升级平台的能力,让它能够满足更多使用需求。
五、登录态自动保持 在进入下期教程前,大家可以运用自己学过的知识,对项目自行做一波优化。比如之前我们每次重启服务器都要重新登陆,既然已经整合了 Redis,不妨使用 Redis 管理 Session,更好地维护登录态。
Redis 分布式 Session 操作方式也很简单,1 分钟就能完成。
(1) 先在 Maven 中引入 spring-session-data-redis 库:
<dependency >
<groupId > org.springframework.session</groupId >
<artifactId > spring-session-data-redis</artifactId >
</dependency >
(2) 修改 application.yml 配置文件,更改 Session 的存储方式和过期时间:
spring:
session:
store-type: redis
timeout: 2592000
server:
port: 8123
servlet:
context-path: /api
session:
cookie:
max-age: 2592000
这就搞定了,可以测试下重启服务器后是否还需要重新登陆,并且查看 Redis 中是否有登录相关的 key。
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Gemini 图片去水印 基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online