项目技术栈关键词
Spring Boot、Mybatis、Minio
场景和问题描述
表单中有附件上传功能,当用户重复上传文件,或者上传文件后不提交表单,那么在OSS存储上会存在大量的无用文件,这些冗余文件会导致以下几个问题:
- 耗费存储空间,提高了硬件成本,如果没有做好防范,会出现前端恶意上传导致服务器磁盘空间不足,但又无法判断哪些文件可以删除,哪些文件是正常的用户文件。
- 备份成本增加,有可能出现备份了100G的数据,实际上只有10G是真正有用的,那么这90G的存储成本、更多的备份时长就是无意义的开销了。
- 如果服务器有审核、监控的背景需要,那么前端上传到服务器的违规文件,如涉黄涉毒的图片会被审核机制检测到,被要求清理,同样的,我们无法判断哪些是需要清理的。
提前说明,本文提供了一种较为优雅的处理方式,但是仅能处理绝大部分的情况,并不能涵盖所有场景,所以本文提出的问题仍需要进一步的研究讨论。
问题严重程度思考
首先是耗费空间的问题,如果使用的是阿里云、腾讯云等云存储平台,那么不需要费神,一股脑扔进去就好了,文件冗不冗余的与我们无关。
但是如果是自建存储,比如使用Minio搭建的分布式存储服务器,那么在考虑硬件成本的前提下,我们应该开始考虑如何解决,当然为了解决这个问题,我们会增加一些项目的复杂度,这可能会给原来稳定的系统带来一些潜在的bug。
如果财大气粗,硬盘不够可以随便加,那么在做好恶意攻击防范的前提下,可以不理会,毕竟硬盘成本在当下来说已经不算昂贵了。
然后是备份成本增加的问题,和上面说的一样,这只是经费成本,见仁见智。
最后是服务器资源审核,在一些政务相关的平台,对这方面还是比较重视的,即便是其他类型的平台,也可能因为平台存在违规资源被举报。
现实情况分析
既然如此,那我们就可以开始着手研究如何处理掉这些冗余文件,还我们一个干净的服务器。
在思考前,以我的项目为例,先对当前的文件上传流程说明清楚,然后再分析。
系统对于文件上传,需要结合三端:前端表单、后端、Minio存储端。
先说Minio存储端,简单介绍就好,这和阿里云OSS是几乎一样的产品,都是当前主流的文件存储服务,我们把所有的文件放在这里,在我们搭建好Minio服务后,文件上传的地址是这样的:
bash
http://localhost:9000/minio/
我们可以直接往这里上传文件,当然实际上并不会,用过阿里云OSS的就会知道,我们上传文件之前是需要获取一个上传凭证的,通过这个凭证才能上传文件,这可以解决恶意上传的问题。因为Minio是我们自己搭建的存储服务,所以就需要结合后端来生成这个上传凭证,流程是这样的:
- 前端向后端发起请求,获取上传凭证
- 后端接收请求,判断前端用户的上传权限,通过后返回凭证内容
- 前端拿着这个凭证向Minio服务器上传文件
这样我们的文件上传流程就走完了,很明显的,这个过程有文件冗余的问题,即前端不断的上传文件,那么就会有很多文件并没有使用到,甚至前端不保存表单,那么刚刚上传的一堆文件都成了无用文件了。
现在我们了解了整个流程,也知道问题所在。
我的处理方案
我希望能尽可能优雅的解决这个问题,并且不会对现有系统有太大的侵入性。
所以我采用了MySQL增加文件表结合Mybatis拦截器的方案,具体是这样的。
在每一次前端向后端请求上传凭证的时候,后端都插入一条数据到文件表中,先假设用户获取完凭证就一定会上传文件。
文件表示例:
mysql
CREATE TABLE IF NOT EXISTS `base_file`
(
`id` BIGINT(20) UNSIGNED NOT NULL UNIQUE COMMENT '主键id',
`filename` VARCHAR(64) NOT NULL COMMENT '文件名',
`data_id` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联数据id',
`share` BIT(1) NOT NULL DEFAULT 0 COMMENT '共享',
PRIMARY KEY (`id`),
UNIQUE KEY (`filename`)
) ENGINE = InnoDB
CHARSET = utf8mb4 COMMENT ='文件';
关键字段是:data_id,表示跟哪一个数据做了绑定,一开始当然是空的,因为前端表单还没有保存。
为了避免id冲突,我们应该使用雪花id,所以这里只有data_id即可,如果用的是自增id,那么文件表还应该加一个数据表名:data_table。
data_id如果为空,表示该文件没有被关联,那么就可以被清理,这是最重要的部分。
新增的这个文件对象会跟上传凭证一同返回给前端:
json
{
"code": 10000,
"msg": "请求成功",
"data": {
// 这个是文件对象
"baseFile": {
"id": "1724333453853327360",
"filename": "561033b669a9480bb9b154d0a57e9b32i.jpeg"
},
// 这个是上传凭证
"policy": {
"bucket": "java-zwld",
"x-amz-date": "20231114T074826Z",
"x-amz-signature": "7423a7d2d186e2edaf55e0e5402ffcf1d9025e63d5d999dc12bca523fbadb7fc",
"key": "i.jpeg",
"x-amz-algorithm": "AWS4-HMAC-SHA256",
"x-amz-credential": "minio/20231114/us-east-1/s3/aws4_request",
"policy": "eyJleHBpcmF0aW9uIjoiMjAyMy0xMS0xNFQwNzo1NjoyNi42MjFaIiwiY29uZGl0aW9ucyI6W1siZXEiLCIkYnVja2V0IiwiamF2YS16d2xkIl0sWyJlcSIsIiRrZXkiLCJpLmpwZWciXSxbImVxIiwiJHgtYW16LWFsZ29yaXRobSIsIkFXUzQtSE1BQy1TSEEyNTYiXSxbImVxIiwiJHgtYW16LWNyZWRlbnRpYWwiLCJtaW5pby8yMDIzMTExNC91cy1lYXN0LTEvczMvYXdzNF9yZXF1ZXN0Il0sWyJlcSIsIiR4LWFtei1kYXRlIiwiMjAyMzExMTRUMDc0ODI2WiJdXX0="
}
},
"traceId": "325c0d5856264093968b54ff532cf564",
"success": true
}
前端在上传文件成功,提交表单的时候,把这个文件对象也一并保存,那么表单数据参数如:
json
{
"name": "小红",
"age": "20",
"address": "xxx地址",
"baseFile": {
"id": "1724333453853327360",
"filename": "561033b669a9480bb9b154d0a57e9b32i.jpeg"
}
}
现在表单数据来到了后端,我们新增一个Mybatis拦截器,在这个拦截器,对数据插入、更新、删除做不同的处理:
-
插入
-
单个插入:获取该实体的id,获取该实体的文件id数组(因为可能有多个附件),去更新这个文件的dataId为该实体的id。
实体id很容易获取,问题是这个文件id数组怎么拿呢?我的方案是给文件对象加一个自定义注解,通过反射的方式找到这个属性并获取值:
java@FileField @ApiModelProperty(value = "附件") private BaseFile baseFile; @FileField @ApiModelProperty(value = "附件列表") private List<BaseFile> baseFileList;
这样就能得到附加id数组:ids
更新方式依项目而定,我这里用的是Mybatis Example:
java// 赋值新的dataId,这样旧的文件就冗余了 BaseFileExample example = new BaseFileExample(); example.createCriteria() .andIdIn(ids); BaseFile baseFile = new BaseFile(); baseFile.setDataId(dataId); updateByExampleSelective(baseFile, example);
-
批量插入:同理,上面的代码for循环执行即可。
-
-
更新
-
单个更新:获取实体id,获取文件id数组,置空旧数据,赋值新数据
意思是,该实体数据原来的附件为A,更新了新的附件B,那么附件A就无用了,就应该把这个关联id置空,让他不绑定数据。
java// 将旧数据的dataId置空 BaseFileExample example = new BaseFileExample(); example.createCriteria() .andDataIdEqualTo(dataId); BaseFile baseFile = new BaseFile(); baseFile.setDataId(0L); updateByExampleSelective(baseFile, example);
-
批量更新:同理,单个更新的代码for循环执行即可。
-
条件更新:
条件更新比较特殊,比如这样的场景,把该表内所有的附件都设置成同一个文件,
这时候就不能用关联id了,所以文件表可以新增一个share字段,表示共享,共享的文件不能被清理。
但是如果这样做了,那这个文件就很难判断什么时候该清理了,所以还是尽量避免这种操作,让每一个文件都有对应的数据,不然还是容易出现冗余文件的问题。
-
-
删除
- 通过id删除:这个简单,将以该实体id作为dataId的数据置空即可。
- 条件删除:同条件更新,因为拿不到实体,所以不好处理,应尽量避免这种操作。
然后写一个定时任务,逻辑可以是将一个月前的、dataId为空的文件删除掉。
兜底方式
在上面的处理方式中,为了清理冗余文件,我们增加了:
- 实体类附件属性的文件注解,便于获取文件id数组
- Mybatis拦截器
- 定时任务
这种方式并不能完全杜绝冗余文件的产生:
- 条件更新、删除的操作,获取不到实体id,因为很难知道有哪些数据是受影响的,可能10条,也可能1000条,使用share字段标志仅仅能避免其被误删,但是冗余情况还是存在。
- 如果数据是由Navicat等数据库工具直接修改,那则断了与文件表的关联,更难处理了。
除此之外,还会为系统增加了性能压力:
-
数据的增删改操作都增加了对文件表的处理,单数据操作时还好,批量操作时性能影响就会很明显。
所以在考虑系统性能的前提下,可以将对文件表的处理以异步的方式进行,比如打日志、消息队列。
还能想到其他的方式,比如全表扫描,如果文件表的id不存在于任意表的JSON字段中,那么则认为该数据未绑定,但是这种方式成本太高。
总结
为了清理OSS中的冗余文件,我们必不可少的要对数据关联性做记录,但是程序功能的复杂性,使得我尚未想到处理这个问题的最优解,所以该问题还需要持续的研究。
也希望有解决办法的读者可以分享以下,一同进步,谢谢。