
简介面向Python Web开发者的分片上传与下载演示项目后端基于Flask框架前端采用WebUploader组件可实现大文件分段上传、服务器端合并以及低内存流式下载适合学习文件传输处理、内存控制和并发场景的工程师。压缩包共26个文件包含两处Python源码服务入口与上传模块、两个HTML模板页面以及Bootstrap、WebUploader所需的CSS、JavaScript、字体和映射文件整体仅427KB目录结构清晰能快速定位前后端代码和静态资源。该项目已有651人浏览学习示例代码完整展示了分片上传、分片合并、流式下载等关键逻辑还处理了多用户同时上传互不干扰的可靠机制说明文档还特别指出未来可优化方向即下载时按序直接重新合并分片回传而不先在服务端拼接全部分片从而进一步降低内存占用。对研究大文件传输场景、希望复用成熟前后端交互方案的开发者有直接参考价值。1. 为什么大文件上传总翻车分片不是可选项是必选项折腾过文件上传的人应该都有过这种血压拉满的时刻一个 2GB 的压缩包进度条稳如老狗地爬到 97%然后浏览器给你弹一个“网络错误”后端日志里连个影子都没有。一怒之下重传服务器又开始新一轮的磁盘和带宽消耗。这不是网络玄学而是单次 HTTP 请求承载大文件时稳定性天然脆弱——连接超时、代理断开、内存溢出任何一个环节抖动整趟上传就白搭。分片上传fragmentation / chunked upload就是冲着这个痛点去的把大文件切成若干个 2MB~5MB 的小块失败时只重传失败的那片而不是从头再来。这份upload-demo做的事情很纯粹——用 Flask 做后端接收分片用 WebUploader 做前端切片和并发控制再用 Bootstrap 套一层能直接点着玩的前端页面。适合正在做私有网盘、内部系统、后台管理项目里需要接收大文件的人也适合想搞懂“分段上传前后端到底怎么对齐”的人。下面我会把它的工作流、三个核心接口、前端参数权衡和常见坑拆开讲清楚。2. 选型与工作流WebUploader 如何与 Flask 配合处理分段上传2.1 为什么是 Flask WebUploader而不是其他组合我没选 Django 系或者 Spring 系来拆这个 Demo不是它们不行而是 Flask 在处理分片上传这类“路由少、逻辑集中”的场景里性价比高得离谱。你不需要 ORM、不需要 Admin 后台、不需要中间件体系只要几个路由接收上传请求、读写文件目录就够了。Flask 的request.files和request.form天然就能解析 WebUploader 提交的 multipart 表单几乎没有磨合成本。WebUploader 这边它的价值在于把前端分片这件事封装成了“配置项”而不是“算法”。你不用自己写 FileReader 切片的逻辑也不用自己管理 Blob 的合并它默认就支持按chunkSize自动切割文件多个分片并发上传threads控制并发数失败分片单独重试通过formData追加自定义参数后端要的标识符就靠它传递Bootstrap 只承担最外层的页面样式让你打开 demo 页面时不至于对着裸 HTML 怀疑人生。它的存在不参与上传逻辑但能让整个 demo 看起来像个能交付的东西。这是这类“演示项目”里很实用的取舍——把精力放在上传链路上UI 用现成框架搭个壳。2.2 一次完整的分片上传数据是怎么流的我习惯先把数据流向梳理清楚再去看代码。整个流程是这样一条链路用户在页面选中文件WebUploader 读取文件基本信息文件名、大小、类型计算总大小。前端按chunkSize把文件切成chunk个数个分片每个分片就是一个独立的 Blob。第一个分片开始上传时通过formData携带自定义参数identifier我习惯用文件名的 MD5 值或者时间戳加随机串以及 WebUploader 自动带上来的chunk当前分片序号和chunks总分片数。后端收到每个分片后不直接写进目标文件而是先落到以identifier命名的临时目录里存成chunk_0、chunk_1这样的分片文件。当前端最后一个分片上传完成后触发merge接口后端读取该identifier对应临时目录下的所有分片按顺序拼接成完整文件。拼接完成后校验文件大小是否等于前端上报的size通过则删除临时分片返回成功。我拆过不少上传组件这里最容易翻车的点是第 4 步和第 5 步后端怎么命名分片文件、合并时怎么排序前后端必须达成默契。WebUploader 的formData里不会给你排序用的fileIndex只有chunk字段所以后端必须用chunk作为文件名的一部分合并时按它的数值排序而不是按字典序。这个细节我在第 5 章会单独当坑讲。3. 后端三个接口与合并逻辑路由设计、参数含义和状态机3.1 接收分片的 upload 路由临时目录与分片落盘后端是整个分片上传的核心承重墙前端切得再漂亮后端接不住全白搭。我拆这份 demo 时把后端逻辑拆成了三个独立的接口/upload接收分片、/merge合并文件、/check查询状态。先看接收分片的实现import os import uuid from flask import Flask, request, jsonify app Flask(__name__) UPLOAD_DIR os.path.join(os.getcwd(), uploads) CHUNK_DIR_BASE os.path.join(os.getcwd(), chunks) os.makedirs(UPLOAD_DIR, exist_okTrue) os.makedirs(CHUNK_DIR_BASE, exist_okTrue) app.route(/upload, methods[POST]) def upload_chunk(): # WebUploader 分片上传时文件字段名默认是 file可通过 fileVal 修改 file request.files.get(file) if file is None: return jsonify({status: error, message: no file part}), 400 # identifier 是自定义上传标识我习惯用 uuid 或前端算出的 md5 identifier request.form.get(identifier, str(uuid.uuid4())) chunk_index int(request.form.get(chunk, 0)) total_chunks int(request.form.get(chunks, 1)) # 每个 identifier 独立临时目录避免不同文件的切片互相污染 chunk_dir os.path.join(CHUNK_DIR_BASE, identifier) os.makedirs(chunk_dir, exist_okTrue) # chunk 字段是当前分片的序号必须按它来命名合并时才能正确排序 chunk_path os.path.join(chunk_dir, fchunk_{chunk_index}) file.save(chunk_path) return jsonify({ status: success, identifier: identifier, chunk_index: chunk_index, total_chunks: total_chunks }), 200这段代码里有两个点值得关注。第一是identifier的生成策略demo 里默认用uuid.uuid4()生成但真实场景我一般会让前端把文件名哈希或用户 ID 传过来方便后续做“秒传”后面进阶部分会展开。第二是分片文件的命名chunk_{chunk_index}看起来简单但它是合并环节排序正确与否的关键。分片存储用file.save(chunk_path)这个 Flask 自带的保存方式它会把上传流完整写到磁盘不会出现只写一半的情况。这里不需要你手动做read()和write()因为file.save内部已经处理了流式读取对内存占用很友好。3.2 合并文件的 merge 路由排序与拼接的正确姿势分片全部到齐后前端会调用/merge接口触发合并。这个接口决定最终文件能不能用我见过太多在这里把sorted()字典序排序导致文件损坏的例子。正确实现app.route(/merge, methods[POST]) def merge_chunks(): identifier request.form.get(identifier) if not identifier: return jsonify({status: error, message: identifier missing}), 400 original_name request.form.get(original_name, download.bin) chunk_dir os.path.join(CHUNK_DIR_BASE, identifier) if not os.path.isdir(chunk_dir): return jsonify({status: error, message: chunk dir not found}), 404 chunk_files os.listdir(chunk_dir) # 按 chunk_ 后的数字排序而不是按文件名排序 # sorted([chunk_0, chunk_10, chunk_2]) 默认结果 0/10/2顺序是错的 def chunk_sort_key(name): return int(name.split(_)[1]) chunk_files.sort(keychunk_sort_key) # 拼接所有分片到目标文件使用二进制追加模式 final_path os.path.join(UPLOAD_DIR, f{identifier}_{original_name}) with open(final_path, wb) as final_file: for chunk_name in chunk_files: chunk_path os.path.join(chunk_dir, chunk_name) with open(chunk_path, rb) as chunk_file: final_file.write(chunk_file.read()) chunk_file.close() # 合并完成后清理临时分片目录释放磁盘空间 shutil.rmtree(chunk_dir) return jsonify({ status: success, file_path: final_path, size: os.path.getsize(final_path) }), 200排序这块是血泪经验。os.listdir返回的文件名是字符串chunk_10排在chunk_2前面如果直接按字符串排合并出来的文件大概率是尾部数据跑到前面解压直接报错。chunk_sort_key这个函数把chunk_10转成数字10再按数字排序才能保证顺序正确。合并时用chunk_file.read()把整个分片读进内存再写入对单分片 5MB 以下没问题但如果你的分片调大到了 50MB建议换成shutil.copyfileobj(chunk_file, final_file, length1024*1024)或者手动while True: data chunk_file.read(8192)去循环读写避免内存被大分片撑爆。3.3 状态查询路由 check再传一个完整文件时怎么识别/check路由不是 WebUploader 默认会调用的但我在实际项目里一定会加它用途是让前端在开始上传之前先问一句“这个文件我是不是传过了”实现逻辑app.route(/check, methods[POST]) def check_file(): identifier request.form.get(identifier) if not identifier: return jsonify({status: error, message: identifier missing}), 400 # 在目标目录里查找是否有这个标识符对应的完整文件 for filename in os.listdir(UPLOAD_DIR): # 文件名格式是 {identifier}_{original_name}这里用前缀匹配 if filename.startswith(identifier): target_file os.path.join(UPLOAD_DIR, filename) return jsonify({ status: success, existing: True, file_name: filename, size: os.path.getsize(target_file) }), 200 return jsonify({status: success, existing: False}), 200这个路由的价值在于当前端在formData里把identifier固定为文件内容的哈希值时同一个文件再次被选中前端可以先请求/check后端发现已经存在同名文件直接告诉前端“无需上传”前端就假装上传完成。这就是大文件上传组件里常见的“秒传”逻辑。demo 里如果没有这一步你可以在上面代码的基础上自己补上成本非常低。4. 前端接入与并发控制chunkSize、threads 和重试的权衡4.1 初始化 WebUploader核心参数逐一拆解后端三个接口准备好之后前端的关键是把参数对齐。WebUploader 的初始化代码里chunkSize、threads、fileVal这三个参数决定了上传行为我逐个拆给你看var uploader WebUploader.create({ // 上传接口地址 server: /upload, // 选择文件的按钮 pick: #picker, // 文件上传表单名后端用 request.files.get(file) 取的字段 fileVal: file, // 是否开启分片上传大文件必开 chunked: true, // 每个分片的大小单位是字节这里设为 5MB chunkSize: 5 * 1024 * 1024, // 并发上传线程数根据服务器承受能力调整 threads: 3, // 开启文件去重同一文件不重复上传 duplicate: true, // 客户端内存只保留分片数据不整个文件驻留 formData: { identifier: } });参数层面chunkSize是最值得斟酌的。5MB 是我在多数部署环境下的默认值太大了比如 50MB分片上传退化成单文件上传一遇到网络抖动整个分片重传太小了比如 512KB会导致 HTTP 请求数量暴增对后端文件读写压力反而更大。我的经验是局域网内部系统可以调到 10MB公网环境建议 2MB~5MB。threads设置成 3 是 WebUploader 的默认值并发意味着同时会有 3 个分片在飞对 Flask 这类同步框架来说已经有一定压力不要一上来就调成 10。formData里的identifier在这里是个占位符真正的值需要在文件被添加之后动态灌入。这就是下一部分要处理的事件逻辑。4.2 事件回调里的秘密identifier 赋值与分片进度WebUploader 的事件机制是你理解它的第二把钥匙。fileQueued在文件进入队列时触发这时候我们能拿到文件对象算好identifier并塞进formData。uploadProgress控制进度条uploadAccept判断后端是否接受当前分片uploader.on(fileQueued, function (file) { // 用文件的名称加时间戳生成一个唯一标识 // 如果想要秒传可以在这里改用 SparkMD5 计算文件哈希 var identifier file.name _ new Date().getTime(); uploader.options.formData.identifier identifier; // 把这个 identifier 塞给当前文件后续所有分片请求都会携带 file.identifier identifier; // 显示文件信息到页面上 $(#file-name).text(file.name); $(#file-size).text(file.size); }); uploader.on(uploadProgress, function (file, percentage) { // 这个接口的值范围是 0 到 1直接赋给 Bootstrap 进度条 $(#progress-bar).css(width, percentage * 100 %); }); uploader.on(uploadAccept, function (file, response) { // 后端返回 status 不是 success 时返回 false 让该分片进入重试队列 if (response.status ! success) { return false; } }); uploader.on(uploadComplete, function (file) { // 所有分片传完后手动触发合并请求 $.ajax({ url: /merge, method: POST, data: { identifier: file.identifier, original_name: file.name }, success: function (res) { $(#status).text(上传成功合并文件大小 res.size); } }); });注意uploadAccept这个回调的返回值是 WebUploader 用来判断当前分片要不要重试的关键。回调里return false会让该分片进入失败队列并自动重试返回true才认为这片上传成功。不少开发者忽略了这个回调后端返回 500 时前端还一脸茫然地继续传下一个分片等全部传完去 merge才发现分片缺胳膊少腿。uploadComplete触发的时机是“所有分片都上传成功”注意这里是前端视角后端的分片可能还没完全落盘。稳妥做法是在uploadComplete里加个setTimeout延时 500ms 再调/merge给后端一点处理时间。虽然我这个写法直接调但实际部署时建议加延时。4.3 Bootstrap 页面的拼装静态资源位置别放错demo 的页面结构是三件套Bootstrap 的 CSS、WebUploader 的 JS 和 CSS、自己写的上传业务逻辑。目录结构我一般这样摆upload-demo/ ├── app.py # Flask 后端上面三个路由 ├── uploads/ # 合并完成的文件目录 ├── chunks/ # 分片临时目录 ├── static/ │ ├── bootstrap/ │ │ ├── css/bootstrap.min.css │ │ └── js/bootstrap.min.js │ ├── webuploader/ │ │ ├── webuploader.css │ │ ├── webuploader.js │ │ └── Uploader.swf # 兼容老浏览器的 Flash 模式可忽略 │ └── jquery/ │ └── jquery.min.js ├── templates/ │ └── index.htmlHTML 里引用静态资源时WebUploader要求swf路径单独配置如果你不在 HTML5 模式下工作必须把它指向正确的Uploader.swf位置。我拆过很多类似项目最常见的问题是开发者把webuploader.css漏了导致选择按钮的样式完全错乱明明功能正常但界面看起来像没加载成功。Bootstrap 在这里只负责撑场面把按钮、进度条、状态文字这些元素摆得整齐一些。真正干活的是webuploader.js和 jQuery。如果后期你想要文件列表、上传历史、拖拽上传Bootstrap 的组件生态也能顺手补齐但它不是分片上传的必要条件——WebUploader 本身不依赖 Bootstrap。5. 避坑清单WebUploader Flask 组合的六个常见翻车点5.1 合并后的文件损坏祸根是分片文件名排序现象所有分片上传都提示成功合并接口也返回了 200但下载下来的文件打不开图片只显示上半截压缩包提示“文件已损坏”。原因合并时用了sorted()对文件名做字典序排序chunk_10排在chunk_2前面分片被错误拼接。解决按数字值排序。代码里用int(name.split(_)[1])作为排序 key。这是我在第 3.2 节里反复强调的点也是我见过翻车率最高的一处。5.2 同一个文件上传两次第二次全是脏数据现象上传同一个文件第一次成功第二次选中后还没传完后端就报文件已存在或者合并出来的文件大小不对。原因没有动态生成identifier第二次用了相同的标识临时目录里残留了上次的分片新分片和旧分片混在一起。解决前端在fileQueued里用“文件名 时间戳”生成identifier或者更好的是用文件内容的哈希值结合后端/check路由实现秒传。临时目录在合并完成后要立即删除shutil.rmtree避免磁盘被废弃分片堆满。5.3 前端页面和 Flask 不在一个端口跨域把你卡死在“待上传”现象页面跑在 127.0.0.1:8080后端是 127.0.0.1:5000点完上传按钮后浏览器控制台一堆 CORS 错误WebUploader 的队列状态变成 error。原因Flask 默认不允许跨域资源共享浏览器的同源策略拦截了分片上传请求。解决后端加一个简单的 CORS 中间件我一般用flask-cors扩展两三行搞定from flask_cors import CORS CORS(app, supports_credentialsTrue)如果不想引入扩展也可以用 Flask 的after_request钩子手动添加三个响应头。这个问题的陷阱在于前端请求能发出去后端能收到但浏览器不把响应交给 JS所以uploadAccept收不到response.status你会看到一种“请求成功但回调无效”的诡异现象——绝大多数是 CORS。5.4 所有分片上传成功但服务器上一个分片都没存下来现象页面显示上传进度到了 100%后端/upload接口也打到了 200但chunks/目录是空的或者只看到几个 0 字节的文件。原因request.files.get(file)取不到文件说明前端传的字段名和后端期望的不一致。WebUploader 默认fileVal是file如果后端写成了request.files.get(upload_file)分片数据被丢弃。解决把后端的字段名改成file或者在前端初始化时把fileVal改成和后端一样的名字。我每次排查这类问题时会先在后端加一行print(request.files.keys())看一眼实际收到的字段名是什么而不是凭记忆猜。5.5 合并时后端内存飙升文件一大就崩溃现象上传 4GB 的文件合并到一半后端进程内存占用冲到 2GB 以上程序直接 OOM 崩溃。原因合并逻辑用了chunk_file.read()把整个分片读进内存分片如果设置得太大比如 50MB加上 Flask 的调试模式缓存内存瞬间吃满。解决改用流式复制。Python 标准库的shutil.copyfileobj就是干这个的with open(final_path, wb) as final_file: for chunk_name in chunk_files: with open(os.path.join(chunk_dir, chunk_name), rb) as cf: shutil.copyfileobj(cf, final_file, length1024 * 1024)每次只读 1MB、写 1MB内存占用会稳定在极低水平。这条在任何处理大文件的系统里都适用。5.6 分片全传完了Nginx 却报 413 Request Entity Too Large现象后端直连的时候上传一切正常一放到 Nginx 反代后面分片上传第一次就失败错误码 413。原因Nginx 默认client_max_body_size是 1MB单个分片超过这个值就会被拦截。解决在 Nginx 配置里调大这个参数server { client_max_body_size 20m; }注意这里设置的是单个分片的最大体积不是整个文件的最大体积。如果chunkSize是 5MB把client_max_body_size设成 20MB 已经留足余量。6. 进阶技巧用 MD5 指纹替换随机 identifier顺带实现秒传这份 demo 的核心链路已经完整但如果你要把它用到真实项目中还缺一个重要拼图文件唯一标识目前用的是“文件名 时间戳”它只能保证每次上传的 identifier 不同却没法识别同一个文件重复上传。要让系统具备秒传能力需要把 identifier 换成文件内容的哈希值通常是 MD5让相同内容的文件在首次上传后直接返回“已存在”。WebUploader 官方对 MD5 计算是这样做开启calculateHash后组件会逐块读取文件内容算 MD5在md5回调里拿到值。代码层面uploader.on(md5, function (file, md5) { // 这个回调在所有分片上传前触发拿到的 md5 就是文件内容指纹 uploader.options.formData.identifier md5; file.identifier md5; }); uploader.on(fileQueued, function (file) { // 先请求后端 /check 接口 $.ajax({ url: /check, method: POST, data: { identifier: file.name _ file.size }, success: function (res) { if (res.existing) { // 后端已存在相同 identifier 的文件直接标记为完成 uploader.skipFile(file); $(#status).text(文件已存在秒传完成); } else { uploader.options.formData.identifier file.name _ file.size; } } }); });这里有个权衡要讲清楚计算一个 2GB 文件的 MD5需要完整读取一遍磁盘耗时可能在十几秒到一分钟不等。这是“秒传”付出的前置成本——省去了上传时间但增加了计算时间。我的习惯是文件小于 200MB 直接算 MD5大于 200MB 时退化为“文件名 大小”作为 identifier秒传命中率略降但避免了用户选完文件还要等一分钟才看到进度条。后端/check路由里我第 3.3 节给的是“按 identifier 前缀匹配目标文件名”在秒传场景下需要再确认一点合并后的文件名规则必须是{identifier}_{original_name}这样当前端传来的 identifier 是 MD5 时后端能直接通过文件名定位到已存在的文件然后返回existing: true。这套补全逻辑做完后整个 upload-demo 才算从一个“能跑的分片上传界面”变成一个“具备断点续传和秒传能力的大文件传输系统”。回头看我拆过的那些项目凡是出现“目标文件损坏”又找不到原因的九成本文还有配套的精品资源点击获取