找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4410

积分

0

好友

572

主题
发表于 1 小时前 | 查看: 3| 回复: 0

最近做文件上传功能时,我把原来最普通的 MultipartFile file 上传方式彻底改了一遍。

原因很简单。

上传几十 MB 的图片时完全没问题,但用户开始上传 1GB 视频、3GB 素材包、10GB 备份文件,问题马上就来了。

网络稍微抖一下:

上传到92%
   ↓
请求中断
   ↓
重新从0开始

一个 10GB 文件传了半个小时,最后因为断网几秒全部重来。这种体验显然不能接受。

后来我把上传流程改成了:

分片上传 + 断点续传 + 秒传。

最终效果是:

10GB文件拆成多个小块
       ↓
哪个失败只重新传哪个
       ↓
刷新页面仍然可以继续
       ↓
服务器已有同样文件
       ↓
甚至直接"秒传"

今天就用 Spring Boot 把这个功能完整拆开。

一、为什么普通 MultipartFile 扛不住大文件?

最简单的 Spring Boot 上传接口大家应该都写过:

@PostMapping("/upload")
public String upload(
        @RequestParam MultipartFile file)
        throws IOException {

    file.transferTo(
            Path.of(
                "/data/uploads/",
                file.getOriginalFilename()
            )
    );

    return "success";
}

小文件很好用。问题在于,一个 10GB 文件相当于:

浏览器
   ↓
一次HTTP请求
   ↓
10GB数据
   ↓
Spring Boot
   ↓
最终文件

整个上传是一个长时间任务。中间任何地方出问题:

Wi-Fi断开
手机切网络
Nginx超时
服务重启
浏览器关闭
请求失败

都可能导致上传失败。

所以大文件上传的核心思想不是"怎么让一次 HTTP 请求坚持更久",而是:

不要一次上传整个文件。

二、先把 10GB 文件切成很多小块

假设文件大小 10GB,前端按照 10MB 一个分片,那么会得到大约 1024 个分片。

每一块都有自己的编号:

chunk 0
chunk 1
chunk 2
...
chunk 1023

浏览器上传时,不再发送 movie.mp4 的 10GB 整体,而是不断发送:

fileHash
chunkIndex
totalChunks
chunkData

例如:

fileHash:
7d52a87d...

chunkIndex:
37

totalChunks:
1024

于是整个流程从"一次 10GB 请求"变成"1024 次 10MB 请求"。

如果第 387 个分片失败,只重新上传 387。前面的 0~386 全部保留。这就是断点续传能够实现的基础。

三、Java 后端第一步:给每个文件一个唯一身份

这里不能只使用文件名,因为两个用户完全可能都上传 video.mp4,但文件内容不同。

所以前端通常需要先计算文件摘要,例如 SHA-256。假设最终得到 96e8b7c1a4....,那么这个 Hash 就可以作为文件身份。

Spring Boot 接口可以设计成:

public record ChunkRequest(
        String fileHash,
        Integer chunkIndex,
        Integer totalChunks,
        String fileName
) {
}

上传接口:

@PostMapping("/upload/chunk")
public void uploadChunk(
        @RequestParam String fileHash,
        @RequestParam int chunkIndex,
        @RequestParam int totalChunks,
        @RequestParam String fileName,
        @RequestParam MultipartFile chunk)
        throws IOException {

    uploadService.saveChunk(
            fileHash,
            chunkIndex,
            totalChunks,
            fileName,
            chunk
    );
}

后端保存目录可以设计成:

/data/upload-temp/
    ↓
96e8b7c1a4/
    ↓
0.part
1.part
2.part
3.part
...

也就是说:一个文件对应一个临时目录,每个分片单独存储。

四、真正关键的断点续传,其实是"先问服务器传到哪了"

很多人认为分片上传就等于断点续传,其实还差一步。

用户上传了 0、1、2、3、4、5 分片,然后浏览器关闭。第二天再打开页面,前端怎么知道 0~5 已经上传过了?

答案是:先查询服务器。

增加接口:

@GetMapping("/upload/status/{fileHash}")
public UploadStatus status(
        @PathVariable String fileHash) {

    return uploadService.getStatus(
            fileHash
    );
}

返回:

{
  "uploadedChunks": [
    0,
    1,
    2,
    3,
    4,
    5
  ],
  "completed": false
}

前端拿到以后,0~5 直接跳过,从 6 继续上传。这才是真正的断点续传。

所以实现断点续传最核心的并不是某个高级 Java API,而是保存上传进度状态,让下一次请求可以恢复。

生产环境可以把状态放在 MySQL、Redis 或对象存储 Metadata 中,而不是完全依赖本地内存。

五、所有分片完成后,再让 Java 合并文件

假设 1024 个分片已经全部上传完成,这时候调用 POST /upload/complete,后端执行合并。Java 使用 NIO 就可以完成:

