最近在开发功能的时候,新增了文件上传功能。但是发现了一个问题,就是OSS里堆积了大量“无主”的文件:用户上传了头像但没保存资料、上传了附件却关闭了页面…这些“孤儿文件”静静地躺在存储桶里,没人引用,也没人清理。
1. 背景与痛点
在 Web 系统开发中,“表单 + 文件上传” 是一个极高频的场景。
通常的实现流程是:
- 用户在表单页点击上传,前端调用上传接口。
- 服务端接收文件,上传至 OSS,并返回 URL。
- 前端拿到 URL,填入表单的隐藏域。
- 用户点击“提交”,将表单数据(含 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 核心思想
将文件的生命周期划分为两个阶段:
- 临时态 (TEMP):文件刚上传,只有“临时居住证”,设置过期时间(如 24h)。
- 已用态 (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 吞掉了异常。
对策:务必确保 updateStatus 和 saveBusiness 在同一个 @Transactional 事务内。
2、直接使用 OSS URL 作为参数
现象:前端直接传 URL 给后端,后端还要去反查 ID。
对策:上传接口返回 fileId,前端提交表单时传 fileId。fileId 是我们系统的内码,控制权在自己手里。
作为双重保险,建议在 OSS/S3 控制台配置 Lifecycle Rule:
比如bucket/temp/* 目录下的文件,7 天后自动删除。这样即使定时任务挂了,或者数据库炸了,OSS 也会帮我们守住底线。