孤儿资源治理:如何优雅处理“上传了但未提交”的冗余文件?

最近在开发功能的时候,新增了文件上传功能。但是发现了一个问题,就是OSS里堆积了大量"无主"的文件:用户上传了头像但没保存资料、上传了附件却关闭了页面...这些"孤儿文件"静静地躺在存储桶里,没人引用,也没人清理。

1. 背景与痛点

在 Web 系统开发中,"表单 + 文件上传" 是一个极高频的场景。

通常的实现流程是:

  1. 用户在表单页点击上传,前端调用上传接口。
  2. 服务端接收文件,上传至 OSS,并返回 URL。
  3. 前端拿到 URL,填入表单的隐藏域。
  4. 用户点击"提交",将表单数据(含 URL)发送给服务端保存。

核心痛点: 如果用户在第 2 步上传成功后,因为网络中断、页面关闭或主观放弃等原因,没有执行第 4 步的提交,那么这个文件就已经存在于 OSS 中,但没有任何业务数据引用它。

久而久之,这些孤儿文件(Orphan Files)会占用大量存储空间,增加成本,甚至带来合规风险。

本文将提供一套生产级的解决方案,涵盖轻量级场景与通用场景的治理策略。

2. 方案对比与选型

针对此问题,业界主要有以下几种解法,需根据业务场景灵活选择:

方案 核心逻辑 优点 缺点 适用场景
方案 A:混合提交 (FormData) 文件与表单同接口一次提交 原子性,无孤儿文件 不支持大文件,用户体验差(需等待上传) 小文件、头像、凭证
方案 B:两阶段提交 上传即临时,业务提交才转正 逻辑严密,完全可控,体验好 开发成本稍高,需引入定时任务 大文件、通用业务
方案 C:定期反查 定时扫描文件表,反查业务表 逻辑直观 跨表查询成本高,扩展性差 旧系统改造
方案 D:OSS 生命周期 利用 OSS 规则自动删除 temp/ 零代码 无法精确控制业务状态 兜底辅助

选型结论:

  • 轻量级场景 (如头像修改):优先使用 方案 A(混合提交),简单粗暴。
  • 通用/复杂场景 :推荐采用 方案 B(两阶段提交) 作为主方案,配合 方案 D 作为兜底。

3. 轻量级解法:混合提交 (FormData)

对于文件体积小(< 2MB)、数量少的情况,我们可以放弃异步上传,直接将文件流与 JSON 数据打包在一起提交。

核心优势:利用 HTTP 请求的原子性,要么全成功,要么全失败,根源上消灭孤儿文件。

前端代码示例(Vue/Axios):

javascript 复制代码
async create(data, imageFile) {
  const formData = new FormData()
  
  // 1. 将复杂的 JSON 数据转为 Blob 放入 data 字段
  // 注意设置 Content-Type 为 application/json,方便后端解析
  formData.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' }))
  
  if (imageFile) {
    // 2. (可选) 前端压缩图片
    const webpFile = await convertImageToWebp(imageFile, 0.8)
    formData.append('imageFile', webpFile)
  }
  
  // 3. 一次性提交
  return request({ url: '/reward', method: 'post', data: formData })
}

后端处理(Spring Boot 示例):

