端大文件分片上傳實(shí)戰(zhàn):斷點(diǎn)續(xù)傳與內(nèi)存優(yōu)化)
1. 移動(dòng)端大文件上傳的痛點(diǎn)與整體思路拆解做過移動(dòng)端文件上傳的朋友應(yīng)該都有體會(huì)一個(gè)幾百兆的視頻直接往服務(wù)端扔結(jié)果基本只有兩種要么請(qǐng)求超時(shí)要么內(nèi)存直接爆掉。尤其是現(xiàn)在短視頻、在線教育、社交分享這類場(chǎng)景用戶隨手拍一段視頻就是幾十上百兆如果還用傳統(tǒng)的“一次性表單上傳”思路用戶體驗(yàn)會(huì)非常糟糕。我最近在一個(gè)跨平臺(tái)應(yīng)用項(xiàng)目里就遇到了這個(gè)問題需求很明確用戶從相冊(cè)選一段視頻前端做分片處理然后逐片上傳到服務(wù)端最后在服務(wù)端合并成完整文件。這個(gè)方案的核心價(jià)值在于三個(gè)層面。第一是穩(wěn)定性大文件拆成小塊之后單個(gè)請(qǐng)求的體積可控網(wǎng)絡(luò)抖動(dòng)導(dǎo)致的失敗只需要重傳某一片而不是整個(gè)文件從頭再來。第二是內(nèi)存友好移動(dòng)設(shè)備的內(nèi)存本來就緊張把整個(gè)視頻讀進(jìn)內(nèi)存再上傳很容易觸發(fā)系統(tǒng)殺進(jìn)程分片之后每次只處理一小塊數(shù)據(jù)內(nèi)存占用曲線會(huì)平緩很多。第三是可恢復(fù)性分片上傳天然支持?jǐn)帱c(diǎn)續(xù)傳用戶切到后臺(tái)再回來或者網(wǎng)絡(luò)短暫中斷都能從已上傳的分片繼續(xù)不用重新開始。適合閱讀這篇內(nèi)容的人我大致分三類。一類是剛接觸Flutter跨平臺(tái)開發(fā)、正在做文件上傳功能的開發(fā)者你可以直接參考這里的流程和代碼結(jié)構(gòu)。另一類是有一定經(jīng)驗(yàn)但沒處理過大文件分片的后端或全棧工程師這里會(huì)講到分片策略、合并邏輯和校驗(yàn)機(jī)制。還有一類是技術(shù)負(fù)責(zé)人需要評(píng)估分片上傳方案的復(fù)雜度和落地成本我也會(huì)把踩過的坑和取舍講清楚。整體方案我采用的是“前端分片 逐片上傳 服務(wù)端合并”的經(jīng)典架構(gòu)。前端負(fù)責(zé)讀取視頻文件、按固定大小切分、維護(hù)上傳狀態(tài)服務(wù)端負(fù)責(zé)接收分片、臨時(shí)存儲(chǔ)、校驗(yàn)完整性、最終合并。這個(gè)架構(gòu)不依賴特定的云服務(wù)自己搭一套也能跑起來靈活性比較高。下面我會(huì)從技術(shù)選型、核心細(xì)節(jié)、實(shí)操流程、問題排查幾個(gè)維度展開把每個(gè)環(huán)節(jié)的“為什么”講透。2. 技術(shù)選型與核心細(xì)節(jié)解析2.1 為什么選Flutter做視頻選擇與分片F(xiàn)lutter本身是跨平臺(tái)的一套代碼能跑在Android和iOS上這對(duì)需要快速覆蓋多端的項(xiàng)目來說很關(guān)鍵。視頻選擇這塊社區(qū)里比較成熟的方案是image_picker和file_picker這兩個(gè)庫。image_picker對(duì)相冊(cè)視頻的支持比較直接調(diào)用pickVideo方法就能拿到文件路徑file_picker則更通用能選任意類型文件但視頻預(yù)覽體驗(yàn)稍弱。我最終選了image_picker因?yàn)樗腁PI更簡(jiǎn)潔而且對(duì)相冊(cè)權(quán)限的處理比較完善。拿到文件路徑之后分片操作其實(shí)是在Dart層做的。這里有個(gè)關(guān)鍵點(diǎn)不要試圖把整個(gè)視頻讀成字節(jié)數(shù)組再切分。我試過用File.readAsBytes()一次性讀取一個(gè)200MB的視頻直接讓內(nèi)存飆到幾百兆低端機(jī)當(dāng)場(chǎng)卡死。正確做法是用RandomAccessFile或者File.openRead()配合Stream按偏移量逐段讀取。Dart的File類提供了open方法返回RandomAccessFile可以精確控制讀取位置和長(zhǎng)度這樣每次只把一片的數(shù)據(jù)加載到內(nèi)存。分片大小我建議設(shè)在1MB到5MB之間。太小會(huì)導(dǎo)致請(qǐng)求數(shù)量暴增服務(wù)端壓力大太大又失去了分片的意義。我實(shí)測(cè)下來2MB是一個(gè)比較平衡的值一個(gè)100MB的視頻大概50片上傳過程比較流暢。當(dāng)然這個(gè)值可以根據(jù)網(wǎng)絡(luò)狀況動(dòng)態(tài)調(diào)整比如WiFi下用5MB移動(dòng)網(wǎng)絡(luò)下用1MB但那是進(jìn)階優(yōu)化第一版先固定值跑通再說。2.2 分片上傳的通信協(xié)議設(shè)計(jì)分片上傳不是簡(jiǎn)單地把文件切開發(fā)出去就完事了前后端需要約定一套協(xié)議。我設(shè)計(jì)的接口大致是這樣的前端每次上傳一個(gè)分片請(qǐng)求里帶上fileId文件唯一標(biāo)識(shí)、chunkIndex當(dāng)前分片序號(hào)、totalChunks總分片數(shù)、chunkData分片二進(jìn)制數(shù)據(jù)。服務(wù)端收到后把分片存到以fileId命名的臨時(shí)目錄下文件名用chunkIndex區(qū)分。這里有個(gè)細(xì)節(jié)容易被忽略fileId的生成時(shí)機(jī)。我一開始是在前端生成一個(gè)UUID但后來發(fā)現(xiàn)如果用戶重復(fù)選擇同一個(gè)視頻fileId會(huì)變導(dǎo)致服務(wù)端無法識(shí)別這是同一個(gè)文件。更好的做法是用文件的哈希值或者“文件名文件大小修改時(shí)間”組合生成一個(gè)穩(wěn)定ID。不過計(jì)算哈希需要讀取整個(gè)文件對(duì)大文件來說耗時(shí)較長(zhǎng)所以我最終用了“文件名大小最后修改時(shí)間戳”的MD5既穩(wěn)定又不用全量讀取。另一個(gè)關(guān)鍵點(diǎn)是分片校驗(yàn)。每個(gè)分片上傳時(shí)我會(huì)額外傳一個(gè)chunkHash服務(wù)端收到后校驗(yàn)分片完整性。如果校驗(yàn)失敗返回錯(cuò)誤碼前端重傳這一片。全部上傳完成后前端再發(fā)一個(gè)“合并請(qǐng)求”服務(wù)端按順序拼接所有分片并計(jì)算最終文件的哈希和前端傳來的總哈希比對(duì)確保合并結(jié)果正確。2.3 斷點(diǎn)續(xù)傳的狀態(tài)管理斷點(diǎn)續(xù)傳的核心是“知道哪些片已經(jīng)傳成功了”。我的做法是在前端維護(hù)一個(gè)上傳狀態(tài)對(duì)象記錄fileId、已上傳的分片索引列表、總分片數(shù)。每次上傳前先請(qǐng)求服務(wù)端的“檢查接口”服務(wù)端返回已接收的分片列表前端對(duì)比后只上傳缺失的部分。這個(gè)狀態(tài)對(duì)象需要持久化否則應(yīng)用被殺掉再打開就丟了。我用的是shared_preferences存一個(gè)JSON字符串結(jié)構(gòu)很簡(jiǎn)單{fileId: {uploaded: [0,1,2], total: 50}}。每次分片上傳成功后更新這個(gè)對(duì)象應(yīng)用啟動(dòng)時(shí)讀取如果有未完成的任務(wù)就提示用戶繼續(xù)上傳。注意狀態(tài)持久化不要存分片數(shù)據(jù)本身只存索引和元信息。分片數(shù)據(jù)是臨時(shí)的存了反而占空間而且可能過期。2.4 服務(wù)端合并策略與存儲(chǔ)設(shè)計(jì)服務(wù)端我用的是Node.js配合Express臨時(shí)分片存在本地磁盤的temp/{fileId}/目錄下。合并的時(shí)候按chunkIndex排序用fs.createWriteStream逐片追加寫入最終文件。這里有個(gè)坑不要用fs.readFileSync讀所有分片再拼接那樣內(nèi)存會(huì)爆。正確做法是用流式寫入每次讀一片寫一片內(nèi)存占用恒定。合并完成后我會(huì)做三件事一是計(jì)算最終文件的MD5和前端傳來的總哈希比對(duì)二是刪除臨時(shí)分片目錄釋放空間三是把最終文件移到正式存儲(chǔ)目錄返回訪問URL。如果比對(duì)失敗說明分片有問題需要清理并讓前端重新上傳。存儲(chǔ)設(shè)計(jì)上我建議臨時(shí)目錄和正式目錄分開臨時(shí)目錄可以設(shè)置定時(shí)清理任務(wù)比如超過24小時(shí)未合并的分片自動(dòng)刪除避免磁盤被占滿。正式目錄按日期分文件夾比如uploads/2025/01/15/方便管理和備份。3. 實(shí)操過程與核心環(huán)節(jié)實(shí)現(xiàn)3.1 視頻選擇與文件信息讀取第一步是讓用戶選視頻。用image_picker的代碼大概長(zhǎng)這樣final ImagePicker picker ImagePicker(); final XFile? video await picker.pickVideo(source: ImageSource.gallery); if (video null) return; final File file File(video.path); final int fileSize await file.length(); final String fileName video.name; final DateTime lastModified await file.lastModified();拿到文件后先算fileId。我用的是crypto庫的MD5final String rawId $fileName-$fileSize-${lastModified.millisecondsSinceEpoch}; final String fileId md5.convert(utf8.encode(rawId)).toString();然后算總分片數(shù)const int chunkSize 2 * 1024 * 1024; // 2MB final int totalChunks (fileSize / chunkSize).ceil();這里要注意fileSize是字節(jié)數(shù)chunkSize也是字節(jié)除法之后向上取整。比如一個(gè)5MB的文件totalChunks就是3最后一片可能不足2MB這是正常的。3.2 分片讀取與上傳的完整流程分片讀取我用的是RandomAccessFilefinal RandomAccessFile raf await file.open(); for (int i 0; i totalChunks; i) { final int start i * chunkSize; final int end (start chunkSize fileSize) ? fileSize : start chunkSize; final int length end - start; await raf.setPosition(start); final Uint8List chunk await raf.read(length); // 上傳這一片 await uploadChunk(fileId, i, totalChunks, chunk); } await raf.close();uploadChunk方法用http庫的MultipartRequest發(fā)送Futurevoid uploadChunk(String fileId, int index, int total, Uint8List data) async { final uri Uri.parse(https://your-server.com/upload/chunk); final request http.MultipartRequest(POST, uri); request.fields[fileId] fileId; request.fields[chunkIndex] index.toString(); request.fields[totalChunks] total.toString(); request.files.add(http.MultipartFile.fromBytes(chunk, data, filename: chunk_$index)); final response await request.send(); if (response.statusCode ! 200) { throw Exception(分片 $index 上傳失敗); } }這里有個(gè)性能優(yōu)化點(diǎn)不要串行上傳所有分片。串行雖然簡(jiǎn)單但速度慢。我改成了并發(fā)上傳用Future.wait控制并發(fā)數(shù)比如同時(shí)傳3片。但并發(fā)數(shù)不能太高否則移動(dòng)網(wǎng)絡(luò)下容易超時(shí)而且服務(wù)端壓力也大。我實(shí)測(cè)3到5是比較合適的值。final ListFuturevoid tasks []; for (int i 0; i totalChunks; i) { if (uploadedChunks.contains(i)) continue; // 跳過已上傳的 tasks.add(uploadChunk(fileId, i, totalChunks, chunkData)); if (tasks.length 3) { await Future.wait(tasks); tasks.clear(); } } if (tasks.isNotEmpty) await Future.wait(tasks);3.3 服務(wù)端接收分片與合并的代碼實(shí)現(xiàn)服務(wù)端用Express接收分片const express require(express); const multer require(multer); const fs require(fs); const path require(path); const app express(); const upload multer({ dest: temp/ }); app.post(/upload/chunk, upload.single(chunk), (req, res) { const { fileId, chunkIndex, totalChunks } req.body; const tempDir path.join(__dirname, temp, fileId); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } const chunkPath path.join(tempDir, chunkIndex); fs.renameSync(req.file.path, chunkPath); res.json({ code: 0, message: ok }); });合并接口app.post(/upload/merge, async (req, res) { const { fileId, totalChunks, fileName } req.body; const tempDir path.join(__dirname, temp, fileId); const finalDir path.join(__dirname, uploads, new Date().toISOString().slice(0, 10)); if (!fs.existsSync(finalDir)) { fs.mkdirSync(finalDir, { recursive: true }); } const finalPath path.join(finalDir, fileName); const writeStream fs.createWriteStream(finalPath); for (let i 0; i totalChunks; i) { const chunkPath path.join(tempDir, i.toString()); const chunkData fs.readFileSync(chunkPath); writeStream.write(chunkData); } writeStream.end(); writeStream.on(finish, () { // 清理臨時(shí)目錄 fs.rmSync(tempDir, { recursive: true, force: true }); res.json({ code: 0, url: /uploads/${fileName} }); }); });這段代碼里fs.readFileSync讀單片是沒問題的因?yàn)閱纹挥?MB內(nèi)存占用可控。但如果你把分片大小設(shè)成50MB那就得改成流式讀取了。3.4 上傳進(jìn)度與用戶反饋的實(shí)現(xiàn)用戶需要知道上傳到哪了。我在前端維護(hù)一個(gè)progress變量每上傳成功一片就更新double progress uploadedCount / totalChunks;然后用StreamBuilder或者setState刷新UI。這里有個(gè)細(xì)節(jié)進(jìn)度更新不要太頻繁否則UI會(huì)卡。我是在每片上傳成功后更新一次50片就是50次更新完全沒問題。如果你把分片設(shè)得很小比如100KB那更新次數(shù)會(huì)很多可以考慮每5片更新一次。另外上傳過程中要允許用戶取消。我用了一個(gè)bool _isCancelled標(biāo)志在循環(huán)里檢查如果取消了就跳出循環(huán)并調(diào)用服務(wù)端的“取消上傳”接口清理臨時(shí)分片。提示取消上傳后記得把持久化的狀態(tài)也清掉否則下次進(jìn)來還會(huì)提示繼續(xù)上傳。4. 常見問題與排查技巧實(shí)錄4.1 分片上傳中的典型問題速查表問題現(xiàn)象可能原因排查方法解決方案上傳到一半卡住網(wǎng)絡(luò)超時(shí)或并發(fā)過高查看日志中哪個(gè)分片沒返回降低并發(fā)數(shù)增加超時(shí)重試合并后文件損壞分片順序錯(cuò)亂或缺失比對(duì)分片數(shù)量和索引合并前校驗(yàn)分片完整性內(nèi)存占用飆升一次性讀取整個(gè)文件用Profiler看內(nèi)存曲線改用RandomAccessFile逐片讀斷點(diǎn)續(xù)傳失效狀態(tài)未持久化或fileId變化檢查shared_preferences內(nèi)容用穩(wěn)定fileId及時(shí)保存狀態(tài)服務(wù)端磁盤占滿臨時(shí)分片未清理查看temp目錄大小加定時(shí)清理任務(wù)iOS上傳失敗權(quán)限或后臺(tái)限制查看系統(tǒng)日志申請(qǐng)相冊(cè)權(quán)限處理后臺(tái)任務(wù)4.2 我踩過的三個(gè)坑與解決方案第一個(gè)坑是iOS后臺(tái)上傳被系統(tǒng)掛起。iOS對(duì)后臺(tái)任務(wù)限制很嚴(yán)應(yīng)用切到后臺(tái)幾秒后網(wǎng)絡(luò)請(qǐng)求就會(huì)被暫停。我一開始沒處理用戶切出去回個(gè)消息上傳就斷了。后來用了background_fetch或者flutter_background_service這類庫把上傳任務(wù)放到后臺(tái)isolate里跑情況好了很多。但要注意iOS后臺(tái)時(shí)間有限大文件還是建議提示用戶保持前臺(tái)。第二個(gè)坑是分片哈希校驗(yàn)的性能問題。我一開始對(duì)每個(gè)分片都算MD5結(jié)果發(fā)現(xiàn)CPU占用很高上傳速度反而慢了。后來改成只對(duì)分片做簡(jiǎn)單的長(zhǎng)度校驗(yàn)總文件才做MD5。這樣既保證了完整性又不會(huì)拖慢速度。如果你對(duì)安全性要求極高可以保留分片哈希但要做好性能測(cè)試。第三個(gè)坑是服務(wù)端合并時(shí)的文件句柄泄漏。我用fs.createWriteStream寫文件但忘記在finish事件里關(guān)閉流導(dǎo)致大量文件句柄沒釋放跑一段時(shí)間后服務(wù)端就報(bào)“too many open files”。后來加了writeStream.close()和錯(cuò)誤處理才解決。這個(gè)坑很隱蔽因?yàn)槎唐趦?nèi)看不出問題跑久了才暴露。4.3 性能優(yōu)化的幾個(gè)實(shí)用技巧技巧一動(dòng)態(tài)分片大小。根據(jù)網(wǎng)絡(luò)類型調(diào)整分片大小WiFi下用5MB4G用2MB弱網(wǎng)用512KB。這樣能在不同網(wǎng)絡(luò)下都有不錯(cuò)的表現(xiàn)。實(shí)現(xiàn)上可以用connectivity_plus庫檢測(cè)網(wǎng)絡(luò)類型。技巧二分片壓縮。如果視頻不需要原始畫質(zhì)可以在前端用ffmpeg做壓縮再上傳。不過Flutter里集成ffmpeg比較重我一般建議在服務(wù)端做轉(zhuǎn)碼前端只負(fù)責(zé)上傳原始文件。技巧三秒傳檢測(cè)。上傳前先算文件哈希請(qǐng)求服務(wù)端檢查是否已存在相同文件。如果存在直接返回URL不用上傳。這個(gè)對(duì)熱門視頻特別有效能省大量帶寬。但哈希計(jì)算需要讀整個(gè)文件對(duì)大文件來說耗時(shí)可以只算前1MB的哈希做粗略判斷。技巧四分片預(yù)簽名URL。如果服務(wù)端支持對(duì)象存儲(chǔ)可以給每個(gè)分片生成預(yù)簽名URL前端直接傳到存儲(chǔ)服務(wù)不經(jīng)過應(yīng)用服務(wù)器。這樣能大幅降低服務(wù)器壓力但架構(gòu)會(huì)復(fù)雜一些適合大規(guī)模場(chǎng)景。注意預(yù)簽名URL有有效期生成后要盡快使用否則會(huì)過期導(dǎo)致上傳失敗。4.4 安全性與合規(guī)性注意事項(xiàng)分片上傳涉及文件寫入安全上要特別注意幾點(diǎn)。一是文件類型校驗(yàn)不能只信前端傳來的擴(kuò)展名服務(wù)端要檢查文件頭魔數(shù)確保是真正的視頻文件。二是分片大小限制防止惡意用戶上傳超大分片撐爆內(nèi)存。三是fileId防猜測(cè)不要用自增ID用UUID或者哈希避免被遍歷。四是臨時(shí)目錄權(quán)限確保只有應(yīng)用進(jìn)程能讀寫防止其他用戶訪問。另外如果應(yīng)用面向多用戶要做好鑒權(quán)。每個(gè)上傳請(qǐng)求都要帶token服務(wù)端驗(yàn)證用戶身份后再允許寫入。合并時(shí)也要檢查該用戶是否有權(quán)限操作這個(gè)fileId防止越權(quán)。4.5 測(cè)試與驗(yàn)證的完整流程上線前我做了幾輪測(cè)試。第一輪是功能測(cè)試用不同大小的視頻10MB、100MB、500MB跑完整流程確認(rèn)能選、能分、能傳、能合并。第二輪是異常測(cè)試模擬網(wǎng)絡(luò)中斷、服務(wù)端重啟、磁盤滿等情況看斷點(diǎn)續(xù)傳和錯(cuò)誤處理是否正常。第三輪是性能測(cè)試用JMeter或者wrk壓測(cè)服務(wù)端看并發(fā)上傳時(shí)的吞吐量和響應(yīng)時(shí)間。測(cè)試時(shí)我建議用真機(jī)不要只用模擬器。模擬器的網(wǎng)絡(luò)和存儲(chǔ)行為和真機(jī)差別很大尤其是iOS模擬器后臺(tái)任務(wù)行為和真機(jī)完全不同。真機(jī)上還要測(cè)不同網(wǎng)絡(luò)環(huán)境比如WiFi切4G、弱網(wǎng)、高延遲這些場(chǎng)景下分片上傳的表現(xiàn)才是真實(shí)的。驗(yàn)證合并結(jié)果時(shí)我會(huì)用md5sum對(duì)比前后端文件哈希。如果一致說明分片和合并都沒問題。如果不一致就逐片比對(duì)找出是哪一片出了問題。這個(gè)排查過程比較耗時(shí)但能精確定位問題。5. 方案擴(kuò)展與個(gè)人經(jīng)驗(yàn)體會(huì)這套分片上傳方案跑通之后我做了幾個(gè)擴(kuò)展。一個(gè)是多文件并發(fā)上傳用戶一次選多個(gè)視頻每個(gè)文件獨(dú)立維護(hù)狀態(tài)用隊(duì)列控制總并發(fā)數(shù)。另一個(gè)是上傳優(yōu)先級(jí)小文件優(yōu)先傳大文件后臺(tái)慢慢傳提升用戶感知速度。還有一個(gè)是失敗自動(dòng)重試分片上傳失敗后自動(dòng)重試3次超過次數(shù)才提示用戶。我個(gè)人在實(shí)際操作中的體會(huì)是分片上傳的復(fù)雜度主要不在分片本身而在狀態(tài)管理和異常處理。分片邏輯寫起來很快但斷點(diǎn)續(xù)傳、并發(fā)控制、錯(cuò)誤恢復(fù)這些才是真正花時(shí)間的地方。我建議第一版先把串行上傳跑通確認(rèn)流程沒問題再逐步加并發(fā)、加斷點(diǎn)續(xù)傳、加動(dòng)態(tài)分片。不要一上來就追求完美那樣很容易卡在細(xì)節(jié)里出不來。最后再分享一個(gè)小技巧日志要打全。每個(gè)分片的上傳時(shí)間、大小、結(jié)果都記下來出問題的時(shí)候一看日志就知道是哪一片、什么原因。我一開始日志打得很簡(jiǎn)略排查問題時(shí)只能靠猜后來加了詳細(xì)日志排查效率提升了好幾倍。這個(gè)習(xí)慣在移動(dòng)端開發(fā)里特別重要因?yàn)檎鏅C(jī)調(diào)試不像Web那么方便日志往往是你唯一的線索。