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