资讯动态

Java超大附件上传进阶:分块上传与秒传实战解析

发布时间:2026/9/29 16:01:04 来源:尧图企业网站定制
做 Java Web 项目的朋友基本都躲不开一个需求超大附件上传。视频素材、安装包、数据备份动辄几个 GB直接在网页里用普通 form 表单提交轻则超时重连重则服务端内存直接被打满。我去年在做知识管理平台时就摊上了这个事客户素材库里全是 2GB 以上的音视频上线第二天就收到一堆上传失败的工单。后来把“分块上传”和“秒传”组合起来才算稳住了。这篇文章就把这个方案的完整思路、前后端实现细节以及我踩过的坑一并讲清楚给同样被大文件上传折磨的 Java 开发做个参考。1. 整体设计思路分块上传和秒传为什么要组合使用1.1 先搞清楚超大附件上传到底难在哪先把问题摆到台面上。传统方案是一个请求把整个文件提交给后端看起来简单但实际运行起来有几个绕不过去的坎。第一是网络超时。大文件上传耗时长中间只要断网、手滑刷新页面、代理超时整个请求就废了用户只能从头再来。第二是服务端压力。虽然 Spring Boot 默认把 multipart 解析到临时文件不会把整个文件都装进内存但上传过程中还是存在大对象分配和大量 IO并发一高线程全部卡在 IO 上接口整体变慢。第三是文件重复存储。同一个安装包公司里一百个人每个人都传一遍磁盘上就存了一百份一模一样的文件纯属浪费。分块上传的思路就是把一个大文件切成很多个小块逐个传。每个块独立成一个请求单请求数据量小超时会迅速重试内存开销可控而且多个块可以并发上传速度反而可能更快。秒传的思路更取巧不传文件内容只传一个文件的“指纹”服务端发现指纹匹配已有文件直接把访问链接返回给用户。用户感知就是“我刚选完文件瞬间上传完成”。这两个方案单独用都有短板。只分块不秒传重复文件的问题依旧只秒传不分块那新文件第一次上传还是会被大文件问题卡住。所以真正合理的做法是先秒传判断命中就直接返回没命中就进入分块上传流程全部块合并完成之后再登记一份指纹。这样下次有人传同一个文件秒传就能接住。1.2 分块上传的核心逻辑拆解分块上传由几个关键环节组成文件切片、块上传、块校验、合并。前端拿到 File 对象后按固定大小切片。比如约定 5MB 一块2GB 文件就切成 410 块。切片用浏览器原生的 File.slice 方法底层只是创建文件引用不会真的复制数据内存压力很小。每一块上传时除了块文件本身还要带上一组元数据文件指纹hash、块索引chunkIndex、总块数chunkTotal、原始文件名。后端会为每个文件指纹创建一个临时目录收到的块按照索引写成 00000.part、00001.part 这样的临时文件。等所有块都到达前端调用合并接口后端按索引顺序把临时小文件拼接成最终文件校验块数量、校验文件大小做完整校验然后删除临时目录。这里有一个很关键的细节块文件命名要用固定位数的零填充。比如 00001.part 而不是 1.part。否则以后端 Java 字符串排序10.part 会排在 2.part 前面合并出来的文件顺序会乱掉。这个坑我在测试环境遇到过后面会专门再说。1.3 把“秒传”塞进上传流程的正确位置秒传的本质是“内容寻址存储”文件内容决定了它的标识同样的内容永远对应同一个标识不存在就不传存在就引用。所以设计接口时上传前的第一步不是弹出文件选择框之后直接切片而是先算指纹然后调用秒传检查接口。这里有个先后顺序要明确秒传检查最好放在切片之前。如果先切片再检查前端白白做了大量切片操作用户等待时间更长。正确流程是用户选择文件前端立即开始计算指纹。指纹算完先请求 check 接口。如果命中直接显示上传成功并拿到文件链接整个过程结束。如果没命中再根据后端返回的已有分块信息只上传缺失的分块。这个流程才是分块上传和秒传结合的正确姿势。秒传是“快速通道”分块是“正常通道”两者通过同一个文件指纹串联起来而不是两个独立功能。2. 前端实操文件切片、并发控制与进度条实现2.1 文件切片与指纹预计算的实现先说指纹。我用的方案是 spark-md5它支持增量读取可以分块读取文件内容逐步计算 MD5内存占用很小。计算大文件指纹时用户会看到“正在校验文件”的状态这是正常的因为哈希计算本身就是 IO 密集操作。指纹计算的核心代码如下import SparkMD5 from spark-md5; function calcFileHash(file) { return new Promise((resolve, reject) { const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); const chunkSize 2 * 1024 * 1024; // 每次读 2MB let offset 0; reader.onload (e) { spark.append(e.target.result); offset chunkSize; if (offset file.size) { readNext(); } else { resolve(spark.end()); } }; reader.onerror (e) reject(e); const readNext () { const slice file.slice(offset, Math.min(offset chunkSize, file.size)); reader.readAsArrayBuffer(slice); }; readNext(); }); }切片逻辑更简单。文件大小除以切片大小向上取整得到块数。最后一个块大概率不足 5MB但没关系后端合并时按文件大小截断就行。const CHUNK_SIZE 5 * 1024 * 1024; function createChunks(file) { const chunks []; let start 0; while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size); chunks.push(file.slice(start, end)); start end; } return chunks; }这里有个经验切片大小不要设置得太小。1MB 一块2GB 文件就变成 2048 个请求光请求开销就把并发优势抵消了。5MB 到 10MB 是比较合理的区间既能控制单请求体积又不会产生过多请求数。2.2 并发上传的数量怎么定切完块之后如果逐块上传速度太慢如果一次性把所有块全部并发上传浏览器和服务端都扛不住。我测试下来的结论是并发数控制在 3 到 6 之间比较合适。推荐 3 个并发理由有两点。一是浏览器对同一域名的并发连接有限制HTTP/1.1 下一般不超过 6 个还得留给页面其他请求余地。二是服务端线程资源有限并发太高时合并时同一文件的分块可能竞争同一目录的锁容易出问题。实现一个简单的并发池const MAX_CONCURRENT 3; let nextIndex 0; async function uploadChunks(chunks, fileHash, fileName) { const workers []; for (let i 0; i Math.min(MAX_CONCURRENT, chunks.length); i) { workers.push(worker(chunks, fileHash, fileName)); } await Promise.all(workers); } async function worker(chunks, fileHash, fileName) { while (nextIndex chunks.length) { const index nextIndex; await uploadOne(chunks[index], index, chunks.length, fileHash, fileName); } }实际上断点续传场景下前端还需要跳过已经存在的分块。我会把已上传的块索引集合传进来uploadOne 内部先判断如果 index 已存在就直接算成功。每个块的上传用 fetch 构建 FormData 提交async function uploadOne(chunk, index, total, fileHash, fileName) { const formData new FormData(); formData.append(fileHash, fileHash); formData.append(chunkIndex, index); formData.append(chunkTotal, total); formData.append(fileName, fileName); formData.append(chunk, chunk); const response await fetch(/upload/chunk, { method: POST, body: formData }); if (!response.ok) throw new Error(块 ${index} 上传失败); updateProgress(index, total); }上传失败需要重试不能直接放弃。我在 worker 里给每个块加最多三次重试指数退避每次等待 1 秒、2 秒、4 秒。连续失败三次才真正报错并提示用户检查网络。2.3 进度条计算技巧进度条的计算不是简单地用“已上传字节数 / 总字节数”。分块上传时每个块的大小固定进度可以用“成功上传的块数 / 总块数”来表示但这样最后一个块是小的百分比会跳变。更平滑的做法是把每个块按字节权重计算let uploadedBytes 0; function updateProgress(index, total) { // 假设每个块大小一致最后一小块按实际大小算 uploadedBytes chunks[index].size; const percent Math.min(100, Math.floor(uploadedBytes / file.size * 100)); progressBar.value percent; }这里注意一点chunks 数组不能全部引用到内存里。File.slice 产生的 Blob 本质上还是引用磁盘上的原文件不会把数据拷贝到内存所以切片本身不占内存。但如果把每个分块都 readAsArrayBuffer那才是灾难。务必只在真正上传时才读取分块内容。3. 后端核心Spring Boot 分块接收与合并详解3.1 接口设计与临时文件结构后端我用 Spring Boot三个核心接口就够了接口方法作用/upload/checkPOST秒传检查返回是否命中、已有分块列表/upload/chunkPOST接收单个分块/upload/mergePOST合并全部分块生成最终文件先定义一个统一的文件存储根目录建议放在应用配置里upload: root-path: /data/upload temp-dir: ${upload.root-path}/temp final-dir: ${upload.root-path}/files临时目录结构按文件指纹分文件夹例如/data/upload/temp/d41d8cd98f00b204e9800998ecf8427e/ 00000.part 00001.part 00002.part注意不要把客户端的文件名直接拼到路径里。一是有路径遍历风险二是同名文件会造成覆盖。用文件指纹作为目录名用块索引作为文件名客户端原始文件名只在合并后写入数据库元数据。3.2 分块接收接口的关键代码分块接收接口核心逻辑很简单根据 fileHash 创建目录把块写入对应位置。真正要注意的是文件的写法和并发控制。PostMapping(/upload/chunk) public ResultBoolean uploadChunk(RequestParam String fileHash, RequestParam Integer chunkIndex, RequestParam Integer chunkTotal, RequestParam String fileName, RequestParam MultipartFile chunk) throws IOException { String chunkDir uploadProperties.getTempDir() File.separator fileHash; File dir new File(chunkDir); if (!dir.exists()) { dir.mkdirs(); } String partName String.format(%05d.part, chunkIndex); File partFile new File(dir, partName); chunk.transferTo(partFile); return Result.success(true); }这段代码看起来简单实际生产还有几个点要处理。一是 fileHash 如果包含特殊字符要清洗。虽然前端是我们自己写的但接口是公开的不能假设所有请求都合法。hash 可以限制为字母和数字超过范围直接拒绝。二是块的大小要校验。服务端不能只信前端的 chunkTotal每一块都该检查大小是否符合预期的 chunkSize。否则恶意请求可以无限上传小分块耗尽磁盘。三是同一用户重复上传同一个块。如果上传时块文件已存在transferTo 直接覆盖即可这是幂等设计的好处。断点续传的核心就是允许重复提交相同的块而不会产生副作用。3.3 合并接口与 NIO 效率优化合并接口是分块上传里最容易写错的地方。最容易出错的是排序其次是 IO 实现。合并逻辑如下PostMapping(/upload/merge) public ResultString merge(RequestParam String fileHash, RequestParam Integer chunkTotal, RequestParam String fileName) throws IOException { String chunkDir uploadProperties.getTempDir() File.separator fileHash; String finalName UUID.randomUUID().toString().replace(-, ) - fileName; File finalDir new File(uploadProperties.getFinalDir()); if (!finalDir.exists()) { finalDir.mkdirs(); } File finalFile new File(finalDir, finalName); try (FileChannel out FileChannel.open(finalFile.toPath(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i 0; i chunkTotal; i) { File part new File(chunkDir, String.format(%05d.part, i)); if (!part.exists()) { throw new IllegalStateException(缺少分块: i); } try (FileChannel in FileChannel.open(part.toPath(), StandardOpenOption.READ)) { long position 0; while (position in.size()) { position in.transferTo(position, in.size() - position, out); } } part.delete(); } } // 清理临时目录 File tempDirFile new File(chunkDir); tempDirFile.delete(); // 入库 FileInfo fileInfo new FileInfo(); fileInfo.setFileHash(fileHash); fileInfo.setFileName(fileName); fileInfo.setFileSize(finalFile.length()); fileInfo.setStoragePath(finalFile.getAbsolutePath()); fileInfoService.save(fileInfo); return Result.success(finalFile.getAbsolutePath()); }这里的 FileChannel.transferTo 是零拷贝实现在 Linux 上效率很高。用循环反复 transferTo 是因为单次 transferTo 不保证传输全部内容典型场景是网络文件传输但本地文件之间一般一次就能完成循环是为了保险。合并时要注意 chunkTotal 必须和实际分块数一致。如果某个块缺失直接抛异常不能生成不完整的文件。此时前端会收到合并失败的响应可以再次调用 check 接口获取缺失块列表继续上传缺失块。合并完成后最终文件的文件名我用了 UUID 拼上原始文件名。这样可以避免同名文件冲突也避免下游访问时中文文件名乱码问题。文件指纹写入数据库后秒传才能生效。4. 秒传并不神秘指纹计算、命中判断与断点续传4.1 秒传检查接口的实现秒传的入口是 check 接口。这个接口要承担双重职责一是判断文件是否已经存在二是获取断点续传的已有分块列表。GetMapping(/upload/check) public ResultCheckVO check(RequestParam String fileHash) { FileInfo fileInfo fileInfoMapper.selectByHash(fileHash); if (fileInfo ! null) { return Result.ok(CheckVO.hit(fileInfo)); } // 未命中完整文件检查临时分块是否已存在 File chunkDir new File(uploadProperties.getTempDir() File.separator fileHash); ListInteger uploadedChunks new ArrayList(); if (chunkDir.exists()) { File[] parts chunkDir.listFiles((dir, name) - name.endsWith(.part)); if (parts ! null) { for (File part : parts) { String name part.getName(); uploadedChunks.add(Integer.parseInt(name.substring(0, name.indexOf(.)))); } } } Collections.sort(uploadedChunks); return Result.ok(CheckVO.miss(uploadedChunks)); }这个接口返回两个状态hit文件已经存在前端直接显示上传成功拿到文件 URL。miss文件不存在但同时返回已上传的分块索引数组前端跳过这些块只补传缺失部分。CheckVO 的 JSON 大致是这样{ status: miss, fileUrl: null, uploadedChunks: [0, 1, 2, 5, 6, 7] }前端拿到 uploadedChunks 后构建一个 Set准备切片时把 index 在集合里的块标记为已上传这样断点续传就自然实现了。有人可能会问既然秒传和断点续传都用到了 check 接口为什么不拆成两个接口拆开也可以但合在一起更省事。前端只需要一次请求就能决定要走秒传还是有分块续传体验更好。4.2 大文件指纹计算有没有优化空间这里必须打开天窗说亮话对整个大文件做完整 MD5 计算不是瞬间完成的。2GB 文件即使本地磁盘读取速度有 1GB/s也要 2 秒以上。如果磁盘是普通机械硬盘可能要十几秒。所以“秒传”其实分两段本地验指纹和服务器上传真正的“秒”只体现在后者。但很多场景下用户等这十几秒是可以接受的。文件上传原本就要花几分钟多花十秒做校验换来的是后续完整的秒传体验这笔账是划算的。而且 spark-md5 是增量计算的内存占用非常低不会因为文件大而把浏览器搞崩。如果产品对“选完文件立刻开始上传”有执念那可以考虑用抽样指纹替代全量指纹。规则可以是读文件前 1MB、中间 1MB、末尾 1MB再拼接文件大小生成一个快速指纹。这样做的好处是计算快几乎瞬时坏处是理论上存在不同文件得到相同抽样指纹的概率。生产系统如果这么干必须要做二次确认快速指纹命中后后台再算完整指纹做最终校验校验不过再降级为分块上传。我个人的建议是除非产品经理拿刀逼你否则默认做全量指纹更省心也更安全。4.3 文件元信息表设计秒传可以正常工作的关键是文件信息表必须保存文件指纹和存储路径的映射。数据库设计如下CREATE TABLE file_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, file_hash VARCHAR(128) NOT NULL COMMENT 文件内容指纹, file_name VARCHAR(255) NOT NULL COMMENT 原始文件名, file_size BIGINT NOT NULL COMMENT 文件字节数, storage_path VARCHAR(500) NOT NULL COMMENT 存储绝对路径, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_file_hash (file_hash) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;file_hash 上加了唯一索引这个唯一约束非常有用。当两个用户同时上传同一个文件同时完成合并同时执行 insert只有一个能成功。另一个会抛出 DuplicateKeyException但处理方式不是报错捕获异常重新查询一遍把已存在那条记录返回给用户即可。这能避免重复存储。另外如果业务上对文件访问有权限控制不要把 fileHash 直接暴露成下载路径。更稳妥的方式是用一个短 ID 或者 UUID 作为对外访问标识内部再解析到存储路径。这个在真实项目中尤其重要不然别人猜到 hash 就能访问你的文件了。4.4 秒传命中后的文件引用策略文件指纹相同说明内容相同但不代表业务需求相同。比如同一个安装包被两个不同用户上传两人在自己的空间里都希望看到自己的“文件记录”。这里可以设计文件引用表CREATE TABLE user_file ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, file_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_file (user_id, file_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;秒传命中的时候只需要在 user_file 表里插入一条关联记录实体的 file_info 记录不需要新增。这样既做到磁盘层面文件去重又满足业务层面每个用户有自己的文件列表。这个设计才是秒传方案的精髓文件数据零写入只写一条业务关联记录。5. 真实项目里的坑常见报错与排查速查表5.1 合并后的文件顺序错乱这是最典型的问题。客户端把块名命名为 1.part、2.part、3.part、10.part后端如果用字符串排序10 会排在 2 前面最后合并出来的文件要么无法打开要么音画不同步。解决方案我在前面已经提到命名写成固定五位数的 00001.part00010.part。这样字符串排序和数字排序结果完全一致。如果历史数据已经用了不带前导零的命名合并时就不能用 File.listFiles 排序必须自行解析数字索引再排序。5.2 大文件上传时报 413 Request Entity Too Large413 通常有两种情况一是反向代理Nginx的限制二是应用的 multipart 限制。如果你用了 Nginx 做代理默认的 client_max_body_size 只有 1MB必须显式调大。但分块上传后单次请求体只有 5MB 左右所以 Nginx 的 client_max_body_size 其实只要大于单个分块大小就够了不需要设置成支持整个大文件的体积。我把这个值设成了 20MB留有富余。Spring Boot 这边也要配置 multipart 参数spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MBmax-file-size 限制单个文件大小max-request-size 限制整个 multipart 请求体大小。分块尺寸 5MB配 20MB 完全够用。如果这里配置小于分块大小后端会出现 unexpected EOF 之类的异常。5.3 并发上传同一个文件导致合并失败两个用户同时通过秒传检查发现文件都不存在然后各自上传分块、各自合并最后写入 file_info 表。前面已经说了唯一索引可以保证只有一个记录落库但合并阶段也可能出现冲突。更稳妥的做法是给合并操作加锁锁的粒度是 fileHash。合并之前先尝试创建最终文件用 CREATE_NEW 模式如果文件已经存在说明已经有其他请求合并完成那么直接返回已有的文件路径。如果文件不存在当前线程顺利创建其他并发请求会等锁释放后再检查一遍。这种“先检查、再创建、失败重查”的方式可以有效避免并发合并的重复劳动。5.4 Windows 环境下临时文件删不干净测试环境在 Windows 上跑合并后经常发现 .part 文件删不掉。原因是 FileChannel 没有关闭句柄还被占用。用 try-with-resources 语法可以解决绝大多数情况但还是要留意如果中间有异常抛出块文件可能残留。所以我在服务端加了一个定时清理任务定期清理 72 小时前修改的临时目录。Component public class TempFileCleaner { Scheduled(cron 0 0 3 * * ?) public void clean() { // 遍历 temp 目录根据 lastModified 删除过期目录 } }定时清理对线上环境尤其重要不然一次异常上传就能留下几十 GB 的垃圾文件积累在磁盘上。5.5 前端传的块名没有 parseInt导致排序错乱前端往 FormData 里放 chunkIndex 时如果用字符串后端 Integer 接收时 Spring 会自行转换。问题是数组、JSON、查询参数在不同网络环境下数据格式可能不一致。我遇到过一个低版本浏览器把整数转成字符串的情况后来直接在 controller 里坚持用 Integer 接收参数并且在前端对所有 chunkIndex 做一次 Number() 强转彻底避免了类型问题。5.6 合并时内存溢出有同学图省事把所有分块读进 Listbyte[] 再合并遇到大文件直接把堆内存打爆。正确做法是用 FileChannel 流式传输见 3.3 节的实现。前端同样不要把所有分块转成 ArrayBuffer 再统一上传Blob 引用原文件不占内存一旦转成 ArrayBuffer 内存占用就上去了。记住这条线始终流式处理永远不要把整个文件或全部分块一次性放进内存。6. 上线后我的一些体会这套方案上线之后最直观的变化是上传成功率从最初的 70% 出头提升到 99% 以上那些几 GB 的素材文件再也没有因为网络波动中断而让人抓狂。秒传的命中率其实也比我预想的高反复修改后重新提交的文档、同事之间互相转发的安装包都直接走了秒传快速通道。回头看整个实现有几个小经验特别想分享。第一个是前后端协议字段要提前定死别在开发中随意改名。fileHash、chunkIndex、chunkTotal、fileName这四个字段贯穿整个流程前后端如果各写各的联调时会浪费大量时间。第二个是测试阶段一定要模拟异常场景。断网、合并时缺块、并发上传同一文件、超大文件名、0KB 空文件这些边界情况比正常流程更容易暴露问题。我专门写了一个脚本随机丢分块来验证断点续传是否正确。第三个是安全方面不能省。文件上传接口向来是攻击重点fileHash 要做字符白名单校验文件类型要做后缀白名单检查分块大小要有上限临时目录要有独立权限。这些细节看似简单但在真实环境里都能挡掉不少麻烦。这套方案的后续扩展空间也很大。比如对接阿里云 OSS、腾讯云 COS 的分片上传核心思路完全一致文件存储从本地磁盘换成 MinIO 时只需把合并逻辑替换成对象存储的合并分片接口。分块上传 秒传不是一个“一次性功能”它是大多数大文件上传场景的通用基础设施值得花时间做扎实。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