资讯动态

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

发布时间:2026/10/27 7:57:52 来源:尧图企业网站定制
简介面向Python与Flask初学者及需要实现大文件传输的开发者这份分片上传与流式下载演示覆盖前端切片、后端接收合并、多用户并发上传互不干扰以及低内存下载等关键环节。压缩包共26个文件以样式表、脚本、HTML模板和Python服务端代码为主另含WebUploader配套字体图标、依赖清单与说明文档整体约427KB结构精简便于对照阅读和本地调试。已有653人学习下载。项目用Bootstrap展示上传进度提供上传页与文件列表页服务端集中处理分片接收、合并与文件回传读者可从中梳理分片参数传递、合并时机、流式响应及编码问题排查思路。对于需要理解大文件上传流程、分片落盘与下载响应差异的读者案例保留了清晰的目录划分和基础依赖配置也能作为课程示例或小型文件服务原型参考。1. 大文件上传卡在 99% 的那三分钟upload-demo 先把一个文件拆成几百片把一个 2GB 的虚拟机镜像拖进浏览器进度条走到 99% 之后原地重来——整文件上传的失败往往就是这样之前的时间全白费了而且不给你任何解释。upload-demo 是一份用 Flask 做后端、WebUploader 做前端的分段上传示例前端跑在 Bootstrap 页面上后端用 Python 接收分片、落盘、合并核心目标只有一个让大文件上传失败的时候不用从第 0 字节重来并且让「传过一半」这件事变成服务端可查询的状态。它适合两类人一类是要给现有后台加断点续传、又不想引入对象存储的后端另一类是想把 chunked 上传整条链路切分、指纹、校验、合并、清理真正跑通一遍的开发者。代码量不大但每一个字段、每一个时间点都藏着会让上传静默失败的地方。2. 分片协议怎么定Flask 接片的四个字段与 WebUploader 的 chunked 开关分片上传第一次做的人常犯的错是只盯着「怎么把片发出去」而忽略了「服务端怎么知道这一片属于哪个文件的第几段」。协议设计错了后面续传、秒传、合并全是空的。upload-demo 的契约定得很朴素一次请求里带四样东西分片二进制本身、当前分片序号、总分片数、整文件指纹。2.1 一次上传被切成什么样chunk / chunks / md5 / guid 四件套WebUploader 的 chunked 模式会在浏览器侧把 File 对象切成若干 Blob每片单独发一次 POST。框架自己会在 formData 里塞一个chunk字段当前片序号从 0 开始和一个chunks字段总片数但这两个字段名是我在服务端约定的不是协议强制的——不同版本、不同配置下这些名字都可能变所以要自己在uploadBeforeSend里显式覆盖一遍别赌默认值。真正需要自己补的是md5和guid。md5是整文件的指纹用来做三件事定位分片目录、判断是不是已经传过秒传、合并完成后校验产物。guid是一次上传会话的标识作用是解决「同一个文件被两个标签页同时上传」的冲突——两个会话共用同一个 md5 目录分片会互相覆盖最后的合并结果可能是一堆正常分片拼出来的错误文件这种问题排查起来非常烦人。字段来源作用常见错误chunkWebUploader 生成前端可覆盖当前分片序号0 起后端按 1 起算丢第一片chunks前端按文件大小算出总片数合并时的循环上界用 ceil 后少算最后一片md5前端 spark-md5 计算目录定位、秒传判定、合并校验只取文件名做 key同名文件互相干扰guid前端WebUploader.Base.guid()会话隔离、并发去重不传多标签页同时上传时覆盖分片name浏览器 File.name合并后重命名、下载时回显直接拼进路径中文与特殊字符导致 500总分片数的算法要注意边界。Math.ceil(file.size / chunkSize)在文件大小恰好是 chunkSize 整数倍时是对的但最后一片的大小会是 0 吗不会WebUploader 内部按偏移量切最后一片是剩余字节不会产生空片。真正容易错的是把 chunks 写在fileQueued里算一次就固定住——如果中途改了 chunkSize 的默认值前后两次上传的片数就对不上了。我的习惯是把 chunkSize 抽成一个全局常量前端算片数、后端校验、Nginx 限制三处都引用同一个数字。还有一个容易被忽略的点分片目录用什么命名。用文件名做目录名最省事也最容易出问题——同一个文件改名再传一次就变成两份不同目录下同名文件又会互相覆盖。用 md5 做目录名天然具备去重和幂等两重属性代价是计算 md5 本身要花时间这个代价在第 3 章会详细说。upload-demo 用的是uploads/chunks/{md5前两位}/{md5}/两级结构前两位散列是为了避免单个目录下堆积几十万个分片目录ext4 在单目录条目过多时readdir会明显变慢。2.2 Flask 侧收片接口从 request.files 到磁盘暂存目录后端接口只做一件事把这一片安全地落到它该在的位置然后立刻返回。不要在这个接口里做任何耗时操作比如算整文件 md5、查数据库、写日志到远程服务分片请求是高频短请求任何一个慢操作都会被threads并发放大成雪崩。import os import shutil from flask import Flask, request, jsonify app Flask(__name__) BASE os.path.join(os.path.dirname(os.path.abspath(__file__)), uploads) CHUNK_ROOT os.path.join(BASE, chunks) FILE_ROOT os.path.join(BASE, files) os.makedirs(CHUNK_ROOT, exist_okTrue) os.makedirs(FILE_ROOT, exist_okTrue) def chunk_dir(file_md5): # 两级散列单目录条目数控制在千级避免 readdir 变慢 d os.path.join(CHUNK_ROOT, file_md5[:2], file_md5) os.makedirs(d, exist_okTrue) return d app.route(/upload, methods[POST]) def upload_chunk(): f request.files.get(file) # WebUploader 默认字段名就是 file if f is None: return jsonify(code1, msgmissing file part), 400 md5 request.form.get(md5, ) guid request.form.get(guid, ) name request.form.get(name, unnamed.bin) chunk int(request.form.get(chunk, 0)) chunks int(request.form.get(chunks, 1)) if not md5 or len(md5) ! 32: return jsonify(code1, msgbad md5), 400 d chunk_dir(md5) part os.path.join(d, %05d.part % chunk) tmp part .tmp # 先写临时名再原子改名避免半截文件被 check 当成已上传 f.save(tmp) os.replace(tmp, part) # 元信息只在第一片落盘时写一次合并阶段要用 meta os.path.join(d, meta.txt) if not os.path.exists(meta): with open(meta, w, encodingutf-8) as fp: fp.write(%s\n%s\n%d\n % (name, guid, chunks)) return jsonify(code0, chunkchunk, chunkschunks)这段代码里有两个细节值得单独说。第一个是f.save(tmp)之后os.replace(tmp, part)。os.replace在同一文件系统内是原子操作意味着check接口扫描目录时永远看不到写了一半的分片文件如果直接f.save(part)一个 5MB 的分片在磁盘 IO 慢的时候会存在几百毫秒的「文件已存在但内容不完整」窗口恰好在这个窗口里刷新页面触发续传就会把一个残缺分片当成完整分片跳过最后合并出来的文件 md5 对不上而你还得从头查。这种 bug 的概率不高但一旦出现就是玄学级别。第二个是chunks参数在接口里只是透传回显服务端并不做「片数是否一致」的强校验。真要严谨应该在 meta 里存下第一次上报的 chunks后续每一片都对比不一致直接 409 让前端重来。我一般会在调试阶段打开这个校验上线后关掉因为它会让「用户中途换了浏览器版本」这种边缘情况直接失败而实际业务里更希望它能合出来。提示request.files只有在请求的 Content-Type 是multipart/form-data时才有内容。如果前端把分片以application/octet-stream裸流发过来得用request.stream.read()自己读且必须配合Content-Length判断读完没有。upload-demo 走的是标准表单上传别混用两种方式。分片目录的权限也要提前确认。Flask 以什么用户跑uploads目录就得对该用户可写用 gunicorn systemd 部署时经常出现「开发环境能传、生产环境 500」八成是工作目录和权限的问题。日志里会是一句干巴巴的PermissionError不看堆栈根本不知道是哪一层。2.3 WebUploader 侧配置chunked、chunkSize、threads 三个旋钮前端这一侧真正需要调的只有三个参数其余都是默认值就能跑。但就是这三个参数决定了服务端要承受多大的并发压力、单次请求会不会被网关掐断、失败重传的粒度有多细。var CHUNK_SIZE 5 * 1024 * 1024; // 5MB前后端与网关三处共用同一个值 var uploader WebUploader.create({ swf: /static/webuploader/Uploader.swf, // 老浏览器降级用路径错会静默不报错 server: /upload, pick: { id: #picker, label: 选择文件 }, chunked: true, chunkSize: CHUNK_SIZE, chunkRetry: 3, // 单片失败自动重试次数 threads: 3, // 同时并发上传的分片数 duplicate: true, // 允许重复选择同一文件配合 md5 做秒传 resize: false, // 图片才需要压缩大文件务必关掉 accept: { title: 压缩包, extensions: zip,7z,tar,gz,iso,bin } });chunkSize选 5MB 不是随便定的。太小比如 256KB一个 2GB 文件会切出 8000 个请求HTTP 头、TCP 握手、Flask 路由分发的固定开销加起来非常可观而且服务端日志和文件系统 inode 都会被大量消耗太大比如 50MB单次请求的传输时间长任何一次网络抖动都要重传整片chunkRetry的重试成本被放大且 Nginx 的client_max_body_size和client_body_timeout都得跟着放宽。5MB 是一个折中2GB 文件切成 410 片左右单请求在百兆内网里大约 0.5 秒完成重传代价可接受。threads: 3意味着同一时刻有三个分片在飞。这个数字和服务端能力直接挂钩如果是单进程 Flask 开发服务器3 个并发已经能让它排队如果前面挂了 Nginx 且后端是 gunicorn 多 worker可以开到 58。开太大并不会更快因为瓶颈通常在磁盘写入和带宽而不在连接数反而会让服务端的临时目录产生大量并发写合并阶段的文件顺序校验更容易出问题。chunkRetry的语义是「单片失败后自动重试」注意它不会重置整个文件。这一点很关键网络抖一下只是某一片重传前面已经落盘的分片不受影响。但它只对网络层错误生效服务端返回 500 或 4xx 时框架是否重试取决于版本行为所以真正的兜底还是第 4 章的用户手动重试按钮。resize: false必须显式关掉。WebUploader 默认会对图片做压缩某些版本里这个逻辑对大文件也会有影响表现为上传的字节数和源文件对不上最后 md5 校验失败。这个坑我在一个图像处理 Demo 上踩过查了两个小时才发现是前端偷偷改了数据。3. 断点续传与秒传把已传分片做成一次 GET 查询分片能传上去只是第一步。用户关掉页面再打开、网络断了重连、同一个文件被第二个人上传——这三种场景才是分段上传真正值钱的地方。要把它们做好核心是把「哪些片已经在了」这件事变成一个可以随时查询的服务端状态而不是靠前端内存里的变量记着。3.1 文件指纹md5 怎么算才不卡住浏览器整文件 md5 是这一切的钥匙也是最容易翻车的一环。一个 2GB 文件在普通办公机上用 JS 算 md5三五秒是常态配置差一点的机器能到十几秒。如果直接用同步方式算页面会整个卡死用户以为浏览器崩了直接关掉。WebUploader 内置了md5File方法底层依赖 spark-md5需要在页面里先引入这个库否则调用时会报md5File is not a function而且报错位置在事件回调里控制台不一定显眼。script src/static/webuploader/webuploader.js/script script src/static/spark-md5/spark-md5.min.js/scriptuploader.on(fileQueued, function (file) { file.state hashing; renderState(file); // 页面上显示「正在校验文件」 uploader.md5File(file) .progress(function (p) { // p 是 0~1 的进度用它驱动一个独立的校验进度条 setHashProgress(file, p); }) .then(function (md5) { file.md5 md5; file.state checking; // 指纹算完立刻问服务端这个文件是不是已经存在 $.getJSON(/check, { md5: md5, size: file.size }, function (res) { if (res.skip) { file.state done; markInstant(file); // 秒传直接标记完成 return; } file.uploaded res.uploaded; // 已存在的分片序号数组 file.state ready; uploader.upload(); // 或者等用户点「开始」 }); }); });md5File默认是整文件读取计算内部会分块读进内存不会一次性把 2GB 塞进去但耗时仍然和文件大小线性相关。如果业务上不能接受这几秒有两个降级方案一是「抽样 md5」只取文件头 2MB、尾 2MB 和中间若干偏移各 1MB 拼起来算碰撞概率在业务可接受范围内耗时几乎恒定二是「文件名 大小 修改时间」做弱指纹速度快但去重能力差同名同大小的不同文件会被误判成秒传风险自负。upload-demo 用的是全量 md5因为它的定位是示例正确性优先。注意计算 md5 期间不要允许用户再次点击上传。duplicate: true是为了让同一个文件能被重新选择以触发秒传不是为了让它在上传中被重复入队。3.2 check 接口与前端跳过逻辑/check是整个续传链路里唯一一个读接口它要回答两个问题这个文件是不是已经完整存在秒传、目录里已经有哪些分片续传。app.route(/check, methods[GET]) def check(): md5 request.args.get(md5, ) if not md5 or len(md5) ! 32: return jsonify(code1, msgbad md5), 400 merged os.path.join(FILE_ROOT, md5) if os.path.exists(merged): # 秒传合并产物已在直接告诉前端不用传 return jsonify(code0, skipTrue, uploaded[], sizeos.path.getsize(merged)) d chunk_dir(md5) uploaded [] if os.path.isdir(d): for fn in os.listdir(d): if fn.endswith(.part): try: uploaded.append(int(fn.split(.)[0])) except ValueError: continue # 忽略 meta.txt 之类的非分片文件 return jsonify(code0, skipFalse, uploadedsorted(uploaded))返回的uploaded是分片序号数组。前端拿到之后理论上应该跳过这些片——但 WebUploader 并没有提供「跳过指定分片」的公开 API它只支持skipFile跳过整个文件。想让某个分片不发出去得覆写内部的send方法侵入性太强版本升级就废。我一般采用的方案是服务端幂等前端不跳分片照发/upload接口在落盘前先判断目标文件是否已存在存在就直接返回成功不重写、不重复占空间。part os.path.join(d, %05d.part % chunk) if os.path.exists(part) and os.path.getsize(part) f.content_length: return jsonify(code0, chunkchunk, chunkschunks, skippedTrue)代价是浪费一点上行带宽收益是逻辑简单、不会因为跳过逻辑写错而漏片。对内部系统来说这笔账很划算如果是公网、流量敏感的场景才值得去改 WebUploader 源码。这个取舍要在设计阶段就想清楚别做到一半发现「续传其实是假的」。3.3 合并分片顺序、校验、清理三件事合并是整条链路里最容易出问题的一步因为它同时涉及顺序、完整性和并发。顺序错了文件是坏的完整性没校验就不知道坏了并发没处理好会出现「两个请求同时合并同一个文件」。app.route(/merge, methods[POST]) def merge(): data request.get_json(forceTrue) md5 data.get(md5, ) total int(data.get(chunks, 0)) if not md5 or total 0: return jsonify(code1, msgbad params), 400 out os.path.join(FILE_ROOT, md5) if os.path.exists(out): # 幂等已经合并过就直接返回避免并发重复合并 return jsonify(code0, md5md5, sizeos.path.getsize(out), reusedTrue) d chunk_dir(md5) parts [os.path.join(d, %05d.part % i) for i in range(total)] missing [i for i, p in enumerate(parts) if not os.path.exists(p)] if missing: # 缺片时把缺口告诉前端让它决定补传哪几片 return jsonify(code1, msgmissing chunks, missingmissing[:20]), 409 tmp out .merging with open(tmp, wb) as w: for p in parts: with open(p, rb) as r: shutil.copyfileobj(r, w, 1024 * 1024) # 1MB 缓冲别用 read() w.flush() os.fsync(w.fileno()) # 落盘后再改名防止断电留下半截产物 os.replace(tmp, out) # 原子改名对外的文件永远完整 shutil.rmtree(d, ignore_errorsTrue) # 合并成功才清理分片 return jsonify(code0, md5md5, sizeos.path.getsize(out))四个点值得掰开。第一合并前必须做一次缺片检查而不是边合边发现文件不存在——后者会在磁盘上留下一个半截的.merging文件虽然对外不可见但会占空间且下次重试时行为不确定。第二shutil.copyfileobj要指定缓冲区大小默认值在某些 Python 版本里偏小2GB 文件合并会明显变慢。第三flushfsync之后再replace这是为了应对容器被强杀或机器断电缺了fsync有可能出现「改名成功但内容还在页缓存里没落盘」的情况。第四清理分片目录必须放在合并成功之后先删后合等于自断退路。合并完成后服务端最好再算一次 md5 和传入值比对。这一步很耗时等于把文件完整读一遍所以 upload-demo 默认关掉只在前端传了verify1时执行。真实项目里我会把它放在一个异步任务里跑跑完再更新数据库状态而不是卡在 HTTP 请求里。4. 从 Bootstrap 页面到可用的上传队列进度、并发与失败重传接口通了不代表能用。用户看到的只有一个进度条而进度条背后至少有五个状态在流转任何一个状态没处理好用户就会觉得「卡住了」。这一章讲的是把接口拼成一个能交付的界面。4.1 页面结构与上传状态机Bootstrap 的进度条组件是现成的progress外套progress-bar改width就行。真正要设计的是每个文件卡片上的状态我习惯定成六个hashing算指纹、checking问服务端、ready等待上传、uploading、merging、done外加一个error。div classfile-item idfile-{{file.id}} div classfile-name{{file.name}}/div div classprogress div classprogress-bar progress-bar-striped active roleprogressbar stylewidth: 0%/div /div div classfile-state等待校验/div button classbtn btn-xs btn-default btn-retry styledisplay:none重试/button /div状态机的好处是排错时一眼能看出卡在哪一环。用户说「传不上去」你问他进度条有没有动没动说明卡在hashing动了但不动了说明卡在uploading的某一片走到 100% 但没变成完成说明卡在merging。这三种情况的排查方向完全不同比一句「上传失败」有用得多。页面初始化时还有一件事要做uploader.on(dndupload)之类的拖拽支持要显式绑定容器否则 Bootstrap 的布局会把拖拽事件吃掉。这个不是必需的但拖拽体验对内部工具来说很加分。4.2 进度条与错误重试的前端实现进度更新看起来简单坑在于分片模式下uploadProgress给出的是「当前文件已传字节 / 总字节」这个值是 WebUploader 自己累计的多线程并发时它会正确合并可以直接用来驱动进度条。但uploadSuccess在分片模式下是每传完一片就触发一次不是整个文件传完才触发一次——很多人在这里直接把「收到 uploadSuccess」当成「文件传完了」结果第一片到服务端就触发了合并合并必然失败。var chunkDone {}; // fileId - 已完成分片数 uploader.on(uploadProgress, function (file, percentage) { $(# file.id).find(.progress-bar).css(width, (percentage * 100).toFixed(1) %); }); uploader.on(uploadSuccess, function (file, response) { if (!response || response.code ! 0) return; chunkDone[file.id] (chunkDone[file.id] || 0) 1; var total Math.ceil(file.size / CHUNK_SIZE); if (chunkDone[file.id] total) return; // 还没传完继续等 // 全部分片到位发起合并 setState(file, merging); $.ajax({ url: /merge, type: POST, contentType: application/json, data: JSON.stringify({ md5: file.md5, chunks: total }), success: function (res) { if (res.code 0) setState(file, done); else setState(file, error, res.msg); }, error: function (xhr) { // 409 表示缺片把 missing 数组拿出来重传 var body xhr.responseJSON || {}; setState(file, error, 缺片: (body.missing || []).join(,)); } }); }); uploader.on(uploadError, function (file, reason) { // 不要 removeFile保留在队列里让用户能点重试 setState(file, error, reason); $(# file.id).find(.btn-retry).show(); });重试按钮的做法是uploader.retry(file)它会把该文件重新入队。注意chunkDone计数器要在重试前清零否则重试成功后计数会超过总片数条件永远不成立合并永远不会触发——这个 bug 表现为「重试之后进度条走到 100% 就停住了」非常典型。另一个细节是合并请求要设置超时。2GB 文件合并可能要几十秒jQuery 默认的timeout是 0不超时但如果前面有 Nginxproxy_read_timeout默认 60 秒会把连接掐掉前端收到的是 504而服务端其实还在合并。表现就是「提示失败了但刷新一下文件又在了」。解决方案要么是放宽网关超时要么是把合并改成异步任务、前端轮询状态。前者省事后者才是正路。4.3 服务端并发与限流threads 开多大前端threads: 3意味着同一秒内服务端最多要处理 3 个 5MB 的写入。如果后端是 Flask 自带的开发服务器默认单进程单线程这三个请求会排队前端看起来就是「进度条一顿一顿地跳」。开发环境无所谓生产环境必须换 gunicorn 或 uWSGI。# 4 个 worker每个 worker 单线程配合前端 threads3 足够 gunicorn -w 4 -b 127.0.0.1:5000 --timeout 120 app:appworker 数量不要盲目加大。每个 worker 是一个独立进程合并操作如果发生在不同 worker 上靠的是文件系统的os.replace原子性这个没问题但如果你在代码里用了进程内的锁或者计数器来做去重多 worker 下就完全失效了。所以我前面强调幂等要靠文件是否存在来判断而不是靠内存状态。限流这块内部系统一般不做公网部署就绕不开。最简单的做法是在 Nginx 层按 IP 限速限制每秒请求数而不是带宽limit_req_zone $binary_remote_addr zoneupload:10m rate20r/s; location /upload { limit_req zoneupload burst40 nodelay; client_max_body_size 8m; client_body_timeout 120s; proxy_request_buffering off; proxy_pass http://127.0.0.1:5000; }proxy_request_buffering off这一行经常被忽略。默认情况下 Nginx 会把整个请求体先缓冲到磁盘再转发给后端5MB 的分片会多一次写盘和读盘白白增加延迟关掉之后请求体直接流给 Flaskrequest.files仍然能正常工作因为 Werkzeug 自己会处理 multipart 解析。这个开关对分片上传的性能影响比调 chunkSize 还大。顺带说一句下载。upload-demo 里还带了一个/download/md5路由用send_file(path, conditionalTrue)就能支持 Range 请求浏览器断点续传、视频拖动进度条都靠它。上传和下载是一对上传分片落盘、下载按 Range 读文件两端都用到了「分块」这个思路实现上却是完全不同的两套机制对照着看能理解得更透。5. 避坑与排查五条让上传静默失败的真实记录分片上传的坑有个共同特征——它不报错。接口返回 200进度条走完文件也在但就是打不开或者大小不对。下面这五条是我在不同环境里真实遇到过的每条都按「现象 → 原因 → 解决」记遇到类似症状可以直接对号入座。5.1 分片序号从 0 开始后端却按 1 起算现象合并出来的文件总是比源文件小 5MB 左右且开头的字节是第二片的开头。md5 校验必然失败但上传过程一路绿灯。原因WebUploader 的chunk字段从 0 开始计数第一片的序号是 0。后端如果习惯性地写成for i in range(1, chunks 1)去拼文件就会跳过00000.part从00001.part开始拼正好丢掉开头一片。解决统一约定。服务端合并循环写range(total)落盘文件名用%05d.part % chunk保证字典序和数字序一致——如果直接用str(chunk) .part10.part会排在2.part前面任何按文件名排序的合并逻辑都会出错。这个五位补零看着多余实际是防呆的关键。5.2 413 与超时Nginx 默认配置吃掉分片现象本地 Flask 直连一切正常挂到 Nginx 后面就大量分片失败浏览器控制台看到 413或者请求发出去几十秒后返回 504。原因client_max_body_size默认是 1MB5MB 的分片必被拒client_body_timeout默认 60 秒弱网下 5MB 传不完就断。这两个默认值是为小请求设计的和分片上传天然冲突。解决在对应的location里显式放开值要比 chunkSize 大一档8MB 对 5MB超时按最差网络估算。改完一定要nginx -s reload并且用curl -v看实际返回的状态码别只看浏览器——有些前端框架会把 413 吞掉只显示「上传失败」。curl -v -F filepart_00000.bin -F chunk0 -F chunks3 \ -F md50123456789abcdef0123456789abcdef -F guidg1 \ -F namedemo.bin http://127.0.0.1/upload5.3 合并出 0 字节文件并发落盘与合并的竞态现象偶发几十次里出现一次合并出来的文件大小是 0 或者明显偏小日志里没有任何异常。原因前端计数器和后端落盘不同步。uploadSuccess是在 HTTP 响应返回时触发的但响应返回不代表os.replace已经完成——如果服务端是先返回再异步写或者用了多进程且写入有延迟前端可能在最后一片还没落盘时就发了 merge 请求。missing检查能挡住完全缺失的情况但挡不住「文件存在但大小为 0」的中间态。解决服务端在/upload里先落盘再返回顺序不能反合并前除了检查文件存在还要检查os.path.getsize(p) 0更彻底的做法是required [p for p in parts if not os.path.exists(p) or os.path.getsize(p) 0]把零字节分片也归入缺失列表让前端补传。5.4 磁盘写满没人清理的分片目录现象跑了两周之后所有上传都失败错误五花八门有的是 500有的是合并失败df -h一看根分区 100%。原因用户传了一半就关页面的分片永远留在了uploads/chunks下。5MB 一片一天几十个半途而废的大文件磁盘很快就撑满了。合并成功会rmtree但失败和放弃的不会。解决加一个定时清理任务按目录的 mtime 判断超过 24 小时且没有对应合并产物的分片目录直接删掉。用 cron 或者 APScheduler 都行。import time import shutil import os def gc_chunks(root, max_age24 * 3600): now time.time() for dirpath, dirnames, filenames in os.walk(root, topdownFalse): if dirpath root: continue try: if now - os.path.getmtime(dirpath) max_age: shutil.rmtree(dirpath, ignore_errorsTrue) except OSError: continuetopdownFalse保证先删子目录再删父目录否则os.walk遍历过程中目录被删会抛异常。清理任务本身也要有日志删了多少、释放了多少空间不然磁盘又满了你还得重新查一遍。5.5 中文文件名与 swf 路径现象一上传中文名的文件合并后文件名变成乱码或者%E4%B8%AD这样的转义串。现象二在某些浏览器上整个上传组件不渲染控制台没有任何报错。原因一name字段是通过 formData 传的如果前端没有正确编码、后端没有按 UTF-8 解码就会出现乱码更常见的是把 name 直接拼进了文件系统路径特殊字符导致写入异常。原因二WebUploader 的swf配置指向的Uploader.swf路径不对降级方案失效而这个失效在某些环境下是静默的。解决文件名只作为元信息存进meta.txt绝不用作路径路径一律用 md5Flask 侧读取 form 字段默认就是 UTF-8如果还乱检查一下响应头的Content-Type有没有写charset。swf 路径用绝对路径并且在部署后手动访问一次确认 200。现在的浏览器基本都支持 HTML5 上传swf 更多是兜底但兜底失效意味着老环境直接不可用值得花五分钟验证。6. 上线前的一次全链路压测把 5MB 分片跑成 2GB 文件接口写完、页面能跑不代表这套东西能交付。我习惯在提测前用脚本完整跑一遍「切分 → 上传 → 合并 → 校验」不经过浏览器把所有变量都握在手里。用dd造一个 2GB 的随机文件切成 5MB 的片再逐片 POST 上去最后比对 md5——这条链路跑通浏览器端的问题就只剩 UI 层了。#!/usr/bin/env bash set -e SRCbig_test.bin [ -f $SRC ] || dd if/dev/urandom of$SRC bs1M count2048 SIZE$(stat -c%s $SRC) CHUNK$((5 * 1024 * 1024)) TOTAL$(( (SIZE CHUNK - 1) / CHUNK )) MD5$(md5sum $SRC | awk {print $1}) echo size$SIZE chunks$TOTAL md5$MD5 for i in $(seq 0 $((TOTAL - 1))); do dd if$SRC ofpart.tmp bs$CHUNK skip$i count1 statusnone curl -s -X POST http://127.0.0.1:5000/upload \ -F filepart.tmp -F chunk$i -F chunks$TOTAL \ -F md5$MD5 -F guidbench-$$ -F namebig_test.bin /dev/null done curl -s -X POST http://127.0.0.1:5000/merge \ -H Content-Type: application/json \ -d {\md5\:\$MD5\,\chunks\:$TOTAL} echo server md5: $(md5sum uploads/files/$MD5 | awk {print $1})这个脚本的价值在于它把「网络时序」这个变量隔离掉了串行上传、无并发、无重试如果这样都合不出正确的文件问题一定在服务端逻辑和前端无关。跑通之后再把for循环改成xargs -P 3模拟并发观察合并阶段是否出现缺片——这一步能提前暴露第 5.3 节那个竞态。压测时有几个数字要盯住单个分片的平均耗时超过 1 秒说明磁盘或网关有问题、合并 2GB 文件的耗时正常在 1030 秒取决于磁盘、以及uploads/chunks目录在合并后是否被清空。我一般会在合并接口里加一行日志记录total、missing数量和最终大小出问题时这行日志比什么都管用。检查项期望值异常时的排查方向单片上传耗时 1s内网 5MB网关缓冲、磁盘 IO、multipart 解析合并 2GB 耗时1030s缓冲区大小、是否开了 fsync合并后 md5与源文件一致分片序号、缺片、并发覆盖分片目录合并后为空rmtree 是否执行、异常是否被吞秒传响应重复上传 1s 返回check 是否先于上传触发从那以后我每次给一个上传功能收尾都会强制先跑一遍这个脚本再打开浏览器。血泪经验是浏览器能骗你curl 不会。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