云计算百科
云计算领域专业知识百科平台

她问我:服务器快被垃圾文件塞爆了,怎么破?我说:给文件办个“临时居住证”

深夜代码系列 · 第7期

关注我,和小豆一起看小说

🔥 开篇引爆

周五的傍晚,窗外的晚霞烧得正旺,但我没心思欣赏。因为运维胖哥刚刚在群里发了一张服务器磁盘报警的截图,那鲜红的 92% 看得我心惊肉跳。

“豆子!”胖哥直接杀到了我工位,“你们那个‘用户反馈’功能是不是有毒?我看 OSS 存储桶里的文件数量这周激增了 50%,但数据库里的反馈记录根本没几条啊!”

正说着,小汐端着奶茶凑了过来,一脸无辜又带着点心虚:“那个……我也发现了。很多用户上传了截图,结果没点‘提交’就关页面跑路了。那些图片就成了没人要的孤儿,一直赖在服务器上。”

“好家伙,”我扶额,“合着我们是在做‘网络垃圾回收站’啊。”

这其实是一个非常经典的工程问题:异步的文件上传与原子的业务提交不一致,导致了“孤儿资源(Orphan File)”。

🎯 场景还原

小汐打开了她的代码,指着那个上传组件:

“现在的逻辑是这样的:”

// 1. 用户选图,立即上传
const onUpload = async (file) => {
const url = await uploadAPI(file); // 文件直接落盘
form.imageUrl = url; // 拿到 URL 填入表单
};

// 2. 用户可能… 永远不点击提交
// const onSubmit = async () => { … }

“只要用户上传了图片,”小汐叹了气,“不管他最后提不提交表单,这文件都已经存下来了。现在的服务器里,估计有一半都是这种‘幽灵文件’。”

“如果是小文件,”小汐突然眼睛一亮,“其实我有招!我们能不能别这么急着上传?”

她快速敲了几行代码:

// 方案一:混合提交(FormData)
async create(data, imageFile) {
const formData = new FormData()
// 把 JSON 数据转成 Blob 塞进去
formData.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' }))

if (imageFile) {
// 前端先压缩,随表单一起提交
const webpFile = await convertImageToWebp(imageFile, 0.8)
formData.append('imageFile', webpFile)
}

// 一次请求,搞定所有
return request({ url: '/reward', method: 'post', data: formData })
}

“你看,”小汐得意地说,“这样文件和表单是原子性的。要么都成功,要么都失败,根本不会有孤儿文件!”

🧠 思路分析

“小汐这招‘混合提交’,对付小头像、小截图确实够用。”

阿辰不知何时站在了我们身后,手里依旧是那个保温杯。他看了一眼代码,淡淡地说:“但如果用户要上传一个 500MB 的视频呢?或者弱网环境下上传 10 张高清图呢?你让用户点提交按钮后干等几十秒?体验会崩的。”

小汐愣了一下,默默收回了得意的笑容。

阿辰拉过白板,画了两个圈:

“对于大文件或通用场景,我们还是得走异步上传。但关键在于——‘上传 ≠ 生效’。”

他写下了一个词:两阶段提交。

