实现文件秒传功能时遇到的坑

1,146 阅读6分钟

最近在校验之前写的文件秒传功能的时候发现了一个坑,就是当我其中一个用户将文件删除掉,其他用户的访问不了。

首先介绍一下文件秒传,文件秒传一般是用在一些类似与文件存储网站里面的一个场景,比如最常见的就是网盘,然后所谓的文件秒传其实就是当用户上传一个文件的时候,我先判断一些我的后台是否有存储过这个文件,也就是别人是否也有上传过这个文件,然后已经存储过了,如果存储过了我就不需要再去存储一份,而是直接添加一条数据就行,然后存储的可访问链接就是直接从原本存储过的那个文件的访问链接。那么是如何判断我是否存储过的呢?其实就是通过文件hash值来进行判断,然后和数据库的文件记录的 hash字段进行判断,判断是否有相同的。

当然这些都不是重点,重点是你是如何设计你的文件和用户的关系的?

常见的就是有两种嘛。

第一种就是文件和用户绑定,如果是这种的话,那么我们在判断是否存储过这个文件的时候其实就只会判断这个用户是否上传过这个文件,秒传功能也只局限在单个用户上传过的文件列表里面。

第二种就是文件和用户是不绑定的,也就是我判断是否存储过这个文件的时候就不只局限于这个用户,而是直接查看整个数据表,看看是否有用户上传了这个文件,这样子就不会局限到某个用户。

主包这里是选择第二种,因为主包的文件存储是用的第三方,存太多是很费钱的,所以相同的文件存储一份就行了。

然后我一开始的文件上传流程也是和上面说的一样,就是直接判断一下数据库里面有没有相同的hash值,不过我是直接在File表中进行判断的,这一点倒是没什么问题,然后就是删除,当用户删除掉这个文件的时候,不只是把记录删除掉,还把cos中的文件也删除掉。

这样子造成的一个后果就是,当有一条用户将这个文件删除掉的时候,其他的用户或者是这个用户的其他文件夹下的这个文件都会访问不了。

那有什么解决方法呢?很明显,我们可以统计一下这个文件还有多少个引用,当引用次数为0的时候就可以将这个文件删除掉。但是真的是这样吗。或者说这样判断够吗?有没有一种可能就是我虽然引用次数为0,但是我最近还有在操作这个文件,用户可能删除完就又将这个文件上传回来了。这样子我cos的文件不是白删了吗?

所以还需要再维护一个字段记录最后访问时间,如果是很久没访问(操作)过的文件的话,我们可以标记为低频数据。低频数据可以直接删除掉,包括cos中存储的文件也删除掉。

总结一下要做的具体流程:

1)再维护一张表,用来维护不同hash值的文件被引用的次数

2)每次删除的时候将次数减少

3)当一个访问地址被引用的次数为0且超过30天未引用(重命名、浏览、上传、分享、下载、移动都要更新最近引用时间,删除不用更新)的时候就标记为低频存储

4)写一个定时任务,每天凌晨2点就执行一遍,去查询数据库中的低频存储数据,并将其从cos中删除掉

表结构:

create table file_reference
(
  Id               varchar(36)                        not null comment 'Id'
  primary key,
  fileHash         varchar(255)                       not null comment '文件Hash值',
  access           varchar(255)                       not null comment '访问链接',
  last_accessed_at datetime                           not null comment '最后访问时间',
  count            int      default 0                 not null comment '被引用次数',
  createTime       datetime default CURRENT_TIMESTAMP not null comment '创建时间',
  updateTime       datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
  isDelete         tinyint  default 0                 not null comment '是否删除'
)
    comment '文件被引用次数表' collate = utf8mb4_unicode_ci;

create index file_reference_id_fileHash_access_isDelete_index
    on file_reference (Id, fileHash, access, isDelete);

create index idx_lastTime_count_isDelete
    on file_reference (last_accessed_at, count, isDelete);

提供几个接口方法用来操作

