
简介这份演示项目源码围绕 Flask 与 WebUploader 实现大文件分片上传及流式下载面向希望理解前后端分片协作、并发上传与低内存文件传输的 Python Web 开发者也可作为课程设计或内部工具原型参考。压缩包共 26 个文件约 427KB包含 5 个 CSS、5 个 JS、4 个 map、2 个 HTML 模板、2 个 Python 脚本以及 requirements、Markdown 说明和字体图标等资源分别承担页面渲染、分片上传交互、样式布局与服务端处理。项目支持多用户同时上传且互不干扰接收全部分片后合并为完整文件并提供流式下载以降低内存占用前端借助 Bootstrap 渲染上传进度条便于观察分片状态。已有 653 人学习读者可从中获取分片上传流程、合并逻辑、编码问题处理及后续按序回传优化的完整思路。1. 大文件上传为什么必须分片一个 2GB 素材卡在 99% 的现场某开发者把一段 2GB 的素材拖进上传框进度条走到 99% 停住刷新重来第三次干脆网关超时——这个现场我见过太多次。upload-demo 是一份用 Flask 做服务端、WebUploader 做浏览器端的分段上传大文件实现把整块请求拆成固定大小的分片逐片落盘最后在服务端合并。它解决的不是“能不能传”而是“断一次要不要从头再来”。适合要在内网后台、管理页里塞大文件上传能力的人也适合拿 BootstrapHTML 页面直接接上就跑的新手。前端要的是可控并发与重试后端要的是稳定落盘与合并两边都得先把“分片”这件事说清楚。2. Flask 接收端分片接口、落盘与合并的三段式服务端是整个方案的承重墙。很多人第一次写分片上传会把“接收分片”和“合并文件”塞进同一个路由结果一个请求挂十秒前端超时重试分片写了两遍。合理的做法是按职责切成三条路由一条告诉前端“哪些片已经有了”一条收单片一条负责合并。Flask 本身很轻处理这种 IO 密集的活正合适但它的默认配置和 Werkzeug 的解析行为有几个地方必须提前改掉否则会在压测阶段集中爆出来。2.1 三条路由的约定与参数格式三条路由的职责边界要提前定死前端 WebUploader 的配置项才能对着填。我一般按下面这张表来约定字段名短、语义直白前端在uploadBeforeSend里一次性注入就行。路由方法关键参数返回/upload/checkPOSTfileId、filename、chunkTotal已上传分片索引数组、是否已存在完整文件/upload/chunkPOSTfileId、chunkIndex、chunkTotal、chunk文件体当前分片索引、累计收到数量/upload/mergePOSTfileId、filename、chunkTotal合并后路径、服务端 sha256这里有个容易忽略的点fileId不要用文件名。同名文件在不同用户手里太常见用文件名当目录名会让两份上传互相踩。常见做法是前端用WebUploader.File.id加时间戳拼一个短 ID或者干脆取文件大小、修改时间、随机数做哈希。chunkTotal每次请求都带是为了让服务端能独立判断“片齐没齐”不必依赖前一次请求的状态。# app.py —— 三条路由的骨架 import os from flask import Flask, request, jsonify app Flask(__name__) BASE_DIR os.path.abspath(upload_tmp) CHUNK_DIR os.path.join(BASE_DIR, chunks) FILE_DIR os.path.join(BASE_DIR, files) os.makedirs(CHUNK_DIR, exist_okTrue) os.makedirs(FILE_DIR, exist_okTrue) app.route(/upload/chunk, methods[POST]) def upload_chunk(): file_id request.form[fileId] # 前端生成的上传会话 ID chunk_index int(request.form[chunkIndex]) total int(request.form[chunkTotal]) chunk request.files[chunk] # 字段名要和 WebUploader 的 fileVal 对齐 target_dir os.path.join(CHUNK_DIR, file_id) os.makedirs(target_dir, exist_okTrue) # 零填充命名保证字符串排序等于数字排序 chunk.save(os.path.join(target_dir, f{chunk_index:06d}.part)) return jsonify({ok: True, index: chunk_index, total: total})这段代码的重点不在save而在目录结构和命名。fileId一级目录把不同上传会话隔离开{chunk_index:06d}.part的六位零填充是给后面合并用的——文件系统返回的listdir顺序是字典序10.part会排在2.part前面零填充之后000010一定在000002后面。这个细节不写合并出来的文件就是错位的而且错得很隐蔽文件大小对得上打开就报格式损坏。2.2 分片落盘并发写、幂等与磁盘水位WebUploader 默认可以开多个线程并发上传分片Flask 开发服务器默认单线程但上了 gunicorn 或 uWSGI 就是多 worker。多个分片同时save到同一目录本身没问题因为文件名不同真正要防的是同一个分片被重复提交——网络抖动、前端重试、用户刷新页面都会触发。重复写同一个.part文件最坏情况是两次写入交错得到一个半新半旧的分片。解决办法很土但有效先写临时名再原子重命名。import tempfile app.route(/upload/chunk, methods[POST]) def upload_chunk_safe(): file_id request.form[fileId] chunk_index int(request.form[chunkIndex]) chunk request.files[chunk] target_dir os.path.join(CHUNK_DIR, file_id) os.makedirs(target_dir, exist_okTrue) final_path os.path.join(target_dir, f{chunk_index:06d}.part) # 先落到同目录临时文件再 os.replace 原子替换 fd, tmp_path tempfile.mkstemp(dirtarget_dir, suffix.tmp) os.close(fd) chunk.save(tmp_path) os.replace(tmp_path, final_path) return jsonify({ok: True, index: chunk_index})os.replace在同一文件系统内是原子操作读到的要么是旧分片、要么是新分片不会出现半个。tempfile.mkstemp放在target_dir里而不是系统/tmp是为了保证os.replace不跨设备——跨设备会退化成复制加删除原子性就没了这个坑我在容器环境里踩过一次排查了半天。磁盘水位也得看一眼。分片目录会随着上传堆积一个 2GB 文件按 5MB 切就是 400 个分片文件再加上合并时生成的完整文件峰值占用接近两倍。上线前用df -h确认临时目录所在分区有足够余量别等传到 80% 才报No space left on device。2.3 合并流式拼接、哈希校验与目录回收合并路由是唯一可能长时间占用请求的地方。一个 2GB 文件即使磁盘顺序写也要几秒到十几秒。这里最常见的翻车写法是b.join(open(p, rb).read() for p in parts)2GB 直接进内存容器内存限制一卡就 OOM。正确姿势是固定缓冲区流式拷贝边写边算哈希import hashlib import shutil app.route(/upload/merge, methods[POST]) def merge(): data request.get_json() file_id data[fileId] filename os.path.basename(data[filename]) # 防目录穿越 total int(data[chunkTotal]) src_dir os.path.join(CHUNK_DIR, file_id) if not os.path.isdir(src_dir): return jsonify({ok: False, msg: 分片目录不存在}), 400 parts sorted(os.listdir(src_dir)) if len(parts) ! total: return jsonify({ok: False, msg: f分片缺失 {len(parts)}/{total}}), 400 out_path os.path.join(FILE_DIR, filename) sha hashlib.sha256() with open(out_path, wb) as out: for name in parts: if not name.endswith(.part): continue with open(os.path.join(src_dir, name), rb) as fp: while True: buf fp.read(1024 * 1024) # 1MB 缓冲区 if not buf: break sha.update(buf) out.write(buf) shutil.rmtree(src_dir, ignore_errorsTrue) # 合并成功再清分片 return jsonify({ok: True, path: out_path, sha256: sha.hexdigest()})几个参数值得说清楚。缓冲区取 1MB 是个折中太小系统调用次数多太大对内存不友好2GB 文件下 1MB 缓冲区大约两千次读写耗时可以接受。os.path.basename那行不能省filename来自前端带../就能写到任意路径。shutil.rmtree放在写完之后顺序不能反——先删分片再合并中途失败就彻底没得救了。合并完成后把sha256返回给前端前端可以再算一次本地哈希做比对。这一步在“秒传”场景里是刚需下一章会展开。2.4 Flask 侧三个必须先改的配置第一是请求体大小。Flask 2.3 之前用MAX_CONTENT_LENGTH单位字节设成20 * 1024 * 1024表示单个请求最多 20MB分片 5MB 的话留了四倍余量。超过限制 Werkzeug 直接返回 413前端 WebUploader 只会看到一个uploadError不会告诉你原因。app.config[MAX_CONTENT_LENGTH] 20 * 1024 * 1024 # 单请求上限 app.config[JSON_AS_ASCII] False # 中文错误信息不乱码第二是超时。合并路由耗时可能超过默认的 30 秒gunicorn 的--timeout要放大到 120 秒以上否则 worker 被杀前端收到 502 但文件其实已经合并完了用户重传一次就多一份垃圾。第三是下载端。合并后的文件要能被流式下载send_file的conditionalTrue会启用 Range 请求支持配合前端的 stream-downloader 就能做断点续传下载和上传侧的分片逻辑对称起来from flask import send_file app.route(/download/path:filename) def download(filename): path os.path.join(FILE_DIR, os.path.basename(filename)) return send_file(path, as_attachmentTrue, conditionalTrue)conditionalTrue是这行的关键没有它浏览器只能整文件下载2GB 文件断一次就得重来等于上传侧做了断点、下载侧又退回原点。3. WebUploader 前端chunkSize、threads 与重试参数怎么定前端这层看着只是配置项实际决定了服务端要承受什么样的压力曲线。chunkSize调小请求数变多握手和鉴权开销翻倍调大单请求失败重传成本高弱网下体验反而差。WebUploader 的分片能力是内置的但它默认不会把分片索引按你想要的字段名传也不会自动做断点续传事件链得自己接。3.1 初始化参数chunked、chunkSize、threads 的组合先看一份能直接跑的初始化配置var uploader WebUploader.create({ swf: /static/webuploader/Uploader.swf, server: /upload/chunk, pick: #picker, fileVal: chunk, // 必须和 Flask 的 request.files[chunk] 对齐 chunked: true, chunkSize: 5 * 1024 * 1024, // 5MB 一片 chunkRetry: 3, // 单片失败重试次数 threads: 3, // 同时上传的分片数 duplicate: true, // 允许重复选择同一文件 resize: false, formData: { fileId: , chunkTotal: 0 } });chunkSize取 5MB 是我在多数内网环境下的默认值。理由是小于 1MB400 个分片变成 2000 个请求头开销和 Flask 的解析开销都会明显上来大于 10MB弱网下单请求超时概率上升重传一次就是 10MB 的浪费。5MB 在两者之间400 个分片对 2GB 文件来说目录里文件数不至于多到listdir变慢。threads: 3要结合服务端 worker 数看。gunicorn 开 4 个 worker前端开 8 个线程多出来的请求就在队列里排着反而拉高了单请求耗时。我的经验值是前端线程数不超过后端 worker 数的 75%。duplicate: true不是可有可无的。默认false时用户第二次选同一个文件会被静默忽略看起来像“点了没反应”这个反馈缺失在后台系统里被投诉过很多次。3.2 事件链uploadBeforeSend 注入分片索引WebUploader 内部其实会带chunks、chunk、id这些字段但字段名和服务端约定对不上最稳的做法是在uploadBeforeSend里显式补齐uploader.on(uploadBeforeSend, function (block, data, headers) { var file block.file; data.fileId file.id; // 本次上传会话 ID data.chunkIndex block.chunk; // 当前分片索引从 0 开始 data.chunkTotal Math.ceil(file.size / uploader.option(chunkSize)); data.filename file.name; });block.chunk是当前分片的序号block.file是文件对象file.id在页面生命周期内唯一。chunkTotal每次都重算而不是缓存是因为chunkSize可能在运行中被改比如根据网络状况动态调整缓存值会和服务端收到的实际分片数对不上。分片全部传完后 WebUploader 会触发uploadSuccess注意这个事件是每个分片成功都会触发一次不是整个文件完成才触发。判断整个文件完成要靠计数器var doneMap {}; uploader.on(uploadSuccess, function (file, response) { if (!doneMap[file.id]) doneMap[file.id] 0; doneMap[file.id]; var total Math.ceil(file.size / uploader.option(chunkSize)); if (doneMap[file.id] total) { // 所有分片到齐发起合并 $.post(/upload/merge, { fileId: file.id, filename: file.name, chunkTotal: total }, function (res) { if (res.ok) console.log(合并完成, res.sha256); }); } });这段代码里doneMap用file.id做 key页面刷新就丢了所以断点续传必须依赖服务端的/upload/check不能靠前端内存状态。3.3 Bootstrap 页面骨架与进度条绑定upload-demo 的页面用 Bootstrap 排版结构很简单一个选择按钮、一个文件列表、一个总进度条。关键是进度条的数据来源——WebUploader 的uploadProgress事件给的是单个分片的进度要汇总成文件级进度得自己算div classcard div classcard-body div idpicker classbtn btn-primary选择文件/div div classprogress mt-3 styleheight: 22px; div idbar classprogress-bar roleprogressbar stylewidth: 0%;0%/div /div ul idfilelist classlist-group mt-3/ul /div /divvar progressMap {}; uploader.on(uploadProgress, function (file, percentage) { progressMap[file.id] percentage; // 单分片进度 var loaded 0, total 0; uploader.getFiles().forEach(function (f) { var size f.size || 0; total size; loaded size * (progressMap[f.id] || 0); }); var pct total ? Math.floor(loaded / total * 100) : 0; $(#bar).css(width, pct %).text(pct %); });getFiles()返回当前队列里的所有文件多文件同时上传时进度条是加权平均不会出现“一个传完 100%、另一个刚开始 0%”的跳变。Bootstrap 的.progress-bar只负责样式宽度靠内联 style 控制别去改它的 CSS 类名。3.4 暂停、继续与断点续传的接线WebUploader 自带uploader.stop()和uploader.upload()但这两个是全局的。要做“单文件暂停”得用uploader.stop(true)加文件 ID 过滤或者干脆在uploadBeforeSend里拦截已暂停的文件。更实用的场景是页面刷新后的续传。流程是用户重新选同一个文件 → 前端调/upload/check→ 服务端返回已收到的分片索引 → 前端跳过这些分片。WebUploader 没有“跳过指定分片”的公开 API常见做法是给分片请求加一个拦截var uploadedSet {}; // 由 /upload/check 填充 uploader.on(uploadBeforeSend, function (block, data) { if (uploadedSet[block.file.id] uploadedSet[block.file.id].indexOf(block.chunk) -1) { block.status skipped; // 标记后由 uploadAccept 拦截 return false; } });return false会让 WebUploader 放弃这个分片的发送但不会把它计入成功计数所以合并触发逻辑要单独处理“跳过的分片视为已完成”。这块逻辑容易写漏测试时务必覆盖“传到一半刷新页面再续传”的路径。4. 秒传与断点续传文件指纹、分片索引和临时目录治理分片上传做完下一个自然需求就是“同一个文件别传第二遍”。秒传听着高级本质是一次哈希比对前端算出文件指纹服务端查一下有没有有就直接返回路径。问题在于 2GB 文件的哈希怎么算才不把浏览器卡死以及算出来的指纹怎么和分片索引配合。4.1 整文件 MD5 在 2GB 上的代价直接SparkMD5.hash(file)是行不通的。FileReader.readAsArrayBuffer一次性读 2GB浏览器内存直接爆标签页崩溃。正确做法是切片增量计算function calcFileMd5(file, onProgress) { return new Promise(function (resolve, reject) { var sliceSize 4 * 1024 * 1024; // 每次读 4MB var chunks Math.ceil(file.size / sliceSize); var current 0; var spark new SparkMD5.ArrayBuffer(); var reader new FileReader(); function loadNext() { var start current * sliceSize; var end Math.min(start sliceSize, file.size); reader.readAsArrayBuffer(file.slice(start, end)); } reader.onload function (e) { spark.append(e.target.result); current; onProgress onProgress(Math.floor(current / chunks * 100)); if (current chunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror reject; loadNext(); }); }4MB 的切片大小是经验值太小回调次数多太大会让单次读取的停顿感明显。这个函数即使切片读2GB 也要跑十几秒期间主线程被FileReader回调占着页面会卡。缓解办法是放到 Web Worker 里跑或者先做“抽样哈希”——取文件头 1MB、尾 1MB、中间若干块算一个弱指纹先做粗筛命中后再算全量。提示秒传的判定标准要业务方确认。仅凭文件名和大小判重会误伤两个不同文件同名同大小的情况真实存在哈希是底线。4.2 /upload/check 的返回结构与续传逻辑校验接口返回什么直接决定前端续传代码好不好写。我倾向于返回三个字段语义清晰app.route(/upload/check, methods[POST]) def check(): data request.get_json() file_id data[fileId] filename os.path.basename(data[filename]) total int(data[chunkTotal]) src_dir os.path.join(CHUNK_DIR, file_id) uploaded [] if os.path.isdir(src_dir): for name in os.listdir(src_dir): if name.endswith(.part): uploaded.append(int(name.split(.)[0])) finished os.path.exists(os.path.join(FILE_DIR, filename)) return jsonify({ uploaded: sorted(uploaded), finished: finished, missing: [i for i in range(total) if i not in uploaded] })uploaded是已落盘的分片missing是还缺的finished表示服务端已有完整文件——这三个字段组合起来前端就能判断是“秒传直接完成”“部分续传”还是“全新上传”。返回missing而不是让前端自己算差集是为了避免chunkTotal不一致时的边界错误分片大小改过之后旧会话的索引对不上新会话前端自己算容易越界。前端拿到missing后把不在列表里的分片标记为跳过只传缺失的。合并触发条件改成“已上传数 跳过数 chunkTotal”不再依赖前端计数器。4.3 临时目录的生命周期管理分片目录不会自己消失。用户传了一半关页面、合并失败、秒传命中后旧分片还在这些都会留下垃圾。一个 2GB 文件传一半就是 1GB 的残留几十个用户就能把磁盘吃满。治理思路是两条合并成功后立即删上一章的shutil.rmtree以及定时清理超时目录。定时任务用find就够不必上调度框架#!/usr/bin/env bash # 清理 24 小时未变动的分片目录先打印再删便于观察 find /data/upload_tmp/chunks -mindepth 1 -maxdepth 1 -type d -mmin 1440 \ -print -exec rm -rf {} \;-mmin 1440是 24 小时按目录的修改时间算。这里有个取舍如果用户传一个超大文件超过 24 小时目录会一直被写mtime会刷新不会被误删但如果用户中途暂停超过 24 小时分片就被清了续传时得从头来。这个阈值要根据业务实际的上传时长分布来调后台系统里设 48 小时更稳妥。完整文件目录也要有对应策略。/download路由提供的是最终产物不能随便删但可以按“最后访问时间”做冷热分层或者交给对象存储的生命周期规则这就超出 upload-demo 的范围了。5. 避坑与排查分片错位、413 与合并丢字节的五个现场这套东西跑通不难难的是跑稳。下面五条都是我在实际部署里遇到的按“现象 → 原因 → 解决”记下来遇到了可以对着排查。5.1 现象合并成功但文件打不开大小对得上原因几乎都是分片顺序错。os.listdir返回的是目录项顺序不保证字典序更不保证数字序。如果文件名没做零填充10.part会排在2.part前面合并出来就是乱的。解决办法是双重保险落盘时用{index:06d}.part零填充合并时先sorted()再按后缀过滤最后校验总片数和期望值是否一致。已经出了问题的文件没法救只能删了重传。5.2 现象5MB 分片请求返回 413小文件却正常原因是有两层限制同时存在。Flask 的MAX_CONTENT_LENGTH是一层前面的 Nginx 还有一层client_max_body_size默认 1MB。只改 Flask 不改 Nginx请求在网关就被拒了Flask 根本收不到。解决是两边都放大并且留余量。分片 5MBNginx 设 20MBFlask 设 20MBmultipart 的边界和表单字段也有开销贴着 5MB 设会偶发超限。client_max_body_size 20m; proxy_read_timeout 300s; proxy_request_buffering off; # 大文件直传别让 Nginx 先缓冲整个请求proxy_request_buffering off这行容易被忽略。开着的时候 Nginx 会把整个请求体先收完再转给后端分片上传的并发优势被抵消一半内存压力也上去了。5.3 现象选完文件浏览器卡死几秒进度条不动原因是主线程在算 MD5。FileReader的回调虽然异步但SparkMD5.append是同步计算2GB 数据分四百次追加每次都在主线程跑页面自然卡。解决办法优先是挪到 Web Worker把File对象postMessage过去File是可转移对象算完把哈希传回来。退一步的做法是先算抽样指纹做秒传粗筛只有粗筛命中才跑全量哈希。5.4 现象合并时容器内存飙到限制进程被 kill原因是把分片一次性读进内存拼接。b.join(...)或者read()不带参数都会把整个文件加载进来。2GB 文件在 512MB 内存限制的容器里必死。解决就是上一章那段流式写法固定 1MB 缓冲区while循环读写哈希增量更新。改完之后合并 2GB 文件的内存占用稳定在几 MBCPU 也平稳。5.5 现象两个用户同时上传分片互相覆盖原因是fileId撞了。常见于用文件名或文件大小当 ID 的实现两个人传同名文件就进同一个目录后写的分片覆盖先写的合并出来的文件两边都不对。解决办法是fileId必须包含足够随机的成分前端用file.id加时间戳服务端再校验一次格式比如只允许[A-Za-z0-9_-]{8,64}防止路径穿越。目录创建用os.makedirs(exist_okTrue)两个请求同时创建不会报错。6. 压测与验证用 curl 灌分片、核对哈希、跑一遍断点场景界面点通了不算数我一般会用 curl 绕过前端直接对服务端灌分片这样能把前端的问题和后端的问题分开。第一步是把一个测试文件切成 5MB 的片# 生成 30MB 测试文件并切片-d 用数字后缀-a 6 补齐六位 dd if/dev/urandom ofdemo.bin bs1M count30 split -b 5m -d -a 6 demo.bin part_ ls part_* | wc -l # 应该是 6split出来的文件名是part_000000这种格式和 Flask 侧的零填充命名规则一致但 curl 提交时要用分片索引而不是文件名。循环里10#$i是为了把带前导零的字符串按十进制解析否则 bash 会当成八进制08直接报错for i in $(seq -w 0 5); do curl -s -X POST http://127.0.0.1:5000/upload/chunk \ -F fileIdtest-20240101-001 \ -F chunkIndex$((10#$i)) \ -F chunkTotal6 \ -F chunkpart_$i \ -o /dev/null -w chunk $i - %{http_code}\n done跑完看每个分片的 HTTP 状态码全是 200 才算落盘正常。接着调合并接口把返回的sha256和本地算的比一下curl -s -X POST http://127.0.0.1:5000/upload/merge \ -H Content-Type: application/json \ -d {fileId:test-20240101-001,filename:demo.bin,chunkTotal:6} sha256sum demo.bin两个哈希一致说明分片落盘、合并、哈希计算整条链路没问题。不一致就往回查先看分片目录里文件数和大小对不对再看合并顺序最后看缓冲区读写有没有漏字节。第二步是模拟断点。故意只传前三个分片然后调/upload/check看missing是不是[3,4,5]curl -s -X POST http://127.0.0.1:5000/upload/check \ -H Content-Type: application/json \ -d {fileId:test-20240101-001,filename:demo.bin,chunkTotal:6}返回对得上再补传后三个分片、合并、比哈希。这一步能抓出大部分索引相关的边界错误比在浏览器里点来点去可靠得多。第三步是并发验证。开三个终端同时跑上面的循环但用不同的fileId观察服务端 CPU 和磁盘 IO以及合并耗时是否线性增长。如果并发一上来合并就超时回去看 gunicorn 的--timeout和 worker 数。最后一步是清理验证。合并成功后立刻ls upload_tmp/chunks确认对应目录已删再手动造一个超时的空目录跑一次find ... -mmin 1440的清理脚本确认能删干净。这步容易被跳过等到磁盘告警才想起来就晚了。这套流程我现在每次改完上传相关代码都会强制走一遍尤其是哈希比对那步——有一次本地测试图省事只看了文件大小结果线上合并出来差了几百字节前端下载后打不开回滚排查了两个小时。从那以后我每次改完分片逻辑都把 curl 灌分片加sha256sum比对当成固定动作宁可多花三分钟也不赌运气。希望帮到你。本文还有配套的精品资源点击获取