跳到主要内容
基于 Redis 与 Caffeine 的图片系统性能优化及分布式 Session 实践 | 极客日志
Java java 算法
基于 Redis 与 Caffeine 的图片系统性能优化及分布式 Session 实践 云图库项目的核心优化方案。查询方面,构建了 Redis 与 Caffeine 的多级缓存体系,有效降低数据库压力并提升响应速度,同时解决了缓存穿透、击穿和雪崩问题。上传环节利用腾讯云数据万象服务实现 WebP 格式压缩与缩略图自动生成,显著减少带宽消耗。加载优化涵盖 CDN 加速、浏览器缓存策略及懒加载技术。存储层面实施冷热数据分离与生命周期管理,配合异步清理策略控制成本。最后通过 Spring Session + Redis 实现分布式登录态保持,确保服务重启后用户状态不丢失。
墨染流年 发布于 2026/3/29 更新于 2026/4/25 1 浏览图片优化技术
在云图库项目上线之前,还有很大的优化空间。本节分享主流的图片优化技术,包括查询、上传、加载、存储优化以及登录态保持。
图片查询优化 :分布式缓存、本地缓存、多级缓存
图片上传优化 :压缩、秒传、分片上传、断点续传
图片加载优化 :懒加载、缩略图、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 依赖,使用 Spring Boot Starter 快速整合 Redis:
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, "删除后的值不为空" );
}
}
运行单元测试方法后,我们就通过 JUnit 单元测试文件,测试了使用 StringRedisTemplate 对 Redis 进行的基础操作(增删改查)。
(4) 新写一个使用缓存的分页查询图片列表的接口:
注入 Redis 操作对象:通过 @Resource 注解,将 StringRedisTemplate 对象注入到 Spring 容器中,使其可以在当前类中被使用。
在查询数据库前先查询缓存,如果已有数据则直接返回缓存,如果没有数据则查询数据库,并且将结果设置到缓存中。
@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,钱包两行泪!'大家一定要认真看:
缓存策略 :为静态资源(如图片、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