一、什么是垃圾文件?
垃圾文件是指,在表单中用户上传了文件到服务器,但表单未被提交或保存,导致上传的这个文件将不会被记录到相关业务表中,再也不会被使用,成为了垃圾文件。时间久了服务器上会有越来越多无用的垃圾文件占用服务器存储。
二、我处理垃圾文件的方式
对于垃圾文件,一般可配合缓存+事件监听进行删除;还有一种方式是定时任务,定时扫表,扫描每一个使用文件资源的表字段,汇总成在使用的文件,再对比文件表,即可筛选出需要删除的垃圾文件。本文中我将介绍两种方式:
- Redis+事件监听器
- 定时扫表求差集删除文件
方式一、Redis+事件监听器
当文件通过上传接口上传到服务器时,应在上传接口将fileUrl存入Redis中并设置过期事件。
当提交表单时,在ServiceImpl层方法中使用方法进行验证,如果图片在缓存中有,则说明图片是在有效期内提交的,直接从缓存中删除对应的key即可。这里我设计一个工具类AsyncFileHandler,checkValid()方法一次性可传入多个字段进行fileUrl校验。当然这只能删除新增时上传不提交表单产生的垃圾文件,如果是更新时,旧文件被替换了,提交表单时也不会知道旧文件是谁,这时有朋友可能会说了:"前端文件组件在删除文件时对应的把文件也从服务器删除不就好了嘛。"这么说其实也可以,但是针对富文本情况呢?富文本里上传了图片就没法再通过上传组件删除了。不过别慌,我们可以在提交表单时查询出旧数据,让新旧数据字段进行对比,如果旧字段中的fileUrl未出现在新字段中,则应删除旧字段的fileUrl。当然,在更新业务中也需要执行checkValid()方法校验新上传文件的有效性,再执行compareField()方法检测旧文件是否还被使用,不再使用则删除。
那么,redis中过期的文件怎么处理呢?这需要定义一个过期事件监听器,当key快过期时触发监听器,我们通过监听器拿到redis key删除未使用的垃圾文件即可。key保存的是fileUrl
1.将上传的文件返回的fileUrl存入Redis并设置过期时间
java
String fileUrl = this.storageFile(engine, file, false); //上传minio并返回fileUrl
String redisKey = "file:url:" + fileUrl;
cacheOperator.put(redisKey, fileUrl, 60 * 60 * 24); //将fileUrl存入Redis中并设置24小时过期
2.定义AsyncFileHandler类,方便检测文件有效性和对比新文件删除旧文件。
java
@Slf4j
@Service
@EnableAsync
@Component
public class AsyncFileHandler {
@Resource
private CommonCacheOperator cacheOperator;
@Resource
private DevFileApi devFileApi;
// URL正则表达式模式
private static final Pattern URL_PATTERN = Pattern.compile("/(\\d+)\\.[a-zA-Z0-9]+");
@Async
public void checkValid(String... imgFields) throws IOException {
for (String field : imgFields) {
Matcher matcher = URL_PATTERN.matcher(field);
Set<String> extractedUrls = new HashSet<>();
while (matcher.find()) {
String fileUrl = matcher.group(1);
extractedUrls.add(fileUrl);
}
// 删除Redis中匹配的URL
deleteMatchedUrlsFromRedis(extractedUrls);
}
}
// 比较新旧记录字段中文件的差异,并删除未被使用的旧字段
@Async
public void compareField(String newFiled, String oldFiled){
Matcher matcher1= URL_PATTERN.matcher(newFiled);
Matcher matcher2= URL_PATTERN.matcher(oldFiled);
Set<String> newFileUrls = new HashSet<>();
Set<String> oldFileUrls = new HashSet<>();
while (matcher1.find()) {
String fileUrl = matcher1.group(1);
newFileUrls.add(fileUrl);
}
while (matcher2.find()) {
String fileUrl = matcher2.group(1);
oldFileUrls.add(fileUrl);
}
Set<String> toDelFileUrls = oldFileUrls.stream()
.filter(element -> !newFileUrls.contains(element))
.collect(Collectors.toSet());
// 删除不再使用的文件
deleteDiffFileUrls(toDelFileUrls);
}
// 删除在有效期内提交表单的文件,不删除的话后面会在redis过期事件中被删除。
private void deleteMatchedUrlsFromRedis(Set<String> urls) {
int deletedCount = 0;
for (String url : urls) {
String redisKey = "file:url:" + url;
if (cacheOperator.get(url)!=null){
cacheOperator.remove(redisKey);
log.info("从Redis中删除已引用的文件URL: {}", url);
deletedCount++;
}
}
log.info("共处理{}个文件URL,成功删除{}个Redis键", urls.size(), deletedCount);
}
// 删除新旧差异文件
private void deleteDiffFileUrls(Set<String> urls) {
for (String url : urls) {
devFileApi.deleteFileByUrl(url);
}
}
}
3.定义Redis过期事件监听器
java
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
@Resource
private DevFileApi devFileApi;
/**
* 创建RedisKeyExpirationListener bean时注入 redisMessageListenerContainer
*
* @param redisMessageListenerContainer RedisConfig中配置的消息监听者容器bean
*/
public RedisKeyExpirationListener(RedisMessageListenerContainer redisMessageListenerContainer) {
super(redisMessageListenerContainer);
}
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel()); // __keyevent@*__:expired
String pa = new String(pattern); // __keyevent@*__:expired
String expiredKey = message.toString();
if (expiredKey.startsWith("Cache:file:url:")){
System.out.println("监听到过期文件:" + expiredKey);
devFileApi.deleteFileByUrl(expiredKey);
}
}
}
方式二、定时扫表求差集删除文件
一个系统中可能好几个表中的多个字段使用文件资源,一个情况方式一也可以解决。假设有这样一种情况:系统中的一个文件可能被多个表的字段共享,表中的字段存入的是同一个fileUrl,如果删除这个文件可能会牵扯到好几张表。这种情况就适合扫表求差集删除文件了。首先找出全数据库表中使用文件的字段,找出这些字段使用的fileUrl,肯定有重复的fileUrl,所以我们使用Set集合保存,多个fileUrl只保留一个即可,这个Set集合里存的就是我们数据库中所有在使用的文件,非垃圾文件,那么直接在文件表中使用notIn查询,查询出不在使用的文件,也就是垃圾文件即可,然后删除这些垃圾文件。直接上代码:
java
/**
* 清理垃圾文件定时任务
*/
@Slf4j
@Component
public class ClearGarbageFileTaskRunner implements CommonTimerTaskRunner {
@Resource
private DevFileService devFileService;
@Override
public void action(String extJson) {
// 哪个表哪个字段在使用文件资源
Map<String, String[]> map = new HashMap<>();
map.put("BIZ_CHILD_USER", new String[]{"AVATAR"});
map.put("BIZ_COLLECT", new String[]{"ANALYSIS_VIDEO","ANALYSIS", "COVER", "OPTIONS", "CONTENT"});
map.put("BIZ_QUESTION", new String[]{"ANALYSIS_VIDEO", "ANALYSIS","COVER", "OPTIONS", "CONTENT"});
map.put("BIZ_WRONG", new String[]{"ANALYSIS_VIDEO","ANALYSIS", "COVER", "OPTIONS", "CONTENT"});
map.put("BIZ_EXAM_RECORD", new String[]{"ANALYSIS_VIDEO","ANALYSIS", "COVER", "OPTIONS", "CONTENT"});
map.put("BIZ_LECTURE", new String[]{"VIDEO", "INTRODUCE"});
map.put("BIZ_NOTICE", new String[]{"IMAGE", "CONTENT"});
// 全表中在使用的文件ID集合
Set<String> allTableInUseFileIdSet = new HashSet<>();
for (Map.Entry<String, String[]> entry : map.entrySet()) {
Set<String> oneTableInUseFileIdSet = devFileService.getInUseFileIdList(entry.getKey(), entry.getValue());
allTableInUseFileIdSet.addAll(oneTableInUseFileIdSet);
}
log.info("------------------------------------------------清理垃圾文件Start------------------------------------------------");
log.info("在使用的文件数量:{}", allTableInUseFileIdSet.size());
// 获取垃圾文件ID
List<DevFileIdParam> junkFileIds = devFileService.list(new LambdaQueryWrapper<DevFile>().notIn(DevFile::getId, allTableInUseFileIdSet))
.stream().map(devFile -> {
DevFileIdParam param = new DevFileIdParam();
param.setId(devFile.getId()); // 确保字段名称匹配
return param;
}).toList();
log.info("待清理的文件数量:{}", junkFileIds.size());
if (!junkFileIds.isEmpty()) {
// 执行物理删除垃圾文件
devFileService.deleteGarbageFiles(junkFileIds);
}
log.info("------------------------------------------------清理垃圾文件End------------------------------------------------\n");
}
}
注意:方式二删除垃圾文件,一定不要遗漏需要过滤的数据表字段,否则会被误删除。