一、一个真实的上传失败场景
去年十堰一家装备制造企业找到我们,说他们的官网后台“传图纸老是失败”。具体情况是:工艺部门要把 300MB 到 600MB 的 CAD 图纸和质检视频传给外地客户,用的是我们自己开发的文件管理模块。测试时在办公室里传 20MB 的 PDF 一切正常,一到真实场景就出问题——客户在车间用手机热点打开页面,传到 60% 左右网络抖一下,进度条直接归零,只能从头再来;有时候浏览器直接报 413,连错误提示都没有。
这类问题在十堰的工业品、汽车零部件、工程施工类企业里非常普遍。企业上传的不是几张产品图,而是图纸、投标文件、验收视频、监控录像,单个文件动辄几百兆。传统的一次性 POST 上传在这种文件体积和本地网络条件下几乎必然失败:请求体太大容易被网关拦截,上传时间太长容易超时,中途断网就前功尽弃。解决办法不是换服务器,而是把上传改成分片 + 断点续传。本文把我们在十堰网站开发项目中实际使用的完整前端实现拆开讲,代码可以直接拿去改。
二、整体方案:五步走完一次上传
分片上传的核心思想很朴素:把一个 500MB 的文件切成 100 个 5MB 的小块,逐块上传,服务端最后按顺序拼回去。这样一来单次请求体积可控、失败只影响一个小块、并发上传还能跑满带宽。完整流程分为五步:
- 计算文件指纹:根据文件内容算出一个 hash,作为这次上传的唯一标识。
- 秒传检查:把 hash 发给服务端,如果服务端已经有这个文件,直接返回访问地址,一个字节都不用传。
- 查询已上传分片:服务端返回哪些分片序号已经存在,前端只补传缺失的部分,这就是断点续传。
- 并发上传缺失分片:用并发池控制同时进行的请求数,单块失败按指数退避重试。
- 通知服务端合并:全部分片到位后调用合并接口,服务端按序号拼接、校验、落盘,返回最终文件地址。
这套流程不只解决“传得上去”的问题,还顺带解决了重复上传的带宽浪费。十堰一家企业同一份投标文件经常要发给五个部门,秒传让后四次上传的耗时从十几分钟变成不到一秒。
三、第一步:文件指纹与秒传
3.1 为什么不能直接对整个文件做 MD5
常见教程会让你用 SparkMD5 把整个文件读进内存算 MD5。文件小的时候没问题,500MB 的文件在低配办公电脑上会把主线程卡死十几秒,内存也可能直接爆掉。正确做法是分片增量计算:用 FileReader 逐片读取、逐片 append,内存占用只有一个分片的大小。
import SparkMD5 from "spark-md5";
async function calcFileHash(file, chunkSize = 5 * 1024 * 1024) {
const spark = new SparkMD5.ArrayBuffer();
const total = Math.ceil(file.size / chunkSize);
for (let i = 0; i < total; i++) {
const blob = file.slice(i * chunkSize, (i + 1) * chunkSize);
spark.append(await blob.arrayBuffer());
}
return spark.end();
}
即便如此,500MB 文件全量哈希仍然要 3 到 5 秒。如果业务允许,可以用抽样哈希进一步提速:取文件头 2MB、尾 2MB 加上文件大小拼起来算 hash。碰撞概率在业务上可以接受,因为秒传只是加速手段,最终完整性还有服务端校验兜底。要注意的是,抽样哈希只能用于“判断是否已存在”,不能当作文件的唯一凭证写入数据库。
3.2 秒传的服务端约定
秒传依赖服务端按 hash 建立索引,并且做好引用计数:同一个文件被十个业务引用时,物理上只存一份;删除时要等引用计数归零再清理,否则会误删别人还在用的文件。这一点在文件管理类项目里非常关键,我们见过因为直接按 hash 删除导致历史附件全部 404 的事故。
四、第二步:分片、并发与进度
4.1 分片大小的取舍
分片不是越小越好。1MB 分片意味着 500MB 文件要发 500 个请求,HTTP 头和鉴权开销会把收益吃光;分片太大又会让单次失败的重传成本变高。我们的经验值是 2MB 到 5MB,移动端弱网环境取 2MB,内网或专线取 5MB。分片大小一旦确定就不要随意改,否则历史断点记录会对不上。
const CHUNK_SIZE = 5 * 1024 * 1024;
function createChunks(file) {
const chunks = [];
let index = 0;
for (let start = 0; start < file.size; start += CHUNK_SIZE) {
chunks.push({
index,
blob: file.slice(start, Math.min(start + CHUNK_SIZE, file.size)),
});
index++;
}
return chunks;
}
注意 file.slice 返回的是 Blob 视图,不会真的复制数据,所以切 500MB 文件是毫秒级的,不用担心内存。
4.2 并发池:同时只发 3 到 4 个请求
一次性把 100 个分片全部 Promise.all 发出去,浏览器会排队、服务端会被打满,反而更慢。用一个简单的并发池,保持 3 到 4 个请求在飞即可:
async function runPool(tasks, limit = 4) {
let cursor = 0;
const workers = Array.from(
{ length: Math.min(limit, tasks.length) },
async () => {
while (cursor < tasks.length) {
const i = cursor++;
await tasks[i]();
}
}
);
await Promise.all(workers);
}
这里的 cursor++ 依赖 JavaScript 单线程特性,不会出现两个 worker 抢到同一个下标的竞态,可以放心使用。
4.3 进度条为什么用 XMLHttpRequest
fetch 至今没有稳定的上传进度事件,上传大文件必须用 XMLHttpRequest 的 upload.onprogress。真实项目里进度条要按已上传字节数 / 文件总字节数计算,而不是按“完成的分片数”,否则进度会一跳一跳,用户以为卡死了。
let uploadedBytes = 0;
function uploadChunk({ hash, total, chunk, onProgress }) {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append("hash", hash);
form.append("index", chunk.index);
form.append("total", total);
form.append("chunk", chunk.blob, `${hash}-${chunk.index}`);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload/chunk");
xhr.setRequestHeader("Authorization", `Bearer ${getToken()}`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(chunk.index, e.loaded);
};
xhr.onload = () => {
if (xhr.status === 200) resolve(JSON.parse(xhr.responseText));
else reject(new Error(`HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("network error"));
xhr.ontimeout = () => reject(new Error("timeout"));
xhr.timeout = 60_000;
xhr.send(form);
});
}
4.4 失败重试:指数退避,别死循环
弱网环境下偶发的失败非常正常,关键是重试策略要克制。固定间隔狂重试会把本来就拥塞的网络打得更死,我们用 500ms 起步、每次翻倍的指数退避,最多重试 3 次:
async function withRetry(fn, retries = 3) {
let delay = 500;
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (err) {
if (i === retries) throw err;
await new Promise((r) => setTimeout(r, delay));
delay *= 2; // 500 → 1000 → 2000
}
}
}
重试仍失败的分片要记录下来,在界面上明确告诉用户“已上传 87 个分片,3 个失败,点击继续”,而不是弹一个“上传失败”然后清空进度。用户关掉页面再回来,凭 hash 查询已上传分片就能接着传,这才是真正的断点续传。
五、后端接口设计与合并
前端做得再好,接口约定不清楚一样会翻车。我们通常定四个接口:
GET /api/upload/status?hash=xxx:返回{ exist: true, url }或{ exist: false, uploaded: [0,1,2,5] }。POST /api/upload/chunk:接收hash、index、total和分片文件,分片落在临时目录/data/tmp/{hash}/{index}。POST /api/upload/merge:接收hash、filename、total,按 index 顺序流式拼接。DELETE /api/upload/{hash}:用户取消上传时清理临时分片,避免磁盘被垃圾占满。
合并时不要用 fs.readFile 把分片全部读进内存再拼接,500MB 文件会直接把 Node 进程打挂。正确做法是创建写入流,逐个分片 pipe 进去,最后用 crypto.createHash("sha256") 边写边校验。校验不通过就删除合并结果并返回错误,让前端重传——这一步是防止“文件传上去了但打不开”的最后防线,我们确实遇到过因为分片乱序导致 CAD 图纸损坏的案例。
六、部署环节最容易踩的三个坑
6.1 Nginx 默认 1MB 限制
这是 413 错误的头号原因。Nginx 的 client_max_body_size 默认只有 1MB,5MB 的分片必然被拒。在 server 或 location 块里放开:
location /api/upload/ {
client_max_body_size 8m;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_pass http://127.0.0.1:3000;
}
proxy_request_buffering off 让 Nginx 边收边转发,避免先把整个分片缓冲到磁盘再转发,能明显降低大文件上传的内存和磁盘压力。
6.2 临时目录和磁盘水位
分片目录要单独挂盘,并且设一个定时清理任务,把超过 24 小时没有合并的临时分片删掉。十堰一家客户就因为用户反复取消上传,临时目录堆积了 200 多 GB,最后把系统盘写满,网站直接 502。监控上至少要对临时目录做 80% 水位告警。
6.3 鉴权与文件类型校验
分片接口同样要校验登录态和权限,不能因为“只是传个分片”就放行。文件名要做白名单过滤,禁止 ../ 路径穿越;合并后的文件类型要以服务端检测为准,不能相信前端传来的 MIME。涉及图纸、合同这类敏感文件,还应该加病毒扫描和访问签名,链接有效期控制在 15 分钟以内。
七、真实数据:改造前后的对比
回到开头那家十堰装备制造企业。改造前:单文件上限 200MB,500MB 图纸只能压缩或拆分,弱网下上传成功率约 62%,一次成功上传平均耗时 18 分钟,失败后需要从头再来。改造后:
- 上传成功率从 62% 提升到 98.7%(失败集中在客户主动取消);
- 500MB 文件在办公网下平均耗时 6 分 40 秒,4 台并发、每片 5MB;
- 断网恢复后只需补传缺失分片,实测一次中断只重传了 3 个分片、约 15MB;
- 同一份文件第二次上传触发秒传,耗时 0.8 秒;
- 客服关于“传不上去”的工单从每月 11 条降到 0 条。
这些数字不是实验室跑出来的,是生产环境两周的统计。对十堰本地的制造、工程类企业来说,上传体验直接影响到客户对你信息化水平的判断,值得认真做一次。
八、上线前检查清单
- 分片大小与后端限制一致,Nginx
client_max_body_size已放开。 - hash 计算在 Web Worker 或分片增量模式下进行,主线程不卡顿。
- 断点记录能跨页面刷新恢复(存 localStorage 或服务端查询)。
- 并发数可配置,弱网自动降到 2。
- 失败分片有明确提示和“继续上传”入口,不丢进度。
- 合并接口有顺序校验和 SHA-256 完整性校验。
- 临时分片有定时清理,磁盘水位有告警。
- 上传接口有鉴权、限流和文件类型白名单。
九、结语
大文件上传看起来是个“前端小功能”,实际牵扯到浏览器 API、并发控制、网络重试、后端流式处理、Nginx 配置和磁盘运维,是一条完整的工程链路。十堰网站开发团队在给本地企业做文件管理、招投标、图纸流转这类系统时,把这条链路做扎实,比在页面上多加几个动画更能体现专业度。如果你正在为上传超时、413、断点丢失这些问题头疼,可以按本文的顺序逐项排查,大部分问题在半天内就能定位。
十堰易度网络传媒有限公司在企业网站、管理后台和小程序开发中持续沉淀这类工程实践,后续我们也会把文件存储、权限体系和访问审计的落地经验整理成系列文章,供十堰本地的开发同行参考。
