Python进阶次阅读

Python 分片上传与断点续传(Flask 完整实现)

传一个 2GB 的文件,超时一次就得从头再来?这份 demo 用 Flask 实现前端切片 + 后端合并,并支持秒传和断点续传:传过的分片跳过,中断后接着传。含完整前后端代码和并发合并的注意事项。

分片上传断点续传Flask大文件实战Demo

普通表单传大文件有个死穴:传到 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 里是逐片 readwrite,内存占用可控。千万别 out.write(b''.join(所有分片内容)),2GB 文件直接吃爆内存。

哈希校验别省

合并后算一次完整哈希和前端传来的比对,不一致就删掉重来。网络传输和磁盘写入都可能出错,这一步是数据完整性的最后一道保险。

秒传的原理

check 接口里先看 UPLOAD_DIR 里有没有同名(同 hash)文件,有就直接返回完成。因为 hash 相同意味着内容相同——别人传过的文件,你再传就是"秒传"。这是网盘秒传的基本原理。

要注意的是,如果你用"文件名+大小"当 hash,两个内容不同但同名的文件会撞车,所以 hash 一定要基于文件内容