普通表单传大文件有个死穴:传到 90% 网络抖一下,整个请求失败,从头再来。分片上传把文件切成小块,一片失败只重传那一片,还能做到"传过的文件秒传"。
思路不复杂:前端切片 → 一片片传 → 后端按序号存 → 全部到齐后合并。难点在断点续传的状态管理和合并时的顺序。
后端:三个接口
# app.py
import os
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
UPLOAD_DIR = './uploads'
CHUNK_DIR = './uploads/_chunks'
CHUNK_SIZE = 5 * 1024 * 1024 # 前端每片 5MB,前后端一致就行
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(CHUNK_DIR, exist_ok=True)
1. 检查接口:哪些分片已经传过了
@app.route('/upload/check', methods=['POST'])
def check():
"""
入参: { fileHash, fileName, chunkTotal }
返回: { uploaded: [已存在的分片索引], done: bool, url: 已完成时的文件地址 }
"""
data = request.json
file_hash = data['fileHash']
chunk_total = int(data['chunkTotal'])
ext = os.path.splitext(data['fileName'])[1]
# 整个文件已经存在 → 秒传
final_path = os.path.join(UPLOAD_DIR, file_hash + ext)
if os.path.exists(final_path):
return jsonify({'done': True, 'url': f'/files/{file_hash}{ext}', 'uploaded': []})
# 检查已上传的分片
chunk_folder = os.path.join(CHUNK_DIR, file_hash)
uploaded = []
if os.path.isdir(chunk_folder):
for name in os.listdir(chunk_folder):
if name.isdigit():
uploaded.append(int(name))
return jsonify({'done': False, 'uploaded': uploaded})
2. 上传分片
@app.route('/upload/chunk', methods=['POST'])
def upload_chunk():
"""
form-data: file(二进制), fileHash, index(分片序号, 从0开始)
"""
file_hash = request.form['fileHash']
index = request.form['index']
chunk = request.files['file']
if not index.isdigit():
return jsonify({'error': 'index 必须是数字'}), 400
chunk_folder = os.path.join(CHUNK_DIR, file_hash)
os.makedirs(chunk_folder, exist_ok=True)
# 分片文件名直接用序号,天然保证顺序
chunk.save(os.path.join(chunk_folder, index))
return jsonify({'ok': True, 'index': int(index)})
为什么分片名用序号而不是原文件名:合并时按 0,1,2,3... 排就行,不用管文件名里的特殊字符,也不会因为同一分片重复上传而冲突。
3. 合并
@app.route('/upload/merge', methods=['POST'])
def merge():
data = request.json
file_hash = data['fileHash']
file_name = data['fileName']
chunk_total = int(data['chunkTotal'])
ext = os.path.splitext(file_name)[1]
chunk_folder = os.path.join(CHUNK_DIR, file_hash)
final_path = os.path.join(UPLOAD_DIR, file_hash + ext)
# 校验分片是否齐全
existing = {f for f in os.listdir(chunk_folder) if f.isdigit()}
missing = [i for i in range(chunk_total) if str(i) not in existing]
if missing:
return jsonify({'error': '分片不完整', 'missing': missing}), 400
# 按序追加写入
with open(final_path, 'wb') as out:
for i in range(chunk_total):
with open(os.path.join(chunk_folder, str(i)), 'rb') as f:
out.write(f.read())
# 校验整体哈希(前端传来的 hash 必须和合并结果一致)
actual = file_md5(final_path)
if actual != file_hash:
os.remove(final_path)
return jsonify({'error': '文件校验失败', 'expected': file_hash, 'actual': actual}), 400
# 清理分片目录
import shutil
shutil.rmtree(chunk_folder, ignore_errors=True)
return jsonify({'ok': True, 'url': f'/files/{file_hash}{ext}'})
def file_md5(path, block=1024 * 1024):
m = hashlib.md5()
with open(path, 'rb') as f:
while True:
b = f.read(block)
if not b:
break
m.update(b)
return m.hexdigest()
前端:切片 + 并发上传 + 续传
<input type="file" id="f" />
<button onclick="start()">上传</button>
<progress id="p" value="0" max="100"></progress>
<pre id="log"></pre>
<script>
const CHUNK_SIZE = 5 * 1024 * 1024;
const CONCURRENCY = 3; // 并发上传数,别开太大
function log(msg) {
document.getElementById('log').textContent += msg + '\n';
}
// 计算文件 MD5(大文件用 Web Worker 算,避免卡界面)
async function calcHash(file) {
// 生产环境建议 spark-md5 + 抽样哈希;这里为演示用简单方案
const buf = await file.slice(0, 2 * 1024 * 1024).arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buf);
const head = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('');
// 用「文件名+大小+头部哈希」拼一个标识,够用且不卡
return btoa(`${file.name}-${file.size}-${head}`).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
}
async function uploadChunk(file, fileHash, index, total) {
const start = index * CHUNK_SIZE;
const chunk = file.slice(start, Math.min(start + CHUNK_SIZE, file.size));
const form = new FormData();
form.append('file', chunk);
form.append('fileHash', fileHash);
form.append('index', String(index));
for (let retry = 0; retry < 3; retry++) {
try {
const res = await fetch('/upload/chunk', { method: 'POST', body: form });
if (res.ok) return true;
} catch (e) { /* 继续重试 */ }
}
return false;
}
async function start() {
const file = document.getElementById('f').files[0];
if (!file) return alert('先选文件');
const fileHash = await calcHash(file);
const chunkTotal = Math.ceil(file.size / CHUNK_SIZE);
// 1. 问后端哪些片已经有了
const ck = await fetch('/upload/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash, fileName: file.name, chunkTotal })
}).then(r => r.json());
if (ck.done) {
log('秒传成功:' + ck.url);
return;
}
const uploaded = new Set(ck.uploaded);
log(`已存在 ${uploaded.size}/${chunkTotal} 片,继续上传`);
// 2. 上传缺失的分片,控制并发
const todo = [];
for (let i = 0; i < chunkTotal; i++) {
if (!uploaded.has(i)) todo.push(i);
}
let done = uploaded.size;
const progress = document.getElementById('p');
async function worker() {
while (todo.length) {
const i = todo.shift();
const ok = await uploadChunk(file, fileHash, i, chunkTotal);
if (!ok) { log(`第 ${i} 片失败,可刷新页面续传`); return; }
done++;
progress.value = (done / chunkTotal) * 100;
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
// 3. 合并
const mg = await fetch('/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash, fileName: file.name, chunkTotal })
}).then(r => r.json());
log(mg.ok ? '上传完成:' + mg.url : '合并失败:' + mg.error);
}
</script>
几个必须注意的地方
hash 的计算别卡死浏览器
上面 demo 为了能直接跑,只取了前 2MB 算哈希。真要算全文件 MD5,2GB 文件在主线程算会卡住界面几十秒,得放 Web Worker,或者用抽样哈希(取首、中、尾几段 + 文件大小)折中。这是性能和准确率的取舍。
并发数别开太大
浏览器对同一域名有并发连接数限制(HTTP/1.1 一般 6 个),开 10 个并发也不会更快,反而让每片都变慢、更容易超时。3~5 个比较合适。
分片目录要定期清理
用户传到一半跑了,_chunks/ 里会留下一堆孤儿分片。加个定时任务清理超过 24 小时没合并的分片目录。
合并时的磁盘与内存
demo 里是逐片 read 再 write,内存占用可控。千万别 out.write(b''.join(所有分片内容)),2GB 文件直接吃爆内存。
哈希校验别省
合并后算一次完整哈希和前端传来的比对,不一致就删掉重来。网络传输和磁盘写入都可能出错,这一步是数据完整性的最后一道保险。
秒传的原理
check 接口里先看 UPLOAD_DIR 里有没有同名(同 hash)文件,有就直接返回完成。因为 hash 相同意味着内容相同——别人传过的文件,你再传就是"秒传"。这是网盘秒传的基本原理。
要注意的是,如果你用"文件名+大小"当 hash,两个内容不同但同名的文件会撞车,所以 hash 一定要基于文件内容。