ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flask多线程大文件上传与实时进度条实现指南

Flask多线程大文件上传与实时进度条实现指南 做文件上传功能很多人第一反应是“这有什么难的form里放个input后端request.files接一下就行”。但真到实际项目里尤其是要传大文件、要展示实时进度、要支持多用户并发上传的时候事情就完全不是那么回事了。我这次用Flask做了一个带多线程处理和实时进度展示的文件上传页面前后踩了不少坑把整个实现思路和关键代码整理出来希望能帮到正在做类似需求的人。先讲一下这个功能是干什么的用户通过浏览器选择一个或多个文件点击上传后前端页面能实时显示每个文件的上传百分比和处理状态后端接收到文件后不会只做简单的保存而是会丢到一个多线程任务里做后续处理比如解析、转码、压缩、入库等处理进度同样会反馈到前端。整个过程中页面不需要刷新用户能直观看到“上传中→处理中→完成”的完整链路。这套方案适合谁参考如果你是刚接触Flask的开发者或者正在做内部工具、数据平台、自动化运维系统这一类需要文件上传并且要看得见进度条的场景这篇内容可以直接照着抄。我会把前端交互、后端多线程任务管理、进度数据共享、接口设计全部拆开讲包括我在实际开发中遇到的几个典型问题比如进度丢失、线程风暴、调试模式下reloader导致的诡异行为等。1. 整体设计与方案选型1.1 为什么不用现成的上传组件而要自己实现市面上有不少现成的文件上传组件比如Dropzone.js、webuploader、layui的upload模块甚至Flask也有flask-uploads、flask-reuploaded这类扩展。那为什么还要自己手写一套原因有三点。第一现成组件大多只解决“上传”这一步也就是把文件从浏览器送到服务器。但很多业务场景里真正的重头戏是上传之后的服务端处理比如图片压缩、Excel解析、视频转码、数据清洗等这些组件的进度条在文件落盘之后就结束了服务端处理阶段完全是个黑盒。这个项目要的是“上传处理”全流程的实时进度这是现成组件给不了的。第二自定义实现意味着可以完全掌控交互细节。比如多文件同时上传时每个文件要显示独立的进度条某个文件处理失败时要能单独重试而不用重新传整个文件处理过程中要允许用户取消任务。这些需求在不同组件里的实现方式差别很大与其去读别人的源码做适配不如自己写一套简单的反而更可控。第三从学习角度来说自己实现一遍能让你真正理解HTTP上传机制、多线程协作、前后端通信这些基础原理。这些东西搞明白了以后换任何框架都能快速上手。1.2 技术栈选择Flask XHR 多线程 轮询这套方案的核心技术选型并不复杂但每个选择背后都有它的理由。后端用Flask这个没什么好说的轻量、灵活、上手快特别适合这种单机内部工具类的应用。如果项目规模大了可以换FastAPI但核心思想是完全一样的。前端没有用fetch而是用XMLHttpRequestXHR对象为什么因为fetch虽然支持上传文件但它的上传进度事件支持得不如XHR成熟。XHR有现成的xhr.upload.onprogress事件能拿到当前上传了多少字节做进度条几乎是零成本。fetch想要实现同样的效果得自己用ReadableStream去包装请求体复杂度一下子高很多。所以在这个场景下XHR反而是更务实的选择。进度展示这块我用的是前端显示“上传进度”浏览器→服务器后端用内存字典保存“处理进度”服务器内部任务前端通过轮询接口获取处理进度。为什么不直接用WebSocket或者SSE因为进度信息的更新频率并不高每秒钟轮询一次足够了用WebSocket有点杀鸡用牛刀的意味而且Flask原生要支持WebSocket还得额外装Flask-SocketIO依赖变重了。多线程用来做什么两个地方。第一后端接完文件后把耗时的处理任务丢到后台线程里执行主请求线程立刻返回避免用户一直等着HTTP响应第二一个上传请求进来时Flask本身会开一个线程去处理这个请求天然支持并发上传。所以这里的“多线程”其实是两层含义一个是Flask的请求并发模型另一个是任务层的自定义多线程。2. 前端页面与交互实现2.1 页面结构和基础样式前端页面我先做了一个简洁的上传区域包含一个拖拽区、一个文件选择按钮、一个文件列表。文件列表里的每一项都会显示文件名、文件大小、当前状态等待上传/上传中/处理中/已完成/失败、进度条、以及一个取消按钮。HTML部分核心代码是这样的div classupload-container div classupload-dropzone iddropZone input typefile idfileInput multiple p拖拽文件到这里或点击选择文件/p /div ul classupload-list iduploadList/ul /div这里的multiple属性一定要记得加否则用户一次只能选一个文件体验会比较差。dropZone是整个可拖拽区域点击它时触发隐藏的fileInput的click事件实现“点击选择拖拽上传”两种方式。文件列表项是动态生成的每个文件对应一个li元素结构如下li>const dropZone document.getElementById(dropZone); const fileInput document.getElementById(fileInput); dropZone.addEventListener(dragover, (e) { e.preventDefault(); dropZone.classList.add(dragging); }); dropZone.addEventListener(dragleave, () { dropZone.classList.remove(dragging); }); dropZone.addEventListener(drop, (e) { e.preventDefault(); dropZone.classList.remove(dragging); const files e.dataTransfer.files; for (const file of files) { uploadFile(file); } }); dropZone.addEventListener(click, () { fileInput.click(); }); fileInput.addEventListener(change, () { const files fileInput.files; for (const file of files) { uploadFile(file); } fileInput.value ; });注意最后一行fileInput.value 如果不重置input的值用户连续选择同一个文件时change事件不会触发这个坑我踩过一次。2.3 XHR上传与进度监听核心的上传函数这样写function uploadFile(file) { const taskId generateTaskId(); const listItem createListItem(taskId, file); const formData new FormData(); formData.append(file, file); formData.append(task_id, taskId); const xhr new XMLHttpRequest(); xhr.open(POST, /upload, true); // 上传进度监听 xhr.upload.onprogress (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); updateProgress(taskId, percent, upload); } }; xhr.onload () { if (xhr.status 200) { // 上传完成开始轮询处理进度 startPolling(taskId); } else { updateStatus(taskId, failed, xhr.statusText); } }; xhr.onerror () { updateStatus(taskId, failed, 网络错误); }; xhr.upload.onload () { updateStatus(taskId, processing, 处理中); }; xhr.send(formData); }xhr.upload.onprogress事件里拿到的是上传阶段的数据量这个进度是从浏览器到服务器的传输进度跟后端的处理进度是两码事。当xhr.upload.onload触发时说明文件已经完整到达服务器这时候就应该把状态切成“处理中”然后通过轮询获取后端处理进度。这里要提一个细节FormData里除了文件本身我还附带了一个task_id字段。这个task_id是前端生成的用来把后续的进度查询请求和当前上传的这一个文件关联起来。你也可以让后端在响应里返回一个任务ID但那样就得多等一个请求而且处理起来稍微麻烦一点。2.4 进度轮询机制上传完成后前端要定时去问后端“这个任务处理到百分之多少了”这就是轮询。我用setInterval实现每500毫秒查询一次function startPolling(taskId) { const pollTimer setInterval(async () { const response await fetch(/progress/${taskId}); const data await response.json(); if (data.status done) { clearInterval(pollTimer); updateStatus(taskId, done, 已完成); updateProgress(taskId, 100, process); showSuccessMessage(taskId, data.result); } else if (data.status failed) { clearInterval(pollTimer); updateStatus(taskId, failed, data.error || 处理失败); } else { const percent data.progress || 0; updateProgress(taskId, percent, process); updateStatus(taskId, processing, 处理中 ${percent}%); } }, 500); }轮询逻辑不复杂但有几个点要注意第一轮询的终止条件要明确。状态变成done或failed时必须clearInterval否则会一直请求下去浪费资源。第二轮询的状态机要和前端的展示状态保持一致。文件上传周期里的状态包括等待上传、上传中、处理中、已完成、失败每一个状态都要有对应的UI表现不要出现“上传完了状态还卡在上传中”的尴尬情况。第三轮询间隔不要设得太短。500毫秒已经算是比较激进的频率了如果服务器压力大建议拉长到1秒。实时进度这玩意儿肉眼能区分的变化阈值大概也就是每秒几个百分点太频繁的轮询反而加重服务器负担。3. 后端核心多线程任务处理3.1 Flask接收文件与基础保存后端最基础的接口是接收上传文件。这个逻辑本身不难import os import uuid import threading from flask import Flask, request, jsonify, render_template app Flask(__name__) app.config[MAX_CONTENT_LENGTH] 500 * 1024 * 1024 # 500MB UPLOAD_FOLDER os.path.join(os.path.dirname(__file__), uploads) os.makedirs(UPLOAD_FOLDER, exist_okTrue) # 全局进度字典保存所有任务的进度信息 # 结构task_id - {progress: int, status: str, error: str, result: dict} TASKS {} TASKS_LOCK threading.Lock() app.route(/) def index(): return render_template(index.html) app.route(/upload, methods[POST]) def upload(): if file not in request.files: return jsonify({error: 没有文件}), 400 file request.files[file] task_id request.form.get(task_id, str(uuid.uuid4())) original_filename file.filename if original_filename : return jsonify({error: 文件名为空}), 400 # 安全处理文件名防止路径穿越 safe_filename os.path.basename(original_filename) # 给文件加一个唯一前缀避免重名覆盖 saved_filename f{task_id}_{safe_filename} file_path os.path.join(UPLOAD_FOLDER, saved_filename) # 先保存文件 file.save(file_path) # 注册任务进度 with TASKS_LOCK: TASKS[task_id] { progress: 0, status: processing, error: , result: {}, filename: safe_filename } # 启动后台线程处理文件 thread threading.Thread( targetprocess_file, args(task_id, file_path, safe_filename), daemonTrue ) thread.start() return jsonify({task_id: task_id, message: 上传成功开始处理})这里有几个安全细节值得强调。文件名必须用os.path.basename处理否则用户上传一个路径为../../etc/passwd的文件名直接拼接路径就可能造成文件覆盖或路径穿越问题。这是文件上传里最常见也最严重的安全隐患之一。MAX_CONTENT_LENGTH限制了请求体大小超过这个限制Flask会直接报413错误。这个限制可以根据业务需要调整但不建议取消。3.2 任务处理函数与多线程模型process_file是真正的业务处理函数模拟了一个耗时的处理流程。实际项目中这个函数可能是解析Excel、生成缩略图、压缩视频等等。我用一个简单的循环来模拟方便展示进度更新的模式def process_file(task_id, file_path, original_filename): 后台处理文件模拟一个耗时的操作并实时更新进度 try: # 模拟处理步骤 steps [ (读取文件内容, 20), (数据解析与校验, 40), (业务逻辑处理, 70), (生成结果文件, 90), ] for step_name, target_progress in steps: # 模拟每一步的子过程 time.sleep(1.5) update_task_progress(task_id, target_progress) # 完成 with TASKS_LOCK: TASKS[task_id][progress] 100 TASKS[task_id][status] done TASKS[task_id][result] { output_file: fresult_{task_id}.csv, rows: 1024 } except Exception as e: with TASKS_LOCK: TASKS[task_id][status] failed TASKS[task_id][error] str(e)理解了流程之后应该能get到这套多线程模型的核心思路Flask请求线程负责接收文件并立即返回自定义的worker线程负责耗时处理并周期性地更新一个共享字典。这样用户不用守着浏览器等一个超长的HTTP响应。为什么不用Flask的stream_with_context或生成器来做这个事因为那个是服务端主动推流还是要保持HTTP连接在多用户场景下连接数很容易打满。后台线程轮询的方式虽然看起来“土”但胜在简单可靠服务器资源占用也小。3.3 共享进度字典的线程安全设计多线程操作共享数据最核心的问题就是“竞态条件”。比如两个线程同时读取并修改TASKS[task_id][progress]就有可能出现数据不一致的情况。Python里保证线程安全最常用的方式就是threading.Lock。在上面的代码里所有对TASKS字典的写操作都通过with TASKS_LOCK:包裹了起来确保同一时间只有一个线程能修改共享数据。为什么用锁而不是用queue.Queue因为进度查询接口需要“随机读取”任意任务ID的当前进度用队列的话很难实现这种随机访问。字典天然支持按键查找配合锁做同步刚好满足需求。锁这个东西用的时候要注意粒度。锁的范围越小越好只包裹真正需要同步的操作不要在锁里面做耗时的I/O操作否则会阻塞其他线程。在上面的代码里文件处理本身的耗时逻辑time.sleep和业务处理都在锁外面只有更新进度那一下才进锁这个设计是合理的。3.4 进度查询接口进度查询接口很简单就是查字典然后返回JSONapp.route(/progress/task_id) def get_progress(task_id): with TASKS_LOCK: task TASKS.get(task_id) if task is None: return jsonify({status: notfound, error: 任务不存在}), 404 return jsonify({ status: task[status], progress: task[progress], error: task[error], result: task[result] })这个接口就是给前端轮询用的。返回的JSON结构固定前端拿到之后就能更新UI。关于进度字典的清理我单独说一下。如果不做清理TASKS字典会无限膨胀跑几天就积累几千上万条记录内存迟早吃不消。解决方案是定期清理已完成或失败的任务。我用了一个简单的方式在查询接口里如果任务状态是done或failed且创建时间超过一定阈值就顺手删掉。更系统的做法是单独起一个定时清理线程或者用带过期时间的缓存比如cachetools的TTLCache。这个根据自己的需求来。4. 调试模式下的神秘问题4.1 为什么进度条会“抽风”我在开发过程中遇到过几个特别诡异的现象进度条跳到一半突然跳回0或者后台线程明明执行完了但进度死活停在90%再或者一个文件上传后出现了两条进度记录。排查了很久才发现根因是Flask的调试模式。Flask在debugTrue时Werkzeug服务器会自动启动一个reloader来监听代码变化代码一变就自动重启服务。关键问题在于reloader会启动两个进程一个是监控进程一个是实际运行的应用进程。如果你直接运行app.py代码里的threading.Thread会在两个进程里都启动一遍然后两个进程各自维护一份TASKS字典互不共享进度数据自然就乱了。更麻烦的是你的请求可能被负载均衡到两个进程里的任何一个处理导致上传请求进的是进程A进度查询被进程B处理了查询结果永远是“任务不存在”。4.2 解决方案最直接的解决方案日常开发调试时用debugFalse。如果你确实需要自动重载功能可以用use_reloaderFalseapp.run(debugTrue, use_reloaderFalse)这样既能保留debug模式的错误页面和交互式调试器又不会启动双进程。不过我还是建议开发阶段就使用debugFalse加手动重启的方式避免产生依赖reloader的坏习惯也省得哪天忘记关闭导致生产环境出问题。如果项目真的部署到了生产环境千万不要用Flask自带的开发服务器用gunicorn或uWSGI这类正式的WSGI服务器它们对多进程、多线程的管理要成熟得多。只不过生产环境的进度共享方案就得升级了因为多进程之间内存不共享TASKS字典得换成Redis之类的外部存储才行。5. 完整代码与实操调优5.1 后端完整代码把上面的几个片段拼起来完整的后端代码如下import os import time import uuid import threading from flask import Flask, request, jsonify, render_template app Flask(__name__) app.config[MAX_CONTENT_LENGTH] 500 * 1024 * 1024 UPLOAD_FOLDER os.path.join(os.path.dirname(__file__), uploads) os.makedirs(UPLOAD_FOLDER, exist_okTrue) TASKS {} TASKS_LOCK threading.Lock() def update_task_progress(task_id, progress): with TASKS_LOCK: TASKS[task_id][progress] progress def process_file(task_id, file_path, original_filename): try: steps [ (读取文件内容, 20), (数据解析与校验, 40), (业务逻辑处理, 70), (生成结果文件, 90), ] for step_name, target_progress in steps: time.sleep(1.5) update_task_progress(task_id, target_progress) with TASKS_LOCK: TASKS[task_id][progress] 100 TASKS[task_id][status] done TASKS[task_id][result] { output_file: fresult_{task_id}.csv, rows: 1024 } except Exception as e: with TASKS_LOCK: TASKS[task_id][status] failed TASKS[task_id][error] str(e) app.route(/) def index(): return render_template(index.html) app.route(/upload, methods[POST]) def upload(): if file not in request.files: return jsonify({error: 没有文件}), 400 file request.files[file] task_id request.form.get(task_id, str(uuid.uuid4())) original_filename file.filename if original_filename : return jsonify({error: 文件名为空}), 400 safe_filename os.path.basename(original_filename) saved_filename f{task_id}_{safe_filename} file_path os.path.join(UPLOAD_FOLDER, saved_filename) file.save(file_path) with TASKS_LOCK: TASKS[task_id] { progress: 0, status: processing, error: , result: {}, filename: safe_filename } thread threading.Thread( targetprocess_file, args(task_id, file_path, safe_filename), daemonTrue ) thread.start() return jsonify({task_id: task_id, message: 上传成功开始处理}) app.route(/progress/task_id) def get_progress(task_id): with TASKS_LOCK: task TASKS.get(task_id) if task is None: return jsonify({status: notfound, error: 任务不存在}), 404 return jsonify({ status: task[status], progress: task[progress], error: task[error], result: task[result] }) if __name__ __main__: app.run(debugTrue, use_reloaderFalse, host0.0.0.0, port5000)5.2 前端完整代码前端把HTML、CSS、JavaScript放在templates目录下的index.html里。我这里只贴核心JS部分样式和网页骨架可以根据自己的审美来let taskCounter 0; function generateTaskId() { taskCounter; return task_ Date.now() _ taskCounter; } function formatSize(size) { if (size 1024) return size B; if (size 1024 * 1024) return (size / 1024).toFixed(1) KB; if (size 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) MB; return (size / (1024 * 1024 * 1024)).toFixed(1) GB; } function createListItem(taskId, file) { const list document.getElementById(uploadList); const li document.createElement(li); li.dataset.taskId taskId; li.innerHTML div classfile-info span classfile-name${escapeHtml(file.name)}/span span classfile-size${formatSize(file.size)}/span /div div classprogress-wrapper div classprogress-bar/div /div span classfile-status等待上传/span button classbtn-cancel取消/button ; list.appendChild(li); const cancelBtn li.querySelector(.btn-cancel); cancelBtn.addEventListener(click, () { // 取消逻辑可以根据需要实现 li.remove(); }); return li; } function escapeHtml(str) { const div document.createElement(div); div.appendChild(document.createTextNode(str)); return div.innerHTML; } function updateProgress(taskId, percent, type) { const li document.querySelector(li[data-task-id${taskId}]); if (!li) return; const bar li.querySelector(.progress-bar); bar.style.width percent %; } function updateStatus(taskId, status, text) { const li document.querySelector(li[data-task-id${taskId}]); if (!li) return; const statusEl li.querySelector(.file-status); statusEl.textContent text; li.className status; }前端有个小细节要提一下escapeHtml函数很重要。用户上传的文件名可能包含script之类的HTML标签如果直接拼接进innerHTML就存在XSS注入的风险。这个风险在文件上传场景里特别容易被忽视。用escapeHtml先把文件名里的HTML字符转义掉再拼进DOM就安全了。5.3 启动与测试启动服务python app.py然后打开浏览器访问http://localhost:5000选择一个大文件上传最好选一个几百MB的文件这样上传进度条变化比较明显观察上传完成后的处理阶段进度是否符合预期。我这里用一个小技巧来方便测试开一个几百MB的虚拟文件# Linux / macOS dd if/dev/zero oftest_file.bin bs1M count200 # WindowsPowerShell fsutil file createnew test_file.bin 2097152006. 常见问题与排查技巧6.1 上传大文件超时现象上传一个很大的文件请求在传输中途断开或者页面一直转圈最终报错。可能原因和解决办法第一Nginx或反向代理的client_max_body_size限制不够大默认一般是1MB。需要在Nginx配置里加上client_max_body_size 500m;。第二Flask的MAX_CONTENT_LENGTH设置得不够大请求体超过限制时被拒。这个在上面已经说过了。第三请求超时时间太短。如果是通过Nginx代理的proxy_read_timeout和proxy_send_timeout也要相应调大。6.2 前端进度条乱跳现象进度条已经到90%突然跳回10%然后再慢慢爬到100%。原因大概率是开启了Flask的debug模式reloader双进程导致请求被打到不同的进程上两个进程各自维护了一份进度数据。最简单有效的解决办法就是开发时用use_reloaderFalse生产环境用gunicorn或uWSGI并且把共享进度存储替换成Redis。6.3 Thread线程里抛异常进度卡住不动现象后台线程在处理文件时报错了但前端进度条永远停在最后更新的那个百分比状态也不是failed。原因后台线程的process_file函数里没有做异常捕获线程直接崩溃退出TASKS[task_id][status]一直停留在processing。解决办法process_file函数一定要在最外层包一个try...except在except里把任务状态更新为failed并记录错误信息。我上面的代码已经做了这个处理但实际项目里还是要根据业务需求细化异常分支。6.4 多线程并发更新进度数据错乱现象多个文件同时上传A文件的进度条偶尔会显示B文件的数据。原因所有线程共享一个TASKS字典如果更新操作没有加锁保护两个线程同时写同一个键值时就可能互相覆盖。解决办法对TASKS字典的所有读写操作加锁。如果并发量非常大锁的竞争会很激烈这时候可以把进度字典按task_id分片用多个锁减少冲突。6.5 上传文件保存路径不对现象文件上传成功后后端找不到保存的文件或者文件保存在了一个莫名其妙的位置。原因相对路径问题。开发时用相对路径uploads/但当前工作目录可能不是你项目所在的目录尤其当你在IDE里或通过其他工具启动项目时工作目录往往是设置文件所在目录而不是项目根目录。解决办法路径用绝对路径基于__file__来拼接UPLOAD_FOLDER os.path.join(os.path.dirname(os.path.abspath(__file__)), uploads)6.6 前端XHR CORS跨域问题现象前端页面在一个域名后端接口在另一个域名上传时浏览器报CORS错误。解决办法开发时用Flask-CORS扩展或者手动加响应头app.after_request def add_cors_headers(response): response.headers[Access-Control-Allow-Origin] * response.headers[Access-Control-Allow-Headers] Content-Type response.headers[Access-Control-Allow-Methods] POST, GET, OPTIONS return response生产环境更推荐用Nginx做同域反向代理从根上规避CORS问题。6.7 上传过程中文件数过多导致列表性能问题现象一次上传几十上百个文件DOM里插入大量li元素页面滚动和操作明显卡顿。解决办法如果单次上传的文件数很多建议做分页展示或者虚拟滚动只渲染当前可视区域内的文件项。如果上传量不大用原生DOM操作已经够用不必上框架。7. 实际体验与进一步优化方向这套方案我现在已经用在几个内部工具上了整体稳定性不错日常使用没有出过什么幺蛾子。但如果你想让它在更严苛的环境下跑还有几个方向可以扩展。支持断点续传。当前方案里上传中断后就得重新传整个文件对大文件来说成本太高。断点续传的标准做法是前端先把文件切片然后分片上传后端记录每个分片的上传状态最后合并。这会让代码量翻倍但体验提升也是明显的。用Redis替换内存字典做进度共享。当部署方式从单进程变成多进程或多机负载均衡后内存字典的共享方案就失效了。用Redis的话各进程都能读写同一个进度存储还能天然地处理过期清理算是一步到位的方案。支持取消任务。目前取消按钮只是把前端的列表项删了后端线程还在后台跑着。真要实现取消需要一个threading.Event之类的信号机制让后台线程在处理过程中定期检查这个信号收到取消通知就提前退出。线程本身不能被强制kill只能靠协作式取消。增加文件类型校验和病毒扫描。文件上传是安全重灾区建议在保存文件前做MIME类型校验、扩展名白名单校验再结合内容检测工具做进一步扫描。这部分对内部工具可能不是必需但只要是面向外部用户的服务一定要重视。最后说一个我个人的小体会文件上传这功能看着简单一旦跟多线程、进度反馈、并发这些词沾上边复杂度就指数上升了。好在这套“前端XHR 后端线程 内存字典 轮询”的组合几乎覆盖了所有内部工具类场景代码又少又直观出问题也好排查。如果你正在做类似的功能直接照这个思路来就行后面根据自己的业务往里填处理逻辑会比从零开始捋清思路快很多。
返回列表