尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

大文件上传必知:分块上传与断点续传的Spring Boot实战

大文件上传必知:分块上传与断点续传的Spring Boot实战 1. 为什么大文件上传必须走分块与断点续传1.1 大文件上传的核心痛点入行Java后端第七年我最怕听到的一句话就是“帮我在网页上加个上传功能文件也不大就几个GB”。一个普通的文件上传接口传几十MB问题不大一旦体积上升到GB级你会发现浏览器的上传请求几乎必挂要么等上十几分钟最后告诉你“连接已断开”要么服务器直接返回一个让人摸不着头脑的502要么后端在读取流的时候内存撑不住直接OOM。很多人第一反应是“把服务器的超时时间调大把Tomcat的maxPostSize调大”调完确实能撑一会儿但用户一旦中途切个Wi-Fi、锁个屏、或者不小心点了刷新一切就得从头再来。我见过最绝望的场景是一个设计团队传一段2.7GB的视频素材传到98%的时候网断了然后他们又花了一个多小时重新传第二遍。这种体验放在任何一个面向真实用户的系统里都是灾难级的。根本原因在于传统表单上传把整个文件当成一个连续的字节流一次HTTP请求吞吐全部内容。网络只要抖动一下请求上下文丢失服务端拿到的就是残缺数据而且无从恢复。要解决这个问题思路必须改变既然一个文件传不完那就把它切成很多个小块每一块独立上传、独立确认哪一块失败了只重传那一块而不是整个文件重新来一遍。这就是分块上传。在分块基础上再记录哪些块已经传成功下一次再从失败的块继续传这就是断点续传。1.2 分块上传与断点续传的关系这两个概念经常被混着说但我习惯这样区分分块上传解决的是“怎么把一个大的上传任务拆成若干个小任务并最终拼回原文件”的问题。它负责切分、传块、合并这三件事。断点续传解决的是“在分块上传过程中如何记住进度在任务中断后接着传”的问题。它负责进度的记录、查询、以及跳过已传分块。所以断点续传是建立在分块上传之上的。如果你只做分块但每次重传都让用户重新选文件、然后所有分块全部重传那依然没有解决“白干”的问题。只有把分块状态持久化到服务端客户端在发送分块之前先问一次“我已经传到哪了”才能真正意义上做到断点续传。接下来我会用一套我实际在项目中验证过的方案从前端切分到后端合并完整讲一遍代码实现和踩坑记录。技术选型上前端使用原生JavaScript的File API后端使用Spring Boot。不引入极端复杂的框架但代码可以直接抄进你的项目里改改就能跑。2. 前端分块策略切片、进度管理与重试2.1 使用File.slice()切割文件浏览器端的File对象继承了BlobBlob自带一个slice()方法可以按字节范围切出一段新的Blob。这是实现分块上传的基础不需要任何第三方插件。我习惯定义一个CHUNK_SIZE常量单位是字节。实际项目中我常用5 * 1024 * 1024也就是5MB一块。为什么选5MB后面第五部分我会专门讲现在先看切块代码。const CHUNK_SIZE 5 * 1024 * 1024; // 5MB function sliceFile(file) { const chunks []; let start 0; while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size); const blob file.slice(start, end); chunks.push({ blob: blob, index: chunks.length, start: start, end: end, size: blob.size }); start end; } return chunks; }这里有个容易被忽略的细节file.slice(start, end)的第二个参数是结束位置但它是“不包含end位置”的切片所以最后一块的end直接用file.size不会越界。计算分块总数时可以不用循环里数个数直接Math.ceil(file.size / CHUNK_SIZE)更快但用循环顺便把每块的元数据准备好更常规。切出来的blob不能直接拿来当普通字符串放进JSON里它必须作为FormData的一个部分发送。也就意味着每个分块对应一个独立的multipart请求。2.2 并发控制与进度上报把所有分块一次性全部发出去浏览器和服务器都会很痛苦。正确的做法是限制同时上传的并发数。我有一个常用的轻量级并发控制器一次最多同时传3个分块async function uploadChunksWithConcurrency(chunks, fileId, concurrency 3) { let index 0; let uploaded 0; async function worker() { while (index chunks.length) { const current index; const chunk chunks[current]; const formData new FormData(); formData.append(fileId, fileId); formData.append(chunkIndex, current); formData.append(totalChunks, chunks.length); formData.append(chunk, chunk.blob, chunk- current .part); try { const resp await fetch(/api/upload/chunk, { method: POST, body: formData }); if (!resp.ok) throw new Error(HTTP resp.status); uploaded; const percent Math.round((uploaded / chunks.length) * 100); updateProgress(percent); } catch (err) { // 上传失败这里需要根据失败原因决定重试还是放弃 await retryChunk(chunk, current, fileId); } } } const workers Array.from({ length: concurrency }, () worker()); await Promise.all(workers); }表面上我们用fetch发请求但其实很多细节都藏在retryChunk里。重试不能无脑重试建议区分错误类型网络错误TypeError、AbortError可以重试通常间隔1秒、2秒、4秒这样的指数退避。服务器返回4xx错误通常不要重试因为很可能是参数写错或者文件状态有问题。服务器返回5xx错误可以重试但也要限制次数我一般最多重试3次。下面是一个简化但可用的重试实现async function retryChunk(chunk, index, fileId, maxRetries 3) { let attempt 0; while (attempt maxRetries) { const delay Math.pow(2, attempt) * 1000; await sleep(delay); try { const formData new FormData(); formData.append(fileId, fileId); formData.append(chunkIndex, index); formData.append(totalChunks, chunkCount); formData.append(chunk, chunk.blob, chunk- index .part); const resp await fetch(/api/upload/chunk, { method: POST, body: formData }); if (resp.ok) return; } catch (e) { attempt; } } throw new Error(分块上传失败超出最大重试次数: index); }进度上报不要每次都去算文件整体百分比因为断点续传场景下已经传过的分块不需要再传直接从服务端查一个“已上传分块数”加上当前本次新传的数量再除以总量才是最准确的。2.3 断点续传的前置操作秒传与已传分块查询断点续传不是“接着上次的进度继续传”这么简单它背后有两个前置接口校验文件是否已完整上传秒传用户选了一个文件如果服务器上已经有完全相同的文件内容那就直接提示“上传成功”一秒完成。查询该文件已上传了哪些分块如果文件没传完返回已成功接收的分块索引列表前端跳过这些块只传缺失的块。要做到这两件事首先需要给文件算一个唯一标识。我用的方案是取文件的名称、大小、最后修改时间拼在一起后用SHA-256生成一个字符串作为fileId。这种方式不要读整个文件算MD5否则大文件在算MD5的时候就会卡住。虽然理论上有哈希冲突但文件名加大小加修改时间的组合在业务中已经足够稳。伪代码如下async function generateFileId(file) { const text ${file.name}-${file.size}-${file.lastModified}; const bytes new TextEncoder().encode(text); const digest await crypto.subtle.digest(SHA-256, bytes); return Array.from(new Uint8Array(digest)).map(b b.toString(16).padStart(2, 0)).join(); }然后在上传前调用查询接口async function checkUploadStatus(fileId) { const resp await fetch(/api/upload/status?fileId${fileId}); const data await resp.json(); return { uploaded: data.uploadedChunks, // 已上传的分块索引数组 completed: data.completed // 是否已经完整上传 }; }如果completed为true直接显示“秒传成功”。否则把uploaded当成一个Set在切块完成后过滤掉已存在的索引剩下的就是要传的。到这一步前端的分块逻辑已经成型。但前端这一切都要依赖后端的一套配套接口接下来看Java后端怎么写。3. Java后端的分块接收与合并实现3.1 分块接收接口设计后端我用Spring BootMultipartFile本身就是针对文件上传的封装tomcat默认最大单个文件大小是1MBSpring Boot的spring.servlet.multipart.max-file-size默认是1MB。所以第一步必须是放开限制spring: servlet: multipart: max-file-size: 10MB max-request-size: 100MB这里max-file-size要大于你定的分块大小我前端切5MB后端就配10MB留足余量。但max-request-size不能跟着前面一起改得特别大因为一旦某个分块请求体超过限制整个请求会被拒绝而我们要的是“单个请求只承载一个块”100MB的请求上限在并发场景下其实是对服务的一种保护。接收分块的接口我用了最直接的写法RestController RequestMapping(/api/upload) public class ChunkUploadController { private final ChunkStorageService storageService; public ChunkUploadController(ChunkStorageService storageService) { this.storageService storageService; } PostMapping(/chunk) public ResponseEntityMapString, Object uploadChunk( RequestParam(fileId) String fileId, RequestParam(chunkIndex) int chunkIndex, RequestParam(totalChunks) int totalChunks, RequestParam(chunk) MultipartFile chunk) throws IOException { storageService.saveChunk(fileId, chunkIndex, chunk); MapString, Object result new HashMap(); result.put(received, chunkIndex); result.put(completed, storageService.isAllChunksUploaded(fileId, totalChunks)); return ResponseEntity.ok(result); } }这个接口做的事情很单纯把传上来的块存起来然后判断是不是所有块都已经齐了。fileId、chunkIndex、totalChunks这三个参数是前端在FormData里带上来的缺一不可。如果前端不带totalChunks合并时就不知道到底该等多少个块。3.2 分块临时存储与磁盘空间管理分块不能直接存内存二话不说先落盘。我在ChunkStorageService里维护一个临时目录结构大概是tempDir/ {fileId}/ 0.part 1.part 2.part每个文件一个目录目录名就是fileId目录下存放的是按索引命名的.part文件。主要代码如下Component public class ChunkStorageService { private final Path tempRoot; public ChunkStorageService(Value(${upload.temp-dir}) Path tempDir) { this.tempRoot tempDir; Files.createDirectories(tempRoot); } public void saveChunk(String fileId, int chunkIndex, MultipartFile chunk) throws IOException { Path chunkDir tempRoot.resolve(fileId); Files.createDirectories(chunkDir); Path target chunkDir.resolve(chunkIndex .part); chunk.transferTo(target.toFile()); } }注意一个很重要的点MultipartFile.transferTo()在传一个已有同名文件时不同平台行为不同。Windows下如果目标文件已被占用会抛异常Linux下会直接覆盖。为了避免这种不确定性我更建议先Files.createDirectories然后transferTo之前删掉已存在的目标文件Files.deleteIfExists(target); chunk.transferTo(target.toFile());至于磁盘空间管理这是很多人忽略的。一个大文件切成了几百个块每个块5MB完整传完之前临时目录里全是碎文件。如果你在做批量导入功能多人同时上传磁盘瞬间塞满的例子我见过不少。我建议做两件事每个fileId目录下放一个.meta文件记录块总数、原始文件名、最后更新时间。用一个定时任务扫描临时目录超过24小时没有新分块写入的目录直接整体删掉。定时清理代码大概这样Scheduled(cron 0 0 3 * * ?) public void cleanExpiredUploads() throws IOException { long maxAge TimeUnit.HOURS.toMillis(24); try (DirectoryStreamPath dirs Files.newDirectoryStream(tempRoot)) { for (Path fileDir : dirs) { if (Files.isDirectory(fileDir)) { long lastModified Files.getLastModifiedTime(fileDir).toMillis(); if (System.currentTimeMillis() - lastModified maxAge) { deleteDirectoryRecursively(fileDir); } } } } }3.3 文件合并与完整性校验所有分块都传完之后就进入合并环节。合并的原理很简单按索引顺序把所有.part文件的内容追加到最终文件里。但在合并之前必须校验分块数量是否和totalChunks一致少一个都不能合并。合并代码public Path mergeChunks(String fileId, String originalFilename, int totalChunks) throws IOException { Path chunkDir tempRoot.resolve(fileId); Path target tempRoot.getParent().resolve(uploads).resolve(fileId _ originalFilename); Files.createDirectories(target.getParent()); try (OutputStream out Files.newOutputStream(target, CREATE, TRUNCATE_EXISTING)) { for (int i 0; i totalChunks; i) { Path chunk chunkDir.resolve(i .part); if (!Files.exists(chunk)) { throw new IOException(缺少分块: i); } Files.copy(chunk, out); } } // 合并完成后清理分块目录 deleteDirectoryRecursively(chunkDir); return target; }合并后还要做一次完整性校验。既然前端给了totalChunks且每块大小基本固化那么最终文件大小理论上应该等于所有分块大小之和。更严谨的做法是前端在上传第一个分块时把整个文件的MD5传过来合并后计算MD5比对。不过刚才说了计算大文件MD5很慢所以我在实际业务里采用“分块级MD5校验 文件大小校验”的组合方式前端每传一个分块可以顺便把这一块的MD5传上来存到.meta文件里合并前逐个校验。合并后只对比最终文件大小和原始文件大小差一个字节都视为异常。这一步能把绝大多数磁盘写坏、并发干扰、网络丢包引发的合并问题拦住。4. 断点续传的落地细节状态记录与异常恢复4.1 上传任务状态记录方案断点续传能否成立核心在于“状态是否存在、是否可靠”。我用的是最轻量的方案直接把状态记录在临时目录的文件名和meta文件里。每个分块文件存在就代表这个分块已上传成功。那么“已上传分块索引列表”就可以直接通过遍历目录下的.part文件得到public ListInteger getUploadedChunks(String fileId) throws IOException { Path chunkDir tempRoot.resolve(fileId); if (!Files.exists(chunkDir)) return Collections.emptyList(); ListInteger indexes new ArrayList(); try (DirectoryStreamPath stream Files.newDirectoryStream(chunkDir, *.part)) { for (Path path : stream) { String fileName path.getFileName().toString(); indexes.add(Integer.parseInt(fileName.substring(0, fileName.indexOf(.)))); } } Collections.sort(indexes); return indexes; }这种方案的好处是不需要额外引入Redis或者数据库坏处是如果服务实例有多个临时目录各自独立状态就不共享。如果项目已经上了多节点部署建议把分块状态存到RedisfileId为key已上传块索引用Set结构存储。数据结构不影响整体逻辑只影响你“查询状态”那一步的写法。本篇示例聚焦单机方案多节点的扩展我放到第五部分讲。meta文件的内容可以是一个JSON{ originalFilename: demo.mp4, totalChunks: 200, fileSize: 1048576000, createTime: 2025-01-15T10:00:00 }我一般使用Jackson来读写这个文件保存时机是在第一个分块上传时初始化meta之后每次上传分块只更新分块自身的最后访问时间避免频繁写meta造成IO开销。4.2 分块去重与并发安全前端在断点续传时会跳过已传分块但网络丢包后前端重试或者用户连续点了两次上传都可能导致同一个分块被重复提交。后端必须处理重复分块否则合并时会出问题。最简单的去重方式就是在saveChunk方法里无条件覆盖同名.part文件。同一个分块的索引相同内容也相同因为前端切块稳定覆盖不会产生数据错误。但如果两个请求同时写同一个分块文件可能会产生“半个文件覆盖半个文件”的情况。为了解决这个问题我给保存分块的方法加了一个“按分块文件加锁”的机制。在单机环境里可以用synchronized锁一个字符串但直接锁整个方法会降低并发性能。更稳妥的是使用ConcurrentHashMap做细粒度锁private final ConcurrentMapString, Object chunkLocks new ConcurrentHashMap(); public void saveChunk(String fileId, int chunkIndex, MultipartFile chunk) throws IOException { String lockKey fileId : chunkIndex; Object lock chunkLocks.computeIfAbsent(lockKey, k - new Object()); synchronized (lock) { Path chunkDir tempRoot.resolve(fileId); Files.createDirectories(chunkDir); Path target chunkDir.resolve(chunkIndex .part); Files.deleteIfExists(target); chunk.transferTo(target.toFile()); } }锁的粒度是“同一个文件的同一个块”不同文件、不同块之间完全不互相阻塞。上传结束后要及时从chunkLocks里移除不再需要的锁对象否则长时间运行会有内存泄漏风险。我通常会在合并完成后执行chunkLocks.remove(fileId : chunkIndex)或者干脆在清理临时目录的时候一并清理。4.3 中断恢复流程把中断恢复的整套流程串起来前端逻辑应该是这样的用户选择文件前端计算fileId。请求/api/upload/status拿到uploadedChunks数组和completed状态。如果completed为true直接结束。否则过滤掉已上传分块并发上传剩余分块。当最后一个分块上传成功后前端额外调用/api/upload/merge触发合并。合并成功后服务端清理临时分块返回最终文件地址。这里有个细节需要强调不要在分块上传接口里自动触达合并。原因是如果前端有多个请求并发上传最后一个请求到达的时候其他分块可能还没写盘完成这个“最后一个”只是逻辑上的最后顺序物理上其他块的写入可能还在缓存里没刷盘。如果此时合并极容易发现缺块。稳妥做法是前端在所有上传任务Promise.all结束后显式调用一次merge接口。merge接口内部先检查所有块是否存在缺块直接返回明确错误前端收到错误后可继续补传缺失块再重新合并。5. 生产环境级优化与踩坑记录5.1 分块大小与并发数怎么定分块大小和并发数没有标准答案但有几个约束条件需要权衡分块太小请求数量巨大HTTP握手和multipart解析的开销会占很大比例整体速度反而慢。比如1GB文件切成1MB就有1024个请求每个请求几个毫秒的固定开销累积起来非常可观。分块太大失去了分块的意义网络抖动的重试成本变高服务端内存压力也会增大。并发太高浏览器TCP连接数有限服务器线程被占满带宽被争抢反而导致重传增多。我测试下来的经验值文件大小推荐分块推荐并发100MB ~ 500MB5MB3500MB ~ 2GB10MB32GB以上20MB2 ~ 3并发数不建议超过5。超过5之后绝大多数家庭宽带和普通服务器都会成为瓶颈。很多开发者会为了“更快”把并发调到10结果上传总耗时反而增加了30%因为大量分块在排队等待TCP窗口超时重试又占了额外带宽。5.2 经典问题内存溢出、超时、重复块、合并失败内存溢出。这是最常见的。Spring Boot接收multipart请求时如果没限制max-file-sizeTomcat会把整个请求体先读进内存。即使你限制每个分块5MB如果并发上传10个内存占用也能到50MB。更大的隐患是某些代码里习惯把MultipartFile.getBytes()拿出来再处理一个5MB的块瞬间变成两个5MB的字节数组在内存里。我建议所有对分块的处理都直接走transferTo()落盘永不调用getBytes()。前端请求超时。不要让fetch等一个分块超过2分钟。如果服务器处理慢前端会超时中断然后触发重试。实际上大部分上传慢是因为带宽分块传输本身不会在服务器端停留太久。如果发现单个分块上传经常超过60秒说明分块大小或带宽有问题优先调整分块大小而不是调超时时间。重复块导致合并后文件损坏。这个我在排查一个线上问题时遇到过。前端把已传块查询接口返回的索引处理成了字符串类型的Set和后端返回的整数类型对不上导致已传块被误判为未传整个文件重新传了一遍但合并时某个块因为并发覆盖写坏了。最后解决办法是统一用整数并在合并前校验每个文件的大小是否符合预期。合并失败。除了缺块另一个隐蔽原因是磁盘满了。合并时如果目标盘剩余空间不够Files.copy会在途中抛IOException此时temp目录里的分块还在但最终文件只有残缺的前半部分。我在合并代码里做了“先写临时合并文件成功后原子改名”的策略Path tempResult target.resolveSibling(target.getFileName() .tmp); // 写入tempResult Files.move(tempResult, target, StandardCopyOption.ATOMIC_MOVE);这样即使合并中途崩了也不会留下一个看起来正常但内容不完整的最终文件。5.3 我实测后的建议与后续扩展如果这个功能要长期演进有几个方向值得考虑接入Redis保存上传进度。单机方案用文件系统简单但无法支撑多实例。改成Redis后分块状态查询速度快一个数量级且天然支持多节点共享。合并后同步到对象存储。我现在项目中最终文件不会留在应用服务器本地而是合并完成后立刻上传到MinIO或OSS应用服务器只保留一卷临时数据。上传完成后再把临时目录清掉磁盘压力可控。视频转码等后处理。大文件上传往往伴随着后续处理比如视频转码、图片压缩。合并完成后会往消息队列投递一条任务异步处理避免接口长时间占用。最后再分享一个我自己踩过的坑不要相信浏览器的progress事件里的total。在分块上传模式下progress事件给的是当前请求的进度不是整个文件的进度。整体进度只能由“已成功分块数 / 总分块数”计算否则用户看到进度条卡在99%不动然后又跳回50%体验极差。我自己在早期版本就犯过这个错改成分块计数后进度条平滑多了。大文件上传这个场景代码写起来不难但想在生产环境稳如老狗还是得把细节抠细。上面这套方案我在内部系统里跑了两年传过最大的文件是8.7GB的设计源文件再也没有出现过从头再传的投诉。你完全可以照着这个思路搭一版然后把分块大小、并发数根据你的网络环境重新调一调就能跑起来了。
返回列表