表述
项目开发中,涉及到文件上传的情况其实还是很多的,在这里我想分享一下我最近关于上传文件的一部分设计。 我遇到的难点是,进行无用图片的管理。服务器资源,或者是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
;
字段如下
这边主要说明的是 业务类型和业务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。 以上就是我自己瞎想的办法,如有好办法,欢迎留言指正。