
简介面向大文件传输场景的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 作为后端处理分片上传核心就两步接收分片保存到临时目录合并分片输出完整文件。它不需要数据库不需要后台任务队列只要注意几个细节就能跑得很稳。相比 DjangoFlask 的路由和请求处理更直接几个装饰器就能把接口写明白适合这种单一功能的 Demo 或中台模块。性能上分片上传的本质是把大文件拆成多次请求每一片都只是普通的 POSTFlask 的同步视图足以应对常规并发。有人担心 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 只需要三个依赖flask2.3.3 flask-cors4.0.0 werkzeug2.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_okTrue) os.makedirs(TMP_DIR, exist_okTrue) 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, typeint) total_chunks request.form.get(total_chunks, typeint) 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_okTrue) # 用零填充序号避免合并时字符串排序出错 chunk_name fchunk_{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, typeint) 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(keylambda 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, length1024 * 1024) # 合并成功后清理临时分片目录 shutil.rmtree(chunk_dir) return jsonify({ code: 0, msg: 合并成功, path: /download/ safe_name })这里有个安全点容易被忽视secure_filename会过滤掉文件名里的路径穿越字符和空格避免用户通过filename参数把文件写到目标目录之外。如果你希望保留中文文件名secure_filename会把中文过滤掉可以改用自定义的正则清洗函数或者结合uuid生成新文件名再把原始文件名存到数据库里做映射。合并时shutil.copyfileobj(fin, fout, length1024*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 langzh-CN head meta charsetUTF-8 title大文件分段上传 Demo/title link relstylesheet href/static/lib/webuploader/webuploader.css link relstylesheet href/static/lib/bootstrap/css/bootstrap.min.css /head body div classcontainer mt-5 h3分段上传演练/h3 div idpicker classbtn btn-primary选择文件/div div idprogress-wrap classprogress mt-3 styledisplay: none; div idprogress-bar classprogress-bar roleprogressbar stylewidth: 0%0%/div /div p idstatus classtext-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 typefile。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前面顺序错乱后合并出的文件自然是坏的。解决排序用keylambda 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 计算功能是对整个文件逐字节做 MD54GB 文件在纯前端环境算起来非常慢耗时几十秒期间阻塞主线程。特别是低配电脑卡顿非常明显。解决不要对整个文件做完整 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 校验流程确保不出“上传成功但文件损坏”的玄学问题。希望帮到你。本文还有配套的精品资源点击获取