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

项目技术栈关键词

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中的冗余文件,我们必不可少的要对数据关联性做记录,但是程序功能的复杂性,使得我尚未想到处理这个问题的最优解,所以该问题还需要持续的研究。

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

相关推荐
数据智能老司机21 小时前
精通 Python 设计模式——创建型设计模式
python·设计模式·架构
菜鸟谢1 天前
Manjaro Tab 无自动补全
后端
Java水解1 天前
JAVA经典面试题附答案(持续更新版)
java·后端·面试
Java水解1 天前
Mysql查看执行计划、explain关键字详解(超详细)
后端·mysql
数据智能老司机1 天前
精通 Python 设计模式——SOLID 原则
python·设计模式·架构
追逐时光者1 天前
.NET Fiddle:一个方便易用的在线.NET代码编辑工具
后端·.net
林树的编程频道1 天前
快递的物流地图是怎么实现的
后端
洛小豆1 天前
在Java中,Integer.parseInt和Integer.valueOf有什么区别
java·后端·面试
八怪1 天前
联合索引使用高分区度字段的一个例子
后端
IT_陈寒1 天前
JavaScript 性能优化:5 个被低估的 V8 引擎技巧让你的代码快 200%
前端·人工智能·后端