☰
大文件上传优化实战:分片上传与断点续传全链路设计
2026/10/13 10:46:46 网站建设 项目流程

赶紧把之前记录的几个关键点还原一下思路直接开写。

先讲个我做内部工具时最典型的经历:当时要把一份好几个 GB 的离线数据集传到后端分析服务,最开始用的就是最常规的单次POST提交。办公室网络稍微有点抖动,传一次要失败好几次,每次都是卡在 70%、80% 的地方直接断掉,稳一点的网络条件也得耗掉小半天。最恶心的是失败之后没有任何恢复能力,下次还得从头传。后来下定决心把分片上传和断点续传整套流程补上,才彻底解决这个事。这篇文章就是把我实际落地的客户端切片、服务端接收、断点恢复、合并校验全链路拆开讲一遍,适合正在设计文件上传模块、或者被大文件传输折磨过的后端和前端同学参考。

1. 从一次“几 GB 传很多次”开始:单请求上传的天花板在哪

先说当时为什么会被逼到必须改造。单请求上传在文件体积小、网络稳定的场景下完全够用,但一旦文件变大,问题会集中在几个地方。

连接稳定性是第一个瓶颈。一个几 GB 的文件走单次 HTTP 请求,从浏览器发起请求到收到完整响应可能要持续几分钟甚至更久。网络层面稍微有一点波动,TCP 连接就可能被网关、负载均衡器或浏览器自己掐断。客户端看到的往往是“网络错误”或者“请求超时”,而服务端已经收到的数据全部作废。哪怕你让前端代码加了超时重试,重试几乎都是从第一个字节重新开始,成本极高。

服务端内存容易被打爆。如果后端图省事,直接通过框架把整个上传请求读进内存再做处理,比如用类似file.read()或把请求体完整缓存到内存的操作,那几 GB 的文件会直接把进程内存打满。即便不崩溃,也会让同实例上的其他接口跟着遭殃。很多小团队的第一版上传接口都是这么写的,文件超过 1 GB 就开始现原形。

几乎没有恢复能力。这是单请求上传最让人崩溃的点。你没法告诉服务端“我之前已经传到某个位置了,从这个位置继续传”,因为 HTTP 请求一旦断开,服务端对之前连接内收到的数据是没有记忆的。浏览器层面可能还保留着一个大Blob,但重新上传时依然要从头开始构造完整的请求体。

我把当时遇到的场景整理了一下,单次上传和分片上传的差异大致是这样:

对比项单次上传分片上传
失败重试范围整个文件重传只重传失败分片
断点恢复能力几乎为零依赖服务端进度记录,可恢复
服务端内存压力大,尤其使用内存缓存方案时小,分片逐块落盘
网络波动影响一次波动全盘重来单个分片影响有限
服务端校验成本一次性校验整个文件可按分片校验,局部重传

真正下决心改方案,是在某个粗糙实现怎么调都稳定不下来之后。分片上传不是新概念,很多云存储服务早就这么做了,核心思想很简单:把一个大文件切成多个小块,分别上传,全部完成后服务端再按顺序拼合。每个小请求体量小、占用连接时间短、失败重试成本低,断点续传也变成了一个“跳过已完成分片”的问题。

2. 断点续传的分层设计:先把元数据模型定清楚

刚开始做分片的时候,最容易走进的误区是“只要前端把文件切了,后端收到再拼起来就行”。但断点续传要成立,光有分片还不够,还得有一个双方都能查询的中间状态,否则你没法回答一个核心问题:这些分片传到了哪一步。

我最开始接到的需求很朴素:优化大文件上传,支持断点续传。仅靠直觉设计的话,很容易做成“前端把分片列表记在本地,下次跳过本地上传过的分片”。但这方案有一个致命问题:本地记录只能证明“当前这台设备、当前这个浏览器上传过”,服务端是否真的完整落盘,客户端并不知道。断点续传必须由服务端提供一份权威的进度清单,客户端上传前先问服务端“哪些分片已经收齐了”,而不是看自己本地缓存。