“我们给文件设计个生命周期,就像办签证一样:”

  • 临时态 (TEMP):刚上传的文件,默认都是“临时访客”。给它发个有效期 24 小时的“临时居住证”。
  • 转正 (USED):只有当表单提交成功了,后端才会在事务里给这个文件盖个章,变成“永久居民”。
  • 驱逐:过期还没转正的,直接由定时任务清理掉。
  • 💻 代码实战

    说干就干。我们决定采用 方案二(两阶段提交) 作为主方案,小汐的 方案一(混合提交) 作为轻量级场景的备选。

    1. 数据库层改造:给文件加个身份

    我们需要一张统一的 sys_file 表来管理所有文件。

    CREATE TABLE `sys_file` (
    `id` bigint NOT NULL,
    `url` varchar(500) NOT NULL,
    `status` tinyint DEFAULT 0, — 0: TEMP(临时), 1: USED(已确认)
    `expire_time` datetime DEFAULT NULL, — 临时文件过期时间
    `create_time` datetime DEFAULT CURRENT_TIMESTAMP
    );

    2. 后端逻辑:上传即“临时”

    // 上传接口
    public FileVO upload(MultipartFile file) {
    String url = ossService.upload(file);

    SysFile sysFile = new SysFile();
    sysFile.setUrl(url);
    sysFile.setStatus(Status.TEMP); // 默认是临时态
    sysFile.setExpireTime(LocalDateTime.now().plusHours(24)); // 24小时后过期

    fileMapper.insert(sysFile);
    return new FileVO(sysFile.getId(), url);
    }

    3. 业务提交:事务内“转正”

    这是最关键的一步。只有业务成功了,文件才能活下来。

    @Transactional(rollbackFor = Exception.class)
    public void submitFeedback(FeedbackForm form) {
    // 1. 保存业务数据
    feedbackMapper.save(form);

    // 2. 【关键】将文件标记为“已使用”
    // 这一步必须在事务内,如果保存失败回滚,文件依然是 TEMP,会被后续清理
    if (form.getFileId() != null) {
    fileMapper.updateStatus(form.getFileId(), Status.USED);
    }
    }

    4. 流程图解

    为了让逻辑更清晰,我画了个图:

    定时任务数据库后端API用户定时任务数据库后端API用户#mermaid-svg-tc8Z2NuRQpOgh5wb{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tc8Z2NuRQpOgh5wb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tc8Z2NuRQpOgh5wb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tc8Z2NuRQpOgh5wb .error-icon{fill:#552222;}#mermaid-svg-tc8Z2NuRQpOgh5wb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tc8Z2NuRQpOgh5wb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tc8Z2NuRQpOgh5wb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tc8Z2NuRQpOgh5wb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tc8Z2NuRQpOgh5wb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tc8Z2NuRQpOgh5wb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tc8Z2NuRQpOgh5wb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tc8Z2NuRQpOgh5wb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tc8Z2NuRQpOgh5wb .marker.cross{stroke:#333333;}#mermaid-svg-tc8Z2NuRQpOgh5wb svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tc8Z2NuRQpOgh5wb p{margin:0;}#mermaid-svg-tc8Z2NuRQpOgh5wb .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tc8Z2NuRQpOgh5wb text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-tc8Z2NuRQpOgh5wb .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tc8Z2NuRQpOgh5wb .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-tc8Z2NuRQpOgh5wb .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-tc8Z2NuRQpOgh5wb .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-tc8Z2NuRQpOgh5wb #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-tc8Z2NuRQpOgh5wb .sequenceNumber{fill:white;}#mermaid-svg-tc8Z2NuRQpOgh5wb #sequencenumber{fill:#333;}#mermaid-svg-tc8Z2NuRQpOgh5wb #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-tc8Z2NuRQpOgh5wb .messageText{fill:#333;stroke:none;}#mermaid-svg-tc8Z2NuRQpOgh5wb .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tc8Z2NuRQpOgh5wb .labelText,#mermaid-svg-tc8Z2NuRQpOgh5wb .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-tc8Z2NuRQpOgh5wb .loopText,#mermaid-svg-tc8Z2NuRQpOgh5wb .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-tc8Z2NuRQpOgh5wb .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tc8Z2NuRQpOgh5wb .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-tc8Z2NuRQpOgh5wb .noteText,#mermaid-svg-tc8Z2NuRQpOgh5wb .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-tc8Z2NuRQpOgh5wb .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tc8Z2NuRQpOgh5wb .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tc8Z2NuRQpOgh5wb .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tc8Z2NuRQpOgh5wb .actorPopupMenu{position:absolute;}#mermaid-svg-tc8Z2NuRQpOgh5wb .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-tc8Z2NuRQpOgh5wb .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tc8Z2NuRQpOgh5wb .actor-man circle,#mermaid-svg-tc8Z2NuRQpOgh5wb line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-tc8Z2NuRQpOgh5wb :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}什么都不做alt[用户提交表单][用户跑路]loop[每小时执行]1. 上传文件存入记录 (状态=TEMP, 过期=24h后)返回 fileId2. 提交表单 (带 fileId)开启事务保存业务数据更新文件状态 TEMP ->> USED提交成功扫描 status=TEMP & expire_time < now删除物理文件删除文件记录

    📊 效果验证

    上线一周后。

    胖哥再次丢过来一张截图,这次是存储桶的增长曲线。

    “神了啊,”胖哥发了个大拇指表情,“这周文件增长率直接降了 40%,而且我看了下凌晨的清理日志,每天自动删除了几百个无效文件。那个报警红灯终于灭了。”

    小汐看着监控大屏,长舒了一口气:“终于不用担心我的上传接口变成垃圾场了。”

    💡 经验总结

    这次治理,让我们明白了一个道理:资源必须要有生命周期管理。

    核心要点:

  • 场景分治:小文件(头像/凭证)可用 FormData 混合提交,简单粗暴零孤儿;大文件必须走 两阶段提交。
  • 默认临时:所有异步上传默认都是“临时态”,设置 TTL(Time To Live)。
  • 反向确认:业务提交成功是文件“转正”的唯一条件。
  • 避坑指南:

    • ❌ 坑1:直接返回最终 URL。建议返回 fileId,让后端有控制权。
    • ❌ 坑2:依赖前端删除。永远不要相信前端的 onUnload 或 Cancel 事件,网络一断什么都发不出来。
    • ✅ 推荐:对于 OSS/S3,还可以配置 Bucket 的 Lifecycle 规则作为最后的兜底(比如 temp/ 目录下的文件 7 天自动物理删除)。

    🌙 温馨收尾

    解决完这个问题,已经是深夜。

    阿辰收拾好包,路过我们工位时说:“技术债和垃圾文件一样,如果不设定期限去清理,总有一天会爆掉。”

    我看了看服务器绿色的状态灯,又看了看旁边正在喝奶茶的小汐。

    “走吧,吃夜宵去?”我提议。

    “走!我要吃烧烤!”小汐立刻复活,之前的疲惫一扫而空。

    在这个数据不断膨胀的世界里,懂得“断舍离”的系统,才能跑得更远。


    这里是《深夜代码》,我们下期见。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 她问我:服务器快被垃圾文件塞爆了,怎么破?我说:给文件办个“临时居住证”
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!