关于自建云存储如何清理无用文件的思考

项目技术栈关键词

Spring Boot、Mybatis、Minio

场景和问题描述

表单中有附件上传功能,当用户重复上传文件,或者上传文件后不提交表单,那么在OSS存储上会存在大量的无用文件,这些冗余文件会导致以下几个问题:

  • 耗费存储空间,提高了硬件成本,如果没有做好防范,会出现前端恶意上传导致服务器磁盘空间不足,但又无法判断哪些文件可以删除,哪些文件是正常的用户文件。
  • 备份成本增加,有可能出现备份了100G的数据,实际上只有10G是真正有用的,那么这90G的存储成本、更多的备份时长就是无意义的开销了。
  • 如果服务器有审核、监控的背景需要,那么前端上传到服务器的违规文件,如涉黄涉毒的图片会被审核机制检测到,被要求清理,同样的,我们无法判断哪些是需要清理的。

提前说明,本文提供了一种较为优雅的处理方式,但是仅能处理绝大部分的情况,并不能涵盖所有场景,所以本文提出的问题仍需要进一步的研究讨论。

问题严重程度思考

首先是耗费空间的问题,如果使用的是阿里云、腾讯云等云存储平台,那么不需要费神,一股脑扔进去就好了,文件冗不冗余的与我们无关。

但是如果是自建存储,比如使用Minio搭建的分布式存储服务器,那么在考虑硬件成本的前提下,我们应该开始考虑如何解决,当然为了解决这个问题,我们会增加一些项目的复杂度,这可能会给原来稳定的系统带来一些潜在的bug。

如果财大气粗,硬盘不够可以随便加,那么在做好恶意攻击防范的前提下,可以不理会,毕竟硬盘成本在当下来说已经不算昂贵了。

然后是备份成本增加的问题,和上面说的一样,这只是经费成本,见仁见智。

最后是服务器资源审核,在一些政务相关的平台,对这方面还是比较重视的,即便是其他类型的平台,也可能因为平台存在违规资源被举报。

现实情况分析

既然如此,那我们就可以开始着手研究如何处理掉这些冗余文件,还我们一个干净的服务器。

在思考前,以我的项目为例,先对当前的文件上传流程说明清楚,然后再分析。

系统对于文件上传,需要结合三端:前端表单、后端、Minio存储端。

先说Minio存储端,简单介绍就好,这和阿里云OSS是几乎一样的产品,都是当前主流的文件存储服务,我们把所有的文件放在这里,在我们搭建好Minio服务后,文件上传的地址是这样的:

bash 复制代码
http://localhost:9000/minio/

我们可以直接往这里上传文件,当然实际上并不会,用过阿里云OSS的就会知道,我们上传文件之前是需要获取一个上传凭证的,通过这个凭证才能上传文件,这可以解决恶意上传的问题。因为Minio是我们自己搭建的存储服务,所以就需要结合后端来生成这个上传凭证,流程是这样的:

  1. 前端向后端发起请求,获取上传凭证
  2. 后端接收请求,判断前端用户的上传权限,通过后返回凭证内容
  3. 前端拿着这个凭证向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中的冗余文件,我们必不可少的要对数据关联性做记录,但是程序功能的复杂性,使得我尚未想到处理这个问题的最优解,所以该问题还需要持续的研究。

也希望有解决办法的读者可以分享以下,一同进步,谢谢。

相关推荐
java_heartLake5 分钟前
设计模式之建造者模式
java·设计模式·建造者模式
G皮T5 分钟前
【设计模式】创建型模式(四):建造者模式
java·设计模式·编程·建造者模式·builder·建造者
niceffking9 分钟前
JVM HotSpot 虚拟机: 对象的创建, 内存布局和访问定位
java·jvm
菜鸟求带飞_12 分钟前
算法打卡:第十一章 图论part01
java·数据结构·算法
骆晨学长28 分钟前
基于springboot的智慧社区微信小程序
java·数据库·spring boot·后端·微信小程序·小程序
AskHarries33 分钟前
利用反射实现动态代理
java·后端·reflect
@月落34 分钟前
alibaba获得店铺的所有商品 API接口
java·大数据·数据库·人工智能·学习
liuyang-neu40 分钟前
力扣 42.接雨水
java·算法·leetcode
z千鑫43 分钟前
【人工智能】如何利用AI轻松将java,c++等代码转换为Python语言?程序员必读
java·c++·人工智能·gpt·agent·ai编程·ai工具
Flying_Fish_roe1 小时前
Spring Boot-Session管理问题
java·spring boot·后端