关于上传文件后的简单设计

215 阅读6分钟

表述

项目开发中,涉及到文件上传的情况其实还是很多的,在这里我想分享一下我最近关于上传文件的一部分设计。 我遇到的难点是,进行无用图片的管理。服务器资源,或者是minio,阿里云oss等,都是有限的,如何能尽可能的减少无用图片,其实才是最关心得。

准备

关于文件是设计一张表用于统一存放

CREATE TABLE `cl_sys_file_upload` (
	`id` BIGINT(19) NOT NULL DEFAULT '0' COMMENT '主键',
	`file_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '图片名' COLLATE 'utf8mb4_general_ci',
	`bucket_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '桶名' COLLATE 'utf8mb4_general_ci',
	`business_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '使用上传文件的表' COLLATE 'utf8mb4_general_ci',
	`business_id` BIGINT(19) NULL DEFAULT '0' COMMENT '表id',
	`file_path` VARCHAR(50) NOT NULL DEFAULT '0' COMMENT '文件路径' COLLATE 'utf8mb4_general_ci',
	`file_suffix` VARCHAR(50) NOT NULL DEFAULT '0' COMMENT '文件点后缀' COLLATE 'utf8mb4_general_ci',
	`create_time` DATETIME NOT NULL COMMENT '创建时间',
	`create_user` BIGINT(19) NOT NULL COMMENT '创建人',
	`update_time` DATETIME NOT NULL COMMENT '修改时间',
	`update_user` BIGINT(19) NOT NULL DEFAULT '0' COMMENT '修改人',
	`del_sign` TINYINT(3) NOT NULL DEFAULT '0' COMMENT '逻辑删除标志(0 正常 1 删除)',
	PRIMARY KEY (`id`) USING BTREE,
	INDEX `create_time` (`create_time`) USING BTREE,
	INDEX `business_id` (`business_id`) USING BTREE
)
COMMENT='文件上传记录'
COLLATE='utf8mb4_general_ci'
ENGINE=InnoDB
;

字段如下

图片.png

这边主要说明的是 业务类型和业务id 业务类型,就是用到文件的地方,比如user表(会有用户头像字段【file_id】), 业务id呢,就是用到文件地方的id,比如用户id 这样就实现了双向绑定

关于无用图片的产生,正常情况下会有两种方式

1 用户上传了图片,没有提交,也就是没有绑定,(首先图片上传逻辑是先上传生成一个标识,这里就是文件id了)

2 用户更改了之前使用的图片,比如更换了头像。

接下来说说我的设计思路。 我设计了三重保障

1 最简单得,就是在用户上传,更换图片的时候,进行手动异步对无用图片进行标记(根据之前存在的file_id,进行逻辑删除)

2 定时任务,每天进行使用图片表得扫描和存放所有图片信息,比对和标记

3 贪心算法,每天定时扫描文件表,同时根据查询出来得数据去和使用地方比对,如果比对发现查询出来得数据,无用图片得占比大过阈值(我设定得是10%),则继续随机查询文件表,直至这个占比低于阈值

4 真实物理删除 首先查询哪些没有标记业务id得数据,这些数据是用户上传后,刷新弃用得文件,其次是上面三部标注得需要删除得无用数据

正文

那么我们开始吧

这里主要分享的是 2 3 4步骤 首先 创建一个枚举类,存放的是业务类型和具体表的关联信息

package com.nie.info.share.model.enums
import com.nie.info.share.config.exection.BizException

enum class FileUploadEnum(
   /**
    * 上传图片minio桶名
    * */
   var bucketName: String, tableName: String) {

   /**
    * minio桶命名规则 ,只能小写 不能特殊符号啥的
    * */
   USER_AVATAR("useravatar", "cl_sys_user"),
   GOODS_IMG("goodsimg", "cl_goods_info"),
   SHOP_IMG("shopimg", "cl_user_shop"),
   ;

   /**
    * 上传图片使用表名
    * */
   var businessType: String = tableName

   companion object {
       // 获取所有枚举数据,后面用于动态表
       fun getALLData(): Map<String, String> {
           val map = mutableMapOf<String, String>()
           FileUploadEnum.values().forEach { map[it.bucketName] = it.businessType }
           return map;
       }

       fun getTableName(bucketName: String): String {
           FileUploadEnum.values().forEach { if (it.bucketName == bucketName) return it.businessType }
           throw BizException("500", "上传文件枚举数据异常");
       }
   }

}

因为正常情况下肯定有多处使用文件信息,这里做了统一管理。同时要比对,肯定是需要所有使用的地方都和文件表信息比对。 我这边用的是mybatis-plus,所以我选择了mp的动态表名插件(有时候需要动态表,用起来优雅多了----点我直达)

package com.nie.info.share.task

import com.nie.info.share.config.mybatis.DynamicTableNameHandler
import com.nie.info.share.model.constants.NumberConstant
import com.nie.info.share.model.enums.FileUploadEnum
import com.nie.info.share.model.vo.ClSysFileUploadVo
import com.nie.info.share.service.FileTemplateService
import com.nie.info.share.service.IClSysFileUploadService
import com.nie.info.share.service.UploadService
import com.nie.info.share.tools.annoations.Slf4j.Companion.log
import org.springframework.core.task.TaskExecutor
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.stream.Collectors
import javax.annotation.Resource

/**
 * @author Created by nie on 2024/7/11
 * 本次无用图片删除设计使用到的技术
 * 1 配置了 动态表名,保证类别删除 时候能够优雅删除 配置 congif包下文件
 * 2 越过mp框架的逻辑删除,实现正真的物理删除
 * 3 线程池 异步删除
 * 设计逻辑 三重防护
 * 1 在修改图片的时候进行数据逻辑删除标记
 * 2 每天晚上定时任务 进行比对,进行删除
 * 3 定时任务贪心算法
 * 4 统一使用一个定时任务进行真正的物理删除
 *
 *
 * */
@Component
class RemoveUselessFileJob {

    @Resource
    lateinit var fileTemplateService: FileTemplateService

    @Resource
    lateinit var taskExecutor: TaskExecutor

    @Resource
    lateinit var sysFileUploadService: IClSysFileUploadService

    @Resource
    lateinit var uploadService: Map<String, UploadService>


    companion object {
        val dataFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
    }

    /**
     * 每日查询无用文件标记
     * */
    @Scheduled(cron = "57 54 22 * * ?")
    fun removeUselessFile() {
        log.info("开始执行无用文件标记:{}", LocalDateTime.now().format(dataFormatter))
        FileUploadEnum.values().forEach { enumData ->
            run {
                // 查询对应枚举的表,进行当天修改文件id的汇总
                // 并执行删除
                taskExecutor.execute {
                    DynamicTableNameHandler.setData(enumData.businessType)
                    // 通过模板进行动态表,实现优雅查询
                    val fileTemplateList = fileTemplateService.getFileInfos(
                        listOf(),
                        LocalDateTime.now()
                    )
                    //阅后即焚,将ThreadLocal当前请求线程的数据移除
                    DynamicTableNameHandler.removeData()
                    if (fileTemplateList.isNotEmpty()) {
                        val ids = fileTemplateList.map { it.id!! }
                        val fileUploadVoList = sysFileUploadService.getFileInfoByBusinessType(
                            enumData.businessType,
                            ids
                        )
                        if (fileUploadVoList.isNotEmpty()) {
                            val map = fileTemplateList.associate { it.fileId to it.id }
                            val deleteIds = mutableSetOf<Long>()
                            for (value in fileUploadVoList) {
                                map[value.id].let {
                                    if (it == null || it != value.businessId) deleteIds.add(value.id!!)
                                }
                            }
                            if (deleteIds.isNotEmpty()) {
                                sysFileUploadService.batchDeleteFile(deleteIds)
                            }
                        }
                    }
                }
            }
        }
        log.info("完成执行无用文件标记:{}", LocalDateTime.now().format(dataFormatter))
    }


    // 贪心算法 进行数据的判断
    @Scheduled(cron = "20 1 03 * * ?")
    fun removeFileUseMoreThanTenPercent() {
        log.info("贪心算法清除无用图片:{}", LocalDateTime.now().format(dataFormatter))
        //  贪心算法 需要查找同一个业务id出现两次以上的
        val fileVos: List<ClSysFileUploadVo> = sysFileUploadService.greedyGetList()
        if (fileVos.isNotEmpty()) {
            val map = fileVos.stream().collect(Collectors.groupingBy { it.businessType })
            var outCount = 0
            for ((key, value) in map) {
                val currentIds = value.map { it.businessId!! }
                val fileIds = value.map { it.id!! }
                // 查询   然后 10% 的判断
                DynamicTableNameHandler.setData(key!!)
                // 查询出来使用的文件id
                val selectIds = fileTemplateService.getFileInfos(currentIds).map { it.fileId!! }
                //阅后即焚,将ThreadLocal当前请求线程的数据移除
                DynamicTableNameHandler.removeData()
                // 全量id 与 使用的id 相交
                val outIds = fileIds.subtract(selectIds.toSet())
                if (outIds.isEmpty()) {
                    continue
                }
                sysFileUploadService.batchDeleteFile(outIds)
                outCount += outIds.size
            }
            // 贪心比例
            val rate = (outCount.toFloat() / fileVos.size) * 100.toFloat()
            if (rate > NumberConstant.INT_TEN) {
                //贪心重来一次
                //大于单次查询条数,则进行二次连续判断 二次查询出来的比例小于10 则终止贪心算法
                removeFileUseMoreThanTenPercent()
            }
            log.info("本次贪心算法删除完成:{},删除数据为 xxx", LocalDateTime.now().format(dataFormatter))
        }
    }

    /***
     * 真正的彻底删除无用图片
     *  定时任务
     * */
    @Scheduled(cron = "55 23 8 * * ?")
    fun realRemoveFileUseless() {
        log.info("真实执行删除:{}", LocalDateTime.now().format(dataFormatter))
        // 先清除没有进行绑定的文件,这些文件是确定没有被使用过
        val noBandingData: List<ClSysFileUploadVo> = sysFileUploadService.getNoBandingData()
        // 获取可以删除的数据(保守策略,获取的数据往前推迟了两天)
        val deleteList = sysFileUploadService.getDeleteList(listOf())
        // 删除数据合并
        val finalList = deleteList.plus(noBandingData)
        if (finalList.isNotEmpty()) {
            // 根据业务类型进行分组
            val map = finalList.groupBy { it.bucketName!! }
            for (var1 in map) {
                val filePathList = var1.value.map { it.filePath!! }
                // 先物理删除,保证删除不成功,后面失败重新删除有依据
                uploadService["MINIO_UPLOAD"]!!.deleteFile(var1.key, filePathList)
            }
            // 删除两天前的数据
            sysFileUploadService.realRemoveFileUseless(finalList.map { it.id!! })
        }
        log.info("完成真实执行删除:{}", LocalDateTime.now().format(dataFormatter))
    }
}

另外还有一个,绕过了mp框架的逻辑删除,自己重新写了真实物理删除的CustomSqlInjector。 以上就是我自己瞎想的办法,如有好办法,欢迎留言指正。