存優(yōu)化:前端切片與Java后端流式處理實踐)
先聲明一下這里說的“Java 插件”我理解成兩層意思一層是瀏覽器端的上傳插件比如 webuploader、simple-uploader 這類另一層是它們背后接的 Java 后端。真正要解決“大文件分片上傳內(nèi)存占用”的問題必須把這兩層放在一起看只調(diào)任何一端都不夠徹底。很多人一開始都會有這個困惑我已經(jīng)分片了呀為什么瀏覽器還是卡為什么上傳一個 4GB 的視頻頁面直接白屏為什么后端是 Java明明只是接收文件內(nèi)存卻一直往上飆這篇文章我把自己踩過的一些坑、排查思路和最終落地的方案完整整理出來希望能幫到正在做文件上傳、斷點續(xù)傳、秒傳校驗的 Java 開發(fā)者和前端同學。1. 先把問題看透分片上傳為什么照樣吃內(nèi)存1.1 瀏覽器端的內(nèi)存消耗到底發(fā)生在哪一步先說一個容易被忽略的事實File對象本身只是一份文件引用不等同于內(nèi)存里的數(shù)據(jù)。當你從input typefile拿到一個文件瀏覽器只是把這個文件在磁盤上的路徑、大小、類型這些元數(shù)據(jù)包裝成了一個對象并沒有把整個文件讀進內(nèi)存。真正讓內(nèi)存飆升的動作通常發(fā)生在下面幾個環(huán)節(jié)調(diào)用FileReader.readAsArrayBuffer(file)或readAsDataURL(file)把整個文件讀進內(nèi)存。調(diào)用Blob.text()、Blob.arrayBuffer()這類直接返回完整內(nèi)容的 API。把大文件切完片之后不控制并發(fā)一次發(fā)起幾十個上傳請求瀏覽器為了發(fā)送請求體會在內(nèi)部為每個分片申請緩沖區(qū)。計算 MD5 時習慣性地readAsArrayBuffer全量讀取然后spark.append(result)這一步是內(nèi)存暴漲的重災區(qū)。換句話說問題的本質(zhì)不是“分片”這個動作本身而是你切片的方式、讀取的方式以及發(fā)送時是否做了并發(fā)控制。1.2 分片了還沒有省內(nèi)存通常踩了這三個坑我在幫別人 review 代碼時發(fā)現(xiàn)“分片上傳但還是卡死”的項目基本都能歸到三種情況。第一種是“假分片”。代碼里雖然調(diào)用了 slice但切完片之后又用FileReader把每一片都readAsArrayBuffer拿來做 MD5而且分片大小設置得特別大比如 64MB 一片內(nèi)存瞬間就被打滿。更隱蔽的是有的寫法把讀出來的 ArrayBuffer 存進一個全局數(shù)組用來“之后發(fā)送”結果就是整個文件的大小被完整地復制到了內(nèi)存里。第二種是并發(fā)不加限制。分片大小合理每片 2MB但一次性把幾十個分片同時提交瀏覽器同時打開幾十個 TCP 連接底層要緩沖的數(shù)據(jù)量累加起來一樣能把內(nèi)存壓垮。再加上后端 Java 如果也沒有限流每個請求進來都是獨立線程、獨立緩沖兩邊一起爆。第三種是后端用錯了接收方式。Spring Boot 接MultipartFile時如果直接file.getBytes()相當于把一個分片完整讀成字節(jié)數(shù)組如果分片是 10MB后端并發(fā) 20 個請求瞬間就要吃 200MB 內(nèi)存。正確做法是拿到InputStream之后流式寫入臨時文件或者直接讓容器把 multipart 落盤。1.3 優(yōu)化之前先定一個可量化的目標不要籠統(tǒng)地說“優(yōu)化內(nèi)存”要定一個可以觀測的指標。我在實際操作里的標準是在上傳過程中瀏覽器 JS Heap 和 Java 堆內(nèi)存的曲線應當保持平穩(wěn)不能隨著文件大小線性上升。打開 Chrome DevTools 的 Performance Monitor一邊上傳一邊盯 JS Heap 曲線。如果曲線是一條接近水平的線說明內(nèi)存控制是健康的如果曲線一直往上爬說明有對象被保留住沒有釋放。后端的 Java 進程同理用jstat -gc pid 1000觀察老年代增長情況或者直接看監(jiān)控面板里 JVM 堆的使用率。定目標還有一個好處排查的時候不會瞎猜內(nèi)存有問題就看曲線是出現(xiàn)在前段、中段還是后段再對應去查代碼。2. 方案選型前端插件與 Java 后端的配合方式2.1 惰性讀取是大文件上傳的基礎瀏覽器端的Blob.slice是一個很巧妙的設計——它不會立刻復制數(shù)據(jù)而是生成一個“指向原文件某段范圍”的視圖對象。只有當你真正去讀取它的內(nèi)容比如FileReader、arrayBuffer()或者把它放進FormData發(fā)送時瀏覽器才會把對應那一段字節(jié)讀進內(nèi)存。這就是整條優(yōu)化思路的地基盡量保持分片的“惰性”不要主動去把它物化到內(nèi)存里。如果你用的插件內(nèi)部已經(jīng)走了 Blob.slice FormData/XHR 發(fā)送那它天然就是內(nèi)存友好的前提是不額外讀內(nèi)容做校驗。如果插件里面做了全量 MD5那就要么改配置要么換方案。當前社區(qū)里比較推薦的是 simple-uploader.jsvue-simple-uploader 是它的 Vue 封裝它內(nèi)部的file.slice處理做得比較干凈而且支持并發(fā)數(shù)、分片大小、斷點續(xù)傳的配置可以直接拿來用。老牌的 webuploader 也支持分片但內(nèi)核依賴方式較老HTML5 模式下內(nèi)存表現(xiàn)也還過得去具體項目可以實測后再選。2.2 后端 multipart 解析臨時文件與內(nèi)存閾值的取舍Java 后端這一側的優(yōu)化核心是搞清楚框架到底把上傳內(nèi)容放在內(nèi)存里還是磁盤上。Spring Boot 的 multipart 配置里file-size-threshold這個參數(shù)決定了“超過多大就開始寫臨時文件”。默認情況下Spring Boot 的file-size-threshold0也就是所有文件都會先寫到臨時文件再讓你通過MultipartFile.getInputStream()讀取。這個默認值對大文件其實是很友好的因為它避免了把請求體完整放進 JVM 堆。但有的人會把file-size-threshold調(diào)得很大比如 50MB想著“小文件走內(nèi)存快一點”。如果你的系統(tǒng)只處理 1MB 以下的小文件這個思路沒問題但如果你的業(yè)務就是大文件分片上傳分片大小本身可能就是 5MB、10MB再把 threshold 調(diào)大就相當于后端每個請求都會先占掉 10MB 堆內(nèi)存并發(fā)一高OOM 是遲早的事。這里我建議的做法是保持小 threshold 或者干脆用默認值 0讓容器把 multipart 落盤后端只負責流式讀取。2.3 整體優(yōu)化策略對比方案內(nèi)存占用特點適用場景FileReader 全量讀取極高等于文件大小實現(xiàn)簡單但大文件必崩只適合幾 MB 以內(nèi)的小文件Blob.slice XHR 分片發(fā)送低約為單分片大小 × 并發(fā)數(shù)最常規(guī)、最穩(wěn)妥的方案絕大多數(shù) Web 場景分片計算 MD5 斷點續(xù)傳中約為一兩個分片的大小需要配合增量哈希和手動釋放秒傳、重傳校驗場景IndexedDB/OPFS 暫存分片低幾乎不占 JS 堆把未上傳分片落到瀏覽器本地可斷點續(xù)傳超大文件、弱網(wǎng)、長時上傳Java 端 getBytes() 接收高等于分片大小 × 并發(fā)數(shù)代碼簡單但并發(fā)一高就爆小文件系統(tǒng)Java 端流式寫臨時文件低核心內(nèi)存只有緩沖區(qū)需要額外管理臨時文件和合并邏輯大文件上傳后端可以看到內(nèi)存優(yōu)化的核心思路并不是“不占內(nèi)存”而是“把單次駐留內(nèi)存控制在幾個分片以內(nèi)”并且保證它與文件總大小無關只與“分片大小 × 并發(fā)數(shù)”這個固定公式有關。3. 手把手實操前端切片、Java 接收與合并3.1 前端讀取切片的正確姿勢我最終落地的前端代碼大概長這樣框架無關原生 JS 就能跑。/** * 分片上傳核心類 * chunkSize: 分片大小默認 5MB * concurrency: 同時上傳的分片數(shù)默認 3 */ class ChunkedUploader { constructor(file, { chunkSize 5 * 1024 * 1024, concurrency 3, url /upload/chunk } {}) { this.file file; this.chunkSize chunkSize; this.concurrency concurrency; this.url url; this.pending []; this.active 0; this.uploaded 0; this.totalChunks Math.ceil(file.size / chunkSize); } start() { for (let i 0; i this.totalChunks; i) { this.pending.push(i); } this.next(); } next() { while (this.active this.concurrency this.pending.length 0) { const index this.pending.shift(); this.active; this.uploadChunk(index) .finally(() { this.active--; this.next(); }); } } uploadChunk(index) { const start index * this.chunkSize; const end Math.min(start this.chunkSize, this.file.size); // 關鍵只創(chuàng)建 Blob 引用不讀取內(nèi)容到內(nèi)存 const blob this.file.slice(start, end); const formData new FormData(); formData.append(file, blob, this.file.name); formData.append(index, index); formData.append(totalChunks, this.totalChunks); formData.append(filename, this.file.name); return fetch(this.url, { method: POST, body: formData }).then(res { if (!res.ok) throw new Error(upload failed: index); }); } }這段代碼最關鍵的地方是file.slice(start, end)之后直接把blob丟給FormData。全程沒有調(diào)用FileReader也沒有把分片存到數(shù)組里。瀏覽器發(fā)送時會在內(nèi)部流式讀取這段數(shù)據(jù)單個請求的核心內(nèi)存壓力被限制在分片大小附近。concurrency我建議設置為 3 左右。這個數(shù)字看起來很小但實測下來3 個并發(fā)配 5MB 分片內(nèi)存峰值穩(wěn)定上傳速度也足夠跑滿普通帶寬。把它調(diào)到 10內(nèi)存直接漲到 50MB 以上而帶寬不一定會變快因為瓶頸往往在服務端寫入和網(wǎng)絡延遲上。3.2 Java 后端分片接收與合并實現(xiàn)后端我用 Spring Boot 演示關鍵是要避免getBytes()改用流式寫入。先看 multipart 配置spring: servlet: multipart: max-file-size: 20MB max-request-size: 200MB file-size-threshold: 2MB這里把max-file-size設為 20MB比前端單分片 5MB 大不少留有余量max-request-size設為 200MB防止分片請求在整體限制上被卡住file-size-threshold設為 2MB意味著超過 2MB 的數(shù)據(jù)會走臨時文件不會長期駐留 JVM 堆內(nèi)存。接收分片的 Controller 方法PostMapping(/upload/chunk) public ResponseEntity? uploadChunk( RequestParam(file) MultipartFile file, RequestParam(index) int index, RequestParam(totalChunks) int totalChunks, RequestParam(filename) String filename) throws IOException { // 每個文件一個臨時目錄目錄名為 uuid String uploadTempDir tempRoot UUID.randomUUID(); // 實際項目里更好的做法是根據(jù)文件唯一標識做目錄比如 md5 Path chunkDir Paths.get(uploadTempDir); if (!Files.exists(chunkDir)) { Files.createDirectories(chunkDir); } // 流式寫入分片文件緩沖區(qū) 8KB Path chunkFile chunkDir.resolve(index .part); try (InputStream in file.getInputStream(); OutputStream out Files.newOutputStream(chunkFile)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } // 如果所有分片都已上傳觸發(fā)合并 // 這里用一個簡單的計數(shù)判斷真實項目中建議記錄在 Redis 或數(shù)據(jù)庫 long count Files.list(chunkDir).count(); if (count totalChunks) { mergeChunks(chunkDir, filename); } return ResponseEntity.ok().build(); }真正發(fā)送大文件請求時MultipartFile內(nèi)部只是一個入口數(shù)據(jù)其實已經(jīng)由容器寫到了磁盤臨時文件里。你在這里用getInputStream()把它拿出來再往目標分片文件寫全程 JVM 堆里只有 8KB 的byte[]緩沖區(qū)。合并分片是另一個容易踩內(nèi)存的環(huán)節(jié)。很多人合并時會把所有分片讀出來放到一個byte[]里再一次性寫入這也是典型的 OOM 寫法。正確做法是使用FileChannel.transferToprivate void mergeChunks(Path chunkDir, String filename) throws IOException { Path finalFile uploadRoot.resolve(filename); try (FileChannel out FileChannel.open(finalFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { // 按 index 排序分片文件 ListPath chunks; try (StreamPath stream Files.list(chunkDir)) { chunks stream.sorted(Comparator.comparingInt( p - Integer.parseInt(p.getFileName().toString().replace(.part, )) )).collect(Collectors.toList()); } long position 0; for (Path chunk : chunks) { try (FileChannel in FileChannel.open(chunk, StandardOpenOption.READ)) { long size in.size(); long transferred 0; while (transferred size) { // 單次最多轉(zhuǎn)移 64MB避免長連接占用線程時間過長 transferred in.transferTo(transferred, Math.min(64 * 1024 * 1024, size - transferred), out); } } Files.deleteIfExists(chunk); } Files.deleteIfExists(chunkDir); } }transferTo在 Linux 上的底層是零拷貝機制由內(nèi)核直接把數(shù)據(jù)從磁盤搬到另一個文件不經(jīng)過 JVM 堆。這是 Java 大文件合并里最穩(wěn)的寫法內(nèi)存占用可以忽略不計。3.3 參數(shù)怎么定分片大小、并發(fā)數(shù)與內(nèi)存預算參數(shù)不是拍腦袋定的是可以算出來的。假設前端分片大小設為 5MB并發(fā)數(shù)為 3那么瀏覽器端單個上傳任務的內(nèi)存增量大致是5MB × 3 15MB再加上頁面本身的 JS 占用一個 4GB 大文件上傳時瀏覽器內(nèi)存增量穩(wěn)定在 15MB 左右是合理的。如果你觀察到幾百 MB 甚至上 GB那一定是其它地方把數(shù)據(jù)緩存住了。后端的內(nèi)存預算要分場景如果走流式寫文件每個請求的額外堆占用很小只有InputStream內(nèi)部緩沖和byte[8192]即幾十 KB 的量級。如果調(diào)用file.getBytes()每個請求會額外占用一個分片大小的字節(jié)數(shù)組即分片大小 × 并發(fā)數(shù)。所以后端即使給 50 個并發(fā)請求流式處理內(nèi)存也就幾 MB但如果getBytes()加上 10MB 分片50 個并發(fā)就是 500MB直接觸發(fā) GC 甚至 OOM。前端分片大小的選擇也需要權衡。分片太大單片內(nèi)存峰值高單請求失敗后重傳成本高分片太小請求數(shù)暴增網(wǎng)絡握手和請求頭開銷變大。我的經(jīng)驗是 2MB 到 10MB 是黃金區(qū)間弱網(wǎng)環(huán)境選 2MB普通內(nèi)網(wǎng)/帶寬環(huán)境選 5MB 比較平衡。4. 內(nèi)存治理進階MD5 計算、并發(fā)控制與 GC 調(diào)優(yōu)4.1 計算文件指紋時不把整個文件加載進來大文件上傳通常繞不開秒傳和斷點續(xù)傳計算文件的 MD5/SHA-1 又成了剛需。但很多實現(xiàn)有大問題把整個文件讀成一個 ArrayBuffer喂給 SparkMD5 或 crypto一次性算完。內(nèi)存直接打滿。正確姿勢是分片增量計算。SparkMD5 支持增量 append我們可以逐片讀取、逐片喂給哈希對象用完立刻把 ArrayBuffer 引用置空。function calculateFileMD5(file, chunkSize 2 * 1024 * 1024) { return new Promise((resolve, reject) { const spark new SparkMD5.ArrayBuffer(); let offset 0; function readNext() { const blob file.slice(offset, offset chunkSize); const reader new FileReader(); reader.onload (e) { const buffer e.target.result; spark.append(buffer); offset chunkSize; // 手動釋放讓 GC 可以盡早回收這個 ArrayBuffer e.target.result null; reader.onload null; if (offset file.size) { readNext(); } else { resolve(spark.end()); } }; reader.onerror (e) reject(e); reader.readAsArrayBuffer(blob); } readNext(); }); }這段代碼的峰值內(nèi)存約等于一個分片的大小2MB而不是整個文件的大小。注意e.target.result null這一步目的是主動解除對 ArrayBuffer 的引用幫助 GC 回收。在實際項目里如果文件實在太大也可以采用抽樣哈希算法只取文件頭部、中間、尾部一些片段來計算指紋秒傳校驗的準確性足夠用內(nèi)存占用更是可以忽略。4.2 前端并發(fā)控制與后端信號量限流前面代碼里的active計數(shù)其實是一個簡單的并發(fā)控制。更復雜的場景建議用隊列加 Promise 的方式但原則不變永遠不要讓“等待發(fā)送的分片”同時在內(nèi)存中存在。后端的并發(fā)控制容易被忽略。雖然流式寫文件已經(jīng)極大降低了內(nèi)存占用但如果不限制同時處理的請求數(shù)超過線程池上限的請求也會排隊排隊的請求如果保存的是MultipartFile背后對應的臨時文件和文件描述符會一直占著嚴重時照樣拖垮進程。Spring Boot 的異步處理中我會用Semaphore做簡單的并發(fā)閘門private Semaphore uploadSemaphore new Semaphore(20); PostMapping(/upload/chunk) public ResponseEntity? uploadChunk(...) throws IOException { boolean acquired uploadSemaphore.tryAcquire(3, TimeUnit.SECONDS); if (!acquired) { return ResponseEntity.status(429).body(too many concurrent uploads, please retry); } try { // 處理分片 } finally { uploadSemaphore.release(); } }這樣即使前端并發(fā)控制失效后端也能有一個兜底的并發(fā)上限保護 JVM 堆和磁盤 IO。4.3 Electron/桌面端場景的 GC 檢查如果你的“瀏覽器端”其實是 Electron 這類桌面應用內(nèi)存問題會更敏感。Electron 渲染進程本身基于 ChromiumGC 調(diào)度不一定及時。打包時如果碰到上傳過程中內(nèi)存只升不降可以試試兩個手段。第一在啟動腳本里加上--expose-gc參數(shù)然后在合適的時機手動調(diào)用global.gc()并打印process.memoryUsage()。我這里說的是 Electron 主進程或 preload 腳本的 Node 環(huán)境不是前端頁面。Electron 的 app 啟動時可以這樣寫app.commandLine.appendSwitch(js-flags, --expose-gc);第二在上傳的關鍵節(jié)點比如每個分片發(fā)送完成后做一個溫和的global.gc()觸發(fā)觀察堆內(nèi)存是否回到基線。注意不要頻繁調(diào)用GC 本身也是消耗 CPU 的一般每個分片或者每隔幾秒調(diào)用一次就夠了。瀏覽器端頁面本身看不到process但可以用performance.memory.usedJSHeapSize來觀察堆內(nèi)存。上傳過程中的內(nèi)存趨勢應該是鋸齒狀的每個分片發(fā)送前后有小幅上升和回落但整體不攀升。5. 常見問題與排查實錄5.1 上傳過程中瀏覽器內(nèi)存持續(xù)上漲怎么辦這種問題通常不是分片本身引起的而是某個對象被全局保存了。常見的原因有把每個分片的 Blob 對象 push 到全局數(shù)組里用來斷點續(xù)傳或重試。這個數(shù)組保住了所有已經(jīng)發(fā)送和未發(fā)送的分片引用內(nèi)存等于整個文件。使用FileReader.readAsDataURL讀了 Base64。Base64 比原始數(shù)據(jù)還要多 33% 的內(nèi)存4GB 文件變成 5.3GB 的字符串瀏覽器必崩。開發(fā)模式下開了 Vue/React 的 devtools或者裝了某些調(diào)試插件有額外的內(nèi)存占用。這時候要切換到無痕模式或者禁用插件再測。解決辦法就是保證“只保留當前正在發(fā)送的分片傳完立刻釋放引用”。如果要斷點續(xù)傳不要把分片 Blob 保存在數(shù)組里改成用 IndexedDB 把分片存到瀏覽器本地存儲發(fā)送時讀取發(fā)送完成后刪除。雖然 IndexedDB 本身也基于磁盤但能有效降低 JS 堆壓力。5.2 后端 Java 進程 OOM 或 GC 頻繁先看是不是max-file-size設置不當。Spring Boot 默認max-file-size是 1MB如果收到的分片超過這個值請求會直接拋異常但如果你為了調(diào)大參數(shù)把file-size-threshold也一起調(diào)大了并且代碼里又用了getBytes()那 OOM 簡直是一定的。排查思路是先看監(jiān)控圖。如果 Old 區(qū)持續(xù)上漲說明有大對象被留在堆里如果是 GC 頻繁但最終不會 OOM通常是循環(huán)里不斷產(chǎn)生大對象比如一個 10MB 的分片處理完成后引用沒有及時置空。另外要注意 Tomcat 的maxSwallowSize。這個參數(shù)控制的是“請求體超出限制后Tomcat 會嘗試吞掉的額外字節(jié)數(shù)”。默認 2MB如果分片大小超過它且后端在出錯時沒有及時讀取完整請求體連接可能會被關閉客戶端會收到莫名其妙的連接重置。我把maxSwallowSize也調(diào)整到和max-file-size相近的值減少這類詭異問題。5.3 容易被忽略的“隱形內(nèi)存”有幾個坑平時不容易發(fā)現(xiàn)這里集中列一下隱形內(nèi)存點影響建議臨時文件目錄堆積磁盤空間被耗盡上傳任務全部失敗定時任務定期清理超時未合并的臨時目錄日志打印 file.getBytes()日志框架保底也會持有大對象只打印分片 index、size不打內(nèi)容HTTP/2 多路復用緩沖單個連接上的并發(fā)上傳請求會在底層共享緩沖給連接數(shù)設置上限不要無限并發(fā)Base64 上傳字符串對象無法被有效回收內(nèi)存占用額外增加 33%一律用 FormData 二進制文件不用 Base64前端組件庫的狀態(tài)管理把 File 對象存入 Vuex/Redux組件銷毀后仍被全局引用上傳完成后主動清除 store 中的文件引用我印象最深的一次是一個項目查了很久最后發(fā)現(xiàn)問題出在 Nginx 的client_body_buffer_size。默認配置下Nginx 會把請求體緩沖到內(nèi)存超過大小才寫臨時文件。如果那個值設得過大相當于在 Java 還沒介入之前Nginx 這一層就先把大請求體在內(nèi)存里緩存了。網(wǎng)關和反向代理層面的緩沖配置也是大文件上傳優(yōu)化里容易被漏掉的一環(huán)。根據(jù)我個人經(jīng)驗大文件上傳內(nèi)存優(yōu)化沒有銀彈但只要把“流式讀取”和“并發(fā)控制”這兩條主線抓牢前端和后端各管好各的峰值就能把內(nèi)存占用壓到一個非常舒服的水平。你不需要一次性把所有技術點都上齊先保證上傳不崩、內(nèi)存曲線平緩再去加斷點續(xù)傳、秒傳、進度回溯這些增強能力壓力會小很多。