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

36 阅读5分钟

最近在开发功能的时候,新增了文件上传功能。但是发现了一个问题,就是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):

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 示例):

@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

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: 文件上传(默认为临时)

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: 业务提交(确认转正)

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

@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),每小时执行一次。

@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 也会帮我们守住底线。