ARTICLE DETAIL

资讯详情

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

Java后端分块上传与断点续传实战:从原理到Spring Boot实现

Java后端分块上传与断点续传实战:从原理到Spring Boot实现 你有没有遇到过这种情况向后台提交一个几百MB的培训视频上传进度条爬到80%突然断网重新上传只能从0开始。做业务系统这些年我见过太多项目卡在大文件上传这个坎上——文件越大失败成本越高根本原因就是缺少分块和断点续传机制。这篇文章我会从原理讲到落地用Java后端配合前端切片完整实现一套可用的分块上传与断点续传方案。适合正在做管理系统、资源平台、内部工具并且需要处理百兆以上文件的Java开发。看完之后你不仅能照搬代码还能明白每一步为什么这么设计。1. 大文件上传的痛点为什么不能一把梭1.1 传统POST上传的三个硬伤直接用input typefile配合multipart/form-data提交对几百MB甚至几GB的文件来说几乎是灾难。第一Web容器和框架默认对请求体有限制Spring Boot的spring.servlet.multipart.max-file-size默认只有1MBmax-request-size默认10MB。也就是说超过这个大小请求直接被拦截用户看到500错误。你说我可以改配置改成1GB甚至无限但这就引出第二个问题——内存与临时文件。当请求进入后端Servlet容器会把上传内容先写到内存或临时目录。Spring Boot在拦截到整个请求之前要缓冲解析每个Part。文件越大JVM的内存压力和磁盘IO压力越大。生产环境遇到过频繁Full GC最后查出来就是有人在传2GB的压缩包直接把堆挤爆了。第三个问题最致命网络中断后全量重传。TCP连接一闪断前端拿到异常后端收到的半截文件只能丢弃。用户气呼呼地重新上传实际上之前传的99%全白费了。带宽越大、文件越大这种浪费越明显。这就是需要分块和断点续传的根本原因——把一个大任务拆成若干个小任务每个小任务独立成功已成功的任务可以复用。1.2 分块和断点续传到底解决了什么分块上传的核心思路很简单前端把文件按固定大小切成很多段例如每段2MB然后一段一段地上传。后端把每一段暂存下来等所有段都到齐了再按顺序拼成完整文件。断点续传则是建立在这种机制之上的副产品因为每一块都有独立编号服务端只需要记录哪些编号已经收到了。网络断了下次重新上传时先问服务端一句哪些块已经有了前端跳过这些块只补传缺少的部分。这样即使断了十次每次都只补传几MB而不是从头再来。顺带还带来了两个额外收益一是前端可以显示真实的百分比因为每个块上传完成状态很清楚二是可以做秒传如果服务端发现同样的文件内容已经存在直接返回成功根本不需要真的传。当然秒传依赖文件特征值的计算后面会细说。1.3 不是所有上传场景都需要分块我也见过有的项目文件只有几MB也强行分块前端写得头大后端一堆临时文件最后合并还出问题。分块上传本身是有复杂度的。我的经验是内网环境、文件不超过50MB、网络还算稳定直接用普通上传调大max-file-size就够了。一旦跨公网、文件超过100MB、用户可能在弱网环境操作或者你希望支持暂停续传那就老老实实走分块方案。下面这套设计就是冲着后面这类场景去的。2. 分块上传的整体设计先把地基打牢2.1 核心流程梳理所有分块上传方案本质上都是下面这条流水线前端计算文件的唯一标识比如MD5带着文件名、总大小、分块大小去请求后端后端返回一个uploadId。前端把文件切分成N个分块按索引依次上传。后端为每个uploadId建立一个临时目录将收到的分块保存为独立的part文件。前端所有分块传完后调用合并接口。后端校验分块数量、大小排序后按顺序写入最终文件然后删除临时目录。这个流程里最核心的设计决策有两个唯一标识怎么生成分块大小怎么选。这两个定下来其他都是细节。2.2 文件唯一标识为什么优先用MD5我见过不少项目直接用UUID.randomUUID()当上传ID接口是写完了但断点续传是假的——因为你换个时间重新上传UUID变了服务端不认为是同一个文件已经传过的分块全用不上。真正要实现断点续传uploadId必须能代表同一个文件。最可靠的做法是用文件内容的MD5值再加上文件大小做后缀例如md5(file) _ file.length()。这样即使文件名不同只要内容一致就能命中同一个上传任务也方便秒传。当然前端算MD5是有代价的。一个1GB的文件在全算的情况下可能需要几秒并且要分块读取文件到内存。但注意前端算MD5和上传分块一样也是分块读取不会把整个文件塞进内存。我用crypto.subtle.digest或者SparkMD5分片计算实测2GB文件大约3~5秒这个耗时比重新上传整个文件要划算得多。2.3 分块大小怎么定分块大小直接影响两个指标失败重传的代价和请求数量。块太大比如50MB一旦某一块损坏重传代价仍然很高块太小比如256KB一个2GB文件会有8000多个请求HTTP握手和响应开销会大得吓人后端也全是零碎小文件。我的建议是公网环境选1MB~5MB内网环境可以放宽到10MB~20MB。最常用的配置是2MB因为2MB在普通家庭宽带和移动网络下体验比较均衡。计算总块数用Math.ceil(fileSize / chunkSize)例如500MB文件按2MB切一共256块前端队列并发三五个几分钟就传完了。另外分块大小一旦确定后端合并时也要按这个大小去校验不允许某一块缺失或尺寸异常。2.4 接口设计先定下来再写代码写代码之前先把接口设计写在文档里前后端照着对接。这是我推荐的一套最简接口接口方法说明/upload/initPOST初始化上传传入文件名、文件大小、分块大小、文件MD5返回uploadId/upload/chunkPOST上传单个分块参数uploadId、chunkIndex、file/upload/progressGET查询已上传的分块索引列表参数uploadId/upload/mergePOST合并分块参数uploadId后端不复杂前端也不用猜。下面两章先讲原理再贴核心代码。3. 断点续传的核心服务端怎么记住进度3.1 用文件系统做存储最朴素也最可靠很多第一次做分块的人以为需要在数据库建一张分块表。其实在单机或普通业务场景下文件系统本身就是最天然的存储。我在根目录下建一个upload/文件夹每个uploadId对应一个子目录upload/ 8f14e45fceea167a5a36dedd4bea2543_1024000/ 0.part 1.part 2.part upload.json其中upload.json保存这次上传的元信息文件名、文件大小、分块大小、总块数、MD5。这些信息合并时要用所以初始化接口里必须先写进去。哪些分块已经传过这个问题直接看目录下存在哪些part文件即可。前端拿到这些索引后循环上传时跳过。这就是断点续传的所有秘密。3.2 为什么不要用数据库记录每个分块分块数量可能成千上万每当一个块上传成功就更新一次数据库虽然是可行的但给自己找了不少麻烦。高并发下分块乱序到达需要处理行锁服务重启后数据库和磁盘可能出现不一致事务成本高。文件系统本身有原子性一个块写完就是一个文件写一半就是.tmp文件排查起来非常直观。只有在集群环境中文件没办法共享的时候才需要引入对象存储或者分布式文件系统那时候也不要自己维护分块数据库直接用MinIO、阿里云OSS的分片上传API它们已经封装好了。3.3 progress接口返回什么很简单返回一个数组比如[0, 1, 2, 5, 6]代表这些索引的分块已经存在。前端维护一个Set上传前先查一次循环时如果uploadedSet.has(index)就直接continue。这里有个细节如果网络在传输过程中断了前端拿到的是异常但那个块可能已经在服务端保存成功了。下次续传时progress接口会把这块标记为已上传前端就会跳过它。所以前端不能因为某个块失败了就简单重传一定要先问进度。这也是断点续传相较失败后盲目重传的核心优势。3.4 并发上传时如何保证分块不冲突顺序上传不会有冲突但为了速度我会在前端开3~5个并发。这时候可能出现同一个分块被重复请求或者两个用户在同一时刻传了相同uploadId和chunkIndex。解决办法是先传临时文件再原子重命名后端先接收文件内容写入{index}.part.tmp写入完成后renameTo({index}.part)。如果目标part文件已经存在且大小一致直接返回成功。这样既保证幂等也不会出现两个请求同时写坏同一个文件的尴尬。4. Java后端核心实现一个Spring Boot服务搞定分块与合并4.1 环境准备与配置项目基于Spring Boot 2.7Java 8语法就够用。只依赖spring-boot-starter-web没有额外复杂组件。启动类照常写关键是修改三个配置spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB server: tomcat: max-swallow-size: -1max-file-size必须大于等于单个分块大小建议留两倍余量。max-request-size同理因为请求里除了文件还有表单字段。max-swallow-size是Tomcat在异步处理时对上传内容的限制默认是2MB不改掉的话分块超过2MB也会被Tomcat吞掉。这三个配置不配齐你会在测试时莫名其妙遇到各种请求被拒绝的错误。4.2 初始化接口生成uploadId并落盘元信息RestController RequestMapping(/upload) public class FileUploadController { private final String UPLOAD_ROOT /data/upload-tmp; private final String MERGE_ROOT /data/storage; PostMapping(/init) public MapString, Object init(RequestBody UploadInitParam param) throws IOException { // 用文件内容的MD5 文件大小作为唯一标识 String uploadId param.getFileMd5() _ param.getFileSize(); File dir new File(UPLOAD_ROOT, uploadId); if (!dir.exists()) { dir.mkdirs(); } // 元信息文件只写一次 File metaFile new File(dir, upload.json); if (!metaFile.exists()) { ObjectMapper mapper new ObjectMapper(); mapper.writeValue(metaFile, param); } MapString, Object result new HashMap(); result.put(uploadId, uploadId); return result; } }可以看到uploadId不是UUID而是MD5加文件大小。这样只要前端第二次打开同一个文件计算出的MD5一致就能直接续传。UploadInitParam里就是fileName、fileSize、fileMd5、chunkSize这几个字段代码里用ObjectMapper序列化到JSON文件。4.3 分块上传接口接收单个分块并幂等保存PostMapping(/chunk) public MapString, Object uploadChunk( RequestParam(file) MultipartFile file, RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) int chunkIndex) throws IOException { File dir new File(UPLOAD_ROOT, uploadId); if (!dir.exists()) { throw new IllegalArgumentException(uploadId不存在请先初始化); } File partFile new File(dir, chunkIndex .part); // 幂等判断分块已存在且大小一致不再接收 if (partFile.exists() partFile.length() file.getSize()) { return Collections.singletonMap(success, true); } // 先写临时文件避免并发写坏主文件 File tmpFile new File(dir, chunkIndex .part.tmp); file.transferTo(tmpFile); if (!tmpFile.renameTo(partFile)) { // 如果rename失败比如Windows上目标已存在删掉目标再rename partFile.delete(); tmpFile.renameTo(partFile); } return Collections.singletonMap(success, true); }这段代码有几个关键点。第一transferTo会把上传的临时文件移动到我们的指定位置内部是流拷贝不会一次性读入内存。第二幂等判断必须先做否则用户点击重试按钮时同一个块会被传两次。第三renameTo在Linux上是原子操作在Windows上如果目标文件已经存在可能失败所以加了个兜底delete再rename。这套逻辑应付普通业务足够。4.4 进度查询接口返回已上传的索引列表GetMapping(/progress) public MapString, Object progress(RequestParam String uploadId) { File dir new File(UPLOAD_ROOT, uploadId); ListInteger uploadedIndexes new ArrayList(); if (dir.exists()) { File[] partFiles dir.listFiles((d, name) - name.endsWith(.part)); if (partFiles ! null) { for (File part : partFiles) { String name part.getName(); Integer idx Integer.valueOf(name.substring(0, name.lastIndexOf(.))); uploadedIndexes.add(idx); } } } Collections.sort(uploadedIndexes); return Collections.singletonMap(uploaded, uploadedIndexes); }很简单列出目录下所有.part文件解析出索引并排序。前端拿到这个列表就知道从哪一块开始继续。这个接口不需要登录鉴权吗实际项目中至少要让uploadId与当前用户绑定否则别人可以随意查询你的上传进度。我这里为了简洁没写生产环境一定别漏。4.5 合并接口按索引拼接文件并校验完整性PostMapping(/merge) public MapString, Object merge(RequestParam String uploadId) throws IOException { File dir new File(UPLOAD_ROOT, uploadId); ObjectMapper mapper new ObjectMapper(); UploadInitParam param mapper.readValue(new File(dir, upload.json), UploadInitParam.class); int totalChunks (int) Math.ceil((double) param.getFileSize() / param.getChunkSize()); // 检查所有分块是否存在 for (int i 0; i totalChunks; i) { if (!new File(dir, i .part).exists()) { throw new IllegalStateException(缺少分块 i); } } File targetFile new File(MERGE_ROOT, param.getFileName()); try (OutputStream out new FileOutputStream(targetFile)) { for (int i 0; i totalChunks; i) { try (InputStream in new FileInputStream(new File(dir, i .part))) { in.transferTo(out); } } } // 合并后校验总大小防止网络传输或写盘丢字节 if (targetFile.length() ! param.getFileSize()) { targetFile.delete(); throw new IllegalStateException(文件大小不一致合并失败); } File mergeDone new File(dir, merge_done.flag); mergeDone.createNewFile(); return Collections.singletonMap(path, targetFile.getAbsolutePath()); }这里有个容易被忽略的点合并前一定要检查分块数量而不仅仅是所有part文件存在。如果前端提交的totalChunks是256但最后一个分块因为断网没传后端检查时缺了255会抛异常。异常后前端可以继续补传255再调merge即可。合并完成后的临时目录不要立即删除。我一般会写一个merge_done.flag标记然后交由定时任务清理为什么因为万一用户在合并完成后立刻刷新页面前端还没拿到结果临时目录已经被删了虽然不影响最终文件但排查问题时会困惑。5. 前端配合File切片与断点恢复逻辑5.1 前端切片与上传封装假设你用的是原生JavaScript处理文件切片用Blob.prototype.slice。以下是一个最简封装const CHUNK_SIZE 2 * 1024 * 1024; // 2MB async function uploadFile(file) { const uploadId await calculateMd5(file); // 伪代码可用SparkMD5 const totalChunks Math.ceil(file.size / CHUNK_SIZE); // 1. 初始化后端 await fetch(/upload/init, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({ fileName: file.name, fileSize: file.size, fileMd5: uploadId.split(_)[0], chunkSize: CHUNK_SIZE }) }); // 2. 查询后端已存在的分块 const progressResp await fetch(/upload/progress?uploadId${uploadId}); const uploadedSet new Set((await progressResp.json()).uploaded); // 3. 并发上传缺失分块 await concurrentUpload(file, uploadId, uploadedSet, totalChunks); // 4. 合并 const mergeResp await fetch(/upload/merge?uploadId${uploadId}, {method: POST}); return await mergeResp.json(); }在切片之前先把uploadId确定下来。calculateMd5对普通文件可以直接读入内存但大文件建议用FileReader配合crypto.subtle.digest按分片计算。SparkMD5这个库对大文件支持很好可以参考它的示例代码。5.2 并发控制用固定数量的Worker线程上传顺序上传虽然简单但2MB一块260块一块块排队总耗时很长。我习惯在前端维护一个分块池固定3个并发。最简单的实现是这样async function concurrentUpload(file, uploadId, uploadedSet, totalChunks) { let nextIndex 0; async function worker() { while (nextIndex totalChunks) { const index nextIndex; if (uploadedSet.has(index)) continue; await uploadSingleChunk(file, uploadId, index); } } await Promise.all([worker(), worker(), worker()]); } async function uploadSingleChunk(file, uploadId, index) { const start index * CHUNK_SIZE; const blob file.slice(start, start CHUNK_SIZE); const form new FormData(); form.append(file, blob, chunk-${index}); form.append(uploadId, uploadId); form.append(chunkIndex, index); const response await fetch(/upload/chunk, { method: POST, body: form }); if (!response.ok) { // 失败时重试两次再失败就抛异常让外层处理 throw new Error(分块${index}上传失败); } }并发数量不是越大越好。服务器磁盘IO和连接数有限3~5个并发在公网环境已经能跑满带宽。前端浏览器对同一域名的连接数上限一般在6个设得再多也意义不大。5.3 进度计算与断网恢复进度条计算很简单后端progress接口返回了已上传索引列表你拿它去算已上传字节数。但要注意已上传索引里可能包含第5块、第6块而第7块还没传进度显示时要按已上传块数 / 总块数算百分比而不是按顺序找连续的最大块。断网恢复的完整流程其实就三步捕获上传失败异常把当前正在传的块标记为失败暂停其他任务。给用户一个重试按钮。点击重试后重新调用progress接口刷新uploadedSet再次启动concurrentUpload。整个过程对用户来说就是断网了点一下继续进度直接从60%开始跑。这才是断点续传真正的体感。5.4 大文件计算的卡顿问题如果文件是2GB、4GB前端计算MD5会让页面卡住因为FileReader和回调函数在主线程执行UI会掉帧。建议把计算MD5切片这种重活放到Web Worker里。主线程把File对象传给WorkerWorker算好MD5后把uploadId回传。注意File对象是可以结构化克隆到Worker里的不会拷贝文件内容只用底层引用所以内存压力不大。如果你不想用Worker也可以退而求其次不按MD5来用文件名 文件大小 最后修改时间拼一个uploadId。这样避免了MD5计算但秒传功能会失效而且不同内容如果大小和时间戳一样可能会碰撞。我的态度是只要能接受几秒的MD5计算时间就坚持用MD5方案更牢靠。6. 实战中容易踩的坑磁盘、内存、并发、校验这几个老问题6.1 Spring Boot默认限制必须改很多第一次写分块的同事代码写得没问题但测试时上传1MB多点的分块就报错一看日志是FileSizeLimitExceededException。原因就是spring.servlet.multipart.max-file-size还是默认的1MB。这个配置被很多人忽略因为普通上传时根本不会遇到——传小文件都没超过限制。分块方案里每块是2MB就正好撞到枪口。所以调整配置时必须把max-file-size调大。我建议设置成分块大小的2倍以上比如2MB分块设置成10MB这样即使前端偶尔把两个分块合并成一个请求发送也不至于失败。6.2 临时文件不清理磁盘迟早爆掉分块上传最大的运维隐患就是临时目录。用户传了一半不传了或者网断了再也不回来了这些.part文件就永远躺在服务器上。如果不清理每天都有用户上传磁盘会被大量垃圾文件占满。我的做法是给临时目录加一个定时清理任务Component public class UploadTempCleaner { Scheduled(fixedDelay 3600000) // 每小时跑一次 public void clean() { File root new File(/data/upload-tmp); File[] dirs root.listFiles(); if (dirs null) return; long now System.currentTimeMillis(); for (File dir : dirs) { // 目录最后修改时间超过24小时且没有merge_done.flag则删除 if (now - dir.lastModified() 24 * 3600 * 1000L) { deleteDir(dir); } } } }注意要排除掉正在上传中的目录否则用户传一半临时目录被删了。可以通过最后修改时间判断长时间没动静的目录才清理。合并成功后的目录也可以保留一天再做二次清理防止前端还没拿到结果就被删。6.3 合并大文件时最容易OOM合并接口如果把整个文件读进byte[]再写2GB文件直接触发OutOfMemoryError。正确的做法是分块流式写入上面merge代码里用的in.transferTo(out)就是流拷贝每次只缓冲固定大小不会把整个文件加载到内存。还有一个隐藏点try (OutputStream out new FileOutputStream(targetFile))这个流必须用try-with-resources合并过程中任何一个分块读取失败都要确保流关闭否则文件句柄泄漏。我见过线上报Too many open files排查到最后就是合并代码忘了关输出流。6.4 已上传分块的大小校验不能省前端可能因为程序bug把第3个分块的内容错误地传成第4个分块的。或者用户上传过程中改了文件内容导致前后端算出的MD5不一致。这些情况合并后文件大小可能对得上但内容已经损坏。所以我通常在uploadChunk保存前校验每个分块的实际大小是否等于chunkSize最后一个分块可以小于chunkSize。如果文件大小不符直接拒绝。合并完成后有条件的话再把最终文件的MD5与初始化时记录的fileMd5做一次比对不一致就报错这样能最大程度保证文件完整性。6.5 秒传功能的实现与边界秒传是分块方案带给我们的彩蛋如果服务端已经存在MD5相同的文件init接口可以直接返回一个特殊标记前端不用再分块上传直接调用merge或者干脆返回文件URL。但秒传有一个边界问题不同目录下可能有同名文件也有不同用户传了相同内容的不同文件。我的简单做法是目标存储目录下全局比对MD5相同就复用不做用户隔离。如果要严格隔离用户上传目录秒传逻辑就要改成只在本用户的已上传文件中查找否则会串数据。总之秒传是可选的优化不是分块上传的核心别为了秒传把数据搞乱。6.6 前端刷新页面之后状态别丢有的前端把已上传的分块索引存在内存变量里刷新页面就全没了然后用户点击继续上传时又从第0块开始这样断点续传等于没做。正确的做法是刷新页面后先读取本地文件重新计算MD5或者从本地缓存读取上次的上传ID然后调用progress接口让后端告诉自己进度。这里有个技巧上传过程中可以把uploadId、文件名、文件大小写进localStorage刷新后通过文件大小 文件名匹配到对应的uploadId避免重新计算MD5。当然如果你能忍受计算MD5的时间也可以每次都重新算。6.7 生产环境的高可用演进单机方案够用但如果你部署在集群环境临时目录不能共享分块上传就会失败——因为块1打到A机器块2打到B机器合并时后端根本找不到完整的分块。这时候不要自己硬扛建议换成MinIO或对象存储的分片上传。它们的原理和我上面讲的完全一致只是把临时目录换成了对象存储的桶你只需要把初始化、上传分片、完成合并这几个接口的代码换成SDK调用。从单机到对象存储核心设计没有变唯一标识、分块编号、服务端记录已传部分、最后合并校验。你把这套逻辑吃透了迁移成本极低。最后提一个我在生产环境中养成的习惯uploadId的目录命名里永远带上用户ID或会话ID比如userId_md5_fileSize方便排查问题时定位到具体用户也方便清理异常数据。上传进度接口一定要加权限校验否则别人知道你上传的文件ID就能拼出临时路径存在越权风险。这些细节看着小线上出了事故再回头补代价就大了。
返回列表