java 复制代码
@PostMapping(value = "/reward", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result create(
    @RequestPart("data") RewardDTO rewardDTO, // 自动解析 JSON
    @RequestPart(value = "imageFile", required = false) MultipartFile imageFile
) {
    // 业务逻辑与文件处理在同一线程/事务中
    String url = ossService.upload(imageFile);
    rewardService.save(rewardDTO, url);
    return Result.ok();
}

4. 通用级解法:两阶段提交

对于大文件或体验要求高的场景,必须使用异步上传。此时需引入生命周期管理

4.1 核心思想

将文件的生命周期划分为两个阶段:

  1. 临时态 (TEMP):文件刚上传,只有"临时居住证",设置过期时间(如 24h)。
  2. 已用态 (USED):业务表单提交成功后,确认为"永久居民"。

4.2 数据库设计

我们需要一张统一的文件记录表 sys_file

sql 复制代码
CREATE TABLE `sys_file` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `url` varchar(512) NOT NULL COMMENT '文件地址',
  `status` tinyint DEFAULT 0 COMMENT '0:TEMP(临时), 1:USED(已用)',
  `expire_time` datetime DEFAULT NULL COMMENT '过期时间',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_status_expire` (`status`, `expire_time`) -- 方便定时任务扫描
);

4.3 业务流程实现

Step 1: 文件上传(默认为临时)

java 复制代码
public FileVO upload(MultipartFile file) {
    // 1. 上传至 OSS
    String url = ossClient.putObject(file);
    
    // 2. 记录到本地表,状态为 TEMP
    SysFile sysFile = new SysFile();
    sysFile.setUrl(url);
    sysFile.setStatus(Status.TEMP);
    // 3. 关键:设置过期时间(例如 24 小时后)
    sysFile.setExpireTime(LocalDateTime.now().plusHours(24));
    
    sysFileMapper.insert(sysFile);
    return new FileVO(sysFile.getId(), url);
}

Step 2: 业务提交(确认转正)

这是确保数据一致性的关键步骤,必须在事务中进行。

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void createProduct(ProductForm form) {
    // 1. 保存业务数据
    productMapper.insert(form);
    
    // 2. 将关联的文件 ID 标记为 USED
    // 只有业务保存成功,文件才会被标记,避免了"业务失败文件确保留"的情况
    if (CollectionUtils.isNotEmpty(form.getFileIds())) {
        sysFileMapper.updateStatusBatch(form.getFileIds(), Status.USED);
    }
}

Step 3: 定时清理(垃圾回收)

启动一个定时任务(如 XXL-JOB),每小时执行一次。

java 复制代码
@XxlJob("cleanTempFileJob")
public void cleanTempFileJob() {
    // 1. 扫描所有状态为 TEMP 且已过期的文件
    List<SysFile> expiredFiles = sysFileMapper.selectExpired(Status.TEMP, LocalDateTime.now());
    
    for (SysFile file : expiredFiles) {
        try {
            // 2. 删除 OSS 上的物理文件
            ossClient.deleteObject(file.getUrl());
            // 3. 删除(或归档)本地记录
            sysFileMapper.deleteById(file.getId());
        } catch (Exception e) {
            log.error("文件清理失败: " + file.getId(), e);
        }
    }
}

4.4 架构交互图

sequenceDiagram participant User as 用户 participant App as 后端服务 participant DB as 数据库 participant OSS as 对象存储 participant Job as 清理任务 User->>App: 1. 上传文件 App->>OSS: 物理存储 App->>DB: 记录文件 (Status=TEMP) App-->>User: 返回 fileId alt 用户提交表单 User->>App: 2. 提交业务数据(含 fileId) App->>DB: 开启事务 App->>DB: 保存业务数据 App->>DB: 更新文件 Status=USED App-->>User: 成功 else 用户放弃 Note right of User: 无操作 end loop 定时清理 Job->>DB: 查询 (Status=TEMP & Time < Now) Job->>OSS: 删除物理文件 Job->>DB: 删除数据库记录 end

5. 避坑与最佳实践

1、业务保存失败导致文件泄露

现象:业务代码抛异常回滚了,但紧接着的一行"文件状态更新"没有在同一个事务里,或者手动 try-catch 吞掉了异常。

对策 :务必确保 updateStatussaveBusiness 在同一个 @Transactional 事务内。

2、直接使用 OSS URL 作为参数

现象:前端直接传 URL 给后端,后端还要去反查 ID。

对策 :上传接口返回 fileId,前端提交表单时传 fileIdfileId 是我们系统的内码,控制权在自己手里。


作为双重保险,建议在 OSS/S3 控制台配置 Lifecycle Rule

比如bucket/temp/* 目录下的文件,7 天后自动删除。这样即使定时任务挂了,或者数据库炸了,OSS 也会帮我们守住底线。

相关推荐
a努力。1 天前
中国电网Java面试被问:分布式缓存的缓存穿透解决方案
java·开发语言·分布式·缓存·postgresql·面试·linq
草莓熊Lotso1 天前
脉脉独家【AI创作者xAMA】| 开启智能创作新时代
android·java·开发语言·c++·人工智能·脉脉
爱吃山竹的大肚肚1 天前
Kafka中auto-offset-reset各个选项的作用
java·spring boot·spring·spring cloud
yangminlei1 天前
Spring Boot+EasyExcel 实战:大数据量 Excel 导出(高效无 OOM)
spring boot·后端·excel
只想要搞钱1 天前
java 常用业务方法-记录
java
CodeAmaz1 天前
HashMap 面试全攻略
java·hashmap
moxiaoran57531 天前
Java设计模式的运用
java·开发语言·设计模式
编程(变成)小辣鸡1 天前
Redisson 知识点及使用场景
java·redisson
源代码•宸1 天前
Leetcode—1339. 分裂二叉树的最大乘积【中等】
开发语言·后端·算法·leetcode·golang·dfs