前阵子接了个企业网盘的项目,用户量不大,但需求很具体:能在网页上直接拖入整个项目文件夹,几十个文件带着层级目录一次性传上去,而且网络不稳定时不能从头再来。我最初想着“这不就是个上传吗”,等到真做起来才发现里面的坑比预想的多。断点续传、文件夹结构还原、分块合并、并发控制、MD5计算、秒传……一串问题串下来,最后沉淀出一整套方案。如果你也在用 Java 做网页端的大文件上传,尤其是带文件夹结构的批量上传,这篇文章应该能帮你少走不少弯路。
1. 整体设计与方案选型
1.1 为什么传统表单上传撑不住文件夹场景
先说一个基础认知问题。浏览器原生的文件上传,走的是 multipart/form-data,一次请求一个文件,服务端等请求完整到达后落盘。这个模式在几十 KB 的文档、几百 KB 的图片上没问题,但放到“文件夹上传 + 大文件 + 弱网”的场景里,三个缺陷直接暴露。
第一个缺陷是浏览器默认表单控件根本拿不到文件夹内部结构。你选择 <input type="file"> 时只能拿到文件列表,没有目录树。就算用 webkitdirectory 拿到文件,也需要自己处理 webkitRelativePath。如果后端只按“一个文件对象”接收,不额外传相对路径,那目录结构到了服务端就全丢了。
第二个缺陷是中断之后全部重来。假设一个文件 500MB,上传到 90% 断网了,重新上传又得从 0% 开始。在公网环境,尤其是办公室 Wi-Fi 不稳定的情况下,这个体验完全不可接受。
第三个缺陷是服务端内存压力。传统方案里如果直接在代码里写 file.getInputStream() 然后读进 byte[],一个 500MB 的文件理论上要吃掉至少 500MB 堆内存,并发上来直接 OOM。这就是很多人说的“Java 大文件上传容易爆内存”的根源。
所以结论很直接:要做文件夹结构 + 大文件 + 可靠的批量上传,就必须放弃“整文件一次传”的思路,改成“分块 + 断点 + 目录映射”的组合方案。
1.2 分块上传与断点续传的核心思路
分块上传的计算模型其实不复杂。把一个大文件均匀切成长度固定的分块,例如每块 5MB,那么一个 500MB 的文件就有 100 块。前端逐个(或并发)把分块发送给服务端,服务端把每个分块写成独立的临时文件,所有分块都上传完成后,服务端再按顺序把这些临时文件合并成完整文件。
断点续传的做法是:前端在开始上传之前,先向服务端查询“这个文件我已经传过哪些分块了”。服务端根据自己的记录返回已上传的分块序号集合,前端跳过这些分块,只上传缺失的部分。这样一来,断网、刷新页面、关闭浏览器再回来,都能从上次断掉的位置继续。
文件夹结构的做法是:每个文件除了文件名之外,还携带一个相对路径,例如“项目资料/设计稿/首页改版-v2.fig”。服务端在合并分块时,先根据相对路径创建对应目录,再把文件写入这个目录,从而完整还原整个文件夹层级。
这三个机制互相配合,才是一个完整可用的方案。不能只做分块,不做断点;也不能只做断点,丢弃目录信息。下面我把每一步的原理和实操细节拆开讲。
1.3 技术栈选型:Spring Boot + Vue,我为什么没选现成组件
我最终用的组合是:Java 后端 Spring Boot 2.7,前端 Vue 3 + Element Plus + axios,文件分块计算用 SparkMD5。
你可能会问,前端不是有 WebUploader、Plupload 这类成熟上传组件吗,为什么不用?我以前也在项目里用过,但 WebUploader 对文件夹递归的支持不够顺手,尤其在新版浏览器下面处理 webkitRelativePath 时经常要写不少兼容代码。其次,这类组件一旦要接入自定义的断点查询逻辑、自定义合并接口和秒传判断,反而比直接封装 File API 更绕。所以我最后决定自己写一个轻量的上传调度层,只依赖 axios 和 SparkMD5,整体代码量并不大,但可控性比引第三方组件好太多。
技术上还有一点要注意:Spring MVC 自带的 MultipartFile 完全够用,不需要额外引入专门的分块上传框架。分块的本质就是接收一个 MultipartFile 和一个序号,把它写到磁盘临时目录,逻辑简单清晰。如果项目里没有特殊约束,我建议不要为了“省事”引入重量级组件,先把基础链路跑通,后面真有需要再换成更适合生产的对象存储工具。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个核心机制必须想清楚
2.1 分块大小怎么定:不是越大越好也不是越小越好
分块大小是整个方案里最重要、也最容易被忽略的参数。5MB 是我比较常用的默认值,但这个值不是随便拍的,需要考虑三个因素。
第一是 HTTP 服务器和反向代理的请求体大小限制。Tomcat 默认对单个 POST 请求体有限制,Nginx 的默认 client_max_body_size 是 1MB。如果你分块定成 20MB、50MB,必须同步改 Nginx 配置,否则上传请求到 Nginx 就被拦截,返回 413,前端会一脸懵。5MB 的分块通常只需要把 Nginx 的 client_max_body_size 改成 10m 或 20m,留足余量即可。
第二是网络因素。分块越大,单次请求所需时间越长,某一块在传输过程中失败的代价就越大。2G/3G 弱网环境下,5MB 一块可能要好几十秒,断了重传这一块的耗时也不小。如果面向公网弱网用户,2MB 分块会更稳;如果只在局域网内使用,10MB 也没有问题。分块太小的问题同样存在:一个 1GB 文件如果切成 1MB,那就是 1024 个请求,请求数量翻倍,服务端要处理的连接和 IO 次数也翻倍,整体吞吐反而下降。
第三是服务端合并时的成本。分块越小,合并时需要打开和读取的小文件越多,磁盘随机读写越多。5MB 分块下,1GB 文件只需要合并 200 个分块文件,这个数量级在普通磁盘上合并也就一两秒,完全可以接受。
我现在的经验公式是:局域网或内网部署用 10MB 分块,公网用户默认 5MB,弱网(移动端热点)建议 2MB。前端根据运行环境动态调整,不要写死。
2.2 文件唯一标识与断点定位:MD5之外的取舍
断点续传的前提是服务端要知道“你传的是哪个文件”。所以必须有一个文件唯一标识。最稳妥的方案是 MD5,而且是整个文件的 MD5。但这里有个现实问题:大文件在浏览器端计算完整 MD5 需要时间。一个 1GB 的文件,用 SparkMD5 分片计算,普通笔记本大概要二十秒到一分钟,这个等待体验对用户来说不够友好。
我的做法是分两级处理。第一级是“快标识”,由文件大小 + 最后修改时间 + 文件名做拼接,例如 projects-20240511-184532-104857600,用于 init 接口判断这个文件是否已经上传过;第二级是“最终校验”,在分块合并完成后,后台计算完整 MD5,和前端上传前异步算出的 MD5 比对,确保文件内容没有问题。
前端可以这样计算 MD5,按块读取文件,避免一次性把所有内容加载进内存:
javascript复制import SparkMD5 from 'spark-md5';
async function computeFileMd5(file) {
return new Promise((resolve, reject) => {
const chunkSize = 2 * 1024 * 1024;
const fileReader = new FileReader();
const spark = new SparkMD5.ArrayBuffer();
let offset = 0;
fileReader.onerror = () => reject(new Error('文件读取失败'));
fileReader.onload = (e) => {
spark.append(e.target.result);
offset += e.target.result.byteLength;
if (offset < file.size) {
loadNext();
} else {
resolve(spark.end());
}
};
function loadNext() {
const slice = file.slice(offset, Math.min(offset + chunkSize, file.size));
fileReader.readAsArrayBuffer(slice);
}
loadNext();
});
}
这里解释一下为什么用 ArrayBuffer 而不是直接 readAsBinaryString。readAsBinaryString 在部分浏览器上处理大文件时会有性能问题,而且字符串转码会浪费不少内存。用 ArrayBuffer 配合 SparkMD5 的 append 方法,内存占用更线性,速度也更快。
后端在 init 接口里,先用快标识去查临时记录,如果命中且状态为“已完成全部校验”,直接返回“秒传”标记;如果只传了一部分,返回已上传分块列表。等到所有分块传完,再做最终 MD5 校验,校验通过后这条记录才算真正完成。
2.3 文件夹相对路径的保存与重建
文件夹结构还原,前端要做的核心事情只有一件:拿到文件的相对路径。在设置了 webkitdirectory 的 <input type="file"> 里,每个 File 对象都有 webkitRelativePath 属性。例如你选择的文件夹根目录叫“项目资料”,里面有一张 docs/api.md,那么 file.webkitRelativePath 的值就是 项目资料/docs/api.md。
但是要注意,webkitRelativePath 包含了根目录名,而这个根目录名不应该作为服务端路径的一部分,否则你每次选择的文件夹改名之后,服务端目录也会改变。比如用户第一次拖入的文件夹叫“项目资料”,第二次拖入“project-backup”,服务端如果直接把整个相对路径拼进磁盘路径,就会产生两份不同的目录。
解决方案是:在前端遍历文件时,把 file.webkitRelativePath 的第一个路径片段去掉,只保留相对目录。例如“项目资料/docs/api.md”变成“docs/api.md”。这个处理后的字符串随每个文件的元数据一起传给后端,后端在合并时用它拼接目标路径。
处理逻辑也很简单:
javascript复制function getRelativePath(file) {
const parts = file.webkitRelativePath.split('/');
parts.shift(); // 去掉根目录
return parts.join('/');
}
后端合并时按这个相对路径创建目录:
java复制String relPath = request.getRelativePath(); // 例如 docs/api.md
Path target = Paths.get(rootDir, relPath);
Files.createDirectories(target.getParent());
如果你有更复杂的需求,比如要保留根目录名,那就在前端把根目录名作为单独字段传过来,由后端决定怎么拼路径。无论如何,“根目录名剥离 + 相对路径拼接”是最稳妥的做法。
3. 从零到一的实现:后端接口与前端联动
3.1 后端需要哪几个接口:初始化、分块上传、合并
整套后端接口按职责拆分成三个:upload/init、upload/chunk、upload/merge。下面逐个说明每个接口的接收参数、返回数据和内部逻辑。
首先是初始化接口。它的作用是“确认任务状态”,前端拿到这个结果后决定接下来要上传哪些分块。这个接口也承担了秒传判断的职责。
json复制POST /api/upload/init
请求参数(JSON):
{
"fileMd5": "项目资料-20240511-184532-104857600",
"fileName": "api.md",
"fileSize": 104857600,
"relativePath": "docs/api.md"
}
java复制@PostMapping("/upload/init")
public Result<UploadInitResponse> init(@RequestBody UploadInitRequest request) {
// 1. 根据 fileMd5 查询上传记录
UploadRecord record = uploadRecordMapper.findByMd5(request.getFileMd5());
// 2. 如果记录存在且已经合并完成,则直接返回秒传标记
if (record != null && record.getStatus() == UploadStatus.COMPLETED) {
return Result.ok(UploadInitResponse.simple());
}
// 3. 如果记录不存在,则创建一条状态为 UPLOADING 的记录
// (这里同时把相对路径、文件名存进表里)
if (record == null) {
record = new UploadRecord();
record.setFileMd5(request.getFileMd5());
record.setFileName(request.getFileName());
record.setRelativePath(request.getRelativePath());
record.setFileSize(request.getFileSize());
record.setStatus(UploadStatus.UPLOADING);
uploadRecordMapper.insert(record);
}
// 4. 扫描临时目录,把已经存在的分块序号返回给前端
List<Integer> uploadedChunks = new ArrayList<>();
File tmpDir = getTmpDir(request.getFileMd5());
if (tmpDir.exists()) {
for (File f : tmpDir.listFiles()) {
String name = f.getName();
if (name.endsWith(".part")) {
uploadedChunks.add(Integer.parseInt(name.substring(0, name.indexOf("."))));
}
}
}
return Result.ok(new UploadInitResponse(uploadedChunks, record.getStatus()));
}
这里临时文件命名我直接用“序号.part”,比如 0.part、1.part,这样扫描目录时解析序号非常方便。注意这个方案假设同一个 fileMd5 在同一时刻只对应一个上传任务,如果多用户同时传同一个文件,需要额外增加一个任务ID来隔离。真实项目里我一般用 taskId = UUID + "_" + fileMd5 做复合标识,核心逻辑一样,这里为了讲清原理先简化。
接下来是分块上传接口。它接收一块文件,以及这块的序号和所属的 fileMd5,然后写入临时目录。
java复制@PostMapping("/upload/chunk")
public Result<Void> uploadChunk(UploadChunkRequest request) {
// 参数:fileMd5, chunkIndex, file(MultipartFile)
File tmpDir = getTmpDir(request.getFileMd5());
if (!tmpDir.exists()) {
tmpDir.mkdirs();
}
File target = new File(tmpDir, request.getChunkIndex() + ".part");
request.getFile().transferTo(target);
return Result.ok();
}
UploadChunkRequest 是一个简单的 Form 对象,包含 fileMd5、chunkIndex 和 file 三个字段。这里有一个关键点:一定要用 transferTo,不要手动读 InputStream 再写文件。transferTo 是 Spring 封装好的方法,底层会走 Tomcat 的临时文件机制,在文件较大时可以减少内存占用,防止大文件上传把堆内存撑爆。
最后是合并接口。前端把所有分块传完后调用它,后端按分块序号从小到大把 part 文件写入最终目标文件。
java复制@PostMapping("/upload/merge")
public Result<Void> merge(@RequestBody MergeRequest request) {
File tmpDir = getTmpDir(request.getFileMd5());
// 最终目标目录:根目录 + 相对路径的父目录
Path targetPath = Paths.get(uploadRootDir, request.getRelativePath());
Files.createDirectories(targetPath.getParent());
File finalFile = targetPath.toFile();
// 如果最终文件已存在,先删除或覆盖
if (finalFile.exists()) {
finalFile.delete();
}
// 按分块顺序合并
try (FileOutputStream fos = new FileOutputStream(finalFile);
FileChannel outChannel = fos.getChannel()) {
int totalChunks = request.getTotalChunks();
for (int i = 0; i < totalChunks; i++) {
File partFile = new File(tmpDir, i + ".part");
if (!partFile.exists()) {
throw new IllegalStateException("分块缺失:" + i);
}
try (FileInputStream fis = new FileInputStream(partFile);
FileChannel inChannel = fis.getChannel()) {
inChannel.transferTo(0, inChannel.size(), outChannel);
}
}
}
// 删除临时目录
for (File f : tmpDir.listFiles()) {
f.delete();
}
tmpDir.delete();
// 更新上传记录状态
UploadRecord record = uploadRecordMapper.findByMd5(request.getFileMd5());
if (record != null) {
record.setStatus(UploadStatus.COMPLETED);
uploadRecordMapper.update(record);
}
return Result.ok();
}
合并时用 FileChannel.transferTo 是零拷贝的实现,比逐字节读写的效率高不少,尤其是在大文件全量合并时差别很明显。我在本地测过一个 1.8GB 的文件,200 个分块用 FileChannel 合并大概 2 到 3 秒完成,用普通流拷贝要慢一倍不止。
3.2 前端分块、并发与进度更新
后端接口定义好之后,前端核心就是一件事:把一个文件夹解析成文件集合,再逐个上传。我建议用一个上传队列来管理,而不是简单地在 for 循环里 await axios.post。因为逐块串行上传速度太慢,但如果一次性把所有分块全部并发发出去,服务端 IO 和内存压力又扛不住。
我的方案是固定 3 个并发通道。每次从队列里取 3 个分块任务,各自异步上传,完成一个就从队列里再补一个,直到全部完成。这个并发数在绝大多数普通服务器上都能接受。
核心代码可以这样写:
javascript复制async function uploadChunksWithConcurrency(file, fileMd5, uploadedChunks, concurrency = 3) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const tasks = [];
for (let i = 0; i < totalChunks; i++) {
if (uploadedChunks.includes(i)) {
continue;
}
tasks.push(i);
}
let index = 0;
let completedCount = 0;
const totalNeedUpload = tasks.length;
await new Promise((resolve, reject) => {
async function worker() {
while (index < tasks.length) {
const chunkIndex = tasks[index++];
const formData = new FormData();
const blob = file.slice(
chunkIndex * CHUNK_SIZE,
Math.min((chunkIndex + 1) * CHUNK_SIZE, file.size)
);
formData.append('file', blob, `${file.name}.part`);
formData.append('fileMd5', fileMd5);
formData.append('chunkIndex', chunkIndex);
try {
await axios.post('/api/upload/chunk', formData, {
onUploadProgress: (e) => {
// 更新当前分块的进度
}
});
completedCount++;
// 这里回调更新总进度
onProgress(completedCount / totalNeedUpload);
} catch (err) {
reject(err);
return;
}
}
if (completedCount === totalNeedUpload) {
resolve();
}
}
const workers = [];
for (let i = 0; i < concurrency; i++) {
workers.push(worker());
}
Promise.all(workers).then(resolve).catch(reject);
});
}
这里面有个小细节:分块对象用了 file.slice(chunkIndex * CHUNK_SIZE, ...)。这个操作不会修改原始文件,也不会把文件全部加载进内存,只是生成一个 Blob 视图,所以浏览器端的内存占用也很平稳。
另一个小细节:FormData 里 file 字段的文件名,我附加了一个 .part 后缀。这样后端可以根据文件名判断这不是完成文件,而且在合并逻辑里也不会误用。你当然也可以直接用原文件名,后端反正是按 chunkIndex 保存,不会混,但我习惯在命名上就区分开,方便后续排查问题。
总进度的计算要分两步:每个文件内部的进度、多个文件之间的进度。文件夹里如果有 10 个文件,应当先算出“已完成文件数 / 总文件数”的基础进度,再叠加当前文件的分块完成比例。我前端处理方式是把每个文件封装成一个小任务,任务列表用一个全局状态管理,每个任务内部自己算分块进度,最后加权汇总到顶部进度条。
3.3 秒传与完整性校验怎么落地
秒传的实现在 init 接口已经埋了伏笔:当服务端检测到 fileMd5 对应的记录已经是 COMPLETED 状态时,返回 uploadedChunks 为全部序号,前端直接跳过所有分块上传,合并接口也不必再调。更省事的做法是让 init 返回一个 status = "SECOND_TRANSMIT" 字段,前端拿到这个值直接提示用户“文件已存在,本次为秒传”。
从用户视角看,秒传的体验是选择文件后进度瞬间到 100%。这对企业内部系统来说很受欢迎,因为用户经常上传同一个安装包或者设计源文件。
秒传的判断依据可靠吗?这里必须说清楚风险。如果只用文件名 + 大小 + 修改时间做标识,存在碰撞的可能,尤其是两个不同文件恰好大小相同、修改时间相同的情况。所以我在实现秒传时做了两层校验:init 时用快标识做初步判断,如果命中,再走一遍最终 MD5 校验,校验通过才允许秒传;如果 MD5 不一致,就清掉旧记录重新走分块上传。这样既兼顾了性能,又保证了正确性。
完整性校验这块,我的做法是在合并完成后,后台异步统计上传记录里的 fileSize 和实际合并后的 File.length() 做比对:
java复制long mergedSize = finalFile.length();
if (mergedSize != request.getFileSize()) {
// 删除残缺文件,重置状态,抛出异常
finalFile.delete();
throw new IllegalStateException("文件大小不一致,合并失败");
}
大小一致只是第一关,更严格的校验是算最终 MD5。生产环境里我建议合并完成后在后台线程计算 MD5,再和前端上传前算好的值比对。如果两边不一致,说明某个分块在传输过程中出了静默错误(比较少见,但确实可能在磁盘坏道或网络层偶发出现),这时候要清理已合并的文件,让用户重传。
4. 坑与对策:常见问题排查实录
4.1 并发上传导致分块错乱或文件损坏
第一个坑发生在并发上传的初期版本。我当时没有认真处理并发写入的隔离问题,所有分块直接写到一个共享的目录,结果前端按 5 个并发上传时,偶发出现分块序号错乱。排查下来发现原因有两个:一是前端 worker 里 index++ 在多个异步 worker 同时执行时产生了竞态,导致同一个序号被两个 worker 各自取到;二是个别分块内容过小(文件最后一块),传输过程中 MultipartFile 的流没有完整消费就关闭了。
解决办法是给每个分块加上“先写临时文件再改名”的模式。也就是先写入 {chunkIndex}.part.tmp,写入完成后 rename 成 {chunkIndex}.part。这样前端在 init 扫描目录时,永远不会看到一个写了一半的分块文件。前端侧则把 index++ 放在 while 循环的同步区域里,保证同一时刻只有一个 worker 能从队列中取到某个序号。
4.2 服务器内存溢出或磁盘打满
这个坑对应热搜词里的 “java: outofmemoryerror: insufficient memory”。如果你的分块上传接口是用 InputStream 读取整个文件再转 byte[],那并发一上来非常容易触发。正确做法是上面已经强调过的:用 MultipartFile.transferTo(File) 落盘,不要手动把内容读成 byte[]。transferTo 在 Spring 的 StandardMultipartHttpServletRequest 中会优先使用 MultipartConfig 的临时目录,如果文件较大,Tomcat 会先把内容写到磁盘临时文件,再 copy 到目标位置,内存占用很小。
Nginx 这边也要留意。很多场景下服务真正跑在 Nginx 后面,如果 Nginx 的 client_max_body_size 没调大,5MB 分块本来应该没事,但如果你把分块调大到了 20MB 甚至更大,就有可能出现请求直接被 Nginx 拒绝的情况。我建议 Nginx 配置留一个余量,比如分块 5MB,就设 client_max_body_size 20m,避免各种请求头膨胀导致的问题。
磁盘打满的坑我也踩过。临时目录如果和最终存储目录在同一个磁盘分区,要预留足够的空间。最保险的方式是启动时检查存储分区的剩余空间,如果剩余空间小于某个阈值,直接拒绝新的上传请求,并给前端返回明确的错误提示。否则当磁盘空间不足时,合并阶段会抛 IOException,前端又得从头再来。
4.3 合并失败与断点续传失效
合并失败的常见原因有三个。第一个是分块缺失。这个问题发生在 init 和 chunk 之间时间隔太长,或者中途某个分块由于网络原因上传失败但前端没有正确处理。前端必须为每个分块的上传调用包裹错误处理,失败的分块要重新进入队列,而不是直接让整个 Promise reject。
第二个是文件被占用。Windows 服务器上会经常遇到,合并出来的文件被杀毒软件或其他程序暂时锁定,导致无法写入或删除。面对这种情况我建议在合并逻辑里做一次重试,间隔 200ms 连续尝试 3 次,还是失败再报错。
第三个是跨磁盘移动文件导致的问题。如果你把临时分块目录放在系统盘,最终存储目录放在数据盘,合并时如果使用 Files.move(),在部分环境下会因为跨存储设备抛 FileSystemException。解决方案就是我在代码里写的那种:用 FileChannel 按流复制,而不是 File.move。
断点续传失效的典型表现是:刷新页面后重新上传,明明传了一半的文件又开始从头传。这种问题九成出在 init 接口返回的已上传分块列表不准确,或者前端保存的 fileMd5 变了。比如 SparkMD5 计算时把分块大小改动了,前后两次算出来的 MD5 自然就不同,服务端匹配不到记录。解决办法是把 fileMd5 计算参数固定,并且上传前端缓存里始终保存同一个值,直到整个目录上传完成才清掉。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决措施 |
|---|---|---|
| 上传请求返回 413 | Nginx 的 client_max_body_size 设置过小 | 调大 Nginx 配置,并实际测试生效 |
| 大文件上传后服务端 OOM | 代码里用 InputStream 手动读取整文件 | 改用 MultipartFile.transferTo() 落盘 |
| 刷新后断点续传失效 | fileMd5 计算值前后不一致 | 统一 MD5 计算分块大小和算法,前端保存稳定标识 |
| 合并时报分块缺失 | init 后未上传的分块被跳过或失败未重试 | 前端为每个分块封装重试逻辑,服务端合并前检查缺失序号 |
| 合并后文件打不开 | 分块顺序写错或某个分块内容损坏 | 检查合并循环的序号顺序,增加最终 MD5 校验 |
| 文件夹层级丢失 | 前端未传 relativePath 或后端未创建目录 | 使用 webkitRelativePath 并剥离根目录 |
| 秒传误判 | 快标识冲突导致不同文件命中同一记录 | 秒传前必须做最终 MD5 校验 |
| 磁盘空间不足导致上传中断 | 临时目录和存储目录空间被其他任务耗尽 | 启动时检查剩余空间,低于阈值拒绝新任务 |
5. 写在后面:一点个人体会
这个项目上线后,最让我感慨的其实不是技术本身,而是“上传”这个看似基础的功能,一旦要真正做好,涉及的边界条件远比想象中多。分段、断点、合并、校验、并发控制、失败重试,每一环都需要认真对待。尤其是分块和合并这种看似简单的 IO 操作,一旦遇到并发和异常场景,隐藏的问题会立刻暴露出来。如果你也在实现类似功能,建议先从最基础的单文件分块上传跑通,再逐步加上文件夹结构、断点续传、秒传这些增强能力,切忌一步到位。
另外,如果你的系统已经引入了 MinIO 这类对象存储,可以考虑把“分块上传和合并”这层脏活交给对象存储的接口来完成,思路和现状仍然完全一致,只是把临时分块放在对象存储里管理。但无论底层怎么换,前端这套“分块 + 查询进度 + 跳过已传分块”的交互模型都不会过时,理解这套模型之后,再用任何工具都会顺手很多。
