戰(zhàn):分片、校驗(yàn)與斷點(diǎn)續(xù)傳)
簡介這是一份面向Python Web開發(fā)者的Flask大文件分片上傳與流式下載演示項目適合想學(xué)習(xí)前后端配合處理大文件傳輸、進(jìn)度條渲染及多用戶并發(fā)上傳的初中級開發(fā)者。資源包共26個文件約427KB包含2個Python后端腳本、2個HTML頁面模板、5個CSS與5個JS前端資源以及字體、圖標(biāo)、依賴清單和說明文檔等結(jié)構(gòu)清晰便于直接運(yùn)行與二次開發(fā)。項目基于Flask框架前端借助WebUploader實(shí)現(xiàn)分片上傳與Bootstrap進(jìn)度條展示后端接收全部分片后合并為完整文件并支持流式下載以降低內(nèi)存占用同時允許多用戶同時上傳互不干擾。目前已有651人學(xué)習(xí)下載。通過閱讀源碼與運(yùn)行示例讀者可掌握分片上傳、文件合并、流式響應(yīng)等關(guān)鍵實(shí)現(xiàn)思路并了解后續(xù)優(yōu)化方向如避免合并時內(nèi)存緊張、按序回傳分片等是學(xué)習(xí)大文件傳輸方案的實(shí)用參考。1. 大文件上傳總超時聊聊 Flask WebUploader 的分段上傳方案做過后臺管理系統(tǒng)的同行大概率都遇到過這個場景客戶要傳一個 2GB 的培訓(xùn)視頻或者一整套設(shè)計源文件前端進(jìn)度條卡在 99% 不動后端 Nginx 直接甩回一個 413 Request Entity Too Large或者 Flask 開發(fā)服務(wù)器默默把連接斷掉。傳統(tǒng)表單上傳在這個量級下基本就是玄學(xué)網(wǎng)絡(luò)一抖全部重來用戶體驗(yàn)和服務(wù)器內(nèi)存雙雙爆炸。upload-demo這個資源包給出的思路很直接用 Flask 做后端接收用 WebUploader 做前端分片調(diào)度把一個大文件切成若干固定大小的塊逐塊上傳、逐塊落盤最后在服務(wù)端合并。它解決的不是能不能傳的問題而是傳得穩(wěn)、傳得可恢復(fù)、傳得不吃內(nèi)存的問題。適合正在做文件管理系統(tǒng)、在線教育資料庫、企業(yè)內(nèi)部文檔平臺的 Python 后端和前端開發(fā)者尤其是那些被大文件上傳反復(fù)折磨過的人。2. 分段上傳的核心機(jī)制分片、校驗(yàn)與合并的完整鏈路2.1 為什么不能一次性把文件讀進(jìn)內(nèi)存很多人第一版實(shí)現(xiàn)是這樣的前端FormData塞整個文件后端request.files[file].read()一把梭。文件小的時候沒問題一旦超過幾百 MBFlask 默認(rèn)會把請求體緩存在內(nèi)存或臨時文件里MAX_CONTENT_LENGTH沒設(shè)的話直接撐爆 worker。更麻煩的是這種模式下只要網(wǎng)絡(luò)中斷一次整個上傳就廢了沒有斷點(diǎn)續(xù)傳的可能。分段上傳的本質(zhì)是把一次大事務(wù)拆成多次小事務(wù)。每個分片獨(dú)立發(fā)送、獨(dú)立確認(rèn)服務(wù)端只需要維護(hù)一個分片狀態(tài)表。常見做法是前端按固定大小切片比如每片 2MB給每個分片帶上chunkIndex、totalChunks、fileHash三個關(guān)鍵標(biāo)識。后端收到分片后先存到臨時目錄等所有分片到齊再按順序拼接。這樣做的好處是單次請求體積可控內(nèi)存占用恒定某個分片失敗只需重傳那一片服務(wù)端可以在合并前做完整性校驗(yàn)。WebUploader 在這套流程里扮演的是調(diào)度器角色。它內(nèi)置了分片、并發(fā)控制、重試機(jī)制通過chunked和threads兩個配置就能開啟。相比自己用File.slice()手寫WebUploader 幫你處理了并發(fā)隊列和失敗重試的邊界情況省了不少事。2.2 前端分片配置WebUploader 的關(guān)鍵參數(shù)先看前端初始化部分。WebUploader 的配置項比較多但真正影響分段上傳的就這么幾個配錯了要么不分片要么分片順序亂掉。// 初始化 WebUploader 實(shí)例 var uploader WebUploader.create({ // 后端接收分片的接口地址 server: /api/upload/chunk, // 指定使用 HTML5 模式Flash 模式不支持分片 swf: /static/Uploader.swf, // 選擇文件后自動上傳 auto: true, // 開啟分片上傳 chunked: true, // 每片大小 2MB注意單位是字節(jié) chunkSize: 2 * 1024 * 1024, // 并發(fā)上傳線程數(shù)建議 3-5太高會被瀏覽器限制 threads: 3, // 單文件最大限制這里設(shè) 10GB fileSingleSizeLimit: 10 * 1024 * 1024 * 1024, // 只允許特定類型 accept: { title: Files, extensions: zip,rar,7z,mp4,iso, mimeTypes: .zip,.rar,.7z,.mp4,.iso }, // 分片上傳時攜帶額外參數(shù) formData: { // 業(yè)務(wù)側(cè)的用戶標(biāo)識用于隔離不同用戶的上傳目錄 userId: current_user_id } });這里有幾個參數(shù)值得展開說。chunkSize設(shè) 2MB 是個經(jīng)驗(yàn)值太小會導(dǎo)致請求數(shù)暴增Nginx 日志刷得飛快太大則失去分片的意義單次失敗重傳成本高。threads設(shè) 3 到 5 之間比較穩(wěn)妥瀏覽器對同域名并發(fā)連接數(shù)有限制設(shè)太高反而會排隊。fileSingleSizeLimit必須設(shè)否則用戶選了個 50GB 的鏡像文件前端切片階段就可能卡死。WebUploader 在分片模式下會自動給每個分片附加chunk、chunks、id等參數(shù)后端靠這些字段來識別分片歸屬。你不需要手動傳chunkIndex框架已經(jīng)幫你做了。2.3 后端接收分片F(xiàn)lask 路由與臨時存儲后端這塊是整個方案的核心。Flask 接收分片的邏輯不復(fù)雜但要處理好目錄結(jié)構(gòu)和狀態(tài)記錄。import os import hashlib from flask import Flask, request, jsonify app Flask(__name__) # 分片臨時存儲根目錄 CHUNK_ROOT /data/upload_chunks # 最終合并文件存放目錄 FINAL_ROOT /data/upload_final app.route(/api/upload/chunk, methods[POST]) def receive_chunk(): # WebUploader 自動攜帶的分片元信息 chunk_index int(request.form.get(chunk, 0)) total_chunks int(request.form.get(chunks, 1)) file_id request.form.get(id) # 本次上傳的唯一標(biāo)識 file_name request.form.get(name) user_id request.form.get(userId, anonymous) # 每個上傳任務(wù)獨(dú)立一個臨時目錄避免不同文件分片混淆 task_dir os.path.join(CHUNK_ROOT, user_id, file_id) os.makedirs(task_dir, exist_okTrue) # 分片文件以序號命名合并時按序號排序 chunk_file request.files.get(file) chunk_path os.path.join(task_dir, f{chunk_index:06d}.part) chunk_file.save(chunk_path) # 檢查是否所有分片都已到達(dá) received len([f for f in os.listdir(task_dir) if f.endswith(.part)]) if received total_chunks: # 觸發(fā)合并 final_path merge_chunks(task_dir, FINAL_ROOT, file_name, user_id) return jsonify({code: 0, msg: upload_complete, path: final_path}) return jsonify({code: 0, msg: chunk_received, received: received})這段代碼有幾個設(shè)計點(diǎn)。第一用user_id和file_id兩級目錄隔離防止不同用戶或不同文件的分片互相覆蓋。第二分片文件名用{:06d}格式化保證按字符串排序時順序正確否則10.part會排在2.part前面合并出來就是壞文件。第三每次收到分片都檢查目錄內(nèi)文件數(shù)量等于total_chunks時觸發(fā)合并。這個判斷在高并發(fā)下可能有競態(tài)生產(chǎn)環(huán)境建議用 Redis 計數(shù)器或者數(shù)據(jù)庫狀態(tài)表來替代。2.4 合并分片順序拼接與完整性校驗(yàn)合并邏輯本身不復(fù)雜但有兩個坑一是必須按序號順序?qū)懭攵呛喜⒑笠r?yàn)文件完整性。def merge_chunks(task_dir, final_root, file_name, user_id): # 按文件名排序確保分片順序正確 chunk_files sorted([ os.path.join(task_dir, f) for f in os.listdir(task_dir) if f.endswith(.part) ]) # 最終文件路徑加時間戳防止同名覆蓋 import time safe_name f{int(time.time())}_{file_name} final_dir os.path.join(final_root, user_id) os.makedirs(final_dir, exist_okTrue) final_path os.path.join(final_dir, safe_name) # 流式寫入避免把整個文件讀進(jìn)內(nèi)存 with open(final_path, wb) as out: for chunk_path in chunk_files: with open(chunk_path, rb) as cf: # 每次讀 1MB內(nèi)存占用恒定 while True: buf cf.read(1024 * 1024) if not buf: break out.write(buf) # 合并完成后清理臨時分片 for chunk_path in chunk_files: os.remove(chunk_path) os.rmdir(task_dir) return final_path合并時用read(1024 * 1024)循環(huán)寫入而不是out.write(cf.read())是為了控制內(nèi)存峰值。一個 10GB 的文件如果一次性讀入Python 進(jìn)程直接 OOM。分片清理放在合并成功之后萬一合并中途失敗分片還在可以重試。提示生產(chǎn)環(huán)境建議在合并完成后計算一次文件的 MD5 或 SHA256和前端上傳前計算的哈希值比對確認(rèn)文件沒有在傳輸過程中損壞。3. 避坑指南分段上傳最容易翻車的五個地方3.1 分片順序錯亂導(dǎo)致文件損壞現(xiàn)象上傳進(jìn)度顯示 100%但合并后的文件打不開視頻花屏、壓縮包解壓報錯。原因分片文件名沒有做零填充1.part、10.part、2.part按字符串排序變成1, 10, 2合并順序完全亂了?;蛘咔岸瞬l(fā)上傳時后端沒有按chunkIndex命名而是用了隨機(jī) ID。解決分片文件名統(tǒng)一用固定位數(shù)格式化比如{:06d}.part。合并前打印一下排序后的文件列表確認(rèn)順序是000000, 000001, 000002...。如果還是不放心可以在合并時按chunkIndex顯式排序而不是依賴文件名。3.2 Nginx 的 client_max_body_size 沒調(diào)現(xiàn)象小分片能傳上去稍微大一點(diǎn)的分片直接返回 413前端控制臺看到Request Entity Too Large。原因Nginx 默認(rèn)client_max_body_size是 1MB雖然后端 Flask 設(shè)了MAX_CONTENT_LENGTH但請求還沒到 Flask 就被 Nginx 攔了。解決在 Nginx 配置的server或location塊里加上client_max_body_size 10m;要比chunkSize略大。改完記得nginx -s reload。如果用了云廠商的負(fù)載均衡那一層也要檢查請求體限制。3.3 并發(fā)上傳時臨時目錄沖突現(xiàn)象兩個用戶同時上傳同名文件合并出來的文件內(nèi)容混在一起或者分片數(shù)量對不上。原因臨時目錄只用文件名做標(biāo)識沒有加用戶 ID 或上傳會話 ID不同任務(wù)的分片寫到了同一個目錄。解決臨時目錄路徑必須包含唯一標(biāo)識推薦CHUNK_ROOT/user_id/file_id/三級結(jié)構(gòu)。file_id用 WebUploader 生成的id字段每次選擇文件都是唯一的。不要用文件名做目錄名同名文件太常見了。3.4 合并后臨時分片沒清理磁盤爆滿現(xiàn)象系統(tǒng)跑了一段時間磁盤使用率飆升排查發(fā)現(xiàn)/data/upload_chunks目錄下堆了幾百 GB 的.part文件。原因合并成功后忘記刪除分片或者合并失敗后沒有清理機(jī)制失敗任務(wù)的分片一直留著。解決合并成功后立即刪除分片目錄。另外加一個定時清理任務(wù)掃描超過 24 小時未合并的臨時目錄自動刪除??梢杂胒ind /data/upload_chunks -name *.part -mtime 1 -delete配合 cron 做兜底。3.5 前端計算文件哈希阻塞 UI現(xiàn)象用戶選了文件后頁面卡死幾秒到幾十秒大文件尤其明顯。原因?yàn)榱俗雒雮骰蛲暾孕r?yàn)前端在選文件后立即計算整個文件的 MD5主線程被占滿。解決用 Web Worker 在后臺線程計算哈希或者只計算文件頭尾各 1MB 的抽樣哈希。如果業(yè)務(wù)不需要秒傳干脆跳過前端哈希只靠后端合并后校驗(yàn)。WebUploader 本身不強(qiáng)制要求文件哈希這是業(yè)務(wù)層自己加的。4. 進(jìn)階技巧斷點(diǎn)續(xù)傳與秒傳的落地細(xì)節(jié)4.1 斷點(diǎn)續(xù)傳讓用戶刷新頁面后還能接著傳分段上傳天然支持?jǐn)帱c(diǎn)續(xù)傳但需要后端提供一個查詢已上傳分片的接口。前端在開始上傳前先問后端這個文件已經(jīng)傳了哪些分片然后只傳缺失的那些。app.route(/api/upload/check, methods[POST]) def check_chunks(): file_id request.form.get(id) user_id request.form.get(userId, anonymous) task_dir os.path.join(CHUNK_ROOT, user_id, file_id) if not os.path.exists(task_dir): return jsonify({code: 0, uploaded: []}) # 返回已上傳的分片序號列表 uploaded [ int(f.replace(.part, )) for f in os.listdir(task_dir) if f.endswith(.part) ] return jsonify({code: 0, uploaded: uploaded})前端拿到uploaded列表后在 WebUploader 的uploadBeforeSend鉤子里跳過已存在的分片。不過 WebUploader 原生不支持跳過指定分片常見做法是攔截uploadAccept或者在beforeSend里返回false來取消該分片。更干凈的方式是換用支持?jǐn)帱c(diǎn)續(xù)傳的庫或者自己在 WebUploader 外面包一層調(diào)度邏輯。4.2 秒傳文件哈希命中直接返回秒傳的核心是文件級哈希。前端選文件后計算整個文件的 SHA256先請求后端查這個哈希是否已存在。存在就直接返回文件路徑不需要上傳任何分片。import hashlib def calc_file_hash(file_path): sha hashlib.sha256() with open(file_path, rb) as f: while True: buf f.read(1024 * 1024) if not buf: break sha.update(buf) return sha.hexdigest()后端維護(hù)一張file_hash - file_path的映射表可以用數(shù)據(jù)庫或者 Redis。秒傳的命中率取決于業(yè)務(wù)場景如果是企業(yè)內(nèi)部文檔庫同一份文件被多人上傳的概率很高秒傳能省大量帶寬。但如果是用戶個人相冊基本不會命中反而多了一次哈希計算的開銷。注意秒傳的哈希計算建議放在 Web Worker 里并且只對小于 500MB 的文件啟用。超大文件計算 SHA256 本身就要幾十秒用戶體驗(yàn)反而更差。4.3 分片大小怎么選一張參數(shù)對照表文件大小范圍推薦分片大小并發(fā)數(shù)理由 50MB不分片1分片開銷大于收益50MB - 500MB1MB3平衡請求數(shù)和單次失敗成本500MB - 2GB2MB3減少請求數(shù)降低 Nginx 壓力2GB - 10GB5MB2大分片減少總請求數(shù)并發(fā)降低避免超時 10GB10MB2必須配合斷點(diǎn)續(xù)傳否則失敗成本太高這張表是經(jīng)驗(yàn)值實(shí)際要根據(jù)服務(wù)器帶寬和用戶網(wǎng)絡(luò)環(huán)境調(diào)整。我一般會在測試環(huán)境用不同分片大小跑一遍看總上傳時間和失敗率選一個折中點(diǎn)。4.4 驗(yàn)證上傳結(jié)果別只看進(jìn)度條進(jìn)度條到 100% 不代表文件真的傳完了。WebUploader 的uploadSuccess事件只表示所有分片都收到了 HTTP 200 響應(yīng)但合并可能失敗或者合并后的文件損壞。我習(xí)慣在合并完成后做一次校驗(yàn)后端返回最終文件的 MD5前端拿到后和本地計算的哈希比對。不一致就提示用戶重新上傳。uploader.on(uploadSuccess, function(file, response) { if (response.msg upload_complete) { // 后端返回合并后文件的哈希 var serverHash response.fileHash; // 前端本地計算的哈希需提前在 Worker 中算好 if (serverHash ! localHash) { alert(文件校驗(yàn)失敗請重新上傳); return; } console.log(上傳成功文件路徑, response.path); } });從那以后我每次做上傳功能都會強(qiáng)制走一遍分片順序檢查 合并后哈希比對 臨時目錄清理這三步少一步都可能在上線后收到用戶投訴。希望這套思路能幫到你少踩幾個我踩過的坑。本文還有配套的精品資源點(diǎn)擊獲取