简介:upload-demo 是一份演示大文件分段上传与流式下载的完整项目,基于 Flask 与 WebUploader 搭建。前端采用分片方式将大文件逐一上传,后端接收全部片段后合并还原,支持多用户同时上传互不干扰,并能以低内存方式流式下载文件,适合需要处理大文件传输的 Python Web 开发者参考。资源压缩包共 26 个文件,核心为 2 个 Python 服务端脚本与 2 个 HTML 页面,另含 Bootstrap/WebUploader 相关的 CSS、JS 及字体资源,整体约 427KB,轻量且易于本地运行。已有 651 人学习/下载。项目目录结构清晰,包含服务端入口、模板目录、静态资源与依赖清单,运行安装后即可体验完整流程。通过该 demo 可掌握分片上传的合并思路、进度条渲染方式,并了解流式传输如何降低内存占用,为自建上传下载模块提供可复用模板。
1. 大文件上传的半程真相:分段不是给服务器减负,是给用户续命
很多产品在上传 2GB 视频时,用户等来的是一个转圈后消失的进度条。常规 POST 把整个文件当作一个请求体,断网瞬间服务器什么都没留下,前端只能归零重来。upload-demo 这套组合换个思路:WebUploader 在前端按 2MB 切块,Flask 在后端先收分片、后合并,第 30 块断了就从第 30 块重传,而不是回到第 0 块。它解决的是“大文件总是传不完”的体验问题,也顺带绕开了服务端单请求体积上限。适合网盘、教学视频、工程交付这类场景,个人开发者和几人小团队都能直接复用。
分片上传不是玄学,它把一次巨大请求拆成上千次小请求,失败成本和重试成本都被拆到最低。后端不需要一颗超大内存来接盘整个文件,只需要一个可靠的临时目录和一段几百行的合并逻辑。下面我会把这套方案的流程、参数和踩坑一次讲清,看完你基本能照着搭出一个可用的第一版。
2. 分段上传的分界线:WebUploader切完片,Flask只做三件事
2.1 从一次请求到N次请求:失败成本被拆碎
普通上传是“一个请求走到底”。浏览器把文件流持续写入请求体,网络一抖,连接断开,服务端收到的半个文件往往直接丢弃,用户之前的时间全部白费。分段上传把文件按固定字节数切成若干块,每块独立走一次 HTTP 请求,服务端每收到一块就写进临时目录。由于分片小,单个请求的处理时间从小时级降到秒级,失败粒度也从整个文件变成几 MB。这个转变是量变带来的质变:只要前端记住成功分片,断点续传就是顺理成章的事。
需要注意,分片传输并不会减少总数据量,反而会让总请求数增加,但换来了两个实打实的好处。第一,请求体从几十 GB 变成几 MB,Web 服务器和 Flask 的内存压力都大幅下降;第二,网络抖动时只有正在传的那一片受影响,已落盘分片可以成为后续断点续传的基础。
在制定方案时,我被问最多的问题是“分片越小是不是越好”。不是。分片越小,失败损失越小,但请求数量成反比增长,服务端要处理的 TCP 连接越多,整体失败率反而上升。常见的可靠区间是 1MB 到 5MB,我一般默认 2MB,在文件服务器与客户端同内网时调到 5MB,在公网弱网环境降到 1MB。
2.2 为什么选 WebUploader:切片、并发、进度都替你管了
自己用 File.slice 写分片上传不是不行,但很快会碰到三件麻烦事:分片队列怎么调度、失败分片怎么重试、进度条怎么细分到每片。WebUploader 这类的成熟开源组件把这些收进配置项。它内部基于现代浏览器的文件切片能力做分段,并提供 Flash 作为旧浏览器降级方案,但实际项目里我们一般只走 HTML5 路线。
核心配置里,chunked 开关决定是否启用分片,chunkSize 控制每块大小,threads 控制同时发出的请求数,fileVal 决定 POST 提交时文件字段名,后端要拿它和 request.form 对齐。在 uploadBeforeSend 回调里还能临时塞入业务参数,比如文件名、总片数、当前片号,这三个参数是后端合并的坐标。
这里需要提醒,WebUploader 有自己的默认分片行为,但那未必适合你的服务器。我见过有同事不设置 chunkSize 就直接跑,默认分片偏大,公网上传大文件时后端接收缓慢,改成 2MB 后平均请求耗时明显下降。一套常用参数可以这样固定下来:
| 参数 | 取值 | 说明 |
|---|---|---|
| chunked | true | 启用分段上传 |
| chunkSize | 2MB | 单块大小,公网 1~2MB,内网可 5MB |
| threads | 3 | 同时上传的分片数,别一上来就调 10 |
| fileVal | file | 文件字段名,与后端 request.files 对齐 |
| server | /upload | 后端接收地址 |
| auto | true | 选择文件后自动开始上传 |
这里的 thread 数最容易被人忽略。并发从 3 调到 6 并不一定带来两倍速度,因为瓶颈往往在服务器的带宽和文件句柄,不在前端。后面第四章的避坑记录里会展开讲并发翻车的具体表现。
2.3 后端只做三件事:接收、暂存、合并
Flask 在这个场景里并不需要什么魔改特性。接收就是 request.files 里取分片;暂存就是 save() 到磁盘;合并就是把按序读出的二进制写进目标文件。真正的关键在“如何判断所有分片到齐了”。
大多数 demo 会要求前端在上传最后一个分片后调一次 merge 接口。这种做法依赖前端对“最后一片”的判断准确,一旦前端并发乱序,最后一片不一定是最后到达,接口就可能提前空跑。我的习惯是后端自己数:每次收到分片,统计临时目录下属于该文件的 .part. 文件数量,达到总片数后自动合并。这样做把“切片数”的权威判断放到了服务端,前端只负责传数据,少一个状态同步环节。
后端还需要考虑临时文件命名。常见做法是用“原文件名 + .part. + 分片序号”,但这个约定必须防御路径穿越和重名覆盖。临时文件名里的序号必须用整数规模排序,不能用字符串排序,否则第 10 片会排在 第 2 片前面,合并出的文件直接损坏。
另外,如果要支持多个用户同时上传,应尽早为每个上传任务分配唯一 ID,并把所有分片放进以该 ID 命名的目录里,而不是把文件都堆在同一个公共目录。这个设计我会在第五章用一小段代码说明。
3. 可抄作业的最小实现:WebUploader 前端页面与 Flask 后端接口
3.1 前端单页:从依赖引入到分片参数打包
先搭 HTML 骨架。页面里只需要一个文件选择按钮、一个进度条、一个列表容器。WebUploader 要求引入它的 CSS 控制按钮和队列样式,JS 提供核心行为。这里不做复杂 UI,直接用 div 让 WebUploader 自动渲染。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>upload-demo</title> <link rel="stylesheet" href="/static/webuploader.css"> </head> <body> <div id="uploader"> <div id="picker">选择文件</div> <div id="progress" style="width:0%;height:6px;background:#4caf50;"></div> </div> <script src="/static/webuploader.js"></script> <script src="/static/demo.js"></script> </body> </html>需要留意的是,WebUploader 会扫描#picker作为上传按钮,#uploader作为挂载容器。如果不引入 CSS,按钮的 hover 态和队列布局会失效,但核心功能不受影响。进度条这里用一个简单的 div,后续的 JS 会动态修改它的宽度。
接着是 demo.js,核心是创建 Uploader 实例:
var uploader = WebUploader.create({ pick: '#picker', server: '/upload', chunked: true, chunkSize: 2 * 1024 * 1024, threads: 3, fileVal: 'file', auto: true }); uploader.on('uploadProgress', function(file, percentage) { document.getElementById('progress').style.width = percentage * 100 + '%'; }); uploader.on('uploadBeforeSend', function(file, block) { block.formData = { name: file.name, chunks: Math.ceil(file.size / (2 * 1024 * 1024)), chunk: block.chunk }; });这里有个需要解释的坑:block.chunk在 WebUploader 中是从 0 开始的,而chunks是总片数。后端拿到这两个字段后,分片序号范围是 0 到 chunks-1。如果哪个环节把判断写成了chunk === chunks,最后一片永远不会被正确识别。uploadBeforeSend在每个分片请求发出前触发,我在这里手动挂上 name、chunks、chunk 三个表单字段,后端request.form拿到的 key 与这里一一对应。
chunkSize必须和计算chunks时使用的单位一致。如果配置里把 chunkSize 改成 5MB,但计算总片数仍然用 2MB 去除,最后后端收到的实际片数会比你声称的少,合并时必然缺片。
3.2 Flask 后端:分片落盘、缺片检查、自动合并
后端代码比前端更核心。先构建 Flask 应用,再写 upload 路由和一个独立的 merge 函数。
import os from pathlib import Path from flask import Flask, request, jsonify app = Flask(__name__) UPLOAD_TMP = Path('/tmp/upload_demo') UPLOAD_FINAL = Path('/tmp/upload_demo/final') UPLOAD_TMP.mkdir(exist_ok=True) UPLOAD_FINAL.mkdir(exist_ok=True) def save_chunk(file_obj, filename: str, chunk_index: int): chunk_path = UPLOAD_TMP / f'{filename}.part.{chunk_index}' file_obj.save(str(chunk_path)) return chunk_path def merge_chunks(filename: str, total_chunks: int): final_path = UPLOAD_FINAL / filename with open(final_path, 'wb') as out: for i in range(total_chunks): part = UPLOAD_TMP / f'{filename}.part.{i}' if not part.exists(): raise FileNotFoundError(f'missing chunk {i}') with open(part, 'rb') as f: while True: buf = f.read(1024 * 1024) if not buf: break out.write(buf) for i in range(total_chunks): (UPLOAD_TMP / f'{filename}.part.{i}').unlink() return final_path参数说明:save_chunk 的 file_obj 是 Flask 的 FileStorage,它的 save 方法把分片流式写入磁盘,不会把整个文件读进内存。merge_chunks 里用 range(total_chunks) 保证合并顺序,i 从 0 到 total_chunks-1,对应前端 chunk 从 0 开始的约定。合并时按 1MB 缓冲流式读写,避免把几十 GB 文件一次性装入内存。
之后是 upload 路由,负责接收分片并决定什么时候触发合并:
@app.route('/upload', methods=['POST']) def upload(): chunk = request.form.get('chunk') chunks = request.form.get('chunks') filename = request.form.get('name') if not all([chunk is not None, chunks, filename]): return jsonify({'error': 'missing params'}), 400 chunk = int(chunk) chunks = int(chunks) file_obj = request.files.get('file') save_chunk(file_obj, filename, chunk) part_count = len(list(UPLOAD_TMP.glob(f'{filename}.part.*'))) if part_count >= chunks: merge_chunks(filename, chunks) return jsonify({'done': True, 'path': str(UPLOAD_FINAL / filename)}) return jsonify({'done': False, 'received': part_count, 'chunks': chunks})这里用 request.files.get('file') 取得分片,与前端 fileVal 对应。判断 part_count 达到 chunks 时自动合并,是最省事的方式。需要注意,如果用户中途取消上传,临时目录里会有残缺分片,一旦后续同名文件再次上传,part_count 可能提前达到合并条件,所以 merge_chunks 里的缺片检查是必要的。前面 merge_chunks 里每个分片都检查文件是否存在,就是为这种情况兜底。
3.3 启动与验证:看进度条之外,还要盯住分片文件
Flask 默认开发服务器足够跑通 demo。启动命令:
export FLASK_APP=app.py flask run --host=0.0.0.0 --port=5000启动后访问 http://localhost:5000 ,选择一个大文件,观察 /tmp/upload_demo 是不是陆续出现 .part. 文件。全部传完后,final 目录会出现合并后的原文件,用 md5sum 和原文件对比一致,说明流程通了。
验证时不要只看到进度条 100% 就收工。常见翻车点是:前端显示成功但后端没有合并,多半是 chunks 参数没传对;合并完文件损坏或有乱码,多半是分片顺序没有按数字排序。建议准备一个十行左右的 md5 对比脚本,每次测试都跑一遍。这个部门在第五章我会再展开。
4. 参数调优与避坑记录:并发不是越高越好,Nginx 夹在中间
4.1 三个必调参数以及它的边界
第一个是 chunkSize。2MB 是通用起步值;如果上传目标是公司内网服务器,带宽足够,可以调到 5MB,减少请求数量;如果用户环境是移动网络,1MB 更稳,弱网丢包重传的成本更低。chunkSize 过小的代价是请求数量激增,Flask 的日志、Nginx 的 access log 都会带来额外 IO 压力。
第二个是 threads。WebUploader 的 threads 指同一时刻并发的分片请求数。默认 3 已经很稳定,不要轻易调到 10。并发太高时,浏览器和服务器同时维持大量 TCP 连接,后端每个请求都要创建文件句柄,内存占用会突然抬高。我见过一个测试环境把 threads 调到 8,上传 1GB 文件时 Flask 开发服务器直接无响应,日志里全是连接超时。调到 3 之后一切正常。
第三个是 Nginx 侧的接收限制。分段上传后单个请求体只有几 MB,但不少项目的 Nginx 配置仍写着client_max_body_size 1m,导致分片请求被直接拒掉,返回 413。常见做法是把 client_max_body_size 设为 10m,保证单个分片加上表单字段有足够余量。同时要检查 proxy_read_timeout,如果后端合并动作比较慢,客户端等待时间会超过默认 60s,表现是进度条走到 100% 但拿不到响应,前端误以为失败。可以把该值调到 300s。
4.2 断点续传:前端要主动跳过已完成分片
分片上传和断点续传不是一回事。分片上传只是把大文件拆开,如果用户刷新页面,前端的队列和已传列表全部丢失,之前传好的分片还留在服务端,但前端不知道,下次会全部重传。要实现续传,前端要在 uploadProgress 或 uploadBeforeSend 里记录已成功的分片序号。
我常用的方式是在 localStorage 里按文件名存一个数组,每次成功一片就追加一个索引。重新上传时,在 uploadBeforeSend 里判断当前 block.chunk 是否已存在,存在就让这个分片跳过。示例:
uploader.on('uploadSuccess', function(file, block) { if (!block) return; var key = 'uploaded_' + file.name; var list = JSON.parse(localStorage.getItem(key) || '[]'); if (list.indexOf(block.chunk) === -1) { list.push(block.chunk); localStorage.setItem(key, JSON.stringify(list)); } }); uploader.on('uploadBeforeSend', function(file, block) { var key = 'uploaded_' + file.name; var list = JSON.parse(localStorage.getItem(key) || '[]'); if (list.indexOf(block.chunk) >= 0) { block.data = {}; // 不传数据 return false; // 阻止这个分片发出 } // 正常设置 formData });这个方案有个明显的坑:本地记录的服务端分片可能已经被清理,前端认为已传完,后端却找不到对应文件。所以更稳妥的做法是让后端暴露一个接口,返回该上传任务的已接收分片列表,前端在开始上传前拉取一次,再决定跳过哪些分片。在 demo 阶段,localStorage 足够演示,但不要直接搬进生产。
4.3 五条踩坑记录:现象、原因、解决
合并后的文件能打开,但中间一段乱码。现象是文件字节数正确,但某一段内容错位。原因是合并时用了字符串排序,
filename.part.10被排在filename.part.2前面。解决是合并前用整数提取分片序号:range(total_chunks) 按数字顺序读文件,别依赖 glob 返回的文件名顺序。上传 20MB 小文件也收到 413。现象是前端一上传就失败,查看 Nginx 日志发现大量 413 Request Entity Too Large。原因是 Nginx 的 client_max_body_size 还停留在 1m,而 chunkSize 是 2MB。解决是把这个参数调到 10m,一个分片请求通常在 2MB 左右,10m 足够。
进度条 100% 后后端没有生成最终文件。现象是前端显示成功,但 /tmp/upload_demo 里全是 .part. 文件,final 目录为空。原因是前端计算 chunks 时用的 chunkSize 和后端实际收到的分片数不一致,导致 part_count 始终达不到 chunks,自动合并不触发。解决是前端只在 uploadBeforeSend 里用同一个 chunkSize 常量计算 chunks,后端再校验一下实际分片数是否等于 chunks,不一致时就报错。
threads 一调高,Flask 开发服务器直接卡死。现象是上传到一半页面无响应,Flask 控制台不停输出僵尸连接日志。原因是 Werkzeug 开发服务器是单进程多线程模型,大量并发文件写入把线程池占满。解决是开发时不要用 threads 超过 3;部署时换 gunicorn 并开多 worker,我的经验是
gunicorn -w 4 -t 300 app:app,每个 worker 顺序处理请求也不会阻塞整个服务。文件名包含中文或特殊字符时,后端合并失败。现象是文件名传到后端变成乱码,或者路径中出现
../导致保存到错误目录。原因是 WebUploader 传的 name 是原始文件名,后端直接用Path / filename拼接,既没有做解码,也没有做路径安全处理。解决是前端传 name 前先 encodeURIComponent,后端拿到后解码,并且不要用原始文件名作为落盘名,统一用 uuid 重命名,把原始文件名存到数据库或 JSON 里,合并后再恢复。
5. 上线前再补三招:秒传、校验、临时目录隔离
这一节补三个让方案能上生产的细节。第一是秒传:在真正上传前,用文件哈希向后端查询该文件是否已存在,存在就直接显示成功,跳过传输。但不要在大文件上计算全文件 md5,否则用户等不到进度条就能卡在 md5 阶段。常见做法是用文件大小和修改时间做初步匹配,精确秒传才做完整哈希。WebUploader 有 md5File 方法,但 2GB 文件在浏览器里算完整 md5 仍然很重,我只建议对小文件用,大文件用 文件大小 + 首尾分片哈希 做指纹。
第二是合并后校验。后端合并完成后,不要立刻向前端返回成功,先对最终文件做一次 md5:
import hashlib def file_md5(path: str, chunk_size: int = 1024 * 1024): h = hashlib.md5() with open(path, 'rb') as f: while True: buf = f.read(chunk_size) if not buf: break h.update(buf) return h.hexdigest()我原来的 demo 是合并完直接返回路径,后来在某个图像处理项目里,合并后的 JPG 总比原图少一小片,前端下载下来永远是坏图。查了两小时才发现是合并时有一个分片没读到。加上这段校验后,前后端 md5 不一致就返回错误,并清掉异常临时文件,这类问题当天就能定位。
第三是临时目录隔离。把同一个上传任务的所有分片放到以唯一 ID 命名的目录下,而不是直接拼在公共目录里。最简单的生成 ID 方式是uuid.uuid4().hex,目录结构从/tmp/upload_demo/{task_id}/part.0开始,合并完成后整个目录删除。这样不同用户传同名文件不会相互覆盖,定时清理也容易,直接删掉超过时间阈值的目录即可。
用过一段时间后,我形成了一个习惯:分片上传的代码不写完就立刻交付,必须先在测试环境用一个 1GB 的随机文件跑三遍,一遍正常传,一遍在中途断网,一遍用 threads=5。三遍都能通过,再放给真实用户。断网重试那轮往往能暴露最多问题,因为很多 bug 不是顺序传才出现,而是并发和缺失分片时才会浮出来。希望帮到你。
本文还有配套的精品资源,点击获取