简介围绕大文件上传这一常见后端难题这份演示工程以 Python 配合 Flask 搭建服务端前端借助 WebUploader 完成分片切分与并发提交并用 Bootstrap 渲染上传进度条适合已掌握 Python 基础、想弄清分片上传与流式下载完整链路的开发者参考。整包共 26 个文件、约 427KB其中 2 个 py 文件承载 Flask 路由与分片合并逻辑2 个 html 模板分别对应上传页与文件列表页5 个 css 与 5 个 js 支撑页面样式及 WebUploader 交互另含字体图标与 requirements 依赖清单结构轻量、跑通门槛低。已有 653 人学习。读者可从中获得多用户并发上传互不干扰的处理思路、接收全部分片后合并成完整文件的具体实现以及低内存占用的流式下载方案代码与说明还点出合并分片时内存紧张的隐患并提示不合并、下载时按序重组回传的优化方向便于二次改造与排错。1. 大文件上传别一把梭分片落地要先想清楚的几件事三年前接过一个内网批量从浏览器上传打包文件的活前端XMLHttpRequest一次性发 2GB 压缩包标签页直接卡死网关 413用户重试两次后开始怀疑人生。后来把文件切成 5MB 一片、由 WebUploader 负责排队并发、Flask 在后端按序号落盘再合并整个交互才回到正常人的观感。upload-demo 就是按这个思路搭的一套可跑的前后端前端 Bootstrap 页面 WebUploader 分片投递后端 Flask 提供分片接收和合并接口再配一个 stream-downloader 支持 Range 回传Fragmentation 的脏活都收在服务端。它适合两类人一类是要在本地或内网快速把大文件上传跑通的后端和全栈另一类是想搞清楚分片协议字段、并发参数和断点续传边界的人。下面按“先定协议、再写后端、再接前端、最后排坑”的顺序把这套东西拆开。2. 分片协议怎么定chunkSize、并发数、md5 校验的取值与边界分片上传最难的不是写代码是先把协议砍清楚。协议不统一前端传的和后端接的就对不上chunk从 0 还是从 1 开始、总分片数叫chunks还是total、文件标识用 md5 还是 uuid这些细节决定后面能不能断点续传、能不能去重。这一章就把这些字段拍死同时给出 chunkSize 和线程数的取值区间最后落到 WebUploader 的配置代码上。2.1 先约定请求字段序号、总数、文件标识一个分片请求最少要带四样东西文件唯一标识、原始文件名、当前分片索引、分片总数。WebUploader 在开启chunked后会自动把id、name、chunk、chunks、size、type、lastModifiedDate这几个字段塞进 formData后端直接从request.form取就行不需要前端额外拼。协议字段建议按下面这张表对齐名字不要改来改去。字段类型是否必填说明idstring是文件唯一标识WebUploader 默认生成WU_FILE_x跨会话续传要换成指纹namestring是原始文件名合并时用它命名输出文件chunkint是分片索引从 0 开始chunksint是分片总数sizeint否当前分片字节数用于服务端二次校验filebinary是分片二进制内容字段名由fileVal决定有两个隐藏约定必须一开始就写进注释chunk一定从 0 开始合并时用range(chunks)遍历id默认带会话属性浏览器刷新后 WebUploader 会重新生成所以真正的断点续传不能靠它要换成“文件名 大小 最后修改时间”或前端算出来的 md5。协议定好之后再谈参数否则参数调半天也对不上。2.2 chunkSize 和 threads 怎么选不是越大越快chunkSize 太小分片请求数会爆炸。2GB 文件按 1MB 切是 2048 个请求服务端小文件 IO 和合并时的open次数都会明显上去进度条也会跳得人心里没底。chunkSize 太大单片失败的重传代价高弱网下经常出现“传了 80% 卡住、重传又从头开始”的翻车现场。我给一个常用区间实际按网络和磁盘调整。场景建议 chunkSizethreads备注移动端弱网1–2 MB1–2重传代价低进度更新频繁公网普通2–5 MB2–3最常见的组合内网/局域网5–10 MB3–5带宽够减少请求数超大文件10GB内网10–20 MB4–6注意磁盘顺序写和临时空间threads不是越大越好WebUploader 的并发是按分片算的3 个线程意味着同一时刻有 3 个分片在写盘。如果后端合并逻辑里用“目录里文件数 总数”判断是否合并并发一高就会同时触发多次合并第 5 章会专门讲这个坑。我的习惯是先把threads压到 3 跑通再往上加。还有两个限制要一起对齐网关的client_max_body_size和 Flask 的MAX_CONTENT_LENGTH两者都必须大于 chunkSize通常留 1–2MB 余量给 multipart 边界和表单字段。比如 chunkSize 设 5MB这两个值就别低于 7MB否则每个分片都会被 413 拦掉前端看到的只是“上传失败”根本不知道是网关干的。2.3 md5 校验放前端还是后端别让用户干等md5 有两个用途完整性校验和秒传索引。放前端算2GB 文件在浏览器里用 SparkMD5 跑一遍快的十几秒、慢的半分钟用户点了上传之后界面没反应体验很差。放后端算得等所有分片到齐、合并完成之后才知道对不对出问题已经晚了。常见做法是分层前端只算一个轻量指纹name size lastModified拼起来做一次字符串 hash保证同一文件每次生成同一个id用于服务端去重和断点续传后端在合并完成后对整个文件算一次 md5写进元数据下载或后续校验时用。这段代码不复杂关键是按块读、别把大文件整个读进内存。import hashlib def md5_of_file(path, buf_size4 * 1024 * 1024): h hashlib.md5() with open(path, rb) as f: while True: block f.read(buf_size) if not block: break h.update(block) return h.hexdigest()逻辑说明按 4MB 一块循环读update增量计算内存占用恒定。参数buf_size取 4–8MB 比较均衡太小系统调用多太大对 SSD 和机械盘都没有额外收益。算完的 md5 建议同时写进uploads/name.meta.json别只打印到日志里。2.4 WebUploader 的分片参数落到代码里协议和参数定完前端初始化就那几行配置但每一项都要知道它改的是什么。var uploader WebUploader.create({ swf: /static/webuploader/Uploader.swf, server: /api/upload, pick: #picker, fileVal: file, // 二进制字段名后端 request.files[file] 对应 chunked: true, // 开启分片 chunkSize: 5 * 1024 * 1024, // 每片 5MB chunkRetry: 2, // 单片失败重试 2 次 threads: 3, // 并发分片数 duplicate: true, // 允许重复选择同一个文件 auto: false, // 手动触发 upload() resize: false, // 图片场景才需要 formData: { // 额外业务字段比如目录 id }, accept: { title: 压缩包, extensions: zip,rar,7z,tar,gz } });参数说明fileVal决定后端从哪个字段拿二进制默认就是filechunkRetry是单分片重试次数不是整个文件auto: false配合一个“开始上传”按钮避免用户选完文件立刻开跑duplicate: true在续传场景下必须开否则同一文件第二次选择会被直接过滤。accept.extensions只做前端提示真正的后缀校验必须放后端这一点后面踩坑章节还会提。3. Flask 后端分片落地接收、落盘、合并与清理的完整链路前端把分片投过来之后后端要做的事只有四件校验字段、落盘、判断是否到齐、合并并清理。这四件事里最容易出问题的是“判断到齐”和“合并清理”前者涉及并发后者涉及原子性。这一章按目录结构、接收路由、合并函数、去重接口的顺序写每一步都给可运行的代码。3.1 目录结构和配置临时区和成品区要分开先把目录拍平分片和成品不要放在一起后面清理和排查都方便。upload-demo/ ├── app.py ├── config.py ├── static/ │ └── webuploader/ ├── templates/ │ └── index.html ├── chunks/ # 临时分片目录每个文件一个子目录 │ └── file_id/ │ ├── 000000.part │ └── 000001.part └── uploads/ # 合并后的成品 └── package.zip配置项单独放一个文件方便改参数时一眼看到全部约束。# config.py import os BASE_DIR os.path.abspath(os.path.dirname(__file__)) CHUNK_DIR os.path.join(BASE_DIR, chunks) UPLOAD_DIR os.path.join(BASE_DIR, uploads) MAX_CONTENT_LENGTH 20 * 1024 * 1024 # 单请求上限要大于 chunkSize ALLOWED_EXTENSIONS {zip, rar, 7z, tar, gz} os.makedirs(CHUNK_DIR, exist_okTrue) os.makedirs(UPLOAD_DIR, exist_okTrue)注意MAX_CONTENT_LENGTH的单位是字节这里写 20MB 是按 chunkSize 5MB 留了 4 倍余量方便后续往上调 chunkSize 而不用改配置。如果前面有网关网关那层也要同步放开。3.2 分片接收路由字段校验优先于写盘路由的第一步不是save是校验。缺字段、索引非法、后缀不在白名单这些都要在写盘之前拦掉否则chunks/目录会越来越乱。import os from flask import Flask, request, jsonify from werkzeug.utils import secure_filename app Flask(__name__) app.config.from_pyfile(config.py) def chunk_dir(file_id): 按 file_id 建子目录secure_filename 防止路径穿越 safe_id secure_filename(file_id) path os.path.join(app.config[CHUNK_DIR], safe_id) os.makedirs(path, exist_okTrue) return path app.route(/api/upload, methods[POST]) def upload_chunk(): file_id request.form.get(id, ).strip() name request.form.get(name, ).strip() try: index int(request.form.get(chunk, -1)) total int(request.form.get(chunks, -1)) except (TypeError, ValueError): return jsonify(code400, msg分片参数非法), 400 if not file_id or index 0 or total 0 or index total: return jsonify(code400, msg缺少必要参数), 400 ext name.rsplit(., 1)[-1].lower() if . in name else if ext not in app.config[ALLOWED_EXTENSIONS]: return jsonify(code400, msg文件类型不允许), 400 f request.files.get(file) if f is None: return jsonify(code400, msg没有分片内容), 400 target os.path.join(chunk_dir(file_id), f{index:06d}.part) if os.path.exists(target) and os.path.getsize(target) f.content_length: # 分片已存在且大小一致直接视为成功天然支持重传 return jsonify(code0, msg分片已存在, doneFalse) f.save(target) if _chunks_ready(file_id, total): merged merge_chunks(file_id, name, total) if merged: return jsonify(code0, msg合并成功, doneTrue, pathos.path.basename(merged)) return jsonify(code0, msg分片已保存, doneFalse)逻辑说明secure_filename把file_id里的../之类全部干掉防止写到chunks/外面分片文件名用{index:06d}.part六位补零保证字典序等于数字序os.path.exists那段是重传去重分片已经在且大小一致就不再写盘直接返回成功比前端拦截“跳过已上传分片”要稳得多。参数index和total都从表单里取转 int 失败一律按非法处理不要用默认值兜底。3.3 合并函数按序号显式拼接别依赖目录顺序合并这段最容易翻车的地方是“按目录返回顺序拼接”。os.listdir的顺序在不同文件系统上不一致10.part在字典序里会排在2.part前面拼出来的文件直接损坏。正确的做法是按range(total)显式拼路径。def _chunks_ready(file_id, total): d chunk_dir(file_id) for i in range(total): if not os.path.exists(os.path.join(d, f{i:06d}.part)): return False return True def merge_chunks(file_id, name, total): src_dir chunk_dir(file_id) out_name secure_filename(name) or f{file_id}.bin out_path os.path.join(app.config[UPLOAD_DIR], out_name) tmp_path out_path .tmp with open(tmp_path, wb) as out: for i in range(total): part os.path.join(src_dir, f{i:06d}.part) if not os.path.exists(part): # 理论上前面的 _chunks_ready 已经拦过这里做兜底 if os.path.exists(tmp_path): os.remove(tmp_path) return None with open(part, rb) as pf: while True: block pf.read(4 * 1024 * 1024) if not block: break out.write(block) os.replace(tmp_path, out_path) # 原子替换避免进程崩溃留下半成品 for i in range(total): p os.path.join(src_dir, f{i:06d}.part) if os.path.exists(p): os.remove(p) try: os.rmdir(src_dir) except OSError: pass return out_path逻辑说明先写out_path .tmp全部拼完再os.replace原子替换中间进程挂了也不会让下载端读到半个文件拼完才删分片删除失败不影响主流程。参数读取块 4MB和 md5 计算的块大小保持一致便于后续核对。3.4 去重与查询接口让前端知道这个文件是不是已经有了除了/api/upload还要两个轻量接口。一个是“这个文件合并过没有”用于秒传提示另一个是“这个文件当前传了多少片”用于前端展示进度起点。app.route(/api/exists, methods[GET]) def file_exists(): name secure_filename(request.args.get(name, )) if not name: return jsonify(code400, msg缺少文件名), 400 path os.path.join(app.config[UPLOAD_DIR], name) return jsonify(code0, existsos.path.exists(path)) app.route(/api/progress, methods[GET]) def chunk_progress(): file_id request.args.get(id, ) try: total int(request.args.get(chunks, 0)) except ValueError: total 0 if not file_id or total 0: return jsonify(code400, msg参数非法), 400 d chunk_dir(file_id) uploaded [i for i in range(total) if os.path.exists(os.path.join(d, f{i:06d}.part))] return jsonify(code0, uploadeduploaded, countlen(uploaded), totaltotal)参数说明/api/exists按文件名查前提是合并后的成品名不重复如果业务里同名文件会覆盖就得在文件名后面拼file_id前六位。/api/progress返回已上传索引列表前端可以直接用来渲染“已传 37/400 片”。注意这两个接口都要做参数校验int()异常要接住不然一个手工构造的 URL 就能让服务端 500。4. WebUploader 接 Bootstrap进度条、暂停和续传的页面结构前端这层要做的事比后端直观但细节更多。Bootstrap 负责把文件列表和进度条画得能看WebUploader 负责分片和并发中间靠事件把状态串起来。这一章按页面骨架、文件队列渲染、状态回调和秒传提示的顺序写把“进度条卡在 99%”“每个分片都弹一次成功”这类问题提前堵掉。4.1 页面骨架选择、开始、列表三件套页面尽量简单一个选择按钮、一个开始按钮、一个文件列表就够了。Bootstrap 的 card 和 list-group 拿来就用不需要自定义太多样式。!DOCTYPE html html langzh-CN head meta charsetUTF-8 link relstylesheet href/static/bootstrap/css/bootstrap.min.css link relstylesheet href/static/webuploader/webuploader.css /head body div classcontainer py-4 div classcard div classcard-body div idpicker classbtn btn-primary选择文件/div button idbtn-start classbtn btn-success disabled开始上传/button ul idfile-list classlist-group mt-3/ul /div /div /div script src/static/jquery.min.js/script script src/static/bootstrap/js/bootstrap.bundle.min.js/script script src/static/webuploader/webuploader.min.js/script script src/static/js/upload.js/script /body /html这段 HTML 里没有额外逻辑#picker是 WebUploader 挂载选择按钮的容器#btn-start在fileQueued之后才启用#file-list是渲染队列的地方。注意 WebUploader 会接管#picker的点击事件不要再给它绑自定义 click否则可能一次点击弹两次选择框。4.2 文件队列与进度条用file.id当行标识每个文件入队时 WebUploader 会给一个file.id用它当li的 id后续进度更新就能精确命中不会串行。uploader.on(fileQueued, function (file) { var $li $( li classlist-group-item id file.id div classd-flex justify-content-between align-items-center span classfile-name text-truncate stylemax-width:60% file.name /span span classfile-size text-muted small (file.size / 1024 / 1024).toFixed(2) MB/span /div div classprogress mt-2 styleheight:8px; div classprogress-bar progress-bar-striped progress-bar-animated roleprogressbar stylewidth:0%/div /div div classfile-state small text-muted mt-1等待上传/div /li ); $(#file-list).append($li); $(#btn-start).prop(disabled, false); }); uploader.on(uploadProgress, function (file, percentage) { var $bar $(# file.id).find(.progress-bar); $bar.css(width, (percentage * 100).toFixed(2) %); $(# file.id).find(.file-state) .text(上传中 (percentage * 100).toFixed(1) %); });参数说明percentage是 0–1 的浮点数不是百分数直接乘 100 再toFixedfile.size单位是字节展示时除两次 1024。进度条用了 Bootstrap 的progress-bar-animated视觉上有动感但注意它只是动画不代表真实速率。如果分片数很多比如 400 片以上uploadProgress触发频率会很高可以在回调里做一次节流别每个分片都重排一次布局。4.3 成功、失败与合并完成别把“分片成功”当“文件成功”WebUploader 的uploadSuccess是按分片触发的一个 400 片的文件会触发 400 次。如果在这里直接显示“上传完成”用户会看到进度条刚过 1% 就提示成功后面还有 399 个分片在跑。uploader.on(uploadSuccess, function (file, response) { var $li $(# file.id); if (response response.done) { $li.find(.file-state) .removeClass(text-muted) .addClass(text-success) .text(合并完成 response.path); $li.find(.progress-bar) .removeClass(progress-bar-animated) .addClass(bg-success); } else { $li.find(.file-state).text(分片已传等待合并); } }); uploader.on(uploadError, function (file, reason) { $(# file.id).find(.file-state) .removeClass(text-muted) .addClass(text-danger) .text(失败 reason); }); $(#btn-start).on(click, function () { uploader.upload(); $(this).prop(disabled, true).text(上传中…); });逻辑说明只有后端返回done: true才代表整个文件合并完成其他情况一律视为“分片在传”。uploadError只负责展示不自动重试重试由chunkRetry在 WebUploader 内部处理。参数reason是 WebUploader 给的错误描述通常是http或server想看具体状态码得在uploadAccept里读第二个参数。4.4 秒传提示与上传前预检秒传不是必须但体验提升明显。用户选完文件先请求一次/api/exists命中的话直接在列表里标“已存在无需上传”并把该文件从队列里移除。uploader.on(fileQueued, function (file) { $.getJSON(/api/exists, { name: file.name }, function (res) { if (res.code 0 res.exists) { $(# file.id).find(.file-state) .addClass(text-success) .text(服务端已存在跳过上传); uploader.removeFile(file, true); // 队列空了就把开始按钮恢复初始状态 if (uploader.getFiles().length 0) { $(#btn-start).prop(disabled, true).text(开始上传); } } }); });这段逻辑要注意两点removeFile(file, true)第二个参数传true表示同时从队列和内存里移除不传的话文件还挂在getFiles()里移除之后如果队列空了开始按钮要恢复禁用状态不然用户点一下会报错。秒传本质上是用“文件名 大小”换一次查询严格来说有误判风险同名不同内容预算够的话把前端指纹也带上做联合判断。5. 避坑排查分片上传里最容易翻车的 5 个点这套东西跑通之后我在不同环境里复现过五类问题症状各不相同但原因都指向协议或并发上的疏忽。下面按“现象 → 原因 → 解决”逐条写遇到类似报错可以先对照这一节排查。5.1 所有分片都传完了合并出来的文件却打不开现象前端进度条 100%后端日志显示合并成功但下载下来的压缩包解压报错或者文件大小比原文件小几十字节。原因合并时按os.listdir的返回顺序拼没有按数字序号。chunks/目录里如果有10.part、2.part这种没有补零的文件名字典序会把10排在2前面拼出来的内容就是错位的。另一种情况是分片文件名补零位数不够超过 999 片之后1000.part又排到了999.part前面。解决分片文件名统一用{index:06d}.part六位补零合并时按range(total)显式拼路径不依赖文件系统顺序。合并完成后再算一次 md5跟前端传的指纹或源文件对比不一致就删掉成品重来。5.2 第一片就返回 413前端只显示“上传失败”现象选择文件后立刻报错浏览器 Network 面板里分片请求状态码 413后端日志里连路由都没进。原因请求在网关层就被拦了。网关的client_max_body_size默认值通常只有 1MBFlask 的MAX_CONTENT_LENGTH如果也没调两层都会拦。前端拿到的错误信息只有http看不出具体原因。解决两层都要放开网关配置里加client_max_body_size 20m;Flask 里设MAX_CONTENT_LENGTH 20 * 1024 * 1024两者都要大于 chunkSize 并留 1–2MB 余量。排查时先用curl直接打后端端口绕开网关能定位是哪一层拦的。5.3 并发一高合并函数被触发好几次现象日志里merge_chunks连续跑了三次后两次报FileNotFoundError最终成品只有一个但中间产生了一堆.tmp残留。原因接收路由里用“目录里文件数 总数”判断是否合并threads3时三个分片几乎同时到三个请求都判断为真同时进入合并。第一个合并完把分片删了后两个找不到文件就抛异常。解决合并前抢锁。最简单的方式是用os.open(lock_path, os.O_CREAT | os.O_EXCL)创建一个标记文件创建成功才进入合并创建失败直接返回“分片已保存”。用完在finally里删掉标记文件。生产环境可以用文件锁fcntl.flock或直接丢给队列串行处理。5.4 刷新页面后再传同一个文件进度从 0 开始现象用户传了一半刷新浏览器重新选择同一个文件进度条从 0 开始chunks/目录里出现两套分片。原因WebUploader 默认的id是WU_FILE_x这种会话级标识刷新后重新生成后端按新id建了新目录旧分片成了孤儿。前端也没有做“已上传分片跳过”的处理。解决把id换成稳定指纹name size lastModified拼字符串做一次 hash 即可保证同一文件每次生成同一个值。后端接收时判断目标分片已存在且大小一致就直接返回成功前端不需要拦截重传会自然变成“秒传”。这套方案比在前端跳过已上传分片稳因为 WebUploader 没有原生的“跳过分片”事件硬拦截容易出兼容问题。5.5chunks/目录越传越大磁盘慢慢被吃满现象跑了一周之后chunks/占了几个 GB里面全是没合完的残留分片。原因合并成功才删分片但合并失败、进程重启、用户中途关页面这些情况都不会触发删除残留就一直挂着。解决加一个定时清理任务遍历chunks/下的子目录按最后修改时间判断超过 24 小时没更新的整目录删掉。清理任务要跳过正在合并的目录可以在合并前写一个.merging标记文件清理时看到标记就跳过。这个习惯我从那次磁盘告警之后就一直保留着。6. 上传完还得能下载stream-downloader 的 Range 支持与校验上传跑通只是半条链路另一半是下载。大文件下载如果一次性读进内存再返回服务端内存会被打爆如果客户端下到 99% 断网没有 Range 支持就得从头再来。upload-demo 里的 stream-downloader 就是补这一块的Flask 的send_file开conditionalTrue就能拿到 Range 能力但有几个参数和验证步骤值得单独说。6.1 send_file 的 conditional 参数和 Range 响应头Flask 的send_file和send_from_directory都支持conditionalTrue打开之后会根据请求头里的Range返回 206 状态码和对应片段。这是最省事的做法不用自己写生成器。from flask import send_from_directory app.route(/api/download/path:filename) def download(filename): return send_from_directory( app.config[UPLOAD_DIR], filename, as_attachmentTrue, conditionalTrue )参数说明as_attachmentTrue让浏览器弹下载框而不是直接打开conditionalTrue是 Range 支持的关键不传的话永远返回 200 和完整文件path:filename允许文件名里带子目录但实际用的时候建议只允许单层避免路径穿越。如果下载要走鉴权或限速就不能用send_file得换成逐块yield的生成器同时在响应头里自己写Accept-Ranges: bytes和Content-Range。6.2 用 curl 验证断点续传有没有真的生效改完代码别急着在浏览器里点先用curl看响应头这一步能省掉很多“以为好了其实没好”的返工。# 1. 看响应头里有没有 Accept-Ranges curl -I http://127.0.0.1:5000/api/download/test.zip # 2. 请求前 1KB期望返回 206 和 Content-Range curl -r 0-1023 http://127.0.0.1:5000/api/download/test.zip -o part1.bin # 3. 请求最后 1KB验证尾部也能取到 curl -r -1024 http://127.0.0.1:5000/api/download/test.zip -o part2.bin # 4. 合并后对比大小 ls -l part1.bin part2.bin判断标准很直接第一条命令的响应头里必须有Accept-Ranges: bytes第二条的响应码必须是206 Partial Content且Content-Range形如bytes 0-1023/104857600第三、四条确认首尾两段都能单独取到。少了任何一个说明conditional没生效或者中间有代理把 Range 头吃掉了。6.3 我一般怎么收尾这套链路这套东西我后来在自己机器上反复跑过很多次总结出一个固定动作上传完成后从uploads/里取出成品算一次 md5再用curl走一遍 Range 下载把下载回来的片段和原文件对应区间做一次字节比对。听起来麻烦但它能同时验证合并顺序、Range 切片和文件完整性三件事比等用户反馈“下到 99% 断了”要省心得多。从那以后我每次改完分片或下载相关的代码都强制走一遍这套校验再往上加新功能。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?