芯片制造行业的网页应用,Java后端的文件上传一直是块硬骨头。我最早接触这个需求是在做晶圆厂生产数据管理系统时,光刻机跑出来的GDSII版图文件动辄几十GB,甚至上百GB,还有晶圆检测环节产出的高分辨率缺陷图片,一批Review文件轻松突破10GB。传统网页上传在这种体量面前直接趴窝——浏览器内存扛不住、服务器超时、网络一抖动就前功尽弃。后来我们全面切换到分块上传方案,才把这些场景真正撑起来。这篇文章就从头捋一遍,Java后端到底怎么设计分块上传,以及我在芯片行业真实项目里踩过的坑和验证过的做法。
1. 芯片制造行业的大文件上传到底难在哪
1.1 为什么芯片行业会产生这么多超大文件
很多人不理解,芯片制造明明是硬件行业,怎么整天跟大文件较劲?其实芯片制造的全流程,从设计到量产,每一环都在产数据、传数据、看数据。
首先是芯片设计环节。一颗先进制程芯片的版图文件(GDSII/OASIS格式)包含了几十亿个晶体管图形,文件体积随工艺节点缩小呈爆炸式增长。28nm时代的版图可能几个GB,到了7nm、5nm,单颗SoC的版图文件轻松超过50GB。设计公司要把版图发给晶圆厂做光罩(Mask),这个文件是必须完整无误传输的。
其次是晶圆制造环节。光刻机、刻蚀机、薄膜沉积设备每天产生海量工艺参数日志,一台先进设备一天就能产出几十GB的日志。更夸张的是晶圆检测环节——高分辨率的晶圆表面缺陷图像,一张图就是几十MB,一批晶圆25片,每片拍几千个Die,一轮检测下来几百GB的图片数据很常见。
还有封测环节的良率分析报告、可靠性测试数据,以及芯片应用端的失效分析案例库。这些文件不只是存起来就完事,还需要在网页系统里被评审、被检索、被关联到具体批次和工艺节点。所以网页应用承载大文件上传,不是锦上添花,是刚需。
1.2 网页应用直接上传大文件的三个死穴
我见过不少团队一开始图省事,直接用传统的multipart/form-data整文件上传,结果被现实狠狠教育。这里把三个死穴说清楚,你就知道为什么必须分块。
第一个死穴:浏览器内存吃不消。传统上传方式中,浏览器往往要把整个文件读入内存再提交。Chrome对单个页面标签页的内存是有上限压力的,几十GB的文件一读,页面直接卡死、白屏,甚至整个浏览器崩溃。就算侥幸没崩,用户的电脑也会被拖到没法用。
第二个死穴:一次HTTP请求的脆弱性。一个100GB的文件走一次请求,意味着从第一个字节到最后一个字节,只要中间断一次网、服务器重启一次、或者代理服务器超时一次,整个请求就废了,用户只能重新再来。内网环境稍微稳定点还好说,跨地域的设计公司、封测厂之间传输,网络抖动是常态,大文件单请求基本活不下来。
第三个死穴:服务器端的内存和超时限制。不管是Tomcat还是Spring Boot内嵌的Servlet容器,都有请求体大小限制和IO超时时间。默认的Tomcat maxPostSize只有2MB,Spring的servlet multipart配置不调的话,几十MB都费劲。你当然可以去调大这些参数,但调大之后又面临并发问题——几个用户同时上传大文件,服务器内存就爆了。
分块上传解决的就是这三大死穴:把大文件切成小块,每块单独走一次HTTP请求,内存峰值可控,单块失败只重传这一块,服务器也能按块落盘、按块校验。这是工程上唯一稳妥的路子。
2. 分块上传的整体设计与方案选型
2.1 分块上传的核心思路
分块上传听起来高深,其实思路很简单:把一个大文件切成N个小片,前端逐片(或并发)传给后端,后端把每一片保存下来,等所有分片都到齐了,再把它们按顺序拼回完整的文件。
这里有几个关键决策点,直接决定方案上限:
块大小怎么定。我见过有人固定用1MB,也有人用几十MB。块太小,分片数量太多,HTTP请求数量爆炸,100GB文件按1MB切就是十万个请求,不管是前端调度还是后端落盘,开销都吃不消。块太大,又失去了分块的意义,单块传输时间太长,失败重传成本高。我们芯片行业的场景,因为单文件体量大,我们实测下来8MB到16MB是甜点区。内网千兆环境下,8MB一块,100GB就是12800个请求,配合并发控制在8到16路,整体速度能跑满带宽。如果你走公网传输,建议压到2MB到4MB,降低单块失败率。
分片要不要固定大小。除最后一块外,前面所有分片保持相同大小,这样后端可以精确计算每个分片的序号和期望偏移量,校验逻辑简单可靠。最后一块通常是剩余部分,大小不定是正常的。
分片的身份标识。每个分片必须能被唯一标识,一般用"文件唯一ID + 分片序号"组合。文件唯一ID通常在前端计算文件MD5得到,或者由后端在初始化上传时分配一个UUID。两者各有利弊,后面细说。
元数据先行的策略。文件在真正切分之前,前端先向后端发起一个"初始化上传"请求,把文件名、文件大小、分片大小、分片总数、文件MD5传给后端。后端返回一个uploadId,后续每个分片都携带这个uploadId上报。这有什么好处?后端可以提前校验参数、创建上传任务记录、预估磁盘占用,也方便做秒传判断。
2.2 前后端技术选型的关键考量
Java后端这边,Spring Boot是绝对的主流,分块上传相关的接口用Spring MVC的@RequestParam接收MultipartFile即可,不必引入重量级框架。对象存储层面,芯片行业的数据最终大多要落到分布式存储里,MinIO是绕不开的选择。它兼容S3 API,部署简单,还直接提供了分片上传(Multipart Upload)的服务端能力,能把前端分块和存储层分片衔接起来。
有人会问:既然MinIO本身就支持分片上传,为什么还要自己在Web层做一层分块?这里要区分两个层次:MinIO的分片上传解决的是"客户端到存储服务端"的传输问题,而Web层分块解决的是"浏览器到应用服务端"的传输问题。浏览器不能直接拿MinIO的SDK去传文件,必须先把文件切成片发到你的Java应用,应用再决定是直接落本地磁盘,还是把每个分片转存到MinIO。我们在实际项目里的做法是两层结合:Java应用接收Web前端的分块,分块先落本地临时目录,攒齐后合并成完整文件,再一次性上传到MinIO。这样MinIO上始终是完整的对象文件,不需要依赖它的分片断点能力,省去了和存储层状态同步的复杂度。
前端这边,浏览器原生提供了File对象的slice()方法,可以按字节偏移切出Blob片段。XMLHttpRequest或fetch都可以发送分片,但如果你追求并发效率和进度反馈,建议用XMLHttpRequest,因为它有现成的upload.onprogress事件。至于Web Worker,它的意义在于把"切片 + 计算MD5 + 调度上传"这些耗时操作从UI主线程挪到后台线程,避免上传大文件时页面卡顿、按钮点不动。后面我会给出实际用法。
方案选型上还有一个容易被忽略的点:文件上传的最终落盘路径。芯片行业的文件往往有合规和审计要求,上传的文件需要跟批次号、设备ID、工序节点关联。所以设计上传目录时不要用随机字符串兜底,建议用"业务类型/日期/批次号/文件UUID"这种结构,后面检索和清理都方便。
3. 前端分块与Worker并行上传的实现
3.1 前端切片:File.slice()的正确用法
前端切片的核心就一个API:file.slice(start, end)。它从File对象中截取一段字节流,返回一个Blob。需要注意,这个方法返回的是切割后的"视图",并不复制整个文件数据,所以即使文件有100GB,执行一万次slice也不会把内存吃爆。
切片逻辑里最常见的错误是边界处理不准。循环切片的正确写法是这样:
const CHUNK_SIZE = 8 * 1024 * 1024; // 8MB let start = 0; let index = 0; while (start < file.size) { const end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); chunks.push({ index, chunk, start, end }); start = end; index++; }注意最后一块的处理,end必须用Math.min兜底,不能直接用start + CHUNK_SIZE,否则最后一块会切出一个空Blob或者越界。别笑,我在评审代码时真见过没写Math.min的,前99块正常,最后一块直接溢出,合并出来的文件大小对不上。
还有一个必须处理的点:切片前校验file.size。有些浏览器或者特殊文件系统(比如某些网盘同步目录)里,File对象的size可能是0或者渐变的,取到的size不稳定会导致切片失败。稳妥的做法是判断file.size > 0再开始切,并且在切片过程中如果发现chunk.size === 0,直接中止并报错。
前端切片之后要做什么?两个计算任务:一个是每个分片的MD5,用于后端校验单块完整性;另一个是整个文件的MD5,用于秒传和全局校验。大文件的MD5计算是CPU密集型操作,100GB文件算一次MD5可能要几分钟,这些计算如果放在主线程,页面会卡成PPT。所以必须交给Worker。
3.2 用Web Worker把上传任务丢到后台
Web Worker的用法不复杂,但有几个细节值得注意。
主线程创建Worker:
const worker = new Worker('/upload-worker.js'); worker.postMessage({ type: 'init', file: file, // File对象可以直接传给Worker chunkSize: CHUNK_SIZE, uploadId: uploadId }); worker.onmessage = function(e) { // 接收进度回调、MD5结果、分片完成状态 };Worker内部接收File对象后,就可以独立完成切片、计算MD5、甚至直接发起上传请求。Worker里照样可以使用XMLHttpRequest或fetch,浏览器允许Worker发起网络请求。
Worker脚本的核心逻辑如下:
self.onmessage = async function(e) { const { type, file, chunkSize, uploadId } = e.data; if (type === 'init') { // 1. 计算文件MD5 const fileMd5 = await calculateFileMD5(file); // 2. 切片并逐个上传 let start = 0; let index = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); const chunk = file.slice(start, end); await uploadChunk(uploadId, index, chunk); start = end; index++; self.postMessage({ type: 'progress', uploaded: end, total: file.size }); } self.postMessage({ type: 'done', fileMd5 }); } };这里有一个必须说的性能细节:如果不做并发控制,一个100GB文件切成12800块,逐块顺序上传,千兆网也要传两三个小时。实际项目中必须做并发。但并发也不是越高越好——并发数太高,浏览器会创建大量HTTP连接,服务器端也会因为同时处理的请求过多而出现文件句柄耗尽。我们生产环境的经验是:内网场景并发控制在8到16,公网场景4到8,效果最好。实现并发可以用简单的"批次滑动窗口",一次放N个请求出去,完成一个补一个:
const CONCURRENCY = 8; let nextIndex = 0; let activeCount = 0; async function pump() { while (nextIndex < chunks.length && activeCount < CONCURRENCY) { const chunk = chunks[nextIndex++]; activeCount++; uploadChunk(uploadId, chunk.index, chunk.blob) .finally(() => { activeCount--; pump(); }); } } for (let i = 0; i < Math.min(CONCURRENCY, chunks.length); i++) { pump(); }切片计算MD5这块,很多团队卡在"100GB文件MD5算到天荒地老"。有几个优化手段可以交叉使用:如果系统对秒传要求不高,可以只取文件头256KB、中间256KB、尾部256KB拼接后计算MD5,得到一个"抽样指纹",再用后端统计匹配。这样MD5计算从几分钟压到几十毫秒,适合超大文件。但要注意,抽样指纹存在碰撞风险,涉及数据一致性的场景要配合完整校验兜底。
4. Java后端分块接收与合并的核心实现
4.1 分块上传的接口设计
后端接口设计我用的是三接口方案:初始化上传、上传分块、合并文件。实际项目中还可以加一个"查询已上传分块"的接口用于断点续传,后面单独讲。
初始化上传接口:
@PostMapping("/upload/init") public UploadInitResponse init(@RequestBody UploadInitRequest request) { // request包含: fileName, fileSize, chunkSize, chunkCount, fileMd5 String uploadId = UUID.randomUUID().toString().replace("-", ""); // 记录到上传任务表 uploadTaskMapper.insert(uploadId, request); // 如果fileMd5已存在,可以直接返回"秒传"标记 return new UploadInitResponse(uploadId, isInstant); }上传分块接口:
@PostMapping("/upload/chunk") public ChunkUploadResponse uploadChunk( @RequestParam("uploadId") String uploadId, @RequestParam("chunkIndex") int chunkIndex, @RequestParam("file") MultipartFile chunk) throws IOException { // 校验uploadId存在 // 校验chunkIndex在[0, chunkCount)范围内 // 分块落盘到临时目录 Path chunkPath = Paths.get(tempDir + "/" + uploadId + "/" + chunkIndex + ".part"); chunk.transferTo(chunkPath); return success; }这里有个容易踩的坑:MultipartFile.transferTo()的路径要求目录必须已经存在,否则会抛IOException。而且transferTo在某些Servlet容器下是"复制"语义,文件最终会占用两份磁盘空间,如果频繁上传大分块,临时目录磁盘会被迅速打满。我们实践下来,改用手动流式写入更可控:
try (InputStream in = chunk.getInputStream(); OutputStream out = Files.newOutputStream(chunkPath)) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } }分块落盘后,建议把分块大小也记录下来,合并时做校验。因为理论上存在某个分块传输过程中被截断或篡改的情况(虽然概率低,但要知道怎么防)。
上传任务表我建议至少包含这些字段:uploadId、fileName、fileSize、chunkSize、chunkCount、fileMd5、currentChunkCount、status(INIT/UPLOADING/MERGED/FAILED)、createTime、updateTime。每次分块上传成功后,更新currentChunkCount,这样合并接口可以快速判断"分块是否齐了"。
4.2 分块临时存储与合并策略
分块存储的第一原则:和应用运行目录分离。不要放在Tomcat的webapps下,也不要放业务代码的当前目录,单独配置一个uploadTemp.dir,挂到独立的高性能磁盘上。芯片行业的文件动辄几十GB,临时磁盘必须预留比单文件最大体积大2到3倍的空间,因为我们可能要同时处理多个上传任务。
第二个原则:每个uploadId一个独立子目录,例如/data/uploadTemp/{uploadId}/。这样分块文件之间天然隔离,合并时遍历目录按序号读取,清理时也只需删除整个目录,干净利落。
我曾经见过一个反面案例:有人把所有分块直接平铺在同一个目录下,文件命名是{uploadId}_{chunkIndex}.part,表面上没问题,但当分块数量上万时,目录里文件数膨胀严重,Linux ext4文件系统单目录文件数超过几万之后,文件查找和创建性能急剧下降,合并时遍历目录也慢得离谱。所以分目录存放不是美观问题,是性能问题。
磁盘空间检查也必须在初始化接口就做。一个100GB文件,临时目录至少要能容纳100GB,如果有3个并发用户上传,就要300GB。我们是在初始化时先查磁盘剩余空间,不足直接返回错误码,避免用户上传到一半才发现磁盘满了,所有分块白传。
4.3 文件合并的正确姿势
合并接口的逻辑是:校验分块齐全数合法后,按序号从0到chunkCount-1依次读取分块文件,写入最终目标文件。
@PostMapping("/upload/merge") public MergeResponse merge(@RequestParam("uploadId") String uploadId) throws IOException { UploadTask task = uploadTaskMapper.selectByUploadId(uploadId); if (task == null || task.getStatus() != UPLOADING) { throw new BusinessException("任务不存在或状态非法"); } int expectChunks = task.getChunkCount(); int actualChunks = countChunks(tempDir + "/" + uploadId); if (actualChunks != expectChunks) { throw new BusinessException("分块数量不匹配,期望" + expectChunks + ",实际" + actualChunks); } Path targetPath = Paths.get(finalDir + "/" + task.getFileName()); try (OutputStream out = Files.newOutputStream(targetPath)) { byte[] buffer = new byte[8192]; for (int i = 0; i < expectChunks; i++) { Path chunkPath = Paths.get(tempDir + "/" + uploadId + "/" + i + ".part"); try (InputStream in = Files.newInputStream(chunkPath)) { int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } } } // 合并完成,对比文件大小和MD5 long mergedSize = Files.size(targetPath); if (mergedSize != task.getFileSize()) { throw new BusinessException("合并文件大小不一致"); } // 删除临时分块目录 deleteDirectory(tempDir + "/" + uploadId); return success; }合并过程的几个坑:
坑一:合并时用单线程顺序写,并发合并反而慢。有人想用多线程并行合并多个分块,同一个OutputStream多个线程同时写,最终文件字节顺序会乱。除非你用RandomAccessFile按偏移量seek写入,但那样对IO的随机读写压力反而更大,实测下来还是顺序流式写最稳定。一个40GB文件,机械盘上合并可能需要几分钟,SSD上几十秒,这个时间是值得等的。
坑二:合并后一定要校验。最可靠的校验是完整文件的MD5对比——前端计算的fileMd5和后端合并后重新计算的MD5一致才代表文件完整。很遗憾,大多数团队因为"完整文件MD5计算太慢"而省略这一步,结果某些分块静默损坏时根本发现不了。折中的做法是计算分块级别的MD5:前端上传每个分块时附上该分块的MD5,后端落盘时同样计算一次,不等就拒绝存储。这样单块校验的代价很小,合并时我们确信每一块都是完整的,就不需要整体MD5兜底了。
坑三:合并和清理必须做成事务性的。如果合并中途失败,临时分块不要急着删除,保留现场便于排查。只有在合并成功、大小校验通过之后,才允许删除临时目录。我们有个专有的做法:合并成功后先把临时目录重命名为{uploadId}.bak,异步延迟24小时删除,这样即使后续发现合并文件有异常,还能找回原始分块重新合并。磁盘够的话,这个保留策略非常值。
5. 断点续传与秒传的实现细节
5.1 断点续传的核心:已上传分块查询
断点续传是分块上传方案最有价值的副产品。用户上传到一半网断了、浏览器关了、电脑重启了,重新进入页面,系统问一句"这个文件之前传过没?传到哪里了?",然后只把缺失的分块补齐。这在芯片行业尤为重要——跨地域的设计公司与晶圆厂之间的传输链路,网络抖动是家常便饭,一次中断就重新传100GB,谁都受不了。
实现断点续传,前端在上传前先请求后端查询当前文件已存在的上传任务及其已接收分块列表:
@GetMapping("/upload/progress") public UploadProgressResponse progress( @RequestParam("fileName") String fileName, @RequestParam("fileSize") long fileSize, @RequestParam("fileMd5") String fileMd5) { UploadTask task = uploadTaskMapper.selectByMd5AndSize(fileMd5, fileSize); if (task == null) { return new UploadProgressResponse(null, Collections.emptySet()); } Set<Integer> uploadedIndexes = chunkRecordMapper.selectByUploadId(task.getUploadId()); return new UploadProgressResponse(task.getUploadId(), uploadedIndexes); }前端拿到uploadedIndexes后,切片时跳过已经上传过的分片序号。注意一个前提:已上传分块必须严格按chunkSize校验过大小。因为如果用户在另一个页面用不同的chunkSize上传过同一个文件,已存分块的大小和当前切片逻辑对不上,续传会出问题。稳妥的做法是任务表里存储每个任务创建时的chunkSize,续传时前端必须沿用同一个chunkSize,不一致就让用户重新开始。
5.2 秒传的实现逻辑
秒传在芯片行业不是炫技,是实打实的效率神器。典型场景:Fab厂同一款产品的版图文件,每周都要从设计公司传到厂内系统,文件名带版本号但内容完全相同的情况时有发生。如果每次都要重新传几十GB,纯属浪费带宽和时间。秒传的逻辑很简单:初始化上传时,前端把文件MD5发给后端,后端到文件索引表里查一下,如果发现相同MD5、相同大小的文件已经存在,直接返回"秒传成功",不会真的再传一遍数据。
这里有个细节:如果目标文件已经在MinIO里了,秒传只需把一条业务记录指过去即可,连物理复制都可以省。但如果业务要求每个文件有独立存储路径(比如审计要求保留不同时间点的独立副本),则需要做一次服务端复制,MinIO的copyObject可以做到内部复制,不经过网络传输,比自己下载再上传快几个数量级。
秒传的安全性兜底也要做。芯片行业的文件涉及机密性,两个不同用户上传相同MD5的文件,如果业务上不允许共享物理文件(比如数据隔离要求),就不要启用秒传。我们有一个电子签核系统,上传的合同文件必须每份独立落盘,物理位置不同,即使内容完全一致也不能共用存储。这种时候秒传逻辑要带上租户和业务域判断,不能只认MD5。
6. 常见问题与排查技巧实录
6.1 高频故障与解决方案速查表
实战中累积的坑,直接整理成速查表,方便大家遇到问题时对号入座。
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 前端切片后第一块就报错 | blob.size为0 | 检查file.size是否在切片过程中变化,或浏览器版本对File.slice的兼容问题 |
| 上传进行到一半,服务器返回413 | 网关或Nginx的client_max_body_size未调大 | 调大Nginx的client_max_body_size,建议设为分块大小的2倍 |
| 分块全部传完,合并时提示分块数量不匹配 | 有重复分块被覆盖计数,或某些分块上传失败但前端没感知 | 前端对每个chunk的上传结果做Promise.allSettled,失败重试3次后再报错;后端记录分块索引集合,判断重复提交 |
| 上传速度很慢,带宽利用率低 | 并发数过低,或者每块都等上一块的HTTP响应 | 把并发调大到8~16,用滑动窗口式流水线并发 |
| Tomcat报OutOfMemoryError | 分块大小过大,或线程池堆积了过多请求 | 分块压到8MB以内,调整Tomcat maxThreads,控制上传并发数 |
| 合并后的文件打不开,字节数对不上 | chunkIndex排序错误,或文件名排序导致第十块排到第二块前 | 合并时按整数index排序,不要按字符串排序 |
| 磁盘满了,大量分块残留 | 上传中断后临时目录未清理,或清理任务缺失 | 定时任务扫描uploadTemp目录,清理超过24小时未合并的任务目录 |
6.2 我在生产环境踩过的几个坑
第一个坑是Nginx超时与请求体大小。我们的上传链路是"浏览器 -> Nginx -> Java应用",只调整Tomcat的配置完全不够。Nginx默认的client_max_body_size是1MB,不调的话前端刚发第一块分块就会被Nginx拒掉。即使分块只有8MB,也要把client_max_body_size调到16MB以上,留有余量。还有proxy_read_timeout,默认60秒,内网环境下8MB分块没问题,公网环境下如果用户带宽小,一块传个一分钟很正常,超时后Nginx直接断开连接,前端收到的是网络错误。我们把proxy_read_timeout调到300秒,才彻底解决偶发中断问题。
第二个坑是分块上传的重复提交。前端用了重试机制后,同一个分块可能被提交两次。如果后端不做幂等处理,第二次提交会覆盖第一次的文件,看起来没问题,但如果你在分块落盘时记录了分块大小,出现一个分块两次大小不一致的情况,合并时就有隐患。我们的做法是:上传分块接口先检查该分块是否已存在,存在且大小一致则直接返回成功,不重复写盘;大小不一致则返回冲突错误,让前端重新计算这个分块的MD5。
第三个坑是文件名为中文或特殊字符。芯片行业的文件命名经常带设备编号、批次号、中划线、下划线、括号,甚至空格。前端上传时文件名经过URL编码,后端拿到后如果没有正确解码,落盘的文件名就乱了。我们的统一策略是:数据库存原始文件名,磁盘存uploadId对应的物理文件名,业务上要用原始文件名时从数据库读取。这样彻底绕开文件名编码和非法字符问题。
第四个坑是临时分块被恶意构造。上传接口是暴露在公网上的,攻击者可以伪造uploadId和chunkIndex,往任意目录写文件。我们做了两重防护:一是uploadId必须存在于任务表,二是chunkIndex必须在任务的[0, chunkCount)范围内,三是落盘路径必须强制拼到任务对应的临时目录下,不允许客户端传入任何路径内容。这三道关卡能挡住绝大多数路径穿越和越界写入。
第五个坑是合并大文件时的IO压力。一个100GB文件的合并是顺序写100GB数据,如果这个操作和正常的业务读写混在同一个磁盘上,很容易拖垮整个系统的IO性能。我们的做法是临时目录和最终文件目录尽量分盘,合并操作在凌晨低峰期批量执行,或者用独立的中转服务器做合并再转存MinIO。实测下来,把合并任务从应用服务器挪到独立存储服务器后,页面响应时间恢复到了正常水平。
6.3 前端Worker的一个常见陷阱
Web Worker上传方案里有一个非常容易被忽视的坑:Worker创建的XMLHttpRequest和主线程的请求共享同一个浏览器连接池,并发限制会互相影响。如果你主线程同时有其他接口调用,Worker上的上传并发会被挤压,表现就是上传速度突然掉下来。解决办法是把上传和业务接口的并发控制区分开,实测最有效的方案是给Worker设置独立的fetch keepalive策略,或者干脆在上传前停掉页面里不相关的轮询请求。
还有一个更隐蔽的问题:Worker长时间运行会被浏览器回收。Chrome对Web Worker有内存回收机制,如果Worker长时间不进行任何消息通信,可能被挂起。尤其是大文件上传几个小时,中间没有消息交互,Worker可能就"死"了。我们的对策是定期(每30秒)从主线程发一个心跳消息给Worker,Worker收到后回一个ack,既能保活,又能顺带确认上传线程还没崩。
7. 结尾:关于方案落地的一些体会
如果你要在一个新项目里落地分块上传,我建议不要一上来就追求把所有特性做完。先做最核心的"切片上传+后端落盘+合并校验",跑通之后再叠加断点续传、秒传、并发控制、Worker优化。每加一层特性,都要在真实网络环境下压一遍,尤其是芯片行业这种超大文件的场景,模拟环境里一切正常,到了现场千兆网和跨地域专网的表现天差地别。
我最想强调的一件事是:分块上传不是"写几个接口就行"的简单功能,它横跨前端、后端、网关、存储四层,任何一层没适配好,整体都会翻车。Nginx的body大小限制、Tomcat的线程数、磁盘的IO能力、临时目录的清理策略、浏览器的并发上限,每一环都可能成为瓶颈。设计时先把链路图画出来,明确每一层的职责和限制,再动手写代码,能省掉后面大量的返工。
最后分享一个我用了很久的小技巧:给上传功能做一份监控看板,记录每小时的活跃上传任务数、平均分块大小、合并耗时、失败原因分布。这份数据在系统出问题时救命——有一次用户反馈上传特别慢,我们一眼从看板上看出是某个网段的分块重试率飙高,排查下去发现是那台交换机的光模块故障,和代码一点关系都没有。上传这种重IO场景,没有监控数据,出了问题只能靠猜,有了数据,问题往往几小时就能定位。做技术方案,边界防线和可观测性,比炫技重要得多。