所以整体流程我设计成了五个步骤:

  1. 客户端先发起“创建上传任务”的请求,带上文件名、总大小、预期分片数等基础信息。
  2. 服务端创建任务记录,生成一个全局唯一的uploadId,把任务信息保存下来,返回给客户端。
  3. 客户端根据服务端允许的分片大小把文件切块,带uploadId和chunkIndex逐个上传。
  4. 每个分片上送后服务端落盘,并在任务状态里标记该分片已完成。
  5. 客户端所有分片完成后,主动调用“合并文件”接口,服务端按分片顺序拼出完整文件。

这个流程里元数据是核心。我在实现时给任务表定的关键字段大致如下:

字段作用说明
uploadId唯一标识一次上传任务服务端生成,避免多任务混淆
fileName原始文件名合并时用于还原文件名
fileSize文件总大小服务端校验最终文件是否完整
chunkSize分片大小推荐由服务端返回,避免客户端乱定
chunkTotal总片数合并时按此数量轮询
uploadedChunks已完成分片索引集合断点续传时的核心依据
status任务状态pending、uploading、merged等

为什么要专门弄一个uploadId而不是用文件名当 key?因为实际场景里同名文件会被不同用户、不同时刻反复上传,如果直接用文件名做区分,只要两个人同时传同名的不同文件,分片就会互相覆盖,故障排查起来很酸爽。生成uploadId又不贵,一个随机 ID 就能把所有分片和任务关联起来,最大程度避免这种互相污染。

服务端还需要一个查询接口,一般设计成类似GET /upload/task?uploadId=xxx,返回已完成分片索引列表。这个接口是断点续传的关键:前端重新打开页面,或者网络断了恢复之后,第一件事就是调用它拿到已上传分片,未完成的继续传,已完成的一律跳过。这样“续传”就不再依赖浏览器缓存,只要服务端数据还在,换一台设备甚至换一个浏览器都能接着传。

我额外还在表里加了一个fileMd5字段,用于文件完整校验。要注意的是,这个字段在分片上传流程里不应该被滥用,因为计算整个文件的 MD5 对几 GB 文件来说本身就要读一遍磁盘,耗时明显。我的做法是:分片上传阶段不强制全文件摘要校验,合并之后再做抽样比对或元数据比对,只有需要精确查重时才对完整文件做一次 MD5。

3. 前端切片实现:不只要切,还得管好并发与重试

前端部分说白了有两件事:切分文件,控制传输节奏。

先看切片。浏览器端可以利用File对象的slice(start, end)方法,取出一段Blob,然后把它放进multipart/form-data里上传。下面的伪代码是基本思路:

const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB,具体大小后面单独讲 function getChunkList(file) { const chunks = []; let start = 0; let index = 0; while (start < file.size) { const end = Math.min(start + CHUNK_SIZE, file.size); chunks.push({ index, blob: file.slice(start, end), size: end - start }); start = end; index += 1; } return chunks; } async function uploadChunk(uploadId, chunk, retryTimes = 3) { const form = new FormData(); form.append('uploadId', uploadId); form.append('chunkIndex', chunk.index); form.append('chunkTotal', chunkList.length); form.append('file', chunk.blob, 'chunk.part'); // 失败重试,这里可加指数退避 for (let i = 0; i < retryTimes; i++) { try { const res = await fetch('/upload/chunk', { method: 'POST', body: form }); if (!res.ok) throw new Error('upload failed'); return await res.json(); } catch (err) { if (i === retryTimes - 1) throw err; await sleep(500 * 2 ** i); } } }

用FormData传分片时有个容易被忽略的点:blob是文件块,但服务端还需要知道它在原文件里的索引位置。所以uploadId、chunkIndex这些必须作为普通表单字段一起提交,别试图从文件名里解析。否则一旦用户上传的文件名里有诡异字符,或者在跨平台环境下文件名被重编码,解析逻辑很容易出问题。

切片之后真正难的是并发控制。刚做时我也犯过一个错:把整个分片列表直接Promise.all全量并发上传。小文件没事,几千个分片的大文件一上来,瞬间把浏览器和服务器的连接数全部占满。服务端日志里全是超时重试,前端因为 TCP 连接排队,速度反而比串行还慢。更麻烦的是,浏览器对同一域名有并发连接上限,超过之后所有请求都只会排队等待,并不会带来额外吞吐。

实际工程里应该用“并发池”,只保持几个上传任务在跑,其他分片排队。大概写法是:

async function runPool(tasks, poolSize, handler) { const results = []; let nextIndex = 0; async function worker() { while (true) { const current = nextIndex; nextIndex += 1; if (current >= tasks.length) break; results[current] = await handler(tasks[current]); } } const workers = []; for (let i = 0; i < poolSize; i++) { workers.push(worker()); } await Promise.all(workers); return results; }

这块代码看着土,但非常实用。它保证任意时刻在传输的分片最多只有poolSize个,既控制了并发,又不会让某个网络错误拖垮整个队列。

本地上传记录和查询服务端进度要配合使用。我实现“暂停”和“续传”的逻辑是:暂停时只停止调度新的分片,不中断正在运行的请求;把这些请求的结果等回来之后,把已完成的分片索引存到localStorage或者IndexedDB。真正续传时,先调用服务端进度查询接口,把服务端返回的已完成分片和本地记录合并成一个跳过集合,自然就不会重复上传。

这里要提一个实际坑:不要完全依赖本地记录判断“这个分片传过了”。浏览器清缓存、换设备、或者服务端因为某种原因清理了分片数据后,本地记录可能会误导前端跳过实际不存在的分片。所以每次续传前强制查一次服务端进度是值得的,成本不高,但能避免很多莫名其妙的文件损坏问题。

4. 服务端接分片:落盘、校验、合并、幂等一个都不能少

前端把活儿干麻利了,后端才是断点续传的“最终裁判”。服务端要实现的任务有三块:

  1. 可靠地接收分片并落盘;
  2. 维护任务和分片状态;
  3. 在所有分片齐了之后合并成完整文件。

分片接收接口的核心是校验 + 落盘。我习惯的做法是:

# 伪代码,核心思路 def upload_chunk(upload_id, chunk_index, chunk_total, file_part): task = get_upload_task(upload_id) if not task: raise HTTPException(status_code=404, detail="upload task not found") if chunk_index < 0 or chunk_index >= task.chunk_total: raise HTTPException(status_code=400, detail="invalid chunk index") part_dir = os.path.join(PART_ROOT, upload_id) os.makedirs(part_dir, exist_ok=True) part_path = os.path.join(part_dir, f"{chunk_index}.part") # 直接以流式方式写入磁盘,不要一次性读进内存 with open(part_path, "wb") as out: while True: data = file_part.read(1024 * 1024) if not data: break out.write(data) mark_chunk_uploaded(upload_id, chunk_index) return { "uploadId": upload_id, "uploadedChunks": get_uploaded_chunks(upload_id), "needMerge": len(get_uploaded_chunks(upload_id)) == task.chunk_total }

接收分片时有几个关键决定:

  • 一定要按chunk_index命名临时分片文件。上传顺序通常不是严格的,并发场景下分片 5 可能比分片 3 先到。如果服务端依赖“接收顺序”拼文件,就完蛋了。所以临时文件必须按索引落盘,合并时按数字索引取用。
  • 传输过程用流式写法。上文代码里面file_part.read()循环每次读 1MB 写 1MB,几 GB 文件拆出的分片也不会在服务端内存里积压。这个细节非常关键,因为最初的分片方案如果在这里处理不好,其实和单请求上传没本质区别。
  • 每个分片参数都必须服务端校验。客户端传来的chunk_index、chunk_total、uploadId都不能直接信任。chunk_index越界要拒绝,chunk_total要和任务创建时记录的一致,否则恶意请求或误操作会把临时目录撑爆。

合并接口是分片上传里最需要谨慎的地方。要知道,合并本身也是“大文件读一遍”,而且涉及移动文件,处理不当会造成最终文件损坏或重复生成。

def merge_upload(upload_id): task = get_upload_task(upload_id) if not task: raise HTTPException(status_code=404, detail="upload task not found") # 幂等处理的关键:使用锁和合并标记 with merge_lock(upload_id): if task.status == "merged": return {"finalPath": task.final_path} uploaded_chunks = get_uploaded_chunks(upload_id) if uploaded_chunks != set(range(task.chunk_total)): raise HTTPException(status_code=400, detail="chunks not complete") final_path = os.path.join(FINAL_ROOT, upload_id + "_" + task.file_name) with open(final_path, "wb") as out: for idx in range(task.chunk_total): part_path = os.path.join(PART_ROOT, upload_id, f"{idx}.part") # 流式拷贝,避免读取超大块 with open(part_path, "rb") as part_in: shutil.copyfileobj(part_in, out, length=1024 * 1024) mark_task_merged(upload_id, final_path) cleanup_part_files(upload_id) return {"finalPath": final_path}

这里体现的是“服务端校验 + 幂等合并”的意识。第一次发起合并时正常拼文件;如果客户端因为网络原因没有收到合并响应,重试调用合并接口,只要任务状态已经是merged,直接返回同一个结果,不会再次拼接产生重复文件。这里的“锁”可以用进程内锁,也可以用数据库的唯一约束或 Redis 分布式锁,取决于并发量。

另外,合并不要用“先读全量到内存再一口气写文件”,一定用copyfileobj或文件流移动。否则合并场景又变成了内存炸弹。我见过一个同事做的方案,单请求上传已经改了分片,结果合并接口是open(final, 'wb').write(b''.join(all_part_content)),文件稍微大一点就 OOM。

分片校验和 MD5 要分层处理。对于每个分片,我可以选择在接收时计算分片摘要,和客户端在表单里传的chunkMd5对比,发现不一致就返回错误,前端只需重传该分片。这个校验对网络数据完整性有用,但要注意性能成本。大量分片时,每片都做摘要计算也会消耗不少 CPU。我实际调整后,只在弱网环境下开启分片摘要校验,内网传输阶段先跳过,合并后通过文件大小和抽样校验来兜底。

5. 上线半年后踩过的六个边界问题

方案真正上线之后,踩的坑比写核心逻辑时多得多。下面六个问题是实实在在遇到过、也花时间排查过的,凑在一起给大家避雷。

第一个坑:最后一个分片的大小不等于定义的CHUNK_SIZE。这个现象在测试时很容易忽略,因为大多数测试文件大小正好是分片大小的整数倍,或者虽然不满整片但刚好在最后。如果服务端接收时校验“分片内容长度必须等于CHUNK_SIZE”,最后一个分片就会直接 400 报错。解决方法是服务端按chunkIndex区分,最后一片的校验逻辑放到合并阶段去检查“总大小是否等于fileSize”,分片接收时不要死板校验长度。

第二个坑:分片乱序导致合并时读错文件。这是纯粹靠命名侥幸导致的 bug。我之前某一版临时文件命名用的是“第几个上传的”而不是“第几个分片”,并发一高,分片到达顺序是随机的,合并时按顺序拼出来文件刚好是乱的。后来强制把所有临时文件统一改成{uploadId}/{chunkIndex}.part命名,彻底解决。分片索引必须由客户端显式声明,服务端不得自己推测顺序。

第三个坑:合并任务重复执行。网络超时后客户端重试合并,如果服务端没有幂等保护,会生成两个一模一样的文件,或者第二次因为分片临时文件已经被清理而报错。我后来给任务状态加了merged标记,并且整个合并过程加了锁,只有第一次真正拼接,后面的重复请求都直接返回第一次的成品路径。

第四个坑:临时目录空间被占满。大文件分片落盘后会占用服务器磁盘空间,一旦用户上传到一半放弃,那些.part文件就变成垃圾数据。我们的回收机制很简单:每日定时任务把status不是merged、并且最后更新时间超过 24 小时的临时目录全部清理掉,同时任务库里删掉对应记录。别小看这个清理,没有它,线上服务器磁盘会在连续几天大文件上传后莫名报警。

第五个坑:客户端算出来的分片参数和服务端不一致。服务端在创建任务时就应该返回允许的chunkSize,而不是让客户端随便定义。如果前端写了5 * 1024 * 1024,后端又按4MB做校验,所有上传都会失败。我推荐创建任务接口必须返回两个字段:chunkSize和chunkInfos,后者可以是服务端计算好的预期分片列表,客户端照着列表切,谁错谁先露馅。

第六个坑:并发数过高并不会让速度更快。前边也说了,浏览器对同一域名的并发连接数量是有限制的。在服务端同样有文件句柄、连接数的限制。把并发从 6 调到 20,上传速度不仅没上去,反而因为大量请求排队和 TCP 拥塞控制,出现大面积超时。所以并发控制直接沿用第三节的并发池,不要把所有分片同时丢出去。

6. 分片大小和并发数的调优记录

最后讲讲最现实的问题:分片大小到底设多少,并发数设几个才算合理。这没有万能答案,但有几个标准指标可以直接参照。

我自己定的经验公式是:单个分片在目标带宽下不建议少于 1 秒,也不要超过 15 秒的上传时间。如果分片太小,比如每片只要 200KB,那么网络往返、请求头、服务端写盘的固定开销占比太高,吞吐量反而上不去。如果分片太大,比如 50MB 一片,一旦网络抖动,重传一个 50MB 的分片又回到了“大请求不抗抖”的老路。

结合不同场景,我通常这么调:

场景分片大小并发数理由
内网 / 高带宽8MB - 10MB6稳定性好,减少请求次数
普通办公网络5MB4 - 5平衡重试成本和速度
移动端 / 弱网1MB - 2MB2 - 4单分片传输时间短,断了重传代价小
超大文件(几十 GB)10MB 起步按服务端配额避免分片数量过多,降低索引开销

有一段时间我们的上传任务对象是几十 GB 的包,如果按 2MB 切片会有上万片。管理这么多分片本身就有成本:任务表里记录已完成分片的数据结构会膨胀,进度查询接口响应体也可能达到几百 KB。所以超大文件我是建议提高分片大小,把分片数量控制在几千以内。

并发数上也别只看浏览器限制,还要数一数服务端可能同时处理多少个上传任务。如果平台同时有几十个人在上传,每个人 6 个并发分片,服务端就可能同时承受几百个写流。当初我会在创建任务接口里返回一个“当前允许的并发建议值”,服务端根据负载动态调整最小并发,避免客户端把服务端打崩。

再说两个性能优化细节。第一,分片上传时尽量传Blob而不是先转成base64。base64会把体积膨胀约 1/3,而且编解码消耗 CPU,断点续传做起来也麻烦。第二,服务端写分片时如果磁盘支持,可以考虑设置一个临时缓冲层,比如先写入高吞吐的临时盘,合并时再搬回正式目录,避免频繁小块随机写。我们当时换了临时目录位置后,分片接收响应时间明显稳定下来。

最后分享一个很朴素但让我反复受益的收尾技巧:分片上传全流程一定要加“重放测试”。写个小脚本,随机杀掉正在上传的分片请求,或者强制浏览器断网再恢复,看整个上传任务能不能自动续传完成。这个测试看起来土,却能一次性暴露幂等缺失、状态错乱、合并重复执行这些隐藏问题。我自己就是在某次“断网两分钟再恢复”的测试里发现合并接口重复拼接文件这个大 bug 的。把这套方案跑稳之后,我再也没碰见过当初那种一个文件传好多遍的糟心事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询