端大文件分片上傳與斷點續(xù)傳完整方案)
1. 從一根管道說起巡檢日志附件為什么會大到讓人頭疼先還原一個現(xiàn)場場景。能源化工企業(yè)的管道巡檢可不是拿著手電筒走一圈就完事。巡檢人員要記錄閥門編號、法蘭連接狀態(tài)、防腐層破損情況、保溫層是否脫落遇到疑似泄漏點要用氣體檢測儀讀數(shù)還要拍照留存?,F(xiàn)在很多企業(yè)還配了紅外熱成像儀一張紅外照片就是十幾兆。要是發(fā)現(xiàn)隱患現(xiàn)場拍一段視頻三分鐘就奔著200兆去了。這些素材匯總到一起就是巡檢日志的附件。一條巡檢記錄掛上五六張照片加一段視頻附件總量動輒幾百MB遇到大修期間的專項檢查一條日志掛上幾GB附件也不是什么稀罕事。問題就出在上傳環(huán)節(jié)。傳統(tǒng)化工企業(yè)的現(xiàn)場網(wǎng)絡(luò)環(huán)境沒那么理想。很多廠區(qū)位于偏遠地區(qū)車間里有Wi-Fi但信號不穩(wěn)定地下管廊和罐區(qū)深處甚至只有4G信號還得碰運氣。工人辛辛苦苦拍完素材點擊上傳進度條走到68%忽然斷了再點一次又從頭開始傳。幾百MB的文件傳了三四次都失敗最后只能回到辦公室里連有線網(wǎng)絡(luò)再傳一遍。這種情況在真實生產(chǎn)環(huán)境里每天都在發(fā)生。斷點續(xù)傳的價值恰恰就體現(xiàn)在這種網(wǎng)絡(luò)不靠譜但業(yè)務(wù)不能斷的場景里。它解決的核心問題很簡單文件已經(jīng)傳了一部分能不能把上傳進度記住下次接著傳而不是從頭來過這個需求在Java服務(wù)端落地牽扯到的技術(shù)點比很多人想象的多得多。不是一個MultipartFile接收完事而是要處理分片、斷點、校驗、并發(fā)甚至還要考慮臨時文件的清理策略。我下面把這套方案的完整實現(xiàn)思路、核心代碼和踩坑經(jīng)歷全部拆開講。2. 斷點續(xù)傳方案選型為什么最終選了HTTP Range加分片落盤先明確需求邊界。在能源化工企業(yè)這種場景下做斷點續(xù)傳有幾個硬性約束單文件體積大集中在200MB到2GB之間偶爾有視頻素材超過2GB。上傳環(huán)境不穩(wěn)定Wi-Fi、4G、有線網(wǎng)絡(luò)來回切換隨時可能中斷。服務(wù)端需要知道文件傳了多少、剩多少客戶端需要能從中斷位置恢復(fù)。文件必須完整可校驗巡檢日志涉及安全追溯半截文件不算數(shù)。圍繞這些約束業(yè)界比較成熟的方案就那幾種。2.1 方案對比HTTP Range、分片上傳、第三方SDK第一種是HTTP Range斷點續(xù)傳??蛻舳讼认蚍?wù)端詢問文件已經(jīng)傳了多大服務(wù)端返回已接收的字節(jié)數(shù)客戶端用基于HTTP的Range機制從那個位置繼續(xù)寫。這種方式邏輯簡單服務(wù)端只需要用一個RandomAccessFile按字節(jié)偏移寫入就行但是對網(wǎng)絡(luò)異常特別敏感——只要TCP連接斷了客戶端不是總能準確知道服務(wù)端到底寫到哪個位置需要頻繁發(fā)起探測請求。第二種是前端分片上傳。把大文件切成固定大小的塊比如每片5MB客戶端逐片上傳每片都有獨立的序號和校驗值。服務(wù)端先把分片落盤到臨時目錄全部傳完后合并。這種方式對網(wǎng)絡(luò)抖動容忍度高單片傳失敗只需要重傳這一片而且支持并發(fā)上傳多片。缺點是邏輯復(fù)雜度上來了需要管理分片狀態(tài)、合并、冪等服務(wù)端多一套分片管理表。第三種是直接用云存儲SDK。比如接入對象存儲的斷點續(xù)傳能力服務(wù)端只做轉(zhuǎn)發(fā)。但能源化工企業(yè)很多數(shù)據(jù)不出廠區(qū)內(nèi)網(wǎng)部署是剛需公有云SDK直接出局。最終選型是分片上傳為主、本地落盤續(xù)傳為輔的混合方案。前端把文件切成固定分片一片一片傳服務(wù)端用分片序號加文件標識來記錄上傳進度。前端本地也緩存一份哪些分片已成功的記錄斷網(wǎng)恢復(fù)后直接從中斷處分片重傳而不是整文件重傳。提示分片方案和服務(wù)端斷點續(xù)傳不矛盾。分片上傳解決的是從哪一片繼續(xù)服務(wù)端的RandomAccessFile解決的是寫文件時從哪個字節(jié)偏移繼續(xù)。兩者結(jié)合才是完整可靠的斷點續(xù)傳。2.2 分片大小的選擇邏輯分片大小直接決定整個方案的上限體驗。我做過幾輪對比測試在園區(qū)內(nèi)網(wǎng)條件下結(jié)論是這樣的分片大小網(wǎng)絡(luò)中斷損失并發(fā)效率適用場景1MB小單片幾乎不會失敗需要大量并發(fā)請求才能跑滿帶寬極差網(wǎng)絡(luò)如地下管廊5MB中等單片成功率較高8-16并發(fā)輕松跑滿百兆帶寬廠區(qū)Wi-Fi、4G20MB較大網(wǎng)絡(luò)抖動時容易單片失敗并發(fā)需求低壓力小內(nèi)網(wǎng)有線高帶寬低延遲我推薦5MB。原因很實在5MB的分片在4G信號下就算丟幾個包單片重傳成本也很低而在百兆內(nèi)網(wǎng)下12到16個并發(fā)分片同時傳上傳速率能穩(wěn)定跑到60MB/s以上一上午就能傳完幾GB的巡檢視頻。分片再小到1MB服務(wù)端要處理的請求數(shù)暴增Tomcat線程池壓力大反而拉低整體吞吐。3. Java服務(wù)端收到分片之后核心處理鏈路與關(guān)鍵代碼方案定了接下來是Java服務(wù)端的核心實現(xiàn)。我按接收分片-落盤索引-合并校驗三步拆解。3.1 先定義分片上傳的請求模型前端每次請求傳一片請求參數(shù)里必須帶上文件標識、總分片數(shù)、當前分片序號。這里我用文件指紋來做文件標識也就是文件的MD5值這樣還能順便實現(xiàn)一個秒傳效果——服務(wù)端檢測到文件已經(jīng)存在完整版本直接返回上傳完成不用再傳。public class ChunkUploadRequest { private String fileId; // 文件唯一標識用MD5 private Integer chunkIndex; // 當前分片序號從0開始 private Integer totalChunks; // 總分片數(shù) private Long totalSize; // 文件總字節(jié)數(shù) private MultipartFile file; // 當前分片的文件流 }Controller層用RequestPart接收文件和其他字段這里不贅述SpringMVC的基礎(chǔ)寫法重點講服務(wù)端接收后的核心邏輯。3.2 RandomAccessFile定位寫入斷點續(xù)傳的地基服務(wù)端收到一個分片后要做的第一件事是把這個分片的內(nèi)容寫到臨時文件對應(yīng)的偏移位置。偏移量是分片序號乘以分片大小這個計算很簡單但寫文件的方式有講究。private void writeChunkToFile(String basePath, ChunkUploadRequest request) { Path tempFile Paths.get(basePath, request.getFileId() .tmp); try (RandomAccessFile raf new RandomAccessFile(tempFile.toFile(), rw)) { long offset (long) request.getChunkIndex() * chunkSize; raf.seek(offset); byte[] buffer new byte[4096]; try (InputStream in request.getFile().getInputStream()) { int len; while ((len in.read(buffer)) ! -1) { raf.write(buffer, 0, len); } } } }RandomAccessFile的seek方法決定了寫入起點這就是斷點續(xù)傳最底層的機制。前端傳第13片服務(wù)端就把指針移到13乘以5MB的偏移位置直接寫不用管這個文件前面的部分是從哪來的。但這只是第一步如果只做這個一旦上傳中斷再恢復(fù)我怎么知道第13片前面已經(jīng)寫完了所以還需要分片索引。3.3 分片索引用Redis記錄還差哪幾片我在生產(chǎn)環(huán)境里維護了一張分片上傳狀態(tài)表記錄每個文件標識的接收情況。這張表的數(shù)據(jù)結(jié)構(gòu)不復(fù)雜但設(shè)計上有講究。用Redis位圖來處理這種場景非常合適。文件有多少片位圖就有多少位。收到第5片就把第5位置為1。查詢進度時直接用bitcount統(tǒng)計有多少位是1和總分片數(shù)比較立刻知道傳完了沒有。// 分片到達后標記位圖 stringRedisTemplate.opsForValue().setBit( UPLOAD_CHUNK_KEY fileId, request.getChunkIndex(), true ); // 查詢當前已收到的分片數(shù) Long uploaded stringRedisTemplate.execute( (RedisCallbackLong) connection - connection.bitCount((UPLOAD_CHUNK_KEY fileId).getBytes()) ); // 已傳分片數(shù)和總分片數(shù)相等觸發(fā)合并 if (uploaded ! null uploaded request.getTotalChunks()) { mergeChunks(fileId); }選擇Redis而不是數(shù)據(jù)庫主要有兩個原因。一是位圖操作本身就是Redis的強項一個200MB文件的40個分片bitCount一次O(1)級別就出來了數(shù)據(jù)庫要多查幾十行記錄二是Redis的key過期機制可以順帶處理臨時文件的生命周期設(shè)置24小時過期上傳中斷的后臺任務(wù)會自動大量緩存不用額外寫清理邏輯。3.4 設(shè)置過期時間的副作用這里有個細節(jié)值得說。Redis位圖標記分片狀態(tài)時key的過期時間要從首次寫入時設(shè)置而不是每次寫入都刷新。原因很直接如果一個用戶上傳文件傳了10分鐘分片一直在寫入每次寫入都刷新過期時間那24小時的有效期就會無限順延但如果是傳了一會兒就斷網(wǎng)了服務(wù)端需要的是24小時后這個殘缺文件的臨時狀態(tài)自動清掉所以過期時間只在第一次寫入時設(shè)置。// 只在第一次上傳分片時設(shè)置過期時間 Boolean firstWrite stringRedisTemplate.opsForValue().setIfAbsent( UPLOAD_CHUNK_KEY fileId, 1, Duration.ofHours(24) );3.5 合并分片臨時文件轉(zhuǎn)正的最后一跳所有分片到齊之后觸發(fā)合并。合并不是把多個文件復(fù)制到一個文件里而是把分片按順序?qū)懭胍粋€完整文件。我用的是文件轉(zhuǎn)正策略分片寫入臨時文件合并完成后再把臨時文件命名為正式文件名并刪除標記狀態(tài)。private void mergeChunks(String fileId, String uploadDir, String targetDir) { Path tempFile Paths.get(uploadDir, fileId .tmp); Path targetFile Paths.get(targetDir, fileId _ System.currentTimeMillis() .bin); // 臨時文件已經(jīng)通過RandomAccessFile按偏移寫入了所有分片 // 合并動作本質(zhì)上是驗證完整性 原子性改名 Files.move(tempFile, targetFile, StandardCopyOption.ATOMIC_MOVE); }很多第一次做分片上傳的人會在這一步犯一個錯誤以為每個分片是獨立文件合并時要按順序把分片內(nèi)容復(fù)制進最終文件。其實完全沒必要。所有分片從一開始就是往同一個臨時文件的不同偏移位置寫寫入完成之后臨時文件本身就是完整的文件了。唯一要做的只是驗證大小和校驗值然后改名而已。這樣既快又不會有分片復(fù)制過程中順序錯亂的風險。4. 可靠性保障校驗、冪等、并發(fā)一個都不能漏斷點續(xù)傳如果只做能傳的部分那在生產(chǎn)環(huán)境里撐不過一周。我把這幾個月在生產(chǎn)環(huán)境里遇到過的可靠性問題集中說一下。4.1 校驗不只是MD5還要校驗大小和分片序號連續(xù)性分片上傳過程中最怕的是某個分片因為網(wǎng)絡(luò)問題沒傳完整但服務(wù)端當成正常分片寫入臨時文件了。這種情況如果發(fā)生在中間某個分片最終的臨時文件大小是對的但內(nèi)容錯位合并出來的文件就是壞的。解決辦法是雙校驗。第一層是單分片校驗。前端在請求頭帶兩個值分片的SHA-256值和分片大小。服務(wù)端把分片流寫入臨時文件后立刻計算這個分片接收到的字節(jié)數(shù)。如果字節(jié)數(shù)不等于聲明的大小直接返回錯誤要求前端重傳該分片。第二層是整文件校驗。所有分片合并并改名后計算整個文件的大小和請求時聲明的totalSize比對。大小不一致說明中間有分片寫串了直接刪掉臨時文件讓前端重新傳。巡檢日志領(lǐng)域?qū)ξ募耐暾砸蟾咚晕易罱K加了全文件MD5比對以犧牲一點IO開銷換絕對可靠。if (Files.size(targetFile) ! totalSize) { Files.deleteIfExists(targetFile); throw new UploadException(文件大小校驗失敗所有分片需重傳); }4.2 冪等設(shè)計同一分片重復(fù)上傳不能造成數(shù)據(jù)錯亂斷點續(xù)傳場景下客戶端斷網(wǎng)重連后經(jīng)常會重傳一個其實已經(jīng)成功上送的分片。這時候服務(wù)端如果不清不楚地又寫了一次就會把臨時文件對應(yīng)偏移位置覆蓋掉。RandomAccessFile的seek加write天然是覆蓋寫所以同一分片重復(fù)寫結(jié)果是一致的這個問題倒不大。真正的坑在Redis位圖狀態(tài)上。假設(shè)第10片實際沒傳成功但位圖被提前置為1了那合并時就會拿一個不完整的文件去轉(zhuǎn)正。所以請求處理順序必須是分片落盤成功 - 位圖置1 - 返回成功。任何一步?jīng)]完成都不要更新狀態(tài)。4.3 高并發(fā)分片上傳時的文件鎖問題前面提到過我推薦12到16個并發(fā)分片同時上傳。這意味著同一時間可能有十幾個線程在寫同一個臨時文件的不同偏移位置。RandomAccessFile的寫操作本身到操作系統(tǒng)層面是有并發(fā)保護的但Java層面多個線程同時調(diào)用write時write并不是全程線程安全的尤其是在寫入長度超過單次write緩沖限制時。保險起見我給寫文件的代碼塊加了ReentrantLock按fileId加鎖。同一文件的分片寫操作串行化不同文件之間不互相阻塞。private final ConcurrentHashMapString, ReentrantLock lockMap new ConcurrentHashMap(); private ReentrantLock getFileLock(String fileId) { return lockMap.computeIfAbsent(fileId, id - new ReentrantLock()); } private void writeChunkWithLock(String fileId, ChunkUploadRequest request, String basePath) { ReentrantLock lock getFileLock(fileId); lock.lock(); try { writeChunkToFile(basePath, request); } finally { lock.unlock(); } }5. 生產(chǎn)環(huán)境里的性能調(diào)優(yōu)Tomcat、磁盤IO、大文件緩沖方案跑通之后性能優(yōu)化才是讓系統(tǒng)真的能扛住生產(chǎn)壓力的關(guān)鍵。巡檢日志的上傳窗口期高度集中早上班前一小時、下午班后一小時是上傳高峰幾十個巡檢員同時傳大文件服務(wù)端壓力不小。5.1 Tomcat參數(shù)調(diào)整與上傳限制Spring Boot內(nèi)嵌Tomcat默認限制單次請求大小為1MB做分片上傳前必須改。分片本身只要5MB所以單次請求限制調(diào)到10MB就夠了不用為2GB的文件調(diào)大否則反而容易把線程池拖垮。spring.servlet.multipart.max-file-size10MB spring.servlet.multipart.max-request-size12MB同時把Tomcat的核心線程數(shù)和隊列長度調(diào)整一下。分片上傳的請求特點是并發(fā)量大、單請求處理時間短我按這個特征把最大線程數(shù)從默認的200調(diào)到400等待隊列從默認的100調(diào)到200。server.tomcat.threads.max400 server.tomcat.threads.min-spare40 server.tomcat.accept-count2005.2 磁盤IO臨時目錄和正式目錄不要放在同一塊盤這個坑我踩過。一開始臨時文件和最終文件都在應(yīng)用服務(wù)器的同一塊數(shù)據(jù)盤上巡檢高峰期上傳和合并同時進行磁盤IO直接打滿上傳速率從60MB/s掉到不到10MB/s。后來我把臨時文件目錄放到一塊獨立的固態(tài)盤上正式文件目錄放在另一塊盤上。分片寫入和合并轉(zhuǎn)正錯開IO路徑整個上傳鏈路就通了。如果條件有限只有一塊盤至少要把臨時目錄和正式目錄分在不同目錄層級避免ext4文件系統(tǒng)索引節(jié)點競爭。5.3 合并大文件時的內(nèi)存控制合并分片不要用Files.readAllBytes去讀整個文件2GB的文件直接OOM。我上面用Files.move做轉(zhuǎn)正其實已經(jīng)繞開了這個問題。但文件校驗時計算MD5同樣要注意要用流式計算每次讀64KB緩沖區(qū)而不是一次性載入內(nèi)存。private String calcMd5(Path file) throws IOException { MessageDigest md MessageDigest.getInstance(MD5); try (InputStream is Files.newInputStream(file)) { byte[] buffer new byte[65536]; int len; while ((len is.read(buffer)) ! -1) { md.update(buffer, 0, len); } } return Base64.getEncoder().encodeToString(md.digest()); }強調(diào)一句這塊代碼用Files.readAllBytes寫過的人基本都見過OOM報錯。6. 移入巡檢業(yè)務(wù)后的實際效果與二次踩坑整套方案遷移到巡檢日志系統(tǒng)之后我記錄了三個月的運行數(shù)據(jù)。在同樣的弱網(wǎng)條件下大附件上傳成功率從改造前的不足六成提升到99.2%。剩余0.8%的失敗也主要集中在服務(wù)端重啟或存儲節(jié)點故障這類極端情況斷點續(xù)傳機制本身沒有成為瓶頸。但有幾個業(yè)務(wù)層面的問題值得單獨拿出來說。6.1 斷點續(xù)傳不等于斷網(wǎng)續(xù)傳有同事問過既然有斷點續(xù)傳那我在地下車庫斷網(wǎng)了回家再打開APP是不是文件還能繼續(xù)傳答案是除非把上傳狀態(tài)持久化到本地方便APP重啟后恢復(fù)否則做不到。服務(wù)端的續(xù)傳狀態(tài)默認是24小時過期APP進程一旦被殺掉客戶端持有的分片記錄就丟了重新打開APP會變成全部重傳。解決方案是把前端的分片上傳記錄用Room數(shù)據(jù)庫持久化重新打開APP時先從本地數(shù)據(jù)庫恢復(fù)未完成的上傳任務(wù)。這塊邏輯傳統(tǒng)上歸前端管但服務(wù)端要配合提供一個查詢已收分片的接口前端才能知道從哪片續(xù)傳。GetMapping(/upload/status/{fileId}) public ResponseEntityUploadStatusVO queryStatus(PathVariable String fileId) { Long uploaded stringRedisTemplate.execute( (RedisCallbackLong) connection - connection.bitCount((UPLOAD_CHUNK_KEY fileId).getBytes()) ); return ResponseEntity.ok(new UploadStatusVO(uploaded, totalChunks)); }6.2 同一文件多人重復(fù)上傳的文件標識沖突巡檢場景里經(jīng)常出現(xiàn)好幾臺手機拍到同一個隱患點生成的視頻內(nèi)容非常相似MD5可能碰巧一致。如果服務(wù)端用MD5作為唯一文件標識就會誤判成已經(jīng)傳過了直接返回秒傳成功但文件內(nèi)容是甲拍的隱患點乙的日志里配了一張別人的圖片。解決方式是文件標識用用戶ID加MD5的復(fù)合標識或者引入UUID作為主標識、MD5只做文件內(nèi)容的完整性校驗不承擔文件唯一身份的功能。6.3 不要忽略服務(wù)端存儲空間預(yù)檢2GB的文件分片全部落盤后臨時目錄的占用會瞬時飆升。高峰期幾十個文件同時上傳輕輕松松占掉幾十GB空間。我后來在每次新建上傳任務(wù)時先做一個存儲空間預(yù)檢剩余空間低于閾值就直接拒絕新任務(wù)避免中途存儲寫滿導(dǎo)致所有任務(wù)全部失敗。File storeDir new File(tempDir); long freeBytes storeDir.getUsableSpace(); if (freeBytes RESERVED_SIZE) { throw new UploadException(存儲空間不足請稍后再試); }這個邏輯跟斷點續(xù)傳沒有直接關(guān)系但一個上傳任務(wù)跑到99%因為磁盤滿了掛掉是最讓人懊惱的失敗方式。先把這種底層隱患堵住斷點續(xù)傳用起來才踏實。6.4 巡檢報告自動生成場景里的擴展斷點續(xù)傳的另一種用途文件上傳只是第一步。巡檢日志生成后系統(tǒng)還要把照片和視頻打包成PDF或ZIP發(fā)送給安全部門。這里同樣遇到了超大附件問題——一份包含幾百張現(xiàn)場照片的報告壓縮包動輒300MB通過郵件網(wǎng)關(guān)發(fā)送時頻繁超時。后來我把斷點續(xù)傳的思路反過來用在下載側(cè)報告生成后先持久化到文件服務(wù)器郵件里不放附件只放下載鏈接下載接口支持HTTP Range。這樣即使下載中斷也可以從斷點繼續(xù)拉取和上傳側(cè)的斷點續(xù)傳正好形成閉環(huán)。工程上省掉了郵件網(wǎng)關(guān)的超時改造用戶體驗還更好了。這個經(jīng)驗可以推廣到所有把大文件從A系統(tǒng)搬到B系統(tǒng)的場景。數(shù)據(jù)搬運的速度永遠受制于最差的那段鏈路與其想方設(shè)法提升鏈路的穩(wěn)定性不如讓搬運過程本身具備從中斷處繼續(xù)的能力。這就是斷點續(xù)傳最本質(zhì)的價值。