Flask+WebUploader大文件分段上传与断点续传实战 简介这是一份基于Flask与WebUploader实现的大文件分片上传与流式下载的完整Web开发示例面向Python后端初学者、Web全栈学习者及需要解决大文件传输瓶颈的开发者。资源通过前端分片上传、服务端异步合并、多用户隔离存储与内存友好的流式响应切实应对传统单次HTTP上传易超时、OOM及并发冲突等痛点适用于企业级文件中台、教育平台课件上传等真实场景。压缩包共26个文件含5个CSS与5个JS支撑Bootstrap界面与WebUploader交互、2个Python核心脚本server.py与upload模块、4个Source Map调试文件、2个HTML模板首页与文件列表页以及字体、图标等静态资源整体仅427KB轻量易部署。已有653人学习下载读者可直接运行体验完整上传/下载闭环掌握分片校验、临时分片管理、进度条渲染、流式响应头设置等关键实现细节并参考README中的协议说明与未来优化方向如跳过合并步骤直连分片下载。1. Flask WebUploader 分段上传大文件不是“加个插件就完事”而是要亲手拆开断点续传的黑匣子你有没有试过在网页端上传一个 2GB 的视频前端卡死、后端超时、网络抖动直接归零重来——这种体验不是玄学是没把分段上传的底层逻辑理清楚。upload-demo这个项目不是教你怎么写个“能跑”的上传页面而是把 Flask 后端和 WebUploader 前端之间那层胶水——分片切分、唯一标识、断点校验、合并策略、并发控制——全摊开给你看。它用最轻量的依赖纯 Flask Bootstrap WebUploader JS不引入 Redis 或数据库做状态管理所有元信息靠文件系统临时目录JSON 文件落地适合嵌入到已有 Flask 管理后台、快速交付内部工具也适合新手理解“大文件上传”到底在服务器上发生了什么。如果你正被413 Request Entity Too Large折磨或发现 WebUploader 显示“上传成功”但磁盘上没文件又或者想让上传支持暂停/续传/拖拽/多文件并行——这个 demo 就是你该拆的第一份源码包。2. 分段上传不是前端炫技而是前后端协同的三阶段协议切片 → 校验 → 合并WebUploader 是个成熟前端组件但它本身不解决“服务端怎么接”。upload-demo的价值在于它用不到 300 行 Python 200 行 JS把整个协议闭环跑通。关键不在“用了什么”而在“为什么这么串”。2.1 前端切片逻辑WebUploader 配置必须与后端路由严格对齐WebUploader 默认按chunkSize: 5 * 1024 * 10245MB切片但这个值不是随便写的。它要同时满足小于 Nginx/Apache 的client_max_body_size否则单片请求直接 413大于 HTTP 超时阈值太小导致频繁建连反而慢适配后端磁盘 I/O 批量写入节奏实测 2–10MB 区间最稳。upload-demo的index.html中关键配置如下script var uploader WebUploader.create({ swf: /static/webuploader/Uploader.swf, server: /upload/chunk, // ← 必须与 Flask 路由一致 pick: #picker, chunkSize: 5 * 1024 * 1024, // ← 5MB后端需识别此值 chunks: 10, // ← 并发上传分片数非总片数 chunkRetry: 3, fileNumLimit: 10, fileSizeLimit: 10 * 1024 * 1024 * 1024, // 10GB accept: { title: Videos, extensions: mp4,avi,mov,wmv,flv, mimeTypes: video/* } }); /script注意server: /upload/chunk是核心。WebUploader 每次上传一个分片都会 POST 到这个地址并附带chunk当前分片序号从 0 开始、chunks总分片数、name原始文件名、md5可选用于秒传等字段。后端路由/upload/chunk必须能解析这些参数否则整个流程中断。2.2 后端接收分片Flask 如何安全落盘而不覆盖、不丢片upload-demo的app.py中/upload/chunk路由是整套逻辑的中枢。它不做任何业务判断只干三件事校验请求是否含必要字段chunk,chunks,name,file生成唯一upload_id基于文件名时间戳随机数避免同名文件冲突将分片以upload_id_chunk_00001形式存入./uploads/chunks/目录。app.route(/upload/chunk, methods[POST]) def upload_chunk(): if file not in request.files: return jsonify({code: 400, msg: No file part}), 400 file request.files[file] chunk int(request.form.get(chunk, 0)) chunks int(request.form.get(chunks, 1)) filename secure_filename(request.form.get(name, unknown)) # 生成 upload_id防同名覆盖支持断点续传 upload_id hashlib.md5(f{filename}_{int(time.time())}_{random.randint(1000,9999)}.encode()).hexdigest()[:12] # 创建分片存储目录 chunk_dir os.path.join(UPLOAD_CHUNKS_DIR, upload_id) os.makedirs(chunk_dir, exist_okTrue) # 写入分片使用 chunk 序号补零命名便于后续排序合并 chunk_path os.path.join(chunk_dir, fchunk_{chunk:05d}) file.save(chunk_path) # 记录元数据轻量替代数据库 meta_path os.path.join(chunk_dir, meta.json) meta { filename: filename, total_chunks: chunks, uploaded_chunks: [chunk], upload_time: time.time() } if os.path.exists(meta_path): with open(meta_path, r) as f: existing json.load(f) meta[uploaded_chunks] list(set(existing.get(uploaded_chunks, []) [chunk])) with open(meta_path, w) as f: json.dump(meta, f) return jsonify({code: 0, msg: Chunk saved, upload_id: upload_id})参数说明secure_filename()是 Flask-WTF 提供的安全函数防止路径遍历如../../etc/passwdchunk_{chunk:05d}保证分片文件按数字顺序排列chunk_00000,chunk_00001…避免chunk_1排在chunk_10前面导致合并错乱meta.json是轻量状态记录不依赖数据库靠文件系统原子写入保障一致性每次上传新分片都读旧 meta、合并已传列表、再写回——这是支持断点续传的关键。2.3 合并触发时机不是“最后一片上传完”而是“客户端显式调用 /merge”很多初学者误以为“当chunk chunks-1时后端自动合并”这是典型误区。WebUploader 的chunks是预估总数实际可能因网络重试、取消上传而变化。upload-demo采用显式合并协议前端在所有分片上传成功后主动 POST 到/upload/merge携带upload_id和filename。app.route(/upload/merge, methods[POST]) def merge_chunks(): upload_id request.form.get(upload_id) filename secure_filename(request.form.get(filename, merged.bin)) chunk_dir os.path.join(UPLOAD_CHUNKS_DIR, upload_id) if not os.path.exists(chunk_dir): return jsonify({code: 404, msg: Upload session not found}), 404 meta_path os.path.join(chunk_dir, meta.json) if not os.path.exists(meta_path): return jsonify({code: 500, msg: Meta file missing}), 500 with open(meta_path, r) as f: meta json.load(f) # 校验是否全部分片已到位 expected set(range(meta[total_chunks])) uploaded set(meta.get(uploaded_chunks, [])) if expected ! uploaded: return jsonify({ code: 400, msg: fChunks incomplete: {len(uploaded)}/{meta[total_chunks]} }), 400 # 按序合并分片 output_path os.path.join(UPLOAD_FINAL_DIR, filename) os.makedirs(UPLOAD_FINAL_DIR, exist_okTrue) chunk_files sorted([ os.path.join(chunk_dir, f) for f in os.listdir(chunk_dir) if f.startswith(chunk_) and os.path.isfile(os.path.join(chunk_dir, f)) ]) with open(output_path, wb) as out: for chunk_file in chunk_files: with open(chunk_file, rb) as f: out.write(f.read()) # 清理分片目录可选生产环境建议异步清理 shutil.rmtree(chunk_dir) return jsonify({ code: 0, msg: Merge success, file_url: f/download/{filename} })关键设计点合并前强制校验expected uploaded杜绝“看似传完实则漏片”sorted()确保按chunk_00000→chunk_00001顺序读取避免字典序错误chunk_10chunk_2合并后立即删除chunk_dir释放磁盘空间避免碎片堆积。3. 断点续传不是魔法而是靠 upload_id meta.json 实现的“状态快照”断点续传常被神化其实本质就是客户端记住上次传到哪了服务端能认出这是同一个上传会话并告诉客户端“你已经传了哪些片”。upload-demo用最朴素的方式实现了这一点——没有长连接不依赖 Session全靠 URL 参数和文件系统。3.1 客户端如何发起“续传”请求WebUploader 自身支持断点续传但前提是后端提供/upload/check接口返回已上传的分片列表。upload-demo在app.py中补充了该路由app.route(/upload/check, methods[POST]) def check_upload(): filename secure_filename(request.form.get(name, )) if not filename: return jsonify({code: 400, msg: Missing name}), 400 # 尝试从 meta.json 中恢复 upload_id —— 这里简化处理实际项目应查数据库或缓存 # demo 中我们约定前端首次上传时后端返回 upload_id前端存 localStorage # 续传时前端必须携带 upload_id upload_id request.form.get(upload_id) if not upload_id: return jsonify({code: 400, msg: Missing upload_id}), 400 chunk_dir os.path.join(UPLOAD_CHUNKS_DIR, upload_id) meta_path os.path.join(chunk_dir, meta.json) if not os.path.exists(meta_path): return jsonify({code: 404, msg: No upload session found}), 404 with open(meta_path, r) as f: meta json.load(f) return jsonify({ code: 0, uploaded: meta.get(uploaded_chunks, []), total: meta.get(total_chunks, 0), filename: meta.get(filename, filename) })前端调用逻辑在 WebUploader 初始化后// 续传检查从 localStorage 读 upload_id var uploadId localStorage.getItem(upload_id_ filename); if (uploadId) { $.post(/upload/check, { name: filename, upload_id: uploadId }, function(res) { if (res.code 0 res.uploaded.length 0) { uploader.option(chunkUploaded, res.uploaded); // 告诉 WebUploader 哪些片已存在 } }); }提示uploader.option(chunkUploaded, [...])是 WebUploader 的私有 API非文档公开但它是实现续传的核心。它让 WebUploader 跳过已传分片只上传缺失部分。3.2 upload_id 的生成与生命周期管理upload_id是整个断点续传的锚点。upload-demo当前用md5(filename timestamp random)生成优点是无状态、不依赖外部服务缺点是同一文件多次上传会产生不同 upload_id无法跨浏览器/设备续传。生产环境应升级为前端计算文件 MD5用 SparkMD5.js作为 upload_id 主体后端校验upload_id是否已存在若存在且meta.json中uploaded_chunks非空则直接返回已传列表这样用户换电脑、刷新页面、甚至关机重启只要文件没改就能续传。当前 demo 的upload_id生成逻辑虽简单但已足够说明原理它不是 UUID而是业务语义 ID —— 代表“这个用户正在上传这个文件的这次会话”。3.3 断点续传的边界什么情况下会失效断点续传不是银弹。以下情况会导致续传失败必须提前告知用户场景现象原因解决方案用户清空浏览器 localStorage页面刷新后无法续传upload_id丢失前端无法发起/upload/check前端增加 fallback上传前先用文件名哈希尝试匹配历史 upload_id需后端支持模糊查询分片目录被手动删除/upload/check返回 404chunk_dir被运维清理或磁盘满自动清理设置chunk_dir过期时间如 24h并在meta.json中记录expire_at合并成功后自动清理同一文件被两个标签页同时上传上传完成但文件损坏两个会话共用同一upload_idmeta.json 被并发写覆盖前端加本地锁IndexedDB 或 localStorage 时间戳校验或后端用文件锁flock保护 meta.json 写入4. 避坑五个血泪经验总结全是线上翻车现场复盘别等上线后被用户投诉“上传一半卡住”这五条是我在线上环境踩过的坑每一条都对应一个真实故障。4.1 现象前端显示“100%”但后端./uploads/final/下无文件./uploads/chunks/里只剩空目录原因/upload/merge路由中shutil.rmtree(chunk_dir)执行成功但open(output_path, wb)因磁盘满失败异常未被捕获导致合并中断且无日志。解决在with open(output_path, wb) as out:前加磁盘空间检查并用try/except包裹整个合并块失败时保留chunk_dir并返回明确错误码# 新增磁盘检查 def disk_usage_ok(path, min_gb1): total, used, free shutil.disk_usage(path) if free min_gb * 1024**3: raise OSError(fDisk space low: {free/(1024**3):.1f} GB left) # 合并块外层 try try: disk_usage_ok(UPLOAD_FINAL_DIR, min_gb2) with open(output_path, wb) as out: ... except OSError as e: app.logger.error(fMerge failed for {upload_id}: {e}) return jsonify({code: 507, msg: Insufficient storage}), 5074.2 现象Chrome 上传正常Safari 报413 Request Entity Too Large原因Nginx 默认client_max_body_size 1m而 Safari 对multipart/form-data的分片请求体大小计算更严格即使单片 5MB也可能因 boundary 和 header 膨胀超限。解决在 Nginx 配置中针对/upload/chunk路由单独放大限制location ^~ /upload/chunk { client_max_body_size 10M; # 必须 ≥ chunkSize buffer proxy_pass http://flask_backend; }注意不要全局设client_max_body_size 10G这会放大 DoS 攻击风险。4.3 现象并发上传多个大文件时Flask 进程 CPU 100%响应延迟飙升原因Flask 默认单线程开发服务器Werkzeug/upload/chunk是同步阻塞 IO每个分片上传都占一个线程10 个并发 × 5MB 分片 50MB 内存瞬时占用触发 GC 频繁。解决生产必须用 Gunicorn 异步 worker或改用gevent模式# 启动命令推荐 gunicorn -w 4 -k gevent -b 0.0.0.0:5000 app:app同时/upload/chunk中的file.save()改为流式写入避免内存缓冲# 替换 file.save(chunk_path) with open(chunk_path, wb) as f: chunk file.stream.read(8192) # 每次读 8KB while chunk: f.write(chunk) chunk file.stream.read(8192)4.4 现象用户上传.zip文件后解压报“文件损坏”但单片校验 MD5 全对原因分片合并时未按二进制模式打开Windows 环境下\n被自动转为\r\n破坏二进制完整性。解决所有文件操作必须显式指定moderb/modewbPython 3 默认文本模式必须覆盖# ❌ 错误open(chunk_file) 默认 text mode # ✅ 正确 with open(chunk_file, rb) as f: # ← 必须 rb out.write(f.read())4.5 现象upload_id目录名含特殊字符如,/Linux 下创建失败原因hashlib.md5().hexdigest()生成的字符串含a-z0-9安全但若开发者自行拼接upload_id filename _ str(time.time())filename中的空格、/会破坏路径。解决upload_id必须经secure_filename()处理或直接用 base62 编码比 hex 更短import base64 def to_base62(num): chars 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ if num 0: return chars[0] arr [] while num: arr.append(chars[num % 62]) num // 62 return .join(reversed(arr)) # 生成 upload_id 时 upload_id to_base62(int(time.time() * 1000000))[:10] # 10位唯一ID5. 生产就绪 checklist从 demo 到交付这七项必须手工验证写完代码只是开始。我把upload-demo推到生产环境前必做的七项验证列成清单。不是“应该做”而是“不做就等着半夜三点被电话叫醒”。5.1 验证项 1Nginx 层级超时与 Flask 层级超时必须错开很多人只改 Flask 的timeout忘了 Nginx 的proxy_read_timeout。结果Flask 还在合并 2GB 文件Nginx 已经断开连接前端看到“Network Error”。验证方法在app.py中给/upload/merge加日志和time.sleep(120)模拟长合并用curl -v触发合并观察是否在 60sNginx 默认时断开调整 Nginxlocation /upload/merge { proxy_read_timeout 300; # 5分钟必须 Flask 合并最大耗时 proxy_send_timeout 300; proxy_connect_timeout 300; proxy_pass http://flask_backend; }5.2 验证项 2分片文件权限必须为 644目录为 755Linux 下如果./uploads/chunks/目录权限是777SELinux 或某些容器环境会拒绝写入。upload-demo默认用os.makedirs(..., exist_okTrue)但没设mode。修复代码os.makedirs(chunk_dir, mode0o755, exist_okTrue) # ← 显式设权限 # 分片文件也设权限 os.chmod(chunk_path, 0o644)5.3 验证项 3secure_filename()不能替代业务校验secure_filename(../../../etc/passwd)返回etc_passwd看似安全。但如果用户上传shell.php.jpg后端又错误地用os.path.splitext()取扩展名就可能绕过校验。加固方案白名单校验allowed_exts {.mp4, .avi, .pdf, .zip}MIME 类型二次校验用python-magicimport magic mime magic.from_buffer(file.read(1024), mimeTrue) if not mime.startswith(video/) and not mime.startswith(application/zip): return jsonify({code: 400, msg: Invalid file type}), 4005.4 验证项 4/download/filename必须加as_attachment且禁用目录遍历默认send_from_directory允许../../../etc/passwd必须拦截。安全路由app.route(/download/path:filename) def download_file(filename): # 严格限制路径 if .. in filename or filename.startswith(/): abort(403) safe_filename secure_filename(filename) return send_from_directory(UPLOAD_FINAL_DIR, safe_filename, as_attachmentTrue)5.5 验证项 5meta.json必须加文件锁防并发写覆盖两个请求同时写meta.jsonA 读{uploaded: [0,1]}B 读{uploaded: [0,1]}A 写[0,1,2]B 写[0,1,3]最终丢失分片 2 或 3。加锁方案Linux/macOSimport fcntl with open(meta_path, r) as f: fcntl.flock(f, fcntl.LOCK_EX) try: meta json.load(f) meta[uploaded_chunks].append(chunk) f.seek(0) json.dump(meta, f) f.truncate() finally: fcntl.flock(f, fcntl.LOCK_UN)5.6 验证项 6前端必须监听error事件并透出具体错误码WebUploader 的error事件默认只打 console用户无感知。必须捕获并展示uploader.on(error, function(code, err) { if (code Q_TYPE_DENIED) { alert(不支持的文件类型); } else if (code Q_EXCEED_SIZE_LIMIT) { alert(文件超过 10GB 限制); } else if (code F_EXCEED_SIZE) { alert(单个分片上传失败请检查网络); } else { console.error(Upload error:, code, err); alert(上传失败请重试); } });5.7 验证项 7必须提供/health接口供负载均衡器探测K8s 或 Nginx upstream 需要健康检查端点。upload-demo没有必须加app.route(/health) def health_check(): # 检查磁盘空间、分片目录可写、临时文件可创建 try: test_file os.path.join(UPLOAD_CHUNKS_DIR, health_test) with open(test_file, w) as f: f.write(ok) os.remove(test_file) return jsonify({status: healthy, timestamp: time.time()}) except Exception as e: app.logger.error(fHealth check failed: {e}) return jsonify({status: unhealthy}), 503从那以后我每次交付上传功能都强制走一遍这七项验证——哪怕客户说“就内部用不用那么严”。因为大文件上传的故障90% 发生在深夜、节假日、或用户急着交报告的下午三点。它不考验你的算法多炫只拷问你对 IO、权限、并发、超时这些基础细节抠得多细。希望帮到你。本文还有配套的精品资源点击获取