public Path merge(
        String fileHash,
        int totalChunks,
        String fileName)
        throws IOException {

    Path chunkDir =
            Path.of(
                "/data/upload-temp",
                fileHash
            );

    Path target =
            Path.of(
                "/data/uploads",
                fileName
            );

    try (OutputStream output =
            new BufferedOutputStream(
                    Files.newOutputStream(
                            target
                    )
            )) {

        for (int i = 0;
             i < totalChunks;
             i++) {

            Path part =
                    chunkDir.resolve(
                            i + ".part"
                    );

            Files.copy(
                    part,
                    output
            );
        }
    }

    return target;
}

流程就是:

0.part
   ↓
1.part
   ↓
2.part
   ↓
...
   ↓
最终movie.mp4

注意,千万不要这样写:

byte[] all =
    new byte[(int) fileSize];

然后把所有分片全部读进内存。如果文件 10GB,JVM 基本就可以准备 OutOfMemoryError 了。

正确方式应该始终:流式读取,流式写入。

六、"秒传"其实没有大家想得那么神秘

现在很多网盘上传一个以前传过的文件,会突然显示"上传完成",几 GB 数据一秒钟都没有真正上传。这就是秒传。

它背后的逻辑非常简单。前端上传之前先请求:

POST /upload/check

参数:

{
  "fileHash": "96e8b7c1a4...",
  "fileSize": 10737418240
}

服务器查询:

SELECT id,
       storage_path
FROM file_object
WHERE file_hash = ?
  AND file_size = ?
LIMIT 1;

如果已经存在 fileHash 匹配 + fileSize 匹配,那么根本不需要再上传文件内容,只需要给当前用户创建一条文件引用记录。

例如:

真实文件:

file_object
id = 10001
hash = ABC123
path = /storage/ABC123

用户A:

user_file
user_id = 1
file_id = 10001

用户B再次上传同一个文件:

不重新存储

只增加:
user_file
user_id = 2
file_id = 10001

于是 10GB 文件 0 字节真正上传,用户看起来就是"秒传成功"。

所以秒传真正实现的是:内容寻址 + 文件去重。这套设计还能直接减少服务器存储空间。

七、但生产环境绝不能只相信前端传来的 Hash

这里有一个很重要的安全问题。前端告诉 Java"我的文件 SHA-256 是 ABC123",后端不能无条件相信。

否则攻击者完全可以自己构造请求:fileHash = 某个已经存在文件的 Hash,尝试绕过真正的上传过程。

所以至少应该验证 Hash、文件大小、当前用户权限、文件状态。对于高安全场景,文件合并以后还应该由服务端重新计算一次:

MessageDigest digest =
        MessageDigest.getInstance(
                "SHA-256"
        );

然后验证服务端 SHA-256 是否等于客户端声明 SHA-256。如果不同,上传失败,同时删除错误文件。

另外,上传文件名也不能直接这样使用:

Path.of(
    "/uploads/",
    originalFilename
)

否则还可能面对 ../ 目录穿越、特殊字符、文件覆盖等问题。真正保存时最好使用内部生成的 fileId + 安全扩展名,而不是把用户文件名直接当磁盘路径。

八、并发上传后,我会再增加三个限制

分片以后还有一个很容易犯的错误:既然分片独立,那就一次把 1000 个分片全并发上传。

结果可能变成:

浏览器1000请求
      ↓
Nginx
      ↓
Spring Boot
      ↓
磁盘疯狂随机写
      ↓
CPU / IO / 连接全部上涨

前端通常限制 3~6 个分片同时上传。而 Java 后端在高并发场景下,至少还要增加三个控制。

第一:单个用户最大并发上传数,例如 5。

第二:单文件最大尺寸,例如 20GB。

第三:临时分片自动清理。例如用户传了一半就再也没回来,/data/upload-temp/xxx 不能永远留着。可以每天定时清理超过 24 小时且状态仍然 UPLOADING 的临时分片。

否则真正跑几个月后,最先爆的可能不是 Java Heap,而是磁盘。

九、最后

以前做文件上传时总感觉功能很简单:MultipartFiletransferTo()。真正做到大文件以后才发现,用户需要的不是"把一个文件 POST 到服务器",而是一整套上传系统:

文件Hash
   ↓
秒传检查
   ↓
查询历史分片
   ↓
分片上传
   ↓
失败重试
   ↓
断点恢复
   ↓
完整性校验
   ↓
分片合并
   ↓
生成文件记录
   ↓
清理临时数据

而 Spring Boot 真正适合做的,就是管理整个上传状态和业务生命周期。

如果你的系统以后会出现视频上传、设计素材上传、数据备份、网盘、文档中心、AI 模型文件、大型安装包等场景,我都不建议继续使用一个 @PostMapping("/upload")MultipartFile file 硬扛到底。

小文件上传和大文件上传,本质上已经是两种完全不同的系统设计。

如果只记住一句话,我建议记住:

大文件上传真正要解决的,不是"怎么把 10GB 传上去",而是"传到 9.9GB 失败以后,怎么不用重新开始"。




上一篇:Codex 开启 1M 上下文教程:GPT-5.6 Sol 配置与 OpenCodex 冲突避坑
下一篇:权限系统崩了3次后,我吃透了RBAC:能落地的权限设计指南
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-8-18 05:10 , Processed in 1.496182 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表