/**
* @author zhuangziliang
* @description 针对表【file_reference(文件被引用次数表)】的数据库操作Service实现
* @createDate 2025-05-08 23:33:07
*/
@Service
public class FileReferenceServiceImpl extends ServiceImpl<FileReferenceMapper, FileReference>
implements FileReferenceService {


    @Resource
    private CosManager cosManager;


    /**
     * 根据hash值查询
     * @param hash
     * @return
     */
    @Override
    public FileReference selectByHash(String hash) {
        LambdaQueryWrapper<FileReference> queryWrapper =
        new LambdaQueryWrapper<FileReference>().eq(FileReference::getFileHash, hash);
        FileReference fileReference = this.getOne(queryWrapper);
        return fileReference;
    }

    /**
     * 更新引用次数(需要改变最后访问时间)
     * 如果是增加引用次数的话 type-1 减少次数就是 type-0
     * @param id
     * @return
     */
    @Override
    public boolean updateCount(String id, Integer type) {
        FileReference fileReference = this.getById(id);
        if (fileReference == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "记录不存在");
        }
        LambdaUpdateWrapper<FileReference> updateWrapper =
        new LambdaUpdateWrapper<FileReference>()
        .set(FileReference::getLast_accessed_at, new Date())
        .eq(FileReference::getId, id);
        if (type == 1) {
            updateWrapper.set(FileReference::getCount, fileReference.getCount() + 1);
        } else if (type == 0) {
            updateWrapper.set(FileReference::getCount, fileReference.getCount() - 1);
        } else {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "未知类型");
        }
        return this.update(updateWrapper);
    }

    /**
     * 插入数据(插入hash值以及访问链接)
     * @param hash
     * @param access
     * @return
     */
    @Override
    public boolean addData(String hash, String access) {
        FileReference fileReference = new FileReference();
        fileReference.setFileHash(hash);
        fileReference.setAccess(access);
        fileReference.setLast_accessed_at(new Date());
        fileReference.setCount(1);
        return this.save(fileReference);
    }

    /**
     * 改变最后访问时间
     * @param hash
     * @return
     */
    @Override
    public boolean updateLastAccessedAt(String hash) {
        LambdaUpdateWrapper<FileReference> updateWrapper =
                new LambdaUpdateWrapper<FileReference>()
                        .set(FileReference::getLast_accessed_at, new Date())
                        .eq(FileReference::getFileHash, hash);
        return this.update(updateWrapper);
    }

    /**
     * 删除低频数据(0引用+30天未访问)
     * @return
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteLowFrequencyData() {
        LambdaQueryWrapper<FileReference> queryWrapper =
                new LambdaQueryWrapper<FileReference>().eq(FileReference::getCount, 0);
        List<FileReference> list = this.list(queryWrapper);
        if (list.isEmpty()) {
            return;
        }
        // 计算30天前的日期
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.DAY_OF_YEAR, -30);
        Date thirtyDaysAgo = calendar.getTime();

        // 遍历并删除符合条件的记录
        for (FileReference fileReference : list) {
            Date lastAccessed = fileReference.getLast_accessed_at();
            if (lastAccessed != null && lastAccessed.before(thirtyDaysAgo)) {
                // 将数据库删除掉
                if (!this.removeById(fileReference.getId())) {
                    throw new BusinessException(ErrorCode.OPERATION_ERROR, "引用记录删除失败");
                }
                // 将cos数据删除掉
                String access = fileReference.getAccess();
                if (access.isEmpty()) {
                    throw new BusinessException(ErrorCode.PARAMS_ERROR, "访问路径不能为空");
                }
                String key = FilePathUtil.parseStoragePath(access);
                cosManager.safeDelete(key);
            }
        }
    }
}

添加定时任务组件

@Component
@Slf4j
public class LowFrequencyDataCleanupTask {

    @Resource
    private FileReferenceService fileReferenceService;

    /**
     * 每天凌晨2点执行低频数据清理
     * cron表达式说明:秒 分 时 日 月 周
     */
    @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
    public void cleanupLowFrequencyData() {
        try {
            fileReferenceService.deleteLowFrequencyData();
            log.info("低频数据清理任务执行成功");
        } catch (Exception e) {
            log.error("低频数据清理任务失败: {}", e.getMessage(), e);
        }
    }
}

小贴士:注意在启动类上添加 @EnableScheduling注解,开启定时任务