资讯动态

Flask+WebUploader大文件分片上传与断点续传实战

发布时间:2026/10/27 8:00:55 来源:尧图企业网站定制
简介基于 Flask 与 WebUploader 的大文件分片上传演示项目面向具备 Python 基础、希望理解前端分片与后端合并机制的开发者也适用于课程实验和中小型文件服务原型验证。压缩包共 26 个文件约 427KB含 5 个 CSS、5 个 JS、4 个 map、2 个 HTML 模板和 2 个 Python 脚本并附带 Bootstrap 字体、WebUploader 依赖、requirements 与 README覆盖上传页、文件列表页及静态交互资源模板、依赖与上传处理模块分层较清晰。已有 653 人学习下载。项目展示 Bootstrap 进度条、多用户同时上传互不干扰、分片接收合并及流式下载低内存占用等要点可帮助读者梳理分片上传的前后端协作与成功、失败状态反馈README 还记录分片不合并、下载时按序重组以缓解内存压力的优化思路对多用户并发下的分片命名、隔离与合并顺序亦有参考价值可作为性能改进与代码复用参考。1. 大文件上传为什么要从“整包提交”改成“分片协商”一个 2GB 的视频素材直接塞进表单浏览器要先把它读进内存Flask 要等整个请求体落盘网络抖一下就得从零重来这种需求在课程设计和内部工具里很常见。upload-demo 把 Flask 与 WebUploader 作为主轴前端把文件切成固定大小的 fragmentation 分片后端按分片幂等接收最后合并并用 stream-downloader 的方式回传。它适合要做大文件上传毕设、课设的学生也适合给后台补一个可断点续传上传组件的开发者。整包提交不是不能用而是失败成本太高分片协商把一次长事务拆成多次短请求失败只影响一个块这就是这套方案要解决的核心问题。2. 总体架构与选型Flask 轻服务、WebUploader 前端分片、Bootstrap 页面骨架2.1 四层职责划分与请求时序这套 upload-demo 我一般会拆成四层浏览器层负责选文件、切片、并发控制Flask 接口层负责 init、chunk、complete 和状态查询存储层分两块临时分片放data/chunks/{task_id}/合并后的文件放data/files/下载层只做权限判断和文件回传。前端页面用 BootstrapHTML 骨架搭一个卡片式上传区进度条、开始/暂停按钮、文件列表都放在同一张卡片里WebUploader 只负责上传队列不跟页面框架抢事件。目录结构通常按下面这样落地先让路由、服务、模板分开后面排查问题时不会在一堆代码里翻upload-demo/ ├── app/ │ ├── __init__.py # 创建 Flask 应用、加载配置 │ ├── models.py # 任务、分片、文件记录 │ ├── routes/ │ │ ├── upload.py # init / chunk / complete │ │ └── download.py # 流式下载入口 │ ├── services/ │ │ └── merge.py # 分片合并、校验、清理 │ ├── templates/ │ │ └── index.html # Bootstrap 页面骨架 │ └── static/ │ ├── js/upload.js # WebUploader 初始化与事件 │ └── css/app.css ├── data/ │ ├── chunks/ # 临时分片按 task_id 分目录 │ └── files/ # 合并后的最终文件 ├── config.py └── run.pychunks和files分开的意义在于分片目录可以随时清理最终文件目录只接受合并成功后的文件。请求时序也不是“选完文件直接传”而是先调init服务端返回task_id、chunk_size、chunk_total和已上传分片列表前端再决定从哪个块继续。这个协商动作看起来多了一次请求但它把断点续传、秒传提示、分片大小统一都收在了一个入口里后面改参数不用前后端各改一遍。层主要职责产物浏览器层选文件、切片、并发、进度分片请求Flask 接口层参数校验、幂等落盘、状态推进任务记录、分片文件存储层临时分片、最终文件data/chunks、data/files下载层权限判断、Range 回传下载响应注意临时分片目录名必须来自服务端生成的 UUID不要直接用前端传来的文件名拼路径否则很容易被../这类参数带出目录。2.2 为什么选 Flask WebUploader 而不是手写切片手写File.slice()加fetch不是不能做但要把队列、并发、重试、分片参数、进度事件全部补齐代码量会迅速膨胀。WebUploader 的优势是它已经把这些脏活做完了chunked打开分片threads控制并发chunkRetry处理失败重试uploadProgress直接给进度。Flask 的优势是路由轻、调试直观课程设计里老师能顺着一个文件看到请求怎么进来不用先理解一整套框架约定。选型边界也要说清楚这套组合适合单机、中小规模、教学演示和内部工具不适合一上来就承诺多机合并、对象存储、跨机房容灾。WebUploader 项目偏老文档不如新框架细断点续传要自己补Flask 自带开发服务器也不能直接上生产。下面的参数是我一般会先定下来的起点后面按网络状况再调参数建议值说明chunkSize2MB 或 5MB太小请求多太大重传成本高threads3避免浏览器连接数被占满chunkRetry3网络抖动时给三次机会单文件上限5GB教学项目够用按磁盘调整分片总数上限10000防止异常参数打爆目录2MB 分片在校园网和普通宽带上比较稳失败重传也只丢一个块。并发开到 3 是血泪经验开到 6 以上时浏览器对同一域名的连接数限制会让后面的分片排队进度条看起来一卡一卡其实不是后端慢而是前端自己在等连接。2.3 数据模型与接口约定数据模型不需要一上来就上很重的表但任务状态和分片状态必须落库否则刷新页面就不知道传到哪了。SQLite 足够撑起课程设计换 MySQL 时把自增和日期类型改一下即可。下面这张upload_task记录一次上传upload_chunk记录每个分片是否落盘-- SQLite 版本够课程设计用生产换 MySQL/PostgreSQL 时调整类型 CREATE TABLE IF NOT EXISTS upload_task ( id TEXT PRIMARY KEY, -- 服务端生成的 UUID file_name TEXT NOT NULL, -- 原始文件名 file_size INTEGER NOT NULL, file_hash TEXT, -- 前端指纹用于秒传提示 chunk_size INTEGER NOT NULL, chunk_total INTEGER NOT NULL, status TEXT NOT NULL DEFAULT init, -- init/uploading/merging/done/failed stored_name TEXT, -- 最终落盘文件名 created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS upload_chunk ( task_id TEXT NOT NULL, chunk_index INTEGER NOT NULL, -- 从 0 开始 chunk_size INTEGER NOT NULL, status TEXT NOT NULL DEFAULT done, created_at TEXT NOT NULL, PRIMARY KEY (task_id, chunk_index) );接口不要散着长四个就够跑通主流程POST /api/upload/init收文件名、大小、指纹、分片大小返回任务信息和已传分片POST /api/upload/chunk收task_id、chunk、chunks和文件字段POST /api/upload/complete触发合并GET /download/file_id走下载。这里最容易埋雷的是字段名WebUploader 默认用chunk表示当前分片索引用chunks表示总分片数后端如果写成chunk_index又没做映射前端传过来的值就会进错参数。我一般会在 init 返回里把chunk_total再写一遍complete 时拿它和数据库里的值比对防止前端中途改参数。3. 前端分片实现WebUploader 配置、断点续传与 Bootstrap 交互3.1 WebUploader 初始化参数chunkSize、threads、server前端初始化我一般把auto关掉让用户选完文件、确认信息后再点开始这样 init 协商有时间完成。server指向分片接口fileVal必须和后端request.files.get(file)对上formData里的task_id在 init 返回后动态塞进去。下面这段是能直接跑的主配置// static/js/upload.js const uploader WebUploader.create({ auto: false, // 选完文件后手动触发留出 init 协商时间 server: /api/upload/chunk, // 分片接收接口 pick: { id: #picker, multiple: false }, chunked: true, // 开启分片 chunkSize: 2 * 1024 * 1024, // 2MB 一片按网络情况可改 5MB chunkRetry: 3, // 单个分片失败重试次数 threads: 3, // 同时上传的分片数 fileVal: file, // 后端 request.files 的字段名 duplicate: true, // 允许重复选择同一文件 formData: { task_id: }, // init 之后回填 accept: { title: 文件, extensions: zip,rar,7z,iso,mp4,pdf, mimeTypes: */* } });chunked: true是分片开关不开这个后面所有断点续传逻辑都无从谈起。chunkSize不要和MAX_CONTENT_LENGTH混在一起后者限制的是单次请求体不是文件总大小。threads控制的是并发请求数不是分片总数分片总数由文件大小除以chunkSize决定。formData在 init 之前是空字符串init 返回后要执行uploader.options.formData.task_id taskId否则后端拿不到任务号所有分片都会被打回。accept只是选择框过滤真正的类型校验仍要放在后端前端过滤挡不住改请求的人。3.2 断点续传与秒传init 协商、文件指纹、已传分片跳过断点续传的关键不是“记住进度条”而是“让服务端告诉前端哪些块已经有了”。我一般会在fileQueued事件里先算一个轻量指纹再调 init。教学项目不必对整个大文件做完整 MD5用文件名、大小、头尾各 1MB 采样做 SHA-256 就够做秒传提示合并后仍用完整 SHA-256 做最终校验。下面这段把 init、秒传和跳过已传分片串起来// 采样指纹教学项目够用不能替代合并后的完整校验 async function makeFingerprint(file) { const sampleSize 1024 * 1024; const head file.slice(0, Math.min(sampleSize, file.size)); const tail file.slice(Math.max(0, file.size - sampleSize)); const buf await new Response(new Blob([head, tail])).arrayBuffer(); const digest await crypto.subtle.digest(SHA-256, buf); return [...new Uint8Array(digest)] .map(b b.toString(16).padStart(2, 0)).join(); } let skipChunks new Set(); let skippedCount 0; let uploadedCount 0; uploader.on(fileQueued, async (file) { const fingerprint await makeFingerprint(file); const resp await fetch(/api/upload/init, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ file_name: file.name, file_size: file.size, file_hash: fingerprint, chunk_size: uploader.options.chunkSize }) }).then(r r.json()); if (resp.instant) { // 服务端已有完整文件直接给下载地址 uploader.removeFile(file, true); showDone(resp.download_url); return; } window.currentTask resp; uploader.options.formData.task_id resp.task_id; uploader.options.chunkSize resp.chunk_size; skipChunks new Set(resp.uploaded_chunks || []); }); uploader.on(uploadBeforeSend, (block, data) { // WebUploader 的 data.chunk 从 0 开始和 init 返回的索引保持一致 if (skipChunks.has(data.chunk)) { skippedCount 1; maybeComplete(); return false; // 跳过分片请求但要自己补完成计数 } }); function maybeComplete() { const total window.currentTask?.chunk_total || 0; if (total uploadedCount skippedCount total) { fetch(/api/upload/complete, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ task_id: window.currentTask.task_id }) }); } }uploadBeforeSend里return false能阻止当前分片发出但 WebUploader 不会为这个被跳过的分片触发uploadSuccess所以完成计数必须把skippedCount加进去。更稳的做法是后端对重复分片幂等前端仍然发送逻辑简单但浪费一点流量如果追求“真续传”就得像上面这样自己维护计数并在uploadFinished里兜底调一次maybeComplete防止某个分支漏掉。秒传也要留个心眼采样指纹只能做提示不能当成唯一安全依据否则两个头尾相同、中间不同的文件会被误判成同一个。3.3 上传进度、暂停与失败重试的 UI 状态Bootstrap 的进度条只是壳真正决定显示是否顺滑的是 WebUploader 的事件。uploadProgress给的是当前文件整体进度分片并发时它会在几个块之间来回跳看起来像“进度回退”其实是不同分片完成顺序不同。我一般会在进度条下面加一行小字显示“已传分片 / 总分片”让用户知道不是卡死。// 进度条与完成兜底 uploader.on(uploadProgress, (file, percentage) { const bar document.querySelector(#progress-bar); bar.style.width ${(percentage * 100).toFixed(1)}%; bar.textContent ${Math.round(percentage * 100)}%; }); uploader.on(uploadSuccess, (file, response) { if (response.ok) uploadedCount 1; maybeComplete(); }); uploader.on(uploadError, (file, reason) { showToast(分片失败${reason}WebUploader 会自动重试); }); uploader.on(uploadFinished, () { // 所有分片请求结束后兜底防止跳过逻辑漏掉 complete maybeComplete(); }); document.querySelector(#pause).onclick () uploader.stop(); document.querySelector(#resume).onclick () uploader.upload();暂停用uploader.stop()继续用uploader.upload()不要自己维护一个isPaused变量去拦事件WebUploader 内部队列比手写状态可靠。失败重试靠chunkRetry但重试只解决网络抖动不解决 413、401、路径穿越这类参数错误这类错误重试三次还是失败应该直接提示用户检查文件大小或登录状态。进度条卡在 99% 是玄学高发区多半不是上传没完成而是合并阶段在跑UI 上要给“正在合并”单独一个状态别让用户以为卡死了。状态触发事件页面表现待上传fileQueued显示文件名、大小、开始按钮上传中uploadProgress进度条、已传分片数暂停stop()按钮切成继续合并中complete请求发出进度条走满显示“正在合并”完成complete返回显示下载入口失败uploadError提示原因保留重试按钮4. 后端接收与合并Flask 路由、临时分片、校验与 stream-downloader 下载4.1 接收分片的 Flask 路由与幂等写入后端接收分片的第一原则是幂等同一个task_id chunk_index重复到达不能报错也不能写两份。第二原则是原子落盘先写.tmp再用os.replace换名避免合并线程读到半截文件。下面这段路由把参数校验、目录创建、幂等判断和原子写入放在一起# app/routes/upload.py import os from flask import Blueprint, request, jsonify, current_app upload_bp Blueprint(upload, __name__, url_prefix/api/upload) def chunk_dir(task_id): # task_id 必须是服务端生成的 UUID不能直接拼前端文件名 return os.path.join(current_app.config[CHUNK_ROOT], task_id) upload_bp.post(/chunk) def upload_chunk(): task_id request.form.get(task_id, ) try: index int(request.form.get(chunk, -1)) total int(request.form.get(chunks, -1)) except ValueError: return jsonify(okFalse, msg分片参数非法), 400 if not valid_task(task_id) or index 0 or total 0 or index total: return jsonify(okFalse, msg任务或分片越界), 400 f request.files.get(file) if f is None: return jsonify(okFalse, msg缺少分片文件), 400 d chunk_dir(task_id) os.makedirs(d, exist_okTrue) final os.path.join(d, f{index:08d}.part) if os.path.exists(final): return jsonify(okTrue, duplicatedTrue) # 幂等重复分片直接成功 tmp final .tmp f.save(tmp) os.replace(tmp, final) # 原子换名合并时不会读到半截文件 mark_chunk_uploaded(task_id, index, os.path.getsize(final)) return jsonify(okTrue)request.form.get(chunk)对应 WebUploader 默认字段chunks是总分片数两个都要校验。index total直接拒绝能挡住前端参数错乱和手工构造的越界请求。valid_task要查数据库确认任务存在且状态不是done否则别人拿到一个旧task_id就能往目录里塞文件。f.save(tmp)落的是 Flask 的临时文件2MB 分片不会造成内存压力如果分片调到 50MB就要考虑流式读取请求体别让request.files把内存顶上去。MAX_CONTENT_LENGTH按单分片设置比如 2MB 分片设 4MB 上限不要设成 5GB否则一个异常请求就能把内存打满。4.2 合并策略按序拼接、校验、清理临时文件合并不能把分片一次性读进内存也不能按文件名直接sorted()了事10.part会排在2.part前面。我一般用补零文件名00000000.part合并时按0..chunk_total-1顺序读每块 1MB 缓冲写入最终文件同时更新 SHA-256。下面这段是合并服务的主干# app/services/merge.py import os import hashlib import shutil from flask import current_app def merge_chunks(task, chunk_total): src_dir os.path.join(current_app.config[CHUNK_ROOT], task[id]) dst_path os.path.join(current_app.config[FILE_ROOT], task[stored_name]) os.makedirs(os.path.dirname(dst_path), exist_okTrue) sha hashlib.sha256() with open(dst_path, wb) as out: for i in range(chunk_total): part os.path.join(src_dir, f{i:08d}.part) if not os.path.exists(part): raise FileNotFoundError(f缺少分片 {i}) with open(part, rb) as fp: while True: buf fp.read(1024 * 1024) if not buf: break sha.update(buf) out.write(buf) actual sha.hexdigest() # 前端如果是采样指纹只做提示不做严格比对 if task.get(file_hash) and task.get(hash_is_full): if actual ! task[file_hash]: os.remove(dst_path) raise ValueError(合并后校验失败) shutil.rmtree(src_dir, ignore_errorsTrue) return dst_path, actualstored_name用 UUID 加安全后缀不要直接用用户文件名避免重名覆盖和路径穿越。合并前要把任务状态从uploading条件更新为merging只有一个请求能抢到另一个直接返回“正在合并”否则两个 complete 同时跑会互相删目录。合并成功后先更新数据库状态为done再删临时分片如果先删分片再更新数据库更新失败就丢了重试材料。失败时不要急着rmtree保留分片和日志排查完再清。4.3 流式下载与 Range 支持上传做完下载不能掉链子。大文件下载最忌讳f.read()一次读进内存再返回Flask 的send_file配合conditionalTrue能让 Werkzeug 处理条件请求和 Range浏览器断点续传下载也能接上。下面这个下载入口只做权限判断和文件回传# app/routes/download.py from flask import Blueprint, send_file, abort download_bp Blueprint(download, __name__) download_bp.get(/download/file_id) def download(file_id): record get_file_record(file_id) if not record: abort(404) # conditionalTrue 让 Werkzeug 处理 If-Range / Range return send_file( record[path], as_attachmentTrue, download_namerecord[download_name], conditionalTrue, max_age0 )download_name要做文件名清洗至少去掉路径分隔符as_attachmentTrue让浏览器走下载而不是尝试预览。conditionalTrue是 Range 支持的关键少了它下载器拖进度条时容易拿到整文件重头开始。如果部署时前面有 Nginx我一般会让 Flask 只做鉴权然后用X-Accel-Redirect把实际文件发送交给静态文件层Flask 进程不占着连接读大文件。需要自己加限速或审计时可以用生成器逐块读def iter_file(path, chunk_size1024 * 1024): with open(path, rb) as f: while True: data f.read(chunk_size) if not data: break yield data生成器方案灵活但 Range 要自己解析Range头、算Content-Range、返回 206写不好还不如send_file稳。课程设计里先用send_file把主流程跑通再考虑流式生成器别一上来就手写 Range 解析。5. 避坑与常见问题分片上传最容易翻车的五处5.1 分片序号从 0 还是 1 开始合并顺序错位现象是合并后文件大小对得上但打开是坏的图片只显示上半截视频播到中间花屏。原因多半是前端 WebUploader 的chunk从 0 开始后端合并循环从 1 开始或者临时文件按字符串排序10.part排在2.part前面。解决方式很土但有效统一 0 基索引文件名补零到 8 位合并时按整数循环取不靠sorted(os.listdir())。init 时把chunk_total写进任务表complete 时再校验实际分片数是否等于它少一片就拒绝合并。# 排查分片是否连续输出 0 到 N 的缺口 python - PY import os d data/chunks/你的task_id have {int(n.split(.)[0]) for n in os.listdir(d) if n.endswith(.part)} total max(have) 1 print(缺失:, [i for i in range(total) if i not in have]) PY5.2 断点续传跳过已传分片完成事件不触发现象是刷新页面后继续上传进度条很快到 99%但一直没有下载入口。原因是在uploadBeforeSend里对已传分片return falseWebUploader 不会为这个分片触发uploadSuccess完成计数少了几片complete永远不调。解决办法有两个一是像 3.2 那样维护uploadedCount skippedCount在uploadFinished里兜底调maybeComplete二是后端对重复分片幂等前端不跳过全部重发一遍逻辑简单但费流量。课程设计答辩时建议用第一种能讲清楚“跳过”和“完成”是两套计数生产内部工具可以用第二种少写状态。5.3 并发合并与临时文件清理的竞态现象是偶尔报“缺少分片 37”或者下载到一半文件被删。原因是用户连点两次“完成”两个请求同时进合并或者清理脚本看到任务超过 24 小时把正在合并的目录删了。解决靠状态机init → uploading → merging → done/failedcomplete 用数据库条件更新抢占UPDATE upload_task SET statusmerging WHERE id? AND statusuploading影响行数为 0 就直接返回“正在合并”。合并先写stored_name .merging成功后再os.replace到最终路径清理脚本只删failed或超过 TTL 且状态不是merging的任务。5.4 大文件上传超时、内存与网关层限制现象是小文件正常500MB 以上就 413、502 或超时Flask 进程内存飙升。原因通常是MAX_CONTENT_LENGTH被设成了文件总大小网关层请求体限制太小或者前端threads开得太高。解决MAX_CONTENT_LENGTH按单分片设2MB 分片给 4MB 上限网关层client_max_body_size至少大于单分片threads控制在 3Flask 开发服务器只用于调试部署换 WSGI 服务。上传接口不要做request.get_data()全量读取直接走request.files否则分片再小也会被读进内存。5.5 文件校验与清理策略缺失现象是上传显示成功下载后文件打不开或者跑几天磁盘被临时分片占满。原因是只信前端进度合并后没做完整校验失败任务没有 TTL 清理。解决合并后算完整 SHA-256和前端完整 hash 比对采样指纹只做秒传提示失败任务保留 24 小时定时清理created_at超过阈值的chunks/{task_id}日志里记task_id、chunk_index、分片大小、合并耗时。下载文件名用secure_filename清洗后再存展示名别让用户文件名里的斜杠参与路径拼接。6. 验收与答辩用 6 个场景把分段上传方案讲透验收不要只演示“传一个 10MB 文件成功”那只能证明表单能提交。我一般会准备下面 6 个场景按顺序跑一遍每个场景都有可观察的预期结果场景操作预期小文件传 10MB PDF分片数正确合并后可下载SHA-256 一致大文件传 2GB 视频内存不随文件线性上涨进度条不卡死断网续传传一半断网刷新重选同一文件init 返回已传分片继续后只补缺口重复分片同一分片发两次接口返回duplicated文件不重复写非法分片chunk-1或超出总数返回 400临时目录不新增文件下载 Rangecurl -H Range: bytes0-1023返回 206内容长度 1024答辩讲述顺序也有讲究先演示断网续传让老师看到“刷新后还能接着传”再打开数据库表指出status从uploading到merging再到done的变化最后才翻代码讲init和chunk两个接口怎么对齐字段。不要一上来读upload.js听众没有上下文很容易被uploadBeforeSend里的跳过逻辑绕晕。边界也要主动说当前是单机临时目录方案分片大小 2MB没有接对象存储合并锁靠数据库条件更新这些不是缺陷是当前场景下的取舍。从那以后我每次带人做上传功能都强制先跑“断网-重选-续传”和“重复分片”这两个场景再谈界面好不好看。很多看起来是前端进度条的问题跑一遍验收场景就能定位到是计数没补、还是合并没加锁。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