多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

大文件分片上传内存优化:前端切片与Java后端流式处理实践

大文件分片上传内存优化:前端切片与Java后端流式处理实践 先声明一下这里说的“Java 插件”我理解成两层意思一层是浏览器端的上传插件比如 webuploader、simple-uploader 这类另一层是它们背后接的 Java 后端。真正要解决“大文件分片上传内存占用”的问题必须把这两层放在一起看只调任何一端都不够彻底。很多人一开始都会有这个困惑我已经分片了呀为什么浏览器还是卡为什么上传一个 4GB 的视频页面直接白屏为什么后端是 Java明明只是接收文件内存却一直往上飙这篇文章我把自己踩过的一些坑、排查思路和最终落地的方案完整整理出来希望能帮到正在做文件上传、断点续传、秒传校验的 Java 开发者和前端同学。1. 先把问题看透分片上传为什么照样吃内存1.1 浏览器端的内存消耗到底发生在哪一步先说一个容易被忽略的事实File对象本身只是一份文件引用不等同于内存里的数据。当你从input typefile拿到一个文件浏览器只是把这个文件在磁盘上的路径、大小、类型这些元数据包装成了一个对象并没有把整个文件读进内存。真正让内存飙升的动作通常发生在下面几个环节调用FileReader.readAsArrayBuffer(file)或readAsDataURL(file)把整个文件读进内存。调用Blob.text()、Blob.arrayBuffer()这类直接返回完整内容的 API。把大文件切完片之后不控制并发一次发起几十个上传请求浏览器为了发送请求体会在内部为每个分片申请缓冲区。计算 MD5 时习惯性地readAsArrayBuffer全量读取然后spark.append(result)这一步是内存暴涨的重灾区。换句话说问题的本质不是“分片”这个动作本身而是你切片的方式、读取的方式以及发送时是否做了并发控制。1.2 分片了还没有省内存通常踩了这三个坑我在帮别人 review 代码时发现“分片上传但还是卡死”的项目基本都能归到三种情况。第一种是“假分片”。代码里虽然调用了 slice但切完片之后又用FileReader把每一片都readAsArrayBuffer拿来做 MD5而且分片大小设置得特别大比如 64MB 一片内存瞬间就被打满。更隐蔽的是有的写法把读出来的 ArrayBuffer 存进一个全局数组用来“之后发送”结果就是整个文件的大小被完整地复制到了内存里。第二种是并发不加限制。分片大小合理每片 2MB但一次性把几十个分片同时提交浏览器同时打开几十个 TCP 连接底层要缓冲的数据量累加起来一样能把内存压垮。再加上后端 Java 如果也没有限流每个请求进来都是独立线程、独立缓冲两边一起爆。第三种是后端用错了接收方式。Spring Boot 接MultipartFile时如果直接file.getBytes()相当于把一个分片完整读成字节数组如果分片是 10MB后端并发 20 个请求瞬间就要吃 200MB 内存。正确做法是拿到InputStream之后流式写入临时文件或者直接让容器把 multipart 落盘。1.3 优化之前先定一个可量化的目标不要笼统地说“优化内存”要定一个可以观测的指标。我在实际操作里的标准是在上传过程中浏览器 JS Heap 和 Java 堆内存的曲线应当保持平稳不能随着文件大小线性上升。打开 Chrome DevTools 的 Performance Monitor一边上传一边盯 JS Heap 曲线。如果曲线是一条接近水平的线说明内存控制是健康的如果曲线一直往上爬说明有对象被保留住没有释放。后端的 Java 进程同理用jstat -gc pid 1000观察老年代增长情况或者直接看监控面板里 JVM 堆的使用率。定目标还有一个好处排查的时候不会瞎猜内存有问题就看曲线是出现在前段、中段还是后段再对应去查代码。2. 方案选型前端插件与 Java 后端的配合方式2.1 惰性读取是大文件上传的基础浏览器端的Blob.slice是一个很巧妙的设计——它不会立刻复制数据而是生成一个“指向原文件某段范围”的视图对象。只有当你真正去读取它的内容比如FileReader、arrayBuffer()或者把它放进FormData发送时浏览器才会把对应那一段字节读进内存。这就是整条优化思路的地基尽量保持分片的“惰性”不要主动去把它物化到内存里。如果你用的插件内部已经走了 Blob.slice FormData/XHR 发送那它天然就是内存友好的前提是不额外读内容做校验。如果插件里面做了全量 MD5那就要么改配置要么换方案。当前社区里比较推荐的是 simple-uploader.jsvue-simple-uploader 是它的 Vue 封装它内部的file.slice处理做得比较干净而且支持并发数、分片大小、断点续传的配置可以直接拿来用。老牌的 webuploader 也支持分片但内核依赖方式较老HTML5 模式下内存表现也还过得去具体项目可以实测后再选。2.2 后端 multipart 解析临时文件与内存阈值的取舍Java 后端这一侧的优化核心是搞清楚框架到底把上传内容放在内存里还是磁盘上。Spring Boot 的 multipart 配置里file-size-threshold这个参数决定了“超过多大就开始写临时文件”。默认情况下Spring Boot 的file-size-threshold0也就是所有文件都会先写到临时文件再让你通过MultipartFile.getInputStream()读取。这个默认值对大文件其实是很友好的因为它避免了把请求体完整放进 JVM 堆。但有的人会把file-size-threshold调得很大比如 50MB想着“小文件走内存快一点”。如果你的系统只处理 1MB 以下的小文件这个思路没问题但如果你的业务就是大文件分片上传分片大小本身可能就是 5MB、10MB再把 threshold 调大就相当于后端每个请求都会先占掉 10MB 堆内存并发一高OOM 是迟早的事。这里我建议的做法是保持小 threshold 或者干脆用默认值 0让容器把 multipart 落盘后端只负责流式读取。2.3 整体优化策略对比方案内存占用特点适用场景FileReader 全量读取极高等于文件大小实现简单但大文件必崩只适合几 MB 以内的小文件Blob.slice XHR 分片发送低约为单分片大小 × 并发数最常规、最稳妥的方案绝大多数 Web 场景分片计算 MD5 断点续传中约为一两个分片的大小需要配合增量哈希和手动释放秒传、重传校验场景IndexedDB/OPFS 暂存分片低几乎不占 JS 堆把未上传分片落到浏览器本地可断点续传超大文件、弱网、长时上传Java 端 getBytes() 接收高等于分片大小 × 并发数代码简单但并发一高就爆小文件系统Java 端流式写临时文件低核心内存只有缓冲区需要额外管理临时文件和合并逻辑大文件上传后端可以看到内存优化的核心思路并不是“不占内存”而是“把单次驻留内存控制在几个分片以内”并且保证它与文件总大小无关只与“分片大小 × 并发数”这个固定公式有关。3. 手把手实操前端切片、Java 接收与合并3.1 前端读取切片的正确姿势我最终落地的前端代码大概长这样框架无关原生 JS 就能跑。/** * 分片上传核心类 * chunkSize: 分片大小默认 5MB * concurrency: 同时上传的分片数默认 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); // 关键只创建 Blob 引用不读取内容到内存 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。全程没有调用FileReader也没有把分片存到数组里。浏览器发送时会在内部流式读取这段数据单个请求的核心内存压力被限制在分片大小附近。concurrency我建议设置为 3 左右。这个数字看起来很小但实测下来3 个并发配 5MB 分片内存峰值稳定上传速度也足够跑满普通带宽。把它调到 10内存直接涨到 50MB 以上而带宽不一定会变快因为瓶颈往往在服务端写入和网络延迟上。3.2 Java 后端分片接收与合并实现后端我用 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 的数据会走临时文件不会长期驻留 JVM 堆内存。接收分片的 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(); // 实际项目里更好的做法是根据文件唯一标识做目录比如 md5 Path chunkDir Paths.get(uploadTempDir); if (!Files.exists(chunkDir)) { Files.createDirectories(chunkDir); } // 流式写入分片文件缓冲区 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); } } // 如果所有分片都已上传触发合并 // 这里用一个简单的计数判断真实项目中建议记录在 Redis 或数据库 long count Files.list(chunkDir).count(); if (count totalChunks) { mergeChunks(chunkDir, filename); } return ResponseEntity.ok().build(); }真正发送大文件请求时MultipartFile内部只是一个入口数据其实已经由容器写到了磁盘临时文件里。你在这里用getInputStream()把它拿出来再往目标分片文件写全程 JVM 堆里只有 8KB 的byte[]缓冲区。合并分片是另一个容易踩内存的环节。很多人合并时会把所有分片读出来放到一个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) { // 单次最多转移 64MB避免长连接占用线程时间过长 transferred in.transferTo(transferred, Math.min(64 * 1024 * 1024, size - transferred), out); } } Files.deleteIfExists(chunk); } Files.deleteIfExists(chunkDir); } }transferTo在 Linux 上的底层是零拷贝机制由内核直接把数据从磁盘搬到另一个文件不经过 JVM 堆。这是 Java 大文件合并里最稳的写法内存占用可以忽略不计。3.3 参数怎么定分片大小、并发数与内存预算参数不是拍脑袋定的是可以算出来的。假设前端分片大小设为 5MB并发数为 3那么浏览器端单个上传任务的内存增量大致是5MB × 3 15MB再加上页面本身的 JS 占用一个 4GB 大文件上传时浏览器内存增量稳定在 15MB 左右是合理的。如果你观察到几百 MB 甚至上 GB那一定是其它地方把数据缓存住了。后端的内存预算要分场景如果走流式写文件每个请求的额外堆占用很小只有InputStream内部缓冲和byte[8192]即几十 KB 的量级。如果调用file.getBytes()每个请求会额外占用一个分片大小的字节数组即分片大小 × 并发数。所以后端即使给 50 个并发请求流式处理内存也就几 MB但如果getBytes()加上 10MB 分片50 个并发就是 500MB直接触发 GC 甚至 OOM。前端分片大小的选择也需要权衡。分片太大单片内存峰值高单请求失败后重传成本高分片太小请求数暴增网络握手和请求头开销变大。我的经验是 2MB 到 10MB 是黄金区间弱网环境选 2MB普通内网/带宽环境选 5MB 比较平衡。4. 内存治理进阶MD5 计算、并发控制与 GC 调优4.1 计算文件指纹时不把整个文件加载进来大文件上传通常绕不开秒传和断点续传计算文件的 MD5/SHA-1 又成了刚需。但很多实现有大问题把整个文件读成一个 ArrayBuffer喂给 SparkMD5 或 crypto一次性算完。内存直接打满。正确姿势是分片增量计算。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(); }); }这段代码的峰值内存约等于一个分片的大小2MB而不是整个文件的大小。注意e.target.result null这一步目的是主动解除对 ArrayBuffer 的引用帮助 GC 回收。在实际项目里如果文件实在太大也可以采用抽样哈希算法只取文件头部、中间、尾部一些片段来计算指纹秒传校验的准确性足够用内存占用更是可以忽略。4.2 前端并发控制与后端信号量限流前面代码里的active计数其实是一个简单的并发控制。更复杂的场景建议用队列加 Promise 的方式但原则不变永远不要让“等待发送的分片”同时在内存中存在。后端的并发控制容易被忽略。虽然流式写文件已经极大降低了内存占用但如果不限制同时处理的请求数超过线程池上限的请求也会排队排队的请求如果保存的是MultipartFile背后对应的临时文件和文件描述符会一直占着严重时照样拖垮进程。Spring Boot 的异步处理中我会用Semaphore做简单的并发闸门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(); } }这样即使前端并发控制失效后端也能有一个兜底的并发上限保护 JVM 堆和磁盘 IO。4.3 Electron/桌面端场景的 GC 检查如果你的“浏览器端”其实是 Electron 这类桌面应用内存问题会更敏感。Electron 渲染进程本身基于 ChromiumGC 调度不一定及时。打包时如果碰到上传过程中内存只升不降可以试试两个手段。第一在启动脚本里加上--expose-gc参数然后在合适的时机手动调用global.gc()并打印process.memoryUsage()。我这里说的是 Electron 主进程或 preload 脚本的 Node 环境不是前端页面。Electron 的 app 启动时可以这样写app.commandLine.appendSwitch(js-flags, --expose-gc);第二在上传的关键节点比如每个分片发送完成后做一个温和的global.gc()触发观察堆内存是否回到基线。注意不要频繁调用GC 本身也是消耗 CPU 的一般每个分片或者每隔几秒调用一次就够了。浏览器端页面本身看不到process但可以用performance.memory.usedJSHeapSize来观察堆内存。上传过程中的内存趋势应该是锯齿状的每个分片发送前后有小幅上升和回落但整体不攀升。5. 常见问题与排查实录5.1 上传过程中浏览器内存持续上涨怎么办这种问题通常不是分片本身引起的而是某个对象被全局保存了。常见的原因有把每个分片的 Blob 对象 push 到全局数组里用来断点续传或重试。这个数组保住了所有已经发送和未发送的分片引用内存等于整个文件。使用FileReader.readAsDataURL读了 Base64。Base64 比原始数据还要多 33% 的内存4GB 文件变成 5.3GB 的字符串浏览器必崩。开发模式下开了 Vue/React 的 devtools或者装了某些调试插件有额外的内存占用。这时候要切换到无痕模式或者禁用插件再测。解决办法就是保证“只保留当前正在发送的分片传完立刻释放引用”。如果要断点续传不要把分片 Blob 保存在数组里改成用 IndexedDB 把分片存到浏览器本地存储发送时读取发送完成后删除。虽然 IndexedDB 本身也基于磁盘但能有效降低 JS 堆压力。5.2 后端 Java 进程 OOM 或 GC 频繁先看是不是max-file-size设置不当。Spring Boot 默认max-file-size是 1MB如果收到的分片超过这个值请求会直接抛异常但如果你为了调大参数把file-size-threshold也一起调大了并且代码里又用了getBytes()那 OOM 简直是一定的。排查思路是先看监控图。如果 Old 区持续上涨说明有大对象被留在堆里如果是 GC 频繁但最终不会 OOM通常是循环里不断产生大对象比如一个 10MB 的分片处理完成后引用没有及时置空。另外要注意 Tomcat 的maxSwallowSize。这个参数控制的是“请求体超出限制后Tomcat 会尝试吞掉的额外字节数”。默认 2MB如果分片大小超过它且后端在出错时没有及时读取完整请求体连接可能会被关闭客户端会收到莫名其妙的连接重置。我把maxSwallowSize也调整到和max-file-size相近的值减少这类诡异问题。5.3 容易被忽略的“隐形内存”有几个坑平时不容易发现这里集中列一下隐形内存点影响建议临时文件目录堆积磁盘空间被耗尽上传任务全部失败定时任务定期清理超时未合并的临时目录日志打印 file.getBytes()日志框架保底也会持有大对象只打印分片 index、size不打内容HTTP/2 多路复用缓冲单个连接上的并发上传请求会在底层共享缓冲给连接数设置上限不要无限并发Base64 上传字符串对象无法被有效回收内存占用额外增加 33%一律用 FormData 二进制文件不用 Base64前端组件库的状态管理把 File 对象存入 Vuex/Redux组件销毁后仍被全局引用上传完成后主动清除 store 中的文件引用我印象最深的一次是一个项目查了很久最后发现问题出在 Nginx 的client_body_buffer_size。默认配置下Nginx 会把请求体缓冲到内存超过大小才写临时文件。如果那个值设得过大相当于在 Java 还没介入之前Nginx 这一层就先把大请求体在内存里缓存了。网关和反向代理层面的缓冲配置也是大文件上传优化里容易被漏掉的一环。根据我个人经验大文件上传内存优化没有银弹但只要把“流式读取”和“并发控制”这两条主线抓牢前端和后端各管好各的峰值就能把内存占用压到一个非常舒服的水平。你不需要一次性把所有技术点都上齐先保证上传不崩、内存曲线平缓再去加断点续传、秒传、进度回溯这些增强能力压力会小很多。
返回列表