ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot大文件上传实战:分片、断点续传与秒传方案

SpringBoot大文件上传实战:分片、断点续传与秒传方案 芯片制造场景有个常被忽略却又特别头疼的问题就是各种大文件的上传。晶圆厂里的设备日志、AOI检测图片、量测数据、GDSII版图文件单个动辄几百MB甚至几个GB再加上产线数据要定时回传网络稍微抖一下上传就断了。这些年做了不少半导体相关的后端项目SpringBoot在这类场景里依然是最稳的底座但“大文件上传”这四个字如果只是把表单的max-file-size调大那坑会一个接一个。这篇就把我在实战里沉淀下来的分片上传、断点续传、秒传方案完整梳理一遍从设计思路到SpringBoot落地再到参数计算和问题排查都是可以直接抄作业的内容。1. 芯片制造场景下的大文件上传到底难在哪1.1 哪些文件需要上传文件有多大先搞清楚我们服务的对象是什么才能对症下药。芯片制造工厂里需要上传的文件类型和普通互联网项目里的用户头像、文档附件完全是两个量级。第一类是设备日志文件。半导体设备比如光刻机、刻蚀机、薄膜沉积设备会持续输出SECS/GEM标准日志有的设备日志一小时就能攒出几百MB。第二类是检测图像。AOI自动光学检测和电子显微镜拍出来的晶圆缺陷图一张高清原图可能就有50到100MB一批产品的检测结果打包后轻松超过2GB。第三类是设计数据。GDSII/OASIS版图文件在先进制程节点下单个文件超过10GB都很常见。再加上产线的上下游系统之间经常要做批量数据交换比如把一批lot的检测数据从工厂端上传到数据分析平台一次传输的总量可能达到几十GB。这种场景下上传已经不是“功能”而是“基础设施”稳定性比速度更关键。1.2 传统上传方式为什么撑不住很多人习惯直接拿SpringBoot的MultipartFile接收文件小文件没问题但到了大文件场景就会遇到一连串连锁反应。首先是内存问题。SpringBoot默认将上传文件写入内存超过阈值才落盘但不管是写内存还是写临时目录当你在一个请求里把几个GB文件全部load进来GC压力会直接拉满严重的直接OOM。之前见过一个线上事故设备日志上传接口在处理2GB文件时老年代直接打满整个服务挂了十几分钟。其次是网络中断问题。产线网络不是绝对可靠的一个千兆文件的传输过程时延波动、丢包、连接重置都可能发生。传统方式一旦中断整个文件就得从头再来。芯片制造场景里动辄数GB的文件重传一次时间成本是产线无法接受的。第三是业务无法感知进度。操作人员点击上传后面对一个不确定的等待时间不知道当前传了多少、还剩多久体验很差。更关键的是如果服务端处理超时被网关切断用户只会看到“上传失败”四个字完全没有排查依据。所以核心痛点可以归纳为内存压力大、断点无法续传、进度不可见、失败成本高。这四点决定了我们必须换一种架构思路。2. 从需求到方案分片上传的整体设计2.1 方案选型为什么选分片断点续传秒传针对上面四个痛点业界有一套成熟组合拳分片上传断点续传秒传。这套方案不是新鲜东西但用在芯片制造的文件传输场景里有几个天然优势。分片上传的核心逻辑是把一个大文件切成多个小块前端并发或串行地把每个分片单独上传到服务端全部传完后由服务端合并成完整文件。这样每个请求只承载一个小分片内存压力骤降也不容易触发网关的超时限制。某个分片失败只需要重传这个分片而不是整个文件这就是断点续传的基础。秒传则是利用文件指纹MD5或SHA-256实现的优化手段。前端在分片前先计算整个文件的哈希值上传前先请求服务端如果服务端已经存在相同哈希的文件直接返回成功根本不用真正传输。在芯片制造场景里同一型号设备、相同配方产生的日志常常内容完全一致秒传能把大量重复上传直接过滤掉节省的带宽和时间非常可观。2.2 整体流程拆解整套流程可以拆成四个阶段前端和后端各司其职第一阶段是文件预处理。前端读取文件信息计算文件的MD5哈希值大文件建议用增量计算避免一次性读入内存然后把文件切成指定大小的分片每个分片有自己的序号和哈希。第二阶段是上传预检。前端请求后端的上传初始化接口带上文件的哈希、文件名、总大小、总分片数。后端先查秒传表如果文件已存在就直接返回“已上传”如果不存在则创建上传任务记录返回uploadId给前端。第三阶段是分片传输。前端拿到uploadId后把每个分片依次或并发上传。后端每收到一个分片就做大小和哈希校验校验通过才落盘并在数据库记录分片状态。前端根据响应结果推进进度条失败的分片自动重试。第四阶段是合并收尾。所有分片上传完成后前端调用合并接口。后端检查分片完整性数量、大小、哈希确认无误后按顺序合并文件然后更新任务状态做文件归档和后续处理比如触发解析任务。2.3 接口与数据模型设计接口层面我习惯设计三个核心接口POST /api/upload/init初始化上传入参是fileName、fileSize、fileHash、chunkCount返回uploadId和是否秒传。POST /api/upload/chunk上传单个分片入参是uploadId、chunkIndex、chunkHash文件体用multipart/form-data承载。POST /api/upload/merge请求合并文件入参是uploadId、fileName服务端校验后完成合并。数据存储上我一般建两张表一张是upload_task表记录上传任务的元信息字段类型说明idbigint主键upload_idvarchar上传任务唯一IDfile_namevarchar原始文件名file_hashvarchar文件MD5/SHA-256file_sizebigint文件总大小chunk_countint总分片数chunk_sizebigint分片大小statusint状态1传分片、2合并中、3完成、4失败create_timedatetime创建时间update_timedatetime更新时间另一张是upload_chunk表记录每个分片的上传状态字段类型说明idbigint主键upload_idvarchar关联任务IDchunk_indexint分片序号chunk_hashvarchar分片哈希chunk_sizebigint分片实际大小statusint状态0未传、1已传upload_timedatetime上传时间这套模型简单可靠任何一个分片是否传过一查便知。断点续传时前端只需要向服务端查询已上传分片列表把没传的补传就行。3. 前端实现worker分片与并发上传3.1 使用Web Worker切分文件前端负责把文件切成一个个分片。这里有个关键点千万不要在主线程里用FileReader一口气把文件读进内存做切片遇到2GB文件页面直接卡死。正确做法是用Web Worker处理。Web Worker是浏览器提供的独立线程可以在后台执行耗时任务而不阻塞UI。我在项目里一般这样组织代码主线程拿到File对象后把文件句柄传给一个WorkerWorker内部用file.slice()方法按指定大小切分文件然后逐个生成分片的Blob对象并计算哈希。// 主线程 const worker new Worker(/workers/chunkWorker.js); worker.postMessage({ file: file, chunkSize: 5 * 1024 * 1024, fileHash: hash }); worker.onmessage (e) { const { type, data } e.data; if (type chunks) { uploadChunks(data.chunks, file.name, file.size, hash); } };Worker内部的切片逻辑很简单但要注意一点file.slice()在某些浏览器低版本上有兼容问题不过现代浏览器和Electron环境都支持得很好了。切片之后每个分片的Blob大小和预估大小不一定完全一致最后一片通常会小于设定值这是正常现象后端合并时要按实际大小处理不能按预设大小硬算位置。3.2 并发控制与重试机制分片准备好了下一步是上传。这里有一个常见的并发陷阱一次性把所有分片并发发出服务端压力大浏览器也会因为连接数限制产生大量排队。我建议做一个并发控制比如同时最多3到5个分片在传。async function uploadChunks(chunks, fileName, fileSize, fileHash) { const { uploadId, uploadedChunks } await checkUpload({ fileName, fileSize, fileHash, chunkCount: chunks.length }); const needUpload chunks.filter( (_, index) !uploadedChunks.includes(index) ); const concurrency 4; let cursor 0; const tasks new Array(concurrency).fill(null).map(async () { while (cursor needUpload.length) { const index cursor; const chunk needUpload[index]; await retryUploadChunk(uploadId, chunk, index, 3); } }); await Promise.all(tasks); await mergeRequest({ uploadId, fileName, fileHash }); }这段代码里有两个容易踩坑的地方。第一是获取uploadedChunks这一步在断点续传场景里很关键接口返回已经上传成功的分片索引列表前端跳过这些分片实现秒级续传。第二是retryUploadChunk这个函数我一般会做最多3次的重试只重试失败的分片并且每次重试间隔要递增避免网络抖动瞬间集中重试导致雪上加霜。3.3 进度反馈和暂停恢复进度反馈不能简单地用“已传分片数/总分片数”来算因为每个分片大小可能不同最后一个分片通常更小。更准确的做法是统计已成功上传分片的字节数总和除以文件总大小得到真实的百分比。在并发上传场景下我会用一个变量累计已完成分片的大小每次响应返回后更新。let uploadedBytes 0; const totalBytes file.size; function updateProgress(chunkSize) { uploadedBytes chunkSize; const percent Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)); postMessage({ type: progress, percent, uploadedBytes }); }暂停功能是断点续传的好搭档。用户点击暂停时不需要取消已经发出的请求直接把并发循环的开关关掉让后续分片不再上传即可。已经传完的分片会保留在服务端下次上传时通过checkUpload接口拿到uploadedChunks只补传缺失的分片。这个机制在工厂网络不稳定的环境下帮了大忙操作人员可以在网络恢复后继续而不是重来。4. SpringBoot后端实现分片接收、校验与合并4.1 项目基础配置SpringBoot做后端首先要处理好两个基础配置文件上传大小限制和临时目录。spring: servlet: multipart: max-file-size: 100MB max-request-size: 100MB有人看到这里会奇怪分片上传为什么还要设置单文件大小因为每个分片本质上还是通过MultipartFile接收的这个100MB的上限要能覆盖单个分片的大小。我建议分片大小设成5MB到20MB之间配置100MB的上限留足余量既能挡住异常超大请求又不妨碍正常分片。另外要注意临时目录的磁盘空间。SpringBoot处理multipart请求时会先把文件写入临时目录如果/tmp空间不足上传会直接报错。生产环境里我会通过配置把临时目录指到数据盘spring: servlet: multipart: location: /data/upload-tmp这一步很多人忽略等到磁盘满了才发现问题产线数据传输中断的代价我至今记得清楚。4.2 分片上传接口的实现分片上传接口是整个方案的核心亮点在于它的幂等性同一个分片被重复上传返回的结果一致不会产生脏数据。PostMapping(/chunk) public ResultChunkUploadVO uploadChunk( RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(chunkHash) String chunkHash, RequestParam(file) MultipartFile file) { // 1. 校验uploadId是否存在 UploadTask task uploadTaskMapper.selectByUploadId(uploadId); if (task null) { return Result.error(上传任务不存在请重新初始化); } // 2. 校验分片大小拒绝异常数据 if (file.isEmpty() || file.getSize() 0) { return Result.error(分片内容为空); } // 3. 如果该分片已上传过直接返回成功 if (chunkMapper.exists(uploadId, chunkIndex)) { return Result.success(ChunkUploadVO.alreadyUploaded(uploadId, chunkIndex)); } // 4. 保存分片到临时目录 String chunkPath chunkStoragePath(uploadId, chunkIndex); try { file.transferTo(new File(chunkPath)); } catch (IOException e) { log.error(分片保存失败, uploadId{}, chunkIndex{}, uploadId, chunkIndex, e); return Result.error(分片保存失败); } // 5. 校验分片哈希 String realHash FileDigestUtil.md5(new File(chunkPath)); if (!chunkHash.equalsIgnoreCase(realHash)) { FileUtil.delete(chunkPath); return Result.error(分片哈希校验失败); } // 6. 记录分片状态 chunkMapper.insert(uploadId, chunkIndex, chunkHash, file.getSize()); return Result.success(ChunkUploadVO.uploaded(uploadId, chunkIndex)); }这段代码看起来简单但里面有几个细节值得展开。幂等处理第3步特别重要。当网络超时时前端会重试同一个分片如果没有这个判断同一个分片会被写入两次甚至更多次合并时就会产生重复数据文件损坏。我遇到过一次现场就是没做幂等合并出来的文件大小比原文件大了几MB排查了很久才定位到是重复分片导致的。哈希校验第5步一定要做。芯片制造场景里一个光刻配方文件的数据是否准确直接影响产品良率我们不能依赖网络的错误校验应用层必须做端到端校验。分片哈希用MD5足够因为分片只有几MB碰撞概率极低而且计算成本可以接受。4.3 合并接口与完整性校验所有分片传完后前端调用合并接口。这个接口要做的第一件事不是立刻合并而是校验分片的完整性。PostMapping(/merge) public ResultMergeVO merge(RequestBody MergeRequest request) { UploadTask task uploadTaskMapper.selectByUploadId(request.getUploadId()); if (task null) { return Result.error(上传任务不存在); } // 事务状态控制防止并发请求重复合并 boolean locked uploadTaskMapper.compareAndSetStatus( request.getUploadId(), UploadStatus.CHUNK_UPLOADING, UploadStatus.MERGING); if (!locked) { if (task.getStatus() UploadStatus.COMPLETED) { return Result.success(MergeVO.completed(task.getFilePath())); } return Result.error(当前任务正在合并中请勿重复提交); } try { // 1. 校验分片数量 int uploadedChunks chunkMapper.countByUploadId(request.getUploadId()); if (uploadedChunks ! task.getChunkCount()) { return Result.error(分片数量不足请检查缺失分片); } // 2. 按顺序合并分片 String tempDir chunkStorageDir(request.getUploadId()); String outputPath finalStoragePath(task.getFileName()); try (FileOutputStream fos new FileOutputStream(outputPath)) { for (int i 0; i task.getChunkCount(); i) { File chunkFile new File(tempDir, String.valueOf(i)); try (FileInputStream fis new FileInputStream(chunkFile)) { byte[] buffer new byte[8192]; int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } } } // 3. 校验合并后的文件哈希与大小 String mergedHash FileDigestUtil.md5(outputPath); long mergedSize FileUtil.size(outputPath); if (!request.getFileHash().equalsIgnoreCase(mergedHash) || mergedSize ! task.getFileSize()) { FileUtil.delete(outputPath); return Result.error(合并文件校验失败请重新上传); } // 4. 更新任务状态清理分片 task.setStatus(UploadStatus.COMPLETED); task.setFilePath(outputPath); uploadTaskMapper.update(task); chunkMapper.deleteByUploadId(request.getUploadId()); return Result.success(MergeVO.completed(outputPath)); } catch (IOException e) { log.error(文件合并异常, uploadId{}, request.getUploadId(), e); uploadTaskMapper.updateStatus(request.getUploadId(), UploadStatus.FAILED); return Result.error(文件合并失败); } }合并时用compareAndSetStatus做乐观锁是个关键设计。前端如果因为超时重复点击合并按钮或者两个请求同时到达没有这个状态控制就会产生两个线程同时写同一个文件轻则文件损坏重则IO异常。我们用的数据库更新语句类似于UPDATE upload_task SET status 2 WHERE upload_id #{uploadId} AND status 1受影响行数为0说明状态已经被别的事务修改直接拒绝或返回已完成即可。4.4 异步处理与资源映射大文件合并完成后往往还要做后续处理。比如设备日志上传后要解析、AOI图片上传后要生成缩略图、版图文件上传后要归档到存储系统。这些操作如果放在合并接口里同步执行一个文件处理几十分钟接口会被长时间占用请求超时风险很高。我的做法是合并接口只负责完成文件合并和落盘然后发布一个Spring事件异步处理后续业务。用Async注解或者消息队列都可以重点是让上传接口快速返回让业务处理在后台跑。Async(uploadExecutor) EventListener public void handleFileUploaded(FileUploadedEvent event) { // 解析文件、生成缩略图、归档等后续业务 fileProcessService.process(event.getFilePath(), event.getFileType()); }还有一个容易被忽略的点上传后的文件怎么让前端访问。SpringBoot默认只处理静态资源目录下的文件生产环境里大文件一般存储在独立的数据盘或对象存储里不在classpath下。这时需要配置资源映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceLocations(file:/data/uploads/); } }这样上传到/data/uploads/目录下的文件可以通过http://host/files/xxx直接访问。但要注意大文件走静态资源映射时SpringBoot默认的异步请求超时时间可能会成为瓶颈必要时调整spring.mvc.async.request-timeout。5. 防OOM与性能调优参数背后的计算逻辑5.1 分片大小怎么定分片大小是整个系统里最能体现权衡的参数。太小了分片数量多请求次数多管理开销大太大了单个请求承载的数据量大容易触发网关超时和服务端内存压力。我一般按以下逻辑选如果网络环境稳定、服务端和客户端都在同一内网分片可以设大一点比如20MB如果是跨公网传输、网络波动明显分片设小一点比如5MB这样单个分片失败重试的代价小。芯片工厂里很多系统是内网部署我常用10MB作为默认值。假设一个2GB文件10MB分片就是2048个分片。并发4个请求每个请求100MB的multipart上限服务端即使4个请求同时到达内存占用约40MB文件内容写临时目录占的是磁盘不是堆GC压力完全可以接受。5.2 内存与线程池配置“大文件分片上传会OOM嘛”是出现频率很高的问题。我的结论是只要按照分片上传的思路做OOM是可以避免的但有几个地方必须注意。第一是避免把MultipartFile直接转byte[]。有人习惯写了file.getBytes()这一个分片10MB如果有4个并发就是40MB堆内存再叠加其他业务堆很容易被打满。正确做法是用file.transferTo()直接落盘文件内容不进堆内存。第二是自定义线程池。Spring的Async默认线程池是SimpleAsyncTaskExecutor每次新建线程高并发下极其危险。我配置了一个专门的上传任务线程池Bean(uploadExecutor) public Executor uploadExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix(upload-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }线程数不是越大越好。大文件合并是磁盘IO密集型操作线程太多反而会因为频繁切换上下文和磁盘竞争导致性能下降。实测下来4到8个线程在机械硬盘和SSD上都有不错的表现。第三是JVM参数。如果服务上还要跑其他业务建议给上传服务单独设置堆内存比如-Xmx2g -Xms2g。分片上传场景下堆内存不需要设得特别大关键是避免堆外内存和临时文件堆积。5.3 存储选型与磁盘规划存储是整个方案里容易被低估的一环。分片文件在合并前是散落在一级临时目录下的合并完成后原始分片需要清理否则磁盘会被占满。我经历过的教训是上传临时目录和最终存储目录必须分开并且两个目录都做磁盘空间监控。分片目录可以放在HDD上成本低最终文件如果要频繁访问建议放SSD。还要预留至少文件总大小2倍的空间因为分片在合并完成前不会删除2GB文件至少需要4GB的临时空间。文件命名也要有规划。分片文件名用uploadId加chunkIndex来组织避免同名文件互相覆盖。最终文件名建议加上时间戳和随机串防止不同任务产生同名文件。国内项目里中文文件名是常态存储时可以保留原始文件名但物理文件路径最好用拼音或ID命名避免编码问题。6. 常见问题与排查实录6.1 合并后的文件损坏或大小不对这个问题的根因通常是并发上传时数据库状态和磁盘文件不一致。排查路径先看分片表确认有没有重复分片再看临时目录确认分片文件实际大小和数据库记录是否一致最后检查合并代码里有没有遗漏某个分片。还有一个隐蔽原因分片上传接口没有做幂等校验前端重试时重复写入了分片文件。日志里能看出来同一个chunkIndex在后端insert时没有走“已存在直接返回”的逻辑。解决方式是补上幂等判断同时在合并前用总数和总字节数双重校验。6.2 断点续传失效每次都要从头传排查思路是验证checkUpload接口返回的uploadedChunks是否准确。如果该接口没有查数据库而是每次都返回空列表前端自然无法跳过已传分片。另一个案例是upload_task表被定期清理上传中断超过一定时间后任务记录被删除续传找不到uploadId。解决方式是延长任务保留时间或者增加“预约续传”接口在上传前主动检查相同文件哈希的未完成任务。6.3 Nginx/网关超时即使做了分片合并接口在大文件场景下依然可能耗时较长。如果合并一个2GB文件需要几十秒而Nginx的proxy_read_timeout默认只有60秒就可能超时。解决的思路有三层第一层是优化合并速度比如用RandomAccessFile按分片大小直接写入对应偏移位置代替逐字节流合并性能能提升不少。第二层是调大代理超时时间location /api/upload/ { proxy_read_timeout 300s; proxy_send_timeout 300s; client_max_body_size 100m; }第三层是异步化把合并操作放入消息队列前端通过轮询任务状态来感知结果。第三种方案对用户体验最友好但实现成本也最高。6.4 并发合并冲突多个设备同时上传多个大文件或者同一文件被重复提交合并请求就会出现并发问题。我在合并接口里用数据库乐观锁解决了同一任务重复合并的问题但不同任务写同一个文件名的场景也会触发覆盖冲突。解决方案是在生成最终文件路径时加入uploadId或UUID保证物理文件路径唯一。文件名可以单独存储在数据库字段里下载时通过Content-Disposition头还原原始文件名。7. 我的几点实操心得这套方案在芯片制造项目里已经稳定运行了很长时间有些经验是踩过坑才悟出来的在这里一并分享。第一上传功能一定要有完善的日志。大文件上传链路长涉及前端、后端、存储、网络多个环节问题定位全靠日志。我在关键路径上都打了监控日志包括分片接收耗时、哈希校验耗时、合并耗时以及每次请求的uploadId和chunkIndex。拿不到日志出了问题只能靠猜。第二秒传的哈希计算要放在Worker里做不能阻塞主线程。刚开始我把文件哈希放在主线程算2GB文件算出来要好几十秒页面直接卡死用户反馈“上传页面怎么点不动”。后来挪到Worker里体验立刻好多了。第三一定要给上传接口做限流和鉴权。虽然分片上传减轻了单请求压力但攻击者如果伪造大量上传请求依然能把磁盘打满。建议至少加一个简单的Token校验并且对每个uploadId的并发请求数做限制。第四不要忽视测试环境与生产环境的差异。测试环境是千兆内网生产环境如果有跨地域传输延迟和丢包率完全不同。压测一定要在真实网络环境中做否则上线后会收到一堆“上传失败”的工单。大文件上传不是高深的技术但确实是一个从设计到落地都充满细节的工程。把分片、断点续传、秒传、异步合并、资源映射这些环节逐个打磨到位SpringBoot就能扛住芯片制造场景里最苛刻的传输需求。
返回列表