Java大文件上传实战:分块上传与断点续传全解析

发布时间:2026/10/20 4:43:25
Java大文件上传实战:分块上传与断点续传全解析
去年做内部课程平台的时候被大文件上传折磨得不轻。业务方要求网页端直接上传几百MB的教学视频文件一大普通表单上传的问题全暴露出来了——传了五分钟连接被重置传一半办公室断电最气人的是传到93%突然断网刷新页面后一切归零。用户骂声一片技术上也确实没话说因为我们压根没有记住上传进度这个能力。那阵子我把整个上传模块推倒重写核心就是做Java网页开发里的大文件分块上传 断点续传。重构之后弱网环境下能断点续传、服务器异常能自动重试、相同内容还能秒传用户那边的抱怨几乎在一周内消失。这篇文章我不讲虚的把整体设计思路、前后端关键代码、以及我在实际项目中踩过的坑一并写出来希望能帮到正在被同样问题折磨的Java开发者。1. 从一次上传失败说起大文件上传为什么必须分块1.1 普通上传在百MB文件面前的真实状态传统的网页上传几乎都是把整个文件塞进一次HTTP请求里后端用MultipartFile一次性接收。文件小的时候没问题一旦超过几百MB问题就是一连串的请求长时间占用连接。一个200MB的文件在普通家用宽带上要传好几分钟连接一直挂在那里中间任何一次网络抖动都可能让整个请求失败。网关和服务器的超时限制。很多部署环境里Nginx、Tomcat、云负载均衡都有接收超时时间。我见过不少配置在30到60秒的大文件没传完连接就被服务端掐断了。失败成本极高。整个文件只要有一个字节失败全部重新开始。用户传了60%断了个网这60%全部作废体验极其糟糕。这也是为什么大文件上传场景里几乎没有人还坚持用单文件直传。服务端一旦把max-file-size调到2GB实际内存压力、临时目录写满是小事更大的问题是单个请求把整个链路的可靠性要求推到了极致——你很难保证用户网络在几分钟内一次抖动都没有。1.2 分块上传和断点续传到底解决了什么分块上传本质上是把一个大任务拆成一批小任务。一个200MB的文件按5MB一块分出来40块每一块独立传到服务端暂存。单块失败只需要重传这一块而不是整个文件。它解决的不是文件变大了而是任务失败的影响范围变小了。断点续传是在分块的基础上记住哪些块已经成功传完。用户断网、刷新页面、甚至换一台电脑重新发起上传时前端先向服务端查一下这个任务已经传好了哪些分块然后只传缺失的部分。在网络质量不稳定的场景下这几乎是大文件上传的刚需。我习惯用搬家来类比整卡车装一屋子的东西路上翻车一次全部重来换成几十个打好标签的纸箱哪个箱子丢了补哪个损失完全可控。分块就是装箱断点续传就是装箱单两者配合上传从一次博弈变成了可恢复的长期任务。2. 整体方案设计分片、uploadId与暂存区的协作2.1 一次完整上传的生命周期是怎样的我在重构前先画了完整的调用流程确保前后端对齐。整个上传分为四个阶段文件指纹计算前端读取文件计算出整个文件的MD5值。这个MD5有两个用途一是判断是否已经上传过相同文件秒传二是作为文件在服务端归档时的目录特征。初始化上传任务前端把文件名、文件大小、MD5发给后端后端生成一个全局唯一的uploadId并创建本次上传的暂存目录。分块上传前端用File.slice()把文件切成若干块然后带上uploadId和分块序号chunkIndex逐块上传到后端。后端把每一块写入暂存目录里的独立文件。合并与归档所有分块传完后前端调用合并接口后端按序号读取所有分块拼接成一个完整文件落盘到最终存储目录然后清理暂存区返回文件的访问地址。这里的重点在于前端的进度和后端的状态必须一致。前端自己记进度是不可靠的因为用户可能换浏览器、清缓存所以每次断点续传时前端都要通过后端的状态查询接口拿到真正已存在的分块列表只上传缺失的。后端的暂存文件才是进度的唯一权威来源。2.2 三个必须约定好的关键信息这套设计里有三个信息直接决定了通信的准确性uploadId任务唯一标识每次上传任务的身份证。它绑定着文件名、总大小、总分块数、MD5等元数据。我比较推荐用UUID生成避免暴露业务信息也避免并发冲突。chunkIndex分块序号从0开始的整数表示这是第几个分块。合并时按这个序号排序所以后端不能省掉这个参数。md5文件指纹整个文件级别的MD5不做秒传时还可以用来做完整性校验。分块层面也可以为每个分块生成md5但开销较大实际项目里更多是先用文件级MD5。我见过有团队用文件MD5直接当uploadId用听起来顺理成章但有个问题两个用户同时上传内容相同的文件后一个人的任务会复用前一个人的上传状态我分块传了10块另一个人传第1块把文件覆盖了状态全乱。所以最稳妥的方案是MD5只用来判断文件是否已经存在任务唯一性交给uploadId。2.3 存储选型为什么用文件系统做暂存区分块上传过程中每个分块需要落盘等待合并。存哪里常见方案有这么几个存储位置优点缺点适用场景本地文件系统实现简单读写快占用内存极小依赖磁盘空间分布式环境需要共享存储单机部署、中小型项目Redis读写快支持过期清理大文件二进制块写Redis成本高内存占用大极小文件的特殊场景云对象存储OSS/S3本身就是分块上传机制可靠性高需要额外SDK和费用扇出较复杂直接对接云存储的生产系统就绝大多数Java Web项目来说在服务端落盘暂存是最务实的方案。我当时的做法是在配置的上传根目录下为每个uploadId创建独立子目录/${uploadId}/分块文件按序号命名比如0.part、1.part。后续合并时直接遍历该目录即可。这样做的好处是调试方便——一个人传了一半你登录服务器看一眼目录里的.part文件就知道问题在哪。配套的还需要一张数据库表来做元数据管理。核心字段参考如下字段类型说明idbigint主键upload_idvarchar任务唯一标识file_namevarchar原始文件名file_sizebigint完整文件大小chunk_sizeint分块大小字节total_chunksint总分块数file_md5varchar整个文件的MD5statusvarchar状态如processing / completed / failedcreate_timedatetime创建时间finish_timedatetime合并完成时间用这张表维护任务生命周期同时配合文件系统里的.part文件作为实际数据两者结合断点续传和秒传的逻辑都能顺畅跑起来。3. 前端实现切片、并发控制与进度恢复3.1 用File.slice()把文件切成数据块前端的核心API是File.prototype.slice()。它可以在不把文件完全读入内存的情况下取出一段二进制数据。示例代码如下const CHUNK_SIZE 5 * 1024 * 1024; // 每块5MB function splitFile(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({ index: chunks.length, blob: blob, size: end - start }); start end; } return chunks; }分块大小选多少合适我实际项目里比较推荐5MB到10MB。小于1MB会导致分块数量过多几百个请求并发起来浏览器和服务器的连接管理开销反而变大大于20MB又回到了单块太大容易失败的老路。以200MB文件为例5MB一块也就40个请求即便并发3个完成时间也是可接受的。3.2 浏览器的并发上限比你想象的低必须自己控很多前端新手会把40个分块一次性全部Promise.all并发上传结果页面卡死、网络队列爆炸。浏览器对同一域名的并发连接数是有限的HTTP/1.1下一般是6个左右超出的请求会排队。更微妙的是一旦你同时发起几十个请求任何一台中间设备做不了什么额外处理服务端压力也会瞬间拉高。我采用的做法是自建一个简单的并发池把并发数限制在3到4个async function uploadChunks(uploadId, chunks, maxConcurrent 3) { const queue chunks.slice(); let uploadedCount 0; async function worker() { while (queue.length) { const chunk queue.shift(); await uploadChunk(uploadId, chunk); uploadedCount; onProgress(uploadedCount, chunks.length); } } const workers Array.from({ length: maxConcurrent }, () worker()); await Promise.all(workers); } function uploadChunk(uploadId, chunk) { const formData new FormData(); formData.append(uploadId, uploadId); formData.append(chunkIndex, chunk.index); formData.append(file, chunk.blob, chunk-${chunk.index}); return fetch(/api/upload/chunk, { method: POST, body: formData }); }并发控制的价值不只是温柔对待服务器更重要的是当某个分块请求失败时只重试失败的那个不会影响其他块。如果整体并发太高失败重试的链路会变得非常复杂日志也更难排查。3.3 断点续传和秒传都从状态查询开始前端开始真正上传前应该先调用初始化和状态查询拿到哪些块已经上传成功。整个流程大致是这样async function startUpload(file) { const fileMd5 await computeFileMd5(file); // 流式计算整个文件MD5 const initResult await fetch(/api/upload/init, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, md5: fileMd5 }) }).then(res res.json()); if (initResult.skipUpload) { // 服务端已存在相同文件直接秒传 onCompleted(initResult.fileUrl); return; } const uploadId initResult.uploadId; const chunks splitFile(file); // 查询已上传状态过滤掉已完成的块 const status await fetch(/api/upload/status?uploadId${uploadId}).then(res res.json()); const remainChunks chunks.filter(c !status.uploadedChunks.includes(c.index)); await uploadChunks(uploadId, remainChunks); await fetch(/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ uploadId, fileName: file.name, totalChunks: chunks.length }) }).then(res res.json()); onCompleted(/download/ uploadId); }这里有个细节很多人容易忽视断点续传不是靠前端保存的而是每次重新上传时重新询问服务端。用户刷新后浏览器内存里的进度消失但只要服务端暂存区还在状态查询就能还原。这也是为什么后端的状态查询接口必须返回已上传的分块序号列表而不仅仅是是否完成。4. 后端接口设计初始化、分块上传、合并与状态查询4.1 初始化接口生成任务ID并记录元数据后端我用的Spring Boot接口设计比较直接。第一个接口是初始化PostMapping(/api/upload/init) public Result init(RequestBody InitUploadRequest req) { // 1. 先做秒传判断文件md5是否已有完成记录 UploadEntity exists uploadMapper.findByMd5AndStatus(req.getMd5(), completed); if (exists ! null) { return Result.ok(new InitUploadResponse(exists.getUploadId(), true, exists.getFilePath())); } // 2. 生成uploadId并创建暂存目录 String uploadId UUID.randomUUID().toString().replace(-, ); File chunkDir new File(uploadRoot, uploadId); if (!chunkDir.exists() !chunkDir.mkdirs()) { throw new IllegalStateException(创建上传目录失败); } // 3. 记录元数据 UploadEntity entity new UploadEntity(); entity.setUploadId(uploadId); entity.setFileName(req.getFileName()); entity.setFileSize(req.getFileSize()); entity.setFileMd5(req.getMd5()); entity.setStatus(processing); uploadMapper.insert(entity); return Result.ok(new InitUploadResponse(uploadId, false, null)); }秒传的判断放在初始化阶段很关键。只要数据库里存在同一个MD5且状态为completed的记录说明之前有人上传过完全相同的文件后台上可以直接复用文件前端这边一个分块都不用传。需要注意的是MD5作为文件指纹在内部系统做重复检测够用了碰撞概率极低这一点不必过度担忧。4.2 分块上传接口落盘到暂存目录分块上传接口接收uploadId、chunkIndex和file单个分块的MultipartFile。我把每个分块直接写入${uploadRoot}/${uploadId}/${chunkIndex}.partPostMapping(/api/upload/chunk) public Result uploadChunk(RequestParam String uploadId, RequestParam Integer chunkIndex, RequestParam MultipartFile file) throws IOException { // 基础校验uploadId必须存在于数据库中否则就是非法请求 UploadEntity entity uploadMapper.findByUploadId(uploadId); if (entity null) { return Result.error(上传任务不存在请重新初始化); } String realName chunkIndex .part; File chunkDir new File(uploadRoot, uploadId); File targetFile new File(chunkDir, realName); // 幂等保护如果分块已存在且大小一致直接返回成功 if (targetFile.exists() targetFile.length() file.getSize()) { return Result.ok(); } file.transferTo(targetFile.getAbsolutePath()); return Result.ok(); }这里有个我踩过的坑后面细说直接transferTo同名文件在高并发重试时可能相互覆盖。我的优化是先写临时文件再原子性重命名。这里先展示这个问题后面第6节会给出完整的修复方案。4.3 合并接口按序号拼文件并清理暂存区合并是整个流程最容易出性能事故的地方。核心逻辑是按chunkIndex从0到totalChunks-1依次读取.part文件用流式写法写入目标文件最后删除暂存目录PostMapping(/api/upload/merge) public Result merge(RequestBody MergeUploadRequest req) throws IOException { UploadEntity entity uploadMapper.findByUploadId(req.getUploadId()); if (entity null) { return Result.error(上传任务不存在); } File chunkDir new File(uploadRoot, req.getUploadId()); File finalFile new File(uploadRoot, generateStorageName(entity.getFileMd5(), entity.getFileName())); try (FileOutputStream fos new FileOutputStream(finalFile)) { for (int i 0; i req.getTotalChunks(); i) { File chunkFile new File(chunkDir, i .part); if (!chunkFile.exists()) { return Result.error(缺少分块 i); } try (FileInputStream fis new FileInputStream(chunkFile)) { byte[] buffer new byte[1024 * 1024]; // 1MB复制缓冲 int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } } } // 合并完成更新状态清理分块目录 entity.setFilePath(finalFile.getAbsolutePath()); entity.setStatus(completed); entity.setFinishTime(new Date()); uploadMapper.updateStatus(entity); deleteQuietly(chunkDir); return Result.ok(entity); }整个合并过程是流式的中间不使用byte[]一次性放大文件这也是避免内存溢出的核心手段。最终文件的命名我建议做一层脱敏处理比如用md5 _ 原始文件名既能保持文件名可读又不至于重名覆盖。如果对文件名要求不高直接用MD5作为存储文件名更干净展示名称可以通过元数据表还原。4.4 状态查询接口还原断点进度这个接口虽然简单但它是断点续传里最核心的记忆恢复器。实现方式非常直观扫描暂存目录下的.part文件收集已存在的序号返回前端。GetMapping(/api/upload/status) public Result status(RequestParam String uploadId) { File chunkDir new File(uploadRoot, uploadId); ListInteger uploadedChunks new ArrayList(); if (chunkDir.exists() chunkDir.isDirectory()) { File[] files chunkDir.listFiles((dir, name) - name.endsWith(.part)); if (files ! null) { for (File f : files) { String name f.getName(); uploadedChunks.add(Integer.parseInt(name.substring(0, name.indexOf(.)))); } } } Collections.sort(uploadedChunks); return Result.ok(uploadedChunks); }如果你的系统是分布式部署uploadRoot必须是所有节点都能访问的共享目录否则用户连到不同实例时看到的状态不一致断点续传就会失效。单机环境下这个问题不存在但部署前一定要想清楚。5. 断点续传的真正考点元数据、校验与异常恢复5.1 元数据存哪里本地内存、数据库还是Redis断点续传除了要知道文件块在磁盘上还需要知道这个任务属于谁、现在什么状态。我见过不少团队最初用内存Map存上传状态单体演示没问题一重启就全部失效。我在项目里是按这个原则来选的本地内存只适合开发调试。重启丢失多实例不一致生产环境绝对不能靠它。数据库表中小项目首选。虽然每次查询多一次IO但胜在可靠、可追溯重启后状态不丢秒传也能直接查库。Redis适合高并发下需要频繁读取状态的场景。比如热门资源多人同时上传状态查询很频繁Redis可以扛住压力。但它只是缓存层最终任务的落地状态还是应该在数据库里留一份。我的建议是如果你的系统并发不高、也没有多区域部署直接上数据库表就够了。Redis方案看着高级实际引入了一致性、过期策略、序列化等一堆复杂问题对中小型Java后端来说往往是过度设计。5.2 分块校验到底校验到什么粒度上传过程中网络传输可能发生数据损坏尤其是弱网环境下一个分块传完了并不等于内容正确。校验的粒度选择直接影响可靠性和性能。常见的做法有三种仅校验分块大小。最省事targetFile.length()和上传分块大小一致就认为成功。我用这个做幂等判断但严格说它防不了字节错位。分块级MD5校验。前端上传每个分块时带上该块的MD5后端验一遍。精确度最高但每一个分块都要额外算几十次哈希对前端CPU占用不小大文件用户会明显感觉风扇在转。文件级MD5结合合并后校验。整个文件在初始化时算一次MD5合并完成后再算一次两者一致才认为完整。这种方案大部分场景下性价比最高失败重传的是单个分块整体文件在合并后做最后兜底校验。实际项目中我采用的组合是分块上传做长度校验合并时做流式写入合并完成后对整个文件做一次MD5校验和初始化时记录的fileMd5比对。如果对不上说明中间某个分块数据有问题直接让用户重新上传整个文件——这种情况出现的概率极低真遇到了用户重传一次也远好过我们在服务端逐块二次校验带来的性能损失。5.3 断点怎么恢复、秒传怎么命中断点恢复和秒传是同一个查询接口的两个分支断点恢复用户重新上传同一个文件初始化拿到同一个uploadId因为MD5相同我们找到了未完成的任务然后通过/status查出哪些分块已经在暂存区前端只补充缺失的分块。这样用户上次传了80个块这次只需要传剩下的几个。秒传初始化时发现数据库里已经存在该MD5且状态为completed的文件直接返回skipUploadtrue整个上传流程一秒钟就完成。这里有一个常见误区很多人把秒传和uploadId绑定在一起认为同一个用户重复上传同一个文件时才能秒传。其实不是秒传的判断只在服务端完全不依赖用户身份只要MD5命中任何人的相同文件都可秒传。这也是内部知识库、培训系统里最常见的需求——同一份视频几十个同事先后上传秒传把存储和带宽都省了。6. 实际操作中踩过的坑和解决办法6.1 并发重试把分块写坏了上线第一周就遇到一个问题弱网环境下某个分块上传超时后前端自动重试两个并发请求同时写同一个${uploadId}/${chunkIndex}.part文件。transferTo的底层实现是先删后写还是直接覆盖表现不稳定结果就是同一个文件被两个线程轮流写最后落盘的分块是坏的合并后整个文件MD5校验失败。修复方案是给分块文件加中间态先写${chunkIndex}.tmp写完并校验大小无误后再用File.renameTo或Files.move原子性改成.part。由于重命名是同目录下的原子操作即使多个线程同时上传同一个分块最终落下的也是完整文件File tmpFile new File(chunkDir, chunkIndex .tmp); file.transferTo(tmpFile.getAbsolutePath()); if (tmpFile.length() ! file.getSize()) { tmpFile.delete(); return Result.error(分块上传不完整); } File target new File(chunkDir, chunkIndex .part); Files.move(tmpFile.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE);这个改动很小但直接杜绝了并发重试造成的脏数据。从那以后我再也没见过分块文件损坏的问题。6.2 合并文件时内存溢出我最初合并文件的写法很粗暴byte[] bytes Files.readAllBytes(chunkFile.toPath()); fos.write(bytes);单个分块5MB时还好但一旦有人调大了分块大小或者上传的文件特别大每个分块都读成完整的byte[]再加上ByteArrayOutputStream中间缓冲内存很快就顶不住了。有一次测试传一个4GB的文件后端进程直接OutOfMemoryError。改成第4节里那种固定1MB缓冲区的流式读写后无论文件多大内存占用始终是恒定的。合并几百GB的文件JVM堆也不会因此上涨。这是大文件处理最基础、也最重要的原则永远不要试图把整个大块数据一次性装进内存按固定大小的缓冲区循环读写才是正解。6.3 浏览器并发连接数限制导致页面卡死前端最初我把40个分块全部丢进Promise.all然后发现上传速度并没有变快反而有时整个页面的图片资源都加载不出来。检查后确认是浏览器的同域名连接限制——HTTP/1.1下Chrome对同一域名默认6个连接我一下占掉几十个全部在排队其他请求全被挤在后面。后来在uploadChunks里用并发池把并发数限制到3页面其他资源能正常加载上传速度反而更快了。本质原因是连接数超过一定阈值后TCP拥塞窗口争抢、TLS握手开销等都会拖慢整体吞吐并不是开得越多越快。6.4 计算文件MD5时把内存打满了前端计算200MB文件的MD5如果写成const buffer await file.arrayBuffer();再交给crypto.subtle.digest一次性加载200MB到内存。用户传个2GB文件前端页面直接崩溃。正确做法是从File对象读取Blob的分片流式喂给哈希算法。简单实现可以这样async function computeFileMd5(file) { const buffer await file.arrayBuffer(); const hash await crypto.subtle.digest(MD5, buffer); return hex(hash); }这个方法对200MB以内的文件还能接受更大文件就要用ReadableStream配合递增哈希了。如果你不想在前端做复杂计算更省力的方案是把MD5这步放到服务端做前端传完所有分块、合并成功后服务端流式计算整个文件的MD5然后去重归档。前端拿到的只是文件名和大小但这要求你在合并后多等一次校验过程。权衡一下大多数内部系统前端算个几百兆的MD5是能接受的不必过度设计。6.5 用户关掉页面后留下的僵尸分块用户上传到一半关了页面或上传失败后再也没回来暂存目录里就会留着一堆.part文件和一条processing状态的数据库记录。时间一长磁盘被这些垃圾占满。我上线初期没有清理策略一个月后磁盘报警一查发现光无效分块就占了200多GB。后来加了一个简单的定时任务扫描数据库里所有创建时间超过24小时、状态仍为processing的任务找到对应的暂存目录直接删除并删除数据库记录。对于超时未完成的任务来说暂存数据没有保留价值用户重新上传会重新初始化所以可以放心清理。清理任务在单机环境用Scheduled就行Scheduled(cron 0 0 3 * * ?) // 每天凌晨3点 public void cleanExpiredUploads() { ListUploadEntity expired uploadMapper.findProcessingBefore(new Date(System.currentTimeMillis() - 24 * 3600 * 1000L)); for (UploadEntity entity : expired) { File dir new File(uploadRoot, entity.getUploadId()); deleteQuietly(dir); uploadMapper.deleteByUploadId(entity.getUploadId()); } }这个任务虽然简单但在生产环境里必须要配。别等到磁盘被占满了才想起来。6.6 文件名和编码问题原始文件名里经常有中文、空格、括号甚至Windows下不能出现的字符。如果直接用原始文件名作为最终存储文件名可能会碰到文件系统不支持、URL拼接过长、下载时响应头混乱等问题。我最终的实践是存储文件名一律用文件MD5 扩展名原始文件名只存在数据库里。合并完成后前端通过/download?uploadIdxx接口下载服务端根据元数据还原原始文件名并做URL编码。另外前端在初始化时传文件名一定要编码否则JSON传输时转义出问题还是小事下载时响应头Content-Disposition里的中文乱码才是真的坑。给文件名加一层encodeURIComponent后端接收后按需解码能省很多麻烦。7. 最后补充几个进一步优化的思路这套分块上传断点续传的方案跑通之后业务方基本就满意了。但如果你想把上传模块做成更抗造的工程化能力下面几个方向是值得继续深入的服务端合并异步化。合并大文件是CPU和IO密集型操作同步接口会长时间占用Tomcat工作线程。改成提交给线程池异步合并前端轮询状态接口体验会好很多。分块秒传的精细化管理。目前只做了文件级秒传如果要支持部分分块复用可以给每个分块也维护一个MD5索引这样新任务能立刻复用历史分块。代价是存储和查重的复杂度上升一般场景没必要。上传进度实时推送。现在前端自己算进度比例如果要做多人协作、展示服务端状态可以用WebSocket或SSE推送更准确的进度。对接对象存储的分块上传能力。如果你们的文件最终要存OSS或S3完全可以不走自己的暂存目录直接利用云服务的分块上传接口前端分块不变后端只是转发签名和完成合并稳定性更高运维压力也小很多。回看这次重构我最深的体会是大文件上传的难点不在某个单一的API或算法而在状态一致性和异常恢复。分块本身不难难的是把哪块传了、哪块没传、哪块坏了、哪块是垃圾这四件事一直搞清楚。前端用状态查询还原进度后端用磁盘暂存加数据库元数据管理状态定时清理兜底这三板斧组合起来足以应付绝大多数Java Web项目里的大文件上传需求。如果你也在做类似的功能我建议别急着上Redis、别急着异步化先把最简单的文件系统暂存加一张状态表跑通再根据实际压力逐步加复杂度。你会发现问题远比自己预想的少而整个方案稳稳当当跑在线上比什么都重要。