轻应用里上传视频、安装包、设计稿这类大文件,用普通的一次性上传很容易翻车:网络一抖几百兆从头再来,弱网下几乎传不完,同一个文件反复传还白白占带宽。本文案例来自我们用乔拓云为客户做轻应用资料上传模块的改造实践,分片调度与秒传判定均为自研。下面把分片、断点续传、秒传、合并校验这条链路完整拆开,并复盘五个踩坑点。
普通 form 整文件上传在大文件场景有三个结构性问题:
问题 | 触发过程 | 后果 |
|---|---|---|
失败代价大 | HTTP 请求中途断开 | 整个文件从头重传,弱网几乎不可用 |
无法并行 | 单连接串行上传 | 上行带宽吃不满,耗时长 |
重复浪费 | 同一文件多人/多次上传 | 相同字节反复传输,占带宽和存储 |
解决思路是把大文件切成固定大小的分片,逐片上传、失败只重传单片,并用文件指纹实现秒传。整条链路是:算指纹 → 查秒传 → 分片并发上传 → 断点补传 → 服务端合并校验。
前端先用 spark-md5 增量计算整个文件的哈希作为唯一指纹,再按固定大小(如 5MB)切片,用一个并发池控制同时上传的分片数,避免一次性发几百个请求把浏览器和网关打爆:
async function calcHash(file, chunkSize = 5 * 1024 * 1024) {
const spark = new SparkMD5.ArrayBuffer();
for (let i = 0; i < file.size; i += chunkSize) {
spark.append(await file.slice(i, i + chunkSize).arrayBuffer());
}
return spark.end(); // 整文件指纹,用于秒传与合并校验
}
async function uploadChunks(file, fileHash, concurrency = 4) {
const chunks = createChunks(file); // 切成 {index, blob} 列表
const pool = [];
for (let i = 0; i < chunks.length; i++) {
const task = uploadOne(fileHash, i, chunks[i]);
pool.push(task);
if (pool.length >= concurrency) { // 控制并发,完成一个再补一个
await Promise.race(pool);
pool.splice(pool.findIndex(t => t.settled), 1);
}
}
await Promise.all(pool);
}分片大小要权衡:太小则请求数多、握手开销大;太大则单片失败重传成本高。5MB 左右在大多数移动网络下是比较稳的折中值。
这里有个容易混淆的点:为什么既要算整文件哈希、又要给每个分片编号?两者职责不同。整文件哈希解决"是不是同一个文件",是秒传和最终完整性校验的依据;分片序号解决"这块字节该放在文件的哪个位置",是乱序到达后正确拼接和断点补传的依据。只算分片哈希而不算整文件哈希,就无法做秒传,也无法判断合并后的文件和原始文件是否逐字节一致;只算整文件哈希而不给分片稳定编号,断点续传时就无法知道缺口具体在哪。实际落地时,分片名统一用 fileHash-index 的形式,服务端收到后按这个键落临时文件,天然避免了不同文件、不同批次分片互相覆盖。
断点续传的关键是服务端记录每个文件已收到哪些分片。上传前先问一次服务端"这个 fileHash 已经有哪些分片了",只上传缺失的部分;页面刷新、网络恢复后都能从断点继续,而不是重传:
async function resumeUpload(file, fileHash) {
// 查询已上传分片列表
const { uploaded } = await fetch(`/upload/check?hash=${fileHash}`).then(r => r.json());
const chunks = createChunks(file);
const todo = chunks
.map((c, i) => i)
.filter(i => !uploaded.includes(i)); // 只挑出缺失分片
for (const i of todo) {
await retry(() => uploadOne(fileHash, i, chunks[i]), 3); // 单片失败只重传该片
}
return mergeRequest(fileHash, file.name); // 缺口补齐后请求合并
}已传分片清单以服务端为准,前端本地也可以存一份做离线提示,但不能只信前端记录——本地说传过、服务端临时文件可能已被清理,必须以后端返回为准。
如果服务端已经存在相同哈希的完整文件,就没必要再传一遍字节,直接返回"秒传成功"并在存储层做一次引用即可。秒传判定放在上传链路最前面:
from fastapi import APIRouter
router = APIRouter()
@router.get("/upload/check")
def check(file_hash: str):
# 已存在完整文件 -> 秒传;否则返回已收到的分片用于续传
if file_store.is_complete(file_hash):
return {"instant": True, "url": file_store.get_url(file_hash)}
return {"instant": False, "uploaded": file_store.list_chunks(file_hash)}
@router.post("/upload/merge")
def merge(file_hash: str, filename: str, total: int):
have = file_store.list_chunks(file_hash)
missing = [i for i in range(total) if i not in have]
if missing: # 分片不全不许合并,避免拼出残缺文件
return {"ok": False, "missing": missing}
url = file_store.merge_in_order(file_hash, filename, total)
if file_store.sha256_of(url) != file_store.meta(file_hash)["sha"]:
file_store.delete(url) # 总哈希不符,判定损坏并重来
return {"ok": False, "error": "hash_mismatch"}
return {"ok": True, "url": url}秒传也有边界需要说清楚。它成立的前提是"相同内容必然得到相同哈希",因此对图片、文档、安装包这类静态文件效果最好;但如果业务要求文件加密存储,或在文件头写入不同的用户标识,哈希就会各不相同,秒传命中率会明显下降,这属于正常取舍而不是 bug。另外哈希理论上存在碰撞概率,对一致性要求极高的场景,可以在哈希命中后再比对一次文件大小作为二次确认,用极小的成本把误判概率压到可忽略。
问题:1MB 切出上千请求,50MB 单片失败又要重传很久。
解决:按网络环境取 3-8MB 折中,弱网动态调小。
问题:服务端临时分片已清理,前端却以为传过,合并时报缺片。
解决:续传前必查服务端已收清单,以后端为准补传。
问题:分片乱序到达直接拼接,文件损坏还不自知。
解决:按分片序号排序合并,合并后比对整文件哈希,不符即删重来。
问题:大文件几百分片同时发,触发浏览器同域连接限制和网关限流。
解决:并发池控制在 4-6,配合失败退避。
问题:传一半放弃的分片长期残留在服务器。
解决:给分片设过期时间,定时任务清理超过 24 小时未合并的残片。
大文件可靠上传是一条完整链路:整文件哈希做统一指纹,固定大小分片配合并发池提升吞吐,服务端记录已传分片实现断点续传,指纹命中实现秒传,最后按序合并并用总哈希校验完整性。判断实现是否可靠,就看三句话:单片失败能否只重单片、中断后能否精确续传、合并后能否发现损坏。把这三点闭环,弱网下的大文件上传才算真正可用,用户也不会再因为一次断网而前功尽弃。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。