简介:这份演示项目源码围绕 Flask 与 WebUploader 实现大文件分片上传及流式下载,面向希望理解前后端分片协作、并发上传与低内存文件传输的 Python Web 开发者,也可作为课程设计或内部工具原型参考。压缩包共 26 个文件,约 427KB,包含 5 个 CSS、5 个 JS、4 个 map、2 个 HTML 模板、2 个 Python 脚本,以及 requirements、Markdown 说明和字体图标等资源,分别承担页面渲染、分片上传交互、样式布局与服务端处理。项目支持多用户同时上传且互不干扰,接收全部分片后合并为完整文件,并提供流式下载以降低内存占用;前端借助 Bootstrap 渲染上传进度条,便于观察分片状态。已有 653 人学习,读者可从中获取分片上传流程、合并逻辑、编码问题处理及后续按序回传优化的完整思路。
1. 大文件上传为什么必须分片:一个 2GB 素材卡在 99% 的现场
某开发者把一段 2GB 的素材拖进上传框,进度条走到 99% 停住,刷新重来,第三次干脆网关超时——这个现场我见过太多次。upload-demo 是一份用 Flask 做服务端、WebUploader 做浏览器端的分段上传大文件实现:把整块请求拆成固定大小的分片,逐片落盘,最后在服务端合并。它解决的不是“能不能传”,而是“断一次要不要从头再来”。适合要在内网后台、管理页里塞大文件上传能力的人,也适合拿 BootstrapHTML 页面直接接上就跑的新手。前端要的是可控并发与重试,后端要的是稳定落盘与合并,两边都得先把“分片”这件事说清楚。
2. Flask 接收端:分片接口、落盘与合并的三段式
服务端是整个方案的承重墙。很多人第一次写分片上传,会把“接收分片”和“合并文件”塞进同一个路由,结果一个请求挂十秒,前端超时重试,分片写了两遍。合理的做法是按职责切成三条路由:一条告诉前端“哪些片已经有了”,一条收单片,一条负责合并。Flask 本身很轻,处理这种 IO 密集的活正合适,但它的默认配置和 Werkzeug 的解析行为有几个地方必须提前改掉,否则会在压测阶段集中爆出来。
2.1 三条路由的约定与参数格式
三条路由的职责边界要提前定死,前端 WebUploader 的配置项才能对着填。我一般按下面这张表来约定,字段名短、语义直白,前端在uploadBeforeSend里一次性注入就行。
| 路由 | 方法 | 关键参数 | 返回 |
|---|---|---|---|
/upload/check | POST | fileId、filename、chunkTotal | 已上传分片索引数组、是否已存在完整文件 |
/upload/chunk | POST | fileId、chunkIndex、chunkTotal、chunk(文件体) | 当前分片索引、累计收到数量 |
/upload/merge | POST | fileId、filename、chunkTotal | 合并后路径、服务端 sha256 |
这里有个容易忽略的点:fileId不要用文件名。同名文件在不同用户手里太常见,用文件名当目录名会让两份上传互相踩。常见做法是前端用WebUploader.File.id加时间戳拼一个短 ID,或者干脆取文件大小、修改时间、随机数做哈希。chunkTotal每次请求都带,是为了让服务端能独立判断“片齐没齐”,不必依赖前一次请求的状态。
# app.py —— 三条路由的骨架 import os from flask import Flask, request, jsonify app = Flask(__name__) BASE_DIR = os.path.abspath("upload_tmp") CHUNK_DIR = os.path.join(BASE_DIR, "chunks") FILE_DIR = os.path.join(BASE_DIR, "files") os.makedirs(CHUNK_DIR, exist_ok=True) os.makedirs(FILE_DIR, exist_ok=True) @app.route("/upload/chunk", methods=["POST"]) def upload_chunk(): file_id = request.form["fileId"] # 前端生成的上传会话 ID chunk_index = int(request.form["chunkIndex"]) total = int(request.form["chunkTotal"]) chunk = request.files["chunk"] # 字段名要和 WebUploader 的 fileVal 对齐 target_dir = os.path.join(CHUNK_DIR, file_id) os.makedirs(target_dir, exist_ok=True) # 零填充命名,保证字符串排序等于数字排序 chunk.save(os.path.join(target_dir, f"{chunk_index:06d}.part")) return jsonify({"ok": True, "index": chunk_index, "total": total})这段代码的重点不在save,而在目录结构和命名。fileId一级目录把不同上传会话隔离开,{chunk_index:06d}.part的六位零填充是给后面合并用的——文件系统返回的listdir顺序是字典序,10.part会排在2.part前面,零填充之后000010一定在000002后面。这个细节不写,合并出来的文件就是错位的,而且错得很隐蔽:文件大小对得上,打开就报格式损坏。
2.2 分片落盘:并发写、幂等与磁盘水位
WebUploader 默认可以开多个线程并发上传分片,Flask 开发服务器默认单线程,但上了 gunicorn 或 uWSGI 就是多 worker。多个分片同时save到同一目录本身没问题,因为文件名不同;真正要防的是同一个分片被重复提交——网络抖动、前端重试、用户刷新页面都会触发。
重复写同一个.part文件,最坏情况是两次写入交错,得到一个半新半旧的分片。解决办法很土但有效:先写临时名再原子重命名。
import tempfile @app.route("/upload/chunk", methods=["POST"]) def upload_chunk_safe(): file_id = request.form["fileId"] chunk_index = int(request.form["chunkIndex"]) chunk = request.files["chunk"] target_dir = os.path.join(CHUNK_DIR, file_id) os.makedirs(target_dir, exist_ok=True) final_path = os.path.join(target_dir, f"{chunk_index:06d}.part") # 先落到同目录临时文件,再 os.replace 原子替换 fd, tmp_path = tempfile.mkstemp(dir=target_dir, suffix=".tmp") os.close(fd) chunk.save(tmp_path) os.replace(tmp_path, final_path) return jsonify({"ok": True, "index": chunk_index})os.replace在同一文件系统内是原子操作,读到的要么是旧分片、要么是新分片,不会出现半个。tempfile.mkstemp放在target_dir里而不是系统/tmp,是为了保证os.replace不跨设备——跨设备会退化成复制加删除,原子性就没了,这个坑我在容器环境里踩过一次,排查了半天。
磁盘水位也得看一眼。分片目录会随着上传堆积,一个 2GB 文件按 5MB 切就是 400 个分片文件,再加上合并时生成的完整文件,峰值占用接近两倍。上线前用df -h确认临时目录所在分区有足够余量,别等传到 80% 才报No space left on device。
2.3 合并:流式拼接、哈希校验与目录回收
合并路由是唯一可能长时间占用请求的地方。一个 2GB 文件,即使磁盘顺序写,也要几秒到十几秒。这里最常见的翻车写法是b"".join(open(p, "rb").read() for p in parts),2GB 直接进内存,容器内存限制一卡就 OOM。
正确姿势是固定缓冲区流式拷贝,边写边算哈希:
import hashlib import shutil @app.route("/upload/merge", methods=["POST"]) def merge(): data = request.get_json() file_id = data["fileId"] filename = os.path.basename(data["filename"]) # 防目录穿越 total = int(data["chunkTotal"]) src_dir = os.path.join(CHUNK_DIR, file_id) if not os.path.isdir(src_dir): return jsonify({"ok": False, "msg": "分片目录不存在"}), 400 parts = sorted(os.listdir(src_dir)) if len(parts) != total: return jsonify({"ok": False, "msg": f"分片缺失 {len(parts)}/{total}"}), 400 out_path = os.path.join(FILE_DIR, filename) sha = hashlib.sha256() with open(out_path, "wb") as out: for name in parts: if not name.endswith(".part"): continue with open(os.path.join(src_dir, name), "rb") as fp: while True: buf = fp.read(1024 * 1024) # 1MB 缓冲区 if not buf: break sha.update(buf) out.write(buf) shutil.rmtree(src_dir, ignore_errors=True) # 合并成功再清分片 return jsonify({"ok": True, "path": out_path, "sha256": sha.hexdigest()})几个参数值得说清楚。缓冲区取 1MB 是个折中,太小系统调用次数多,太大对内存不友好,2GB 文件下 1MB 缓冲区大约两千次读写,耗时可以接受。os.path.basename那行不能省,filename来自前端,带../就能写到任意路径。shutil.rmtree放在写完之后,顺序不能反——先删分片再合并,中途失败就彻底没得救了。
合并完成后把sha256返回给前端,前端可以再算一次本地哈希做比对。这一步在“秒传”场景里是刚需,下一章会展开。
2.4 Flask 侧三个必须先改的配置
第一是请求体大小。Flask 2.3 之前用MAX_CONTENT_LENGTH,单位字节,设成20 * 1024 * 1024表示单个请求最多 20MB,分片 5MB 的话留了四倍余量。超过限制 Werkzeug 直接返回 413,前端 WebUploader 只会看到一个uploadError,不会告诉你原因。
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 单请求上限 app.config["JSON_AS_ASCII"] = False # 中文错误信息不乱码第二是超时。合并路由耗时可能超过默认的 30 秒,gunicorn 的--timeout要放大到 120 秒以上,否则 worker 被杀,前端收到 502 但文件其实已经合并完了,用户重传一次就多一份垃圾。
第三是下载端。合并后的文件要能被流式下载,send_file的conditional=True会启用 Range 请求支持,配合前端的 stream-downloader 就能做断点续传下载,和上传侧的分片逻辑对称起来:
from flask import send_file @app.route("/download/<path:filename>") def download(filename): path = os.path.join(FILE_DIR, os.path.basename(filename)) return send_file(path, as_attachment=True, conditional=True)conditional=True是这行的关键,没有它浏览器只能整文件下载,2GB 文件断一次就得重来,等于上传侧做了断点、下载侧又退回原点。
3. WebUploader 前端:chunkSize、threads 与重试参数怎么定
前端这层看着只是配置项,实际决定了服务端要承受什么样的压力曲线。chunkSize调小,请求数变多,握手和鉴权开销翻倍;调大,单请求失败重传成本高,弱网下体验反而差。WebUploader 的分片能力是内置的,但它默认不会把分片索引按你想要的字段名传,也不会自动做断点续传,事件链得自己接。
3.1 初始化参数:chunked、chunkSize、threads 的组合
先看一份能直接跑的初始化配置:
var uploader = WebUploader.create({ swf: '/static/webuploader/Uploader.swf', server: '/upload/chunk', pick: '#picker', fileVal: 'chunk', // 必须和 Flask 的 request.files['chunk'] 对齐 chunked: true, chunkSize: 5 * 1024 * 1024, // 5MB 一片 chunkRetry: 3, // 单片失败重试次数 threads: 3, // 同时上传的分片数 duplicate: true, // 允许重复选择同一文件 resize: false, formData: { fileId: '', chunkTotal: 0 } });chunkSize取 5MB 是我在多数内网环境下的默认值。理由是:小于 1MB,400 个分片变成 2000 个,请求头开销和 Flask 的解析开销都会明显上来;大于 10MB,弱网下单请求超时概率上升,重传一次就是 10MB 的浪费。5MB 在两者之间,400 个分片对 2GB 文件来说,目录里文件数不至于多到listdir变慢。
threads: 3要结合服务端 worker 数看。gunicorn 开 4 个 worker,前端开 8 个线程,多出来的请求就在队列里排着,反而拉高了单请求耗时。我的经验值是前端线程数不超过后端 worker 数的 75%。
duplicate: true不是可有可无的。默认false时,用户第二次选同一个文件会被静默忽略,看起来像“点了没反应”,这个反馈缺失在后台系统里被投诉过很多次。
3.2 事件链:uploadBeforeSend 注入分片索引
WebUploader 内部其实会带chunks、chunk、id这些字段,但字段名和服务端约定对不上,最稳的做法是在uploadBeforeSend里显式补齐:
uploader.on('uploadBeforeSend', function (block, data, headers) { var file = block.file; data.fileId = file.id; // 本次上传会话 ID data.chunkIndex = block.chunk; // 当前分片索引,从 0 开始 data.chunkTotal = Math.ceil(file.size / uploader.option('chunkSize')); data.filename = file.name; });block.chunk是当前分片的序号,block.file是文件对象,file.id在页面生命周期内唯一。chunkTotal每次都重算而不是缓存,是因为chunkSize可能在运行中被改(比如根据网络状况动态调整),缓存值会和服务端收到的实际分片数对不上。
分片全部传完后 WebUploader 会触发uploadSuccess,注意这个事件是每个分片成功都会触发一次,不是整个文件完成才触发。判断整个文件完成要靠计数器:
var doneMap = {}; uploader.on('uploadSuccess', function (file, response) { if (!doneMap[file.id]) doneMap[file.id] = 0; doneMap[file.id]++; var total = Math.ceil(file.size / uploader.option('chunkSize')); if (doneMap[file.id] === total) { // 所有分片到齐,发起合并 $.post('/upload/merge', { fileId: file.id, filename: file.name, chunkTotal: total }, function (res) { if (res.ok) console.log('合并完成', res.sha256); }); } });这段代码里doneMap用file.id做 key,页面刷新就丢了,所以断点续传必须依赖服务端的/upload/check,不能靠前端内存状态。
3.3 Bootstrap 页面骨架与进度条绑定
upload-demo 的页面用 Bootstrap 排版,结构很简单:一个选择按钮、一个文件列表、一个总进度条。关键是进度条的数据来源——WebUploader 的uploadProgress事件给的是单个分片的进度,要汇总成文件级进度得自己算:
<div class="card"> <div class="card-body"> <div id="picker" class="btn btn-primary">选择文件</div> <div class="progress mt-3" style="height: 22px;"> <div id="bar" class="progress-bar" role="progressbar" style="width: 0%;">0%</div> </div> <ul id="filelist" class="list-group mt-3"></ul> </div> </div>var progressMap = {}; uploader.on('uploadProgress', function (file, percentage) { progressMap[file.id] = percentage; // 单分片进度 var loaded = 0, total = 0; uploader.getFiles().forEach(function (f) { var size = f.size || 0; total += size; loaded += size * (progressMap[f.id] || 0); }); var pct = total ? Math.floor(loaded / total * 100) : 0; $('#bar').css('width', pct + '%').text(pct + '%'); });getFiles()返回当前队列里的所有文件,多文件同时上传时进度条是加权平均,不会出现“一个传完 100%、另一个刚开始 0%”的跳变。Bootstrap 的.progress-bar只负责样式,宽度靠内联 style 控制,别去改它的 CSS 类名。
3.4 暂停、继续与断点续传的接线
WebUploader 自带uploader.stop()和uploader.upload(),但这两个是全局的。要做“单文件暂停”,得用uploader.stop(true)加文件 ID 过滤,或者干脆在uploadBeforeSend里拦截已暂停的文件。
更实用的场景是页面刷新后的续传。流程是:用户重新选同一个文件 → 前端调/upload/check→ 服务端返回已收到的分片索引 → 前端跳过这些分片。WebUploader 没有“跳过指定分片”的公开 API,常见做法是给分片请求加一个拦截:
var uploadedSet = {}; // 由 /upload/check 填充 uploader.on('uploadBeforeSend', function (block, data) { if (uploadedSet[block.file.id] && uploadedSet[block.file.id].indexOf(block.chunk) > -1) { block.status = 'skipped'; // 标记后由 uploadAccept 拦截 return false; } });return false会让 WebUploader 放弃这个分片的发送,但不会把它计入成功计数,所以合并触发逻辑要单独处理“跳过的分片视为已完成”。这块逻辑容易写漏,测试时务必覆盖“传到一半刷新页面再续传”的路径。
4. 秒传与断点续传:文件指纹、分片索引和临时目录治理
分片上传做完,下一个自然需求就是“同一个文件别传第二遍”。秒传听着高级,本质是一次哈希比对:前端算出文件指纹,服务端查一下有没有,有就直接返回路径。问题在于 2GB 文件的哈希怎么算才不把浏览器卡死,以及算出来的指纹怎么和分片索引配合。
4.1 整文件 MD5 在 2GB 上的代价
直接SparkMD5.hash(file)是行不通的。FileReader.readAsArrayBuffer一次性读 2GB,浏览器内存直接爆,标签页崩溃。正确做法是切片增量计算:
function calcFileMd5(file, onProgress) { return new Promise(function (resolve, reject) { var sliceSize = 4 * 1024 * 1024; // 每次读 4MB var chunks = Math.ceil(file.size / sliceSize); var current = 0; var spark = new SparkMD5.ArrayBuffer(); var reader = new FileReader(); function loadNext() { var start = current * sliceSize; var end = Math.min(start + sliceSize, file.size); reader.readAsArrayBuffer(file.slice(start, end)); } reader.onload = function (e) { spark.append(e.target.result); current++; onProgress && onProgress(Math.floor(current / chunks * 100)); if (current < chunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror = reject; loadNext(); }); }4MB 的切片大小是经验值,太小回调次数多,太大会让单次读取的停顿感明显。这个函数即使切片读,2GB 也要跑十几秒,期间主线程被FileReader回调占着,页面会卡。缓解办法是放到 Web Worker 里跑,或者先做“抽样哈希”——取文件头 1MB、尾 1MB、中间若干块算一个弱指纹,先做粗筛,命中后再算全量。
提示:秒传的判定标准要业务方确认。仅凭文件名和大小判重会误伤,两个不同文件同名同大小的情况真实存在,哈希是底线。
4.2 /upload/check 的返回结构与续传逻辑
校验接口返回什么,直接决定前端续传代码好不好写。我倾向于返回三个字段,语义清晰:
@app.route("/upload/check", methods=["POST"]) def check(): data = request.get_json() file_id = data["fileId"] filename = os.path.basename(data["filename"]) total = int(data["chunkTotal"]) src_dir = os.path.join(CHUNK_DIR, file_id) uploaded = [] if os.path.isdir(src_dir): for name in os.listdir(src_dir): if name.endswith(".part"): uploaded.append(int(name.split(".")[0])) finished = os.path.exists(os.path.join(FILE_DIR, filename)) return jsonify({ "uploaded": sorted(uploaded), "finished": finished, "missing": [i for i in range(total) if i not in uploaded] })uploaded是已落盘的分片,missing是还缺的,finished表示服务端已有完整文件——这三个字段组合起来,前端就能判断是“秒传直接完成”“部分续传”还是“全新上传”。返回missing而不是让前端自己算差集,是为了避免chunkTotal不一致时的边界错误:分片大小改过之后,旧会话的索引对不上新会话,前端自己算容易越界。
前端拿到missing后,把不在列表里的分片标记为跳过,只传缺失的。合并触发条件改成“已上传数 + 跳过数 == chunkTotal”,不再依赖前端计数器。
4.3 临时目录的生命周期管理
分片目录不会自己消失。用户传了一半关页面、合并失败、秒传命中后旧分片还在,这些都会留下垃圾。一个 2GB 文件传一半就是 1GB 的残留,几十个用户就能把磁盘吃满。
治理思路是两条:合并成功后立即删(上一章的shutil.rmtree),以及定时清理超时目录。定时任务用find就够,不必上调度框架:
#!/usr/bin/env bash # 清理 24 小时未变动的分片目录,先打印再删,便于观察 find /data/upload_tmp/chunks -mindepth 1 -maxdepth 1 -type d -mmin +1440 \ -print -exec rm -rf {} \;-mmin +1440是 24 小时,按目录的修改时间算。这里有个取舍:如果用户传一个超大文件超过 24 小时,目录会一直被写,mtime会刷新,不会被误删;但如果用户中途暂停超过 24 小时,分片就被清了,续传时得从头来。这个阈值要根据业务实际的上传时长分布来调,后台系统里设 48 小时更稳妥。
完整文件目录也要有对应策略。/download路由提供的是最终产物,不能随便删,但可以按“最后访问时间”做冷热分层,或者交给对象存储的生命周期规则,这就超出 upload-demo 的范围了。
5. 避坑与排查:分片错位、413 与合并丢字节的五个现场
这套东西跑通不难,难的是跑稳。下面五条都是我在实际部署里遇到的,按“现象 → 原因 → 解决”记下来,遇到了可以对着排查。
5.1 现象:合并成功但文件打不开,大小对得上
原因几乎都是分片顺序错。os.listdir返回的是目录项顺序,不保证字典序,更不保证数字序。如果文件名没做零填充,10.part会排在2.part前面,合并出来就是乱的。
解决办法是双重保险:落盘时用{index:06d}.part零填充,合并时先sorted()再按后缀过滤,最后校验总片数和期望值是否一致。已经出了问题的文件没法救,只能删了重传。
5.2 现象:5MB 分片请求返回 413,小文件却正常
原因是有两层限制同时存在。Flask 的MAX_CONTENT_LENGTH是一层,前面的 Nginx 还有一层client_max_body_size,默认 1MB。只改 Flask 不改 Nginx,请求在网关就被拒了,Flask 根本收不到。
解决是两边都放大,并且留余量。分片 5MB,Nginx 设 20MB,Flask 设 20MB,multipart 的边界和表单字段也有开销,贴着 5MB 设会偶发超限。
client_max_body_size 20m; proxy_read_timeout 300s; proxy_request_buffering off; # 大文件直传,别让 Nginx 先缓冲整个请求proxy_request_buffering off这行容易被忽略。开着的时候 Nginx 会把整个请求体先收完再转给后端,分片上传的并发优势被抵消一半,内存压力也上去了。
5.3 现象:选完文件浏览器卡死几秒,进度条不动
原因是主线程在算 MD5。FileReader的回调虽然异步,但SparkMD5.append是同步计算,2GB 数据分四百次追加,每次都在主线程跑,页面自然卡。
解决办法优先是挪到 Web Worker,把File对象postMessage过去(File是可转移对象),算完把哈希传回来。退一步的做法是先算抽样指纹做秒传粗筛,只有粗筛命中才跑全量哈希。
5.4 现象:合并时容器内存飙到限制,进程被 kill
原因是把分片一次性读进内存拼接。b"".join(...)或者read()不带参数,都会把整个文件加载进来。2GB 文件在 512MB 内存限制的容器里必死。
解决就是上一章那段流式写法:固定 1MB 缓冲区,while循环读写,哈希增量更新。改完之后合并 2GB 文件的内存占用稳定在几 MB,CPU 也平稳。
5.5 现象:两个用户同时上传,分片互相覆盖
原因是fileId撞了。常见于用文件名或文件大小当 ID 的实现,两个人传同名文件就进同一个目录,后写的分片覆盖先写的,合并出来的文件两边都不对。
解决办法是fileId必须包含足够随机的成分,前端用file.id加时间戳,服务端再校验一次格式(比如只允许[A-Za-z0-9_-]{8,64}),防止路径穿越。目录创建用os.makedirs(exist_ok=True),两个请求同时创建不会报错。
6. 压测与验证:用 curl 灌分片、核对哈希、跑一遍断点场景
界面点通了不算数,我一般会用 curl 绕过前端,直接对服务端灌分片,这样能把前端的问题和后端的问题分开。第一步是把一个测试文件切成 5MB 的片:
# 生成 30MB 测试文件并切片,-d 用数字后缀,-a 6 补齐六位 dd if=/dev/urandom of=demo.bin bs=1M count=30 split -b 5m -d -a 6 demo.bin part_ ls part_* | wc -l # 应该是 6split出来的文件名是part_000000这种格式,和 Flask 侧的零填充命名规则一致,但 curl 提交时要用分片索引而不是文件名。循环里10#$i是为了把带前导零的字符串按十进制解析,否则 bash 会当成八进制,08直接报错:
for i in $(seq -w 0 5); do curl -s -X POST http://127.0.0.1:5000/upload/chunk \ -F "fileId=test-20240101-001" \ -F "chunkIndex=$((10#$i))" \ -F "chunkTotal=6" \ -F "chunk=@part_$i" \ -o /dev/null -w "chunk $i -> %{http_code}\n" done跑完看每个分片的 HTTP 状态码,全是 200 才算落盘正常。接着调合并接口,把返回的sha256和本地算的比一下:
curl -s -X POST http://127.0.0.1:5000/upload/merge \ -H "Content-Type: application/json" \ -d '{"fileId":"test-20240101-001","filename":"demo.bin","chunkTotal":6}' sha256sum demo.bin两个哈希一致,说明分片落盘、合并、哈希计算整条链路没问题。不一致就往回查:先看分片目录里文件数和大小对不对,再看合并顺序,最后看缓冲区读写有没有漏字节。
第二步是模拟断点。故意只传前三个分片,然后调/upload/check,看missing是不是[3,4,5]:
curl -s -X POST http://127.0.0.1:5000/upload/check \ -H "Content-Type: application/json" \ -d '{"fileId":"test-20240101-001","filename":"demo.bin","chunkTotal":6}'返回对得上,再补传后三个分片、合并、比哈希。这一步能抓出大部分索引相关的边界错误,比在浏览器里点来点去可靠得多。
第三步是并发验证。开三个终端同时跑上面的循环,但用不同的fileId,观察服务端 CPU 和磁盘 IO,以及合并耗时是否线性增长。如果并发一上来合并就超时,回去看 gunicorn 的--timeout和 worker 数。
最后一步是清理验证。合并成功后立刻ls upload_tmp/chunks,确认对应目录已删;再手动造一个超时的空目录,跑一次find ... -mmin +1440的清理脚本,确认能删干净。这步容易被跳过,等到磁盘告警才想起来就晚了。
这套流程我现在每次改完上传相关代码都会强制走一遍,尤其是哈希比对那步——有一次本地测试图省事只看了文件大小,结果线上合并出来差了几百字节,前端下载后打不开,回滚排查了两个小时。从那以后我每次改完分片逻辑,都把 curl 灌分片加sha256sum比对当成固定动作,宁可多花三分钟,也不赌运气。希望帮到你。
本文还有配套的精品资源,点击获取