做文件上传功能,很多人第一反应是“这有什么难的,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,而是用XMLHttpRequest(XHR)对象,为什么?因为fetch虽然支持上传文件,但它的上传进度事件支持得不如XHR成熟。XHR有现成的xhr.upload.onprogress事件,能拿到当前上传了多少字节,做进度条几乎是零成本。fetch想要实现同样的效果,得自己用ReadableStream去包装请求体,复杂度一下子高很多。所以在这个场景下,XHR反而是更务实的选择。
进度展示这块,我用的是前端显示“上传进度”(浏览器→服务器),后端用内存字典保存“处理进度”(服务器内部任务),前端通过轮询接口获取处理进度。为什么不直接用WebSocket或者SSE?因为进度信息的更新频率并不高,每秒钟轮询一次足够了,用WebSocket有点杀鸡用牛刀的意味,而且Flask原生要支持WebSocket还得额外装Flask-SocketIO,依赖变重了。
多线程用来做什么?两个地方。第一,后端接完文件后,把耗时的处理任务丢到后台线程里执行,主请求线程立刻返回,避免用户一直等着HTTP响应;第二,一个上传请求进来时,Flask本身会开一个线程去处理这个请求,天然支持并发上传。所以这里的“多线程”其实是两层含义:一个是Flask的请求并发模型,另一个是任务层的自定义多线程。
2. 前端页面与交互实现
2.1 页面结构和基础样式
前端页面我先做了一个简洁的上传区域,包含一个拖拽区、一个文件选择按钮、一个文件列表。文件列表里的每一项都会显示文件名、文件大小、当前状态(等待上传/上传中/处理中/已完成/失败)、进度条、以及一个取消按钮。
HTML部分核心代码是这样的:
<div class="upload-container"> <div class="upload-dropzone" id="dropZone"> <input type="file" id="fileInput" multiple> <p>拖拽文件到这里,或点击选择文件</p> </div> <ul class="upload-list" id="uploadList"></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_ok=True) # 全局进度字典,保存所有任务的进度信息 # 结构: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( target=process_file, args=(task_id, file_path, safe_filename), daemon=True ) 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': f'result_{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 进度查询接口
进度查询接口很简单,就是查字典然后返回JSON:
@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'] })这个接口就是给前端轮询用的。返回的JSON结构固定,前端拿到之后就能更新UI。
关于进度字典的清理,我单独说一下。如果不做清理,TASKS字典会无限膨胀,跑几天就积累几千上万条记录,内存迟早吃不消。解决方案是定期清理已完成或失败的任务。我用了一个简单的方式:在查询接口里,如果任务状态是done或failed且创建时间超过一定阈值,就顺手删掉。更系统的做法是单独起一个定时清理线程,或者用带过期时间的缓存(比如cachetools的TTLCache)。这个根据自己的需求来。
4. 调试模式下的神秘问题
4.1 为什么进度条会“抽风”
我在开发过程中遇到过几个特别诡异的现象:进度条跳到一半突然跳回0,或者后台线程明明执行完了但进度死活停在90%,再或者一个文件上传后出现了两条进度记录。排查了很久才发现,根因是Flask的调试模式。
Flask在debug=True时,Werkzeug服务器会自动启动一个reloader来监听代码变化,代码一变就自动重启服务。关键问题在于,reloader会启动两个进程:一个是监控进程,一个是实际运行的应用进程。如果你直接运行app.py,代码里的threading.Thread会在两个进程里都启动一遍,然后两个进程各自维护一份TASKS字典,互不共享,进度数据自然就乱了。
更麻烦的是,你的请求可能被负载均衡到两个进程里的任何一个处理,导致上传请求进的是进程A,进度查询被进程B处理了,查询结果永远是“任务不存在”。
4.2 解决方案
最直接的解决方案:日常开发调试时,用debug=False。如果你确实需要自动重载功能,可以用use_reloader=False:
app.run(debug=True, use_reloader=False)这样既能保留debug模式的错误页面和交互式调试器,又不会启动双进程。不过我还是建议开发阶段就使用debug=False加手动重启的方式,避免产生依赖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_ok=True) 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': f'result_{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( target=process_file, args=(task_id, file_path, safe_filename), daemon=True ) 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(debug=True, use_reloader=False, host='0.0.0.0', port=5000)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 class="file-info"> <span class="file-name">${escapeHtml(file.name)}</span> <span class="file-size">${formatSize(file.size)}</span> </div> <div class="progress-wrapper"> <div class="progress-bar"></div> </div> <span class="file-status">等待上传</span> <button class="btn-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 of=test_file.bin bs=1M count=200 # Windows(PowerShell) 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_reloader=False,生产环境用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 + 后端线程 + 内存字典 + 轮询”的组合,几乎覆盖了所有内部工具类场景,代码又少又直观,出问题也好排查。如果你正在做类似的功能,直接照这个思路来就行,后面根据自己的业务往里填处理逻辑,会比从零开始捋清思路快很多。