简介:面向大文件传输场景的Flask与WebUploader分段上传演示项目,提供分片上传与流式下载的可运行参考,适合Python后端工程师及需要并发上传、断点续传的Web开发人员。压缩包共26个文件、约427KB,含2个Python脚本、2个HTML模板、5个CSS与5个JS静态资源、4个map映射及字体、依赖清单和说明文档,结构清晰。目前已有651人学习下载。项目实现前端分片、后端接收合并完整链路,支持多用户同时上传互不干扰,下载采用流式方式降低内存占用,并演示了上传成功与失败的处理边界。读者可从中学习WebUploader分片交互、Flask后端合并、Bootstrap进度条渲染,以及编码导致合并失败的处理经验,整体简洁,适合作为改造现有上传系统的参考。
1. 分段上传大文件:别再让浏览器把整个文件塞进内存
分段上传(也叫分片上传)的核心,是把一个大文件切成几MB一片,一片片传给后端,后端收齐再合并。WebUploader 是前端上传组件,配合 Flask 可以快速实现带进度条、秒传和断点续传的上传系统。这篇笔记写给被大文件上传折磨过的开发者,适合正在用 Python 写后台、想自己搭一套上传服务而不是依赖云存储的场景。读完你能清楚分片怎么切、接口怎么写、合并怎么没错,以及最容易翻车的几个坑。
2. Flask + WebUploader 选型:为什么是这两个
2.1 WebUploader 的分片与断点续传机制
WebUploader 最早是某前端团队为了解决大文件上传问题开源的组件,它内部封装了 HTML5 File API,在低版本浏览器里可以降级到 Flash。HTML5 模式下,它会把一个 File 对象按照你指定的 chunkSize 切分成多个 Blob,然后逐个上传到后台。这个过程里它自己管理队列、并发数、重试次数,并且提供了uploadProgress、uploadFinished这类事件给前端做进度展示。
断点续传是它最有价值的地方。前端在文件开始上传前会生成一个文件唯一标识,一般用 MD5。每个分片上传时带上这个标识和分片序号,后端就能根据标识建一个临时目录,把分片放进对应序号的位置。如果网络中断,前端重试时只需要问一下服务端“哪些分片已经上传过了”,跳过已传的分片继续传剩下的,就实现了断点续传。注意,WebUploader 本身不会帮你做秒传和后端合并,那部分必须由服务端接口配合。
这个组件有一段时间没怎么更新,但轨迹稳定,现网用得很多。它依赖 jQuery,如果你项目里已经不用 jQuery,需要评估一下引入成本。我的经验是:如果页面是普通多页应用或服务器渲染,用 WebUploader 很顺;如果是前后端完全分离的 SPA,可以考虑用vue-simple-uploader这类同样思路的组件。但后者的分片协议和 WebUploader 不通用,服务端接口不能直接复用。这篇笔记里我按 WebUploader 的协议来写后端。
2.2 Flask 为何够用
Flask 作为后端处理分片上传,核心就两步:接收分片保存到临时目录,合并分片输出完整文件。它不需要数据库,不需要后台任务队列,只要注意几个细节就能跑得很稳。相比 Django,Flask 的路由和请求处理更直接,几个装饰器就能把接口写明白,适合这种单一功能的 Demo 或中台模块。性能上,分片上传的本质是把大文件拆成多次请求,每一片都只是普通的 POST,Flask 的同步视图足以应对常规并发。
有人担心 Flask 的开发服务器不够用,确实生产环境要换成 Gunicorn 或 uWSGI。但分片上传的瓶颈在后端磁盘 IO,而不是框架本身。只要合并时用流式写入,避免一次性把整个文件读进内存,Flask 完成几十 GB 级别的文件合并没有压力。我这里用的思路是:分片保存时按序号写临时文件,合并时用shutil.copyfileobj逐个分片流式写入最终文件。这样内存占用始终只有当前分片的大小,不随文件总大小线性增长。
2.3 完整流程与文件标识
整体流程可以分成五个阶段:
- 前端计算文件 MD5,生成
file_id,并检查秒传状态。 - 前端把文件切成多个 Blob,每个分片带上
file_id、chunk_index、total_chunks。 - 后端收到分片后,在临时目录下以
{file_id}/chunk_{序号}保存。 - 前端所有分片上传完成后,调用合并接口,后端按序号拼接。
- 后端合并完清理临时目录,返回最终文件路径;前端展示成功状态。
file_id是整个流程的核心索引。不要用原始文件名做目录名,因为很容易存在重名或特殊字符问题。我习惯用前端传过来的 MD5 小写字符串,它天然是唯一的。如果同一份文件来自不同用户,MD5 相同,可以直接复用已上传的分片,这就是秒传的基础。
3. 后端实现:接收分片与合并的完整接口
3.1 项目结构与依赖
先搭一个最小可运行的项目结构:
upload-demo/ ├── app.py # Flask 应用主文件 ├── requirements.txt ├── static/ │ ├── css/ │ ├── js/ │ └── lib/ # webuploader 相关文件 └── data/ # 文件落盘目录(需要手动创建) ├── upload_repo/ # 最终文件存放目录 └── tmp/ # 分片临时目录requirements.txt 只需要三个依赖:
flask==2.3.3 flask-cors==4.0.0 werkzeug>=2.3flask-cors 只有在前后端分离部署时需要,如果前端和服务端同源,可以不加。Werkzeug 用来做文件名安全过滤。
3.2 分片上传接口
后端接口的核心是接收一个文件字段,同时拿到分片元信息。参考实现:
import os import uuid from flask import Flask, request, jsonify from werkzeug.utils import secure_filename app = Flask(__name__) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) UPLOAD_DIR = os.path.join(BASE_DIR, 'data', 'upload_repo') TMP_DIR = os.path.join(BASE_DIR, 'data', 'tmp') os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(TMP_DIR, exist_ok=True) @app.route('/api/upload', methods=['POST']) def upload_chunk(): f = request.files.get('file') file_id = request.form.get('file_id', '') chunk_index = request.form.get('chunk_index', type=int) total_chunks = request.form.get('total_chunks', type=int) if not f or not file_id or chunk_index is None: return jsonify({'code': 1, 'msg': '缺少文件或分片参数'}), 400 # 每个文件一个独立临时目录,避免分片互相覆盖 chunk_dir = os.path.join(TMP_DIR, file_id) os.makedirs(chunk_dir, exist_ok=True) # 用零填充序号,避免合并时字符串排序出错 chunk_name = f'chunk_{chunk_index:06d}' chunk_path = os.path.join(chunk_dir, chunk_name) f.save(chunk_path) return jsonify({'code': 0, 'msg': '分片上传成功', 'chunk_index': chunk_index})这段代码做了三件关键事:第一,用file_id隔离不同文件的临时目录;第二,把分片序号格式化成六位零填充数字,比如chunk_000001,这样后面合并时按文件名排序不会出错;第三,直接调f.save(),Flask 会自动处理文件流,不会把分片内容读进内存。total_chunks临时没有用到,但前端传过来后,后端可以在合并时做数量校验。
有个细节容易漏:文件名里的chunk_index如果大于 999999 会溢出,但普通文件分片数不会超过这个量级,除非分片切得特别小。更稳妥的做法是排序时按_后数字转 int 来比较,我合并接口里会这样处理。
3.3 合并接口
合并接口要将若干个分片拼接成一个完整文件。流程是:校验分片数量是否齐全,然后按序号依次读取分片,写入最终文件。参考实现:
import shutil from werkzeug.utils import secure_filename @app.route('/api/merge', methods=['POST']) def merge_chunks(): file_id = request.form.get('file_id', '') filename = request.form.get('filename', '') total_chunks = request.form.get('total_chunks', type=int) if not file_id or not filename or not total_chunks: return jsonify({'code': 1, 'msg': '缺少合并参数'}), 400 chunk_dir = os.path.join(TMP_DIR, file_id) if not os.path.isdir(chunk_dir): return jsonify({'code': 2, 'msg': '分片目录不存在'}), 404 chunk_files = os.listdir(chunk_dir) if len(chunk_files) < total_chunks: return jsonify({ 'code': 3, 'msg': f'分片缺失,已上传 {len(chunk_files)}/{total_chunks}' }), 400 # 按序号排序,这里转 int 是为了避免 'chunk_10' 排在 'chunk_2' 前面 chunk_files.sort(key=lambda x: int(x.split('_')[1])) safe_name = secure_filename(filename) or 'upload.bin' final_path = os.path.join(UPLOAD_DIR, safe_name) # 流式拼接,不把整个文件载入内存 with open(final_path, 'wb') as fout: for chunk_file in chunk_files: chunk_path = os.path.join(chunk_dir, chunk_file) with open(chunk_path, 'rb') as fin: shutil.copyfileobj(fin, fout, length=1024 * 1024) # 合并成功后清理临时分片目录 shutil.rmtree(chunk_dir) return jsonify({ 'code': 0, 'msg': '合并成功', 'path': '/download/' + safe_name })这里有个安全点容易被忽视:secure_filename会过滤掉文件名里的路径穿越字符和空格,避免用户通过filename参数把文件写到目标目录之外。如果你希望保留中文文件名,secure_filename会把中文过滤掉,可以改用自定义的正则清洗函数,或者结合uuid生成新文件名,再把原始文件名存到数据库里做映射。
合并时shutil.copyfileobj(fin, fout, length=1024*1024)每次只读 1MB 写 1MB,内存占用很小。如果分片体积是 2MB,这个值可以设置成 2MB,减少系统调用次数。文件合并完成后立刻删除临时目录,避免磁盘被没传完的分片占满。
3.4 参数设计:分片大小、并发数与超时
这三个参数直接决定上传体验和服务器压力。
分片大小chunkSize一般设在 1MB 到 10MB 之间。太小了请求数量太多,HTTP 握手开销大;太大了失去切分的意义,单个请求超时概率增加。我常用 2MB,适合从几十 MB 到几个 GB 的文件。如果是局域网内高速传输,可以提高到 5MB,减少请求数。
并发数threads控制在 2 到 5 个。并发越高,对后端磁盘 IO 压力越大,而且分片到达顺序会乱掉,后端只是按序号保存分片,所以顺序乱没关系。但并发过高会让 Nginx 的连接数和后端线程池吃紧,服务器配置不高时经常出现 502,建议先从 3 开始测。
超时设置分三层。前端 WebUploader 的timeout属性控制单个请求超时,默认 30 秒,传 2MB 分片到本地服务端通常几百毫秒就完成,可以按需调小或调大。Gunicorn 的timeout默认 30 秒,也必须调大,否则一个分片因为磁盘等待超过 30 秒,Gunicorn 直接把 worker 杀掉。Nginx 的proxy_read_timeout同样要调整,我一般设 120 秒。
4. 前端实现:WebUploader 配置与进度条
4.1 引入依赖与页面骨架
前端页面用 Bootstrap 做基础样式,这是关键词里 BootstrapHTML 的由来。先引入 jQuery、WebUploader 的 JS 和 CSS,然后准备一个上传按钮和进度条容器。示例如下:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>大文件分段上传 Demo</title> <link rel="stylesheet" href="/static/lib/webuploader/webuploader.css"> <link rel="stylesheet" href="/static/lib/bootstrap/css/bootstrap.min.css"> </head> <body> <div class="container mt-5"> <h3>分段上传演练</h3> <div id="picker" class="btn btn-primary">选择文件</div> <div id="progress-wrap" class="progress mt-3" style="display: none;"> <div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%">0%</div> </div> <p id="status" class="text-muted mt-2"></p> </div> <script src="/static/lib/jquery/jquery-3.6.0.min.js"></script> <script src="/static/lib/webuploader/webuploader.min.js"></script> </body> </html>这里#picker是触发文件选择的元素,WebUploader 初始化时会接管它。注意 WebUploader 默认会创建自己的内部结构,所以页面上不需要再写<input type="file">。
4.2 初始化 WebUploader 关键参数
初始化配置是核心,直接决定分片能不能正常工作。看这段代码:
var file_id = null; var current_file = null; var uploader = WebUploader.create({ swf: '/static/lib/webuploader/Uploader.swf', server: '/api/upload', pick: '#picker', auto: false, chunked: true, chunkSize: 2 * 1024 * 1024, threads: 3, fileVal: 'file', fileNumLimit: 1, fileSizeLimit: 10 * 1024 * 1024 * 1024, // 10GB duplicate: true, formData: { file_id: function () { return file_id; }, chunk_index: function () { return uploader.getStats().successNum + uploader.getStats().failNum; } } });chunked: true打开分片上传。chunkSize: 2 * 1024 * 1024是每片大小。threads: 3控制并发。fileVal: 'file'对应后端request.files.get('file')。这里的formData里有个小坑:chunk_index不能这样写,WebUploader 内部会在每个分片请求时自动附加chunkIndex和chunks等字段,但你自定义的chunk_index不会自动更新。正确做法是在上传前调用uploader.option('formData.chunk_index', 当前分片序号),或者依赖 WebUploader 自带的chunkIndex参数。
更可靠的方式是使用uploader.on('uploadStart', ...)和uploader.on('uploadChunkStart', ...)事件里动态设置。实际上 WebUploader 在分片请求里会自动带上chunk字段(当前分片索引)和chunks字段(总分片数),所以后端可以直接用request.form.get('chunk')和request.form.get('chunks'),不需要自定义。用自带字段可以少踩一个坑。所以初始化代码改成这样更省心:
var uploader = WebUploader.create({ // ...其他配置不变 chunked: true, chunkSize: 2 * 1024 * 1024, threads: 3, formData: { file_id: function () { return file_id; } } });上传时 WebUploader 会向/api/upload发送 POST,带有file、file_id、chunk(当前分片从 0 开始)、chunks(总片数)。后端接口里chunk_index要用chunk字段。
4.3 上传进度与合并触发
进度条绑定uploadProgress事件,所有分片完成后触发uploadFinished,然后调用合并接口。代码如下:
uploader.on('uploadProgress', function (file, percentage) { $('#progress-bar').css('width', percentage * 100 + '%').text(Math.round(percentage * 100) + '%'); $('#status').text('上传中:' + Math.round(percentage * 100) + '%'); }); uploader.on('uploadFinished', function () { $('#status').text('分片上传完成,开始合并...'); mergeFile(); }); uploader.on('error', function (type) { if (type === 'Q_TYPE_DENIED') { alert('文件类型不允许'); } else if (type === 'Q_EXCEED_SIZE_LIMIT') { alert('文件大小超限'); } }); function mergeFile() { $.post('/api/merge', { file_id: file_id, filename: current_file.name, total_chunks: Math.ceil(current_file.size / (2 * 1024 * 1024)) }).done(function (res) { if (res.code === 0) { $('#status').text('上传成功,文件已合并:' + res.path); } else { $('#status').text('合并失败:' + res.msg); } }).fail(function () { $('#status').text('合并请求失败,请重试'); }); }注意这里file_id需要在上传开始前就确定。WebUploader 提供了before-send-file事件,我们在该事件里计算 MD5 并赋值给file_id。算 MD5 有大文件的性能问题,放到第 5 章避坑里详细说。
5. 避坑:分片上传的五个经典翻车现场
5.1 Nginx 限制导致分片请求 413
现象:本地 Flask 开发服务器测试正常,一上生产服务器上传小文件名片也正常,但超过一定大小后,第一个分片请求就返回 413。
原因:Nginx 默认client_max_body_size是 1MB,而分片大小设置了 2MB,单个分片请求体超过了 Nginx 限制,直接被 Nginx 拒绝。这个问题和 Flask 没关系,请求还没走到后端。
解决:在 Nginx 的 location 块里加client_max_body_size 10m;,这个值要大于分片大小。如果有反向代理层,每一层代理都要检查。我习惯把分片大小和 Nginx 限制统一设成 10MB,避免以后调大分片时再踩一次。
5.2 合并顺序出错导致文件损坏
现象:分片上传全部成功,合并也没有报错,但最终文件打不开。PDF 显示损坏,视频播放器无法解码。
原因:合并时对分片列表排序用了默认的字符串排序。os.listdir会返回类似chunk_10、chunk_2、chunk_3这样的排列,字符串排序会把chunk_10排到chunk_2前面,顺序错乱后合并出的文件自然是坏的。
解决:排序用key=lambda x: int(x.split('_')[1])。我在 3.3 节的代码里已经用了这个写法。另外,更好的办法是分片文件名统一用零填充序号,比如chunk_000010,这样字符串排序和数字排序结果一致,双保险。
5.3 临时目录被清理导致断点续传失败
现象:用户上传到一半断网,重试时前端提示从断点续传,但后端返回分片缺失,需要重新上传整个文件。
原因:分片临时目录默认放在系统/tmp下,系统重启或者有定时清理任务会把临时文件删掉。另外,如果应用部署在容器里,容器重启后临时文件就没了。断点续传依赖分片临时目录存在,目录被清就续传不了。
解决:把临时目录放在应用自己的数据目录下,比如data/tmp/,并写一个定时任务清理超过 24 小时的临时文件。清理逻辑要注意:不能只按目录创建时间,因为一个文件的多个分片目录是同一个file_id,要按目录的 mtime 判断最后活跃时间,超过一定时间才删除。
5.4 秒传 MD5 计算卡死页面
现象:选择一个 4GB 文件后,浏览器页面直接卡住十几秒,进度条没有反应。用户以为程序死了,实际上是在算 MD5。
原因:WebUploader 自带的 md5 计算功能是对整个文件逐字节做 MD5,4GB 文件在纯前端环境算起来非常慢,耗时几十秒,期间阻塞主线程。特别是低配电脑,卡顿非常明显。
解决:不要对整个文件做完整 MD5。务实做法是取文件头部 2MB 和尾部 2MB,再拼上文件大小,基于这个组合字符串算 MD5 作为file_id。这样既能区分绝大多数不同文件,又不会让用户等待。后端秒传命中率会稍微降低,但可接受。真正需要强校验时,在后端合并完成后对完整文件算一次 MD5,和前端上传前记录的原始 MD5 比对,那时校验不阻塞用户操作。
5.5 合并请求重复触发
现象:上传进度到 100% 后,浏览器发送了两次/api/merge请求,后端合并了两次,第二次提示分片目录不存在。
原因:WebUploader 的uploadFinished事件在某些版本里会因为队列状态变化触发两次。另外一种情况是前端使用了重试机制,在uploadFinished后又有重试队列里的分片继续上传,导致完成事件重复。
解决:在 JS 里用一个布尔变量做锁,if (merged) return; merged = true;。后端合并接口也做幂等设计:如果file_id对应的分片目录不存在但最终文件已经存在,则直接返回成功。这样即使前端误触发两次,也不会对系统造成额外影响。
6. 进阶:秒传和断点续传的完整闭环
6.1 秒传:先查哈希再传分片
秒传的关键是后端能根据file_id判断文件是否存在。最简单的方式是维护一张哈希映射表。用 SQLite 就可以,表结构就三列:file_id、file_path、file_size。前端计算出file_id后,先调一个检查接口:
@app.route('/api/check', methods=['POST']) def check_file(): file_id = request.form.get('file_id', '') if not file_id: return jsonify({'code': 1, 'msg': '缺少file_id'}), 400 record = find_by_file_id(file_id) if record: return jsonify({'code': 0, 'file_exists': True, 'path': record[1]}) return jsonify({'code': 0, 'file_exists': False})如果返回file_exists: True,前端直接显示上传成功,跳过所有分片请求。注意这个方案依赖于前端生成file_id的方式,如果只取了文件首尾部分,可能会有极低概率误判。为了降低风险,我在实际项目里还会把文件大小和最后修改时间加进file_id的原始字符串,这样即使两份文件首尾相同、中间不同,只要大小不同也能区分。
从安全角度看,检查接口可以暴露文件是否存在给任意用户,如果担心信息泄露,可以在file_id生成时加入用户标识或随机盐,让file_id不公开可猜。我在内部系统中一般直接使用随机 UUID 作为file_id,秒传只针对同一个 session 内重复选择相同文件的情况,不跨用户。
6.2 断点续传:查询已上传分片
断点续传的前端逻辑是:文件 MD5 算好后,先请求/api/uploaded接口,带上file_id,后端返回已上传的分片序号列表。前端把所有已存在的分片从队列里跳过,只传剩余分片。
@app.route('/api/uploaded', methods=['POST']) def uploaded_chunks(): file_id = request.form.get('file_id', '') if not file_id: return jsonify({'code': 1, 'msg': '缺少file_id'}), 400 chunk_dir = os.path.join(TMP_DIR, file_id) if not os.path.isdir(chunk_dir): return jsonify({'code': 0, 'chunks': []}) chunk_names = os.listdir(chunk_dir) existing = sorted(int(c.split('_')[1]) for c in chunk_names) return jsonify({'code': 0, 'chunks': existing})前端拿到existing列表后,需要过滤掉已经上传的分片。WebUploader 没有直接跳过指定分片的 API,常见做法是在uploadChunkStart事件里,对已存在的分片直接调用uploader.skipChunk(file, index)或者返回一个中断标记。不同版本 API 有差异,我的习惯是绕开它:既然能拿到已上传分片列表,就直接在前端把文件截断成未上传的部分,通过一个自定义上传队列来控制。如果不想引入复杂逻辑,最简单的做法是后端把已存在的分片请求直接原样返回成功,这样前端不需要知道哪些分片已上传,所有分片都会发请求,但后端对已存在的分片直接跳过保存,节省了传输流量和时间。
验证整个流程是否可靠,我一般会模拟弱网环境测试。用 Chrome 的 DevTools 设置网络为 Slow 3G,上传一个 200MB 文件,看到进度条走一部分后手动断网,恢复网络后继续上传,最终合并出来的文件用 MD5 和本地源文件比对。只有源文件和合并后文件 MD5 完全一致,才能确认分片顺序、完整性和合并逻辑都没问题。我通常把这个校验写进测试脚本,每次改完代码都跑一遍。从那以后我每次上线分片上传功能,都会强制走一遍断网重传和 MD5 校验流程,确保不出“上传成功但文件损坏”的玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取