做了几年Java Web开发最怕接到的需求之一就是上传一个几百MB甚至几个GB的文件。最开始都是直接丢给后端一个POST接口一分钟的文件传二十分钟还中途断掉用户直接开骂。后来把所有文件上传需求都统一改成了分块上传断点续传方案问题基本就绝迹了。这篇文章就围绕这个方案把前后端完整实现讲透包括切片大小怎么定、文件指纹怎么算、并发怎么控制、后端怎么合并以及我踩过的一些坑。1. 分块上传与断点续传到底在解决什么问题1.1 传统表单上传究竟卡在哪大文件上传的第一道坎是HTTP请求会被阻塞。浏览器把一个文件作为 multipart/form-data 请求体提交给服务器时整个请求是一个TCP连接上的完整数据流。连接断开、超时、代理服务器主动断开空闲连接都会导致请求失败。最难受的是失败之后没有状态前后端都不知道哪个字节传到了服务器只能从头再传。GB级文件重传几次用户耐心基本就没了。第二道坎是服务器侧的默认限制。Java Web容器对单次请求大小有上限比如常见框架内嵌容器默认单文件上传限制只有1MB到10MB量级即使调高了反向代理层的请求体大小限制、后端处理线程占用的超时时间也会成为隐患。直接调大这些参数虽然可行但会让服务器在极端情况下承受极大内存压力整体不可控。分块上传只是切分问题真正让它好用的是断点续传这个能力。文件从第5MB处断了重连时客户端先问服务器一句哪些分块已经到了然后只把第6MB往后的块传上去。服务器侧也不需要长时间占用一个巨大的内存缓冲每块都是独立请求、独立持久化坏了哪块就补哪块。1.2 断点续传的完整技术链路一句话描述方案把文件切成固定大小的块每块是一个独立的HTTP请求后端收到每块后落盘到临时目录并记录进度当全部块齐了后端按序号合并出原始文件。链路拆开有三个环节前端切片用 File.slice() 把文件切成若干块并计算整个文件的唯一标识常用哈希值。这个标识相当于业务主键所有分块都挂在这个标识下。分块传输与状态查询上传每个分块前先通过查询分块进度接口拿到已上传分块索引集合跳过已传的只传缺失的。这个查询接口就是断点续传的核心。后端合并与校验所有分块上传完成后调用合并接口后端找出同标识下的所有分块按索引顺序写入最终文件再校验文件大小、总块数必要时校验整个文件的哈希。1.3 这个方案的收益与适用边界收益非常明显。失败重试成本低断点续传不会重复传整包分块可以并行上传整体速率更好服务端内存占用稳定不必为单个大文件开大缓冲顺便还能实现秒传若服务端已有同哈希的文件直接返回访问路径用户省去等待。但它不是万能药。适合几MB到几十GB的文件尤其视频、压缩包、安装包、数据备份不适合实时流媒体这类没有明确文件边界的场景。对普通图片等小文件直接用常规multipart上传更快完全没必要上分块方案否则白白增加接口复杂度和调试成本。2. 前端分块过程怎么设计2.1 切片与分块参数前端关键API就是 File 对象的 slice 方法它可以从文件中切出一个Blob片段。通常按固定大小切割例如5MB一块。代码很简单function createFileChunks(file, chunkSize) { const chunks []; let offset 0; while (offset file.size) { const end Math.min(offset chunkSize, file.size); chunks.push(file.slice(offset, end)); offset end; } return chunks; }chunkSize需要前后端协同约定。我用过2MB、5MB、10MB具体怎么选后面有详细说明。每个分块上传任务的数据结构建议这样组织const uploadTask { file: file, // 原生File对象 identifier: md5Hash, // 整个文件的唯一标识 chunkSize: 5 * 1024 * 1024, // 分块大小 chunks: chunks, // 分块数组 uploadedIndexes: new Set(), // 已上传分块的索引集合 };注意一个容易错的地方index从0开始数totalChunks等于chunks数组长度。前后端对这两个字段的理解必须完全一致否则合并时文件内容会错位。2.2 文件哈希计算不能在主线程硬算断点续传和秒传都需要文件唯一标识identifier。最稳妥的是对整个文件算一次MD5。但大文件一次性读完再计算hash会直接把浏览器主线程卡住几秒甚至几十秒页面像死掉一样。正确做法是用增量算法分片读取文件内容后一段一段append到哈希对象中async function calcFileHash(file) { const spark new SparkMD5.ArrayBuffer(); const chunkSize 2 * 1024 * 1024; let offset 0; while (offset file.size) { const end Math.min(offset chunkSize, file.size); const buffer await file.slice(offset, end).arrayBuffer(); spark.append(buffer); offset end; // 这里可以更新UI上的正在计算文件指纹进度 } return spark.end(); }对大文件建议把计算放到Web Worker里避免阻塞UI渲染。在worker线程里做同样的增量计算主线程只等回调结果。还有一个经验是如果文件实在太大比如超过1GB可以在上传阶段先用首尾部分字节文件大小最后修改时间拼出的弱指纹作为identifier几十毫秒就能算完断点续传完全够用等合并完成后再做一次完整校验。2.3 并发上传队列与失败重试一次性把几十个分块全部并发发起浏览器会创建大量TCP连接服务器也扛不住。实际项目中我会给上传加一个并发控制一般3到5个并发比较稳。用队列实现的伪代码如下async function uploadAllChunks(task, concurrency 3) { const pending task.chunks.map((chunk, index) ({ chunk, index })) .filter(item !task.uploadedIndexes.has(item.index)); const workerPool new Array(concurrency).fill(0).map(async () { while (pending.length) { const item pending.shift(); await uploadOneChunk(task, item.chunk, item.index); } }); await Promise.all(workerPool); } async function uploadOneChunk(task, chunk, index) { const form new FormData(); form.append(file, chunk); form.append(identifier, task.identifier); form.append(chunkIndex, index); form.append(totalChunks, task.chunks.length); const response await fetch(/upload/chunk, { method: POST, body: form }); if (!response.ok) { // 记录失败索引交给失败重试队列 throw new Error(chunk ${index} fail); } task.uploadedIndexes.add(index); }失败重试不能只做一遍。常见做法是给每个分块设置最大重试次数重试超过3次就标记该分块为失败并暂停整个队列提醒用户检查网络。我自己遇到过网络抖动时某一块反复失败其他块都传完了整个任务卡死的情况所以重试逻辑里要加一个失败数过多就报警并中止的判断。2.4 续传时如何准确恢复状态断点续传的核心触发逻辑就一句话打开页面时用identifier向服务端查询已上传分块列表然后只上传缺失部分。const res await fetch(/upload/progress?identifier${task.identifier}); const data await res.json(); task.uploadedIndexes new Set(data.uploadedIndexes);页面刷新后前端可以从localStorage恢复上次的identifier和文件名再调用上面的查询接口。就算localStorage被清掉了也没关系服务端临时目录里的分块还在只要重新计算出identifier查询接口还能返回进度。真正可靠的进度记录一定在服务端localStorage只是锦上添花。3. 后端接口与分块合并实现3.1 三个核心接口的协议设计后端最少需要三个接口GET /upload/progress查询已上传分块索引列表返回 uploadedIndexes。POST /upload/chunk接收单个分块multipart参数为 file、identifier、chunkIndex、totalChunks。POST /upload/merge合并分块参数为 identifier、fileName、totalChunks、fileSize。用Spring Boot实现接口路径和参数按团队规范调整即可。下面这段代码是分块上传接口的核心处理逻辑PostMapping(/upload/chunk) public Result uploadChunk( RequestParam(file) MultipartFile file, RequestParam(identifier) String identifier, RequestParam(chunkIndex) int chunkIndex, RequestParam(totalChunks) int totalChunks) { // 校验identifier合法性防止路径穿越 String safeIdentifier sanitize(identifier); Path chunkDir Paths.get(rootDir, temp, safeIdentifier); if (!Files.exists(chunkDir)) { Files.createDirectories(chunkDir); } Path chunkFile chunkDir.resolve(chunkIndex .part); // 已存在且大小一致就直接跳过保证幂等 if (Files.exists(chunkFile) Files.size(chunkFile) file.getSize()) { return Result.ok(); } file.transferTo(chunkFile); return Result.ok(); }分块文件命名直接用索引号比如0.part、1.part。这个设计非常关键因为后面查询进度时只需要列目录就能知道哪些块已经传过了不需要额外建表记录。3.2 分块存储路径怎么组织我惯用的目录结构如下file-storage/ temp/ identifier/ 0.part 1.part ... final/ identifier.ext其中identifier是整个文件的唯一标识。临时目录按identifier建文件夹分块文件名就是索引。这样设计有几点好处多个分块并发上传互不干扰同一分块重复上传时天然覆盖查询进度只需列目录合并时按索引直接读。必须注意安全校验。identifier如果直接拼进文件路径用户传一个包含路径分隔符的字符串就可能造成路径穿越。我会做一层白名单校验只允许字母、数字、短横线和下划线长度限制32位以内。chunkIndex也要限制在0到totalChunks-1之间。3.3 合并的两种主流写法和原子性合并最朴素的写法是读取所有分块按索引顺序写入最终文件。Java里用Files.copy配合OutputStream非常简单PostMapping(/upload/merge) public Result merge(RequestParam(identifier) String identifier, RequestParam(fileName) String fileName, RequestParam(totalChunks) int totalChunks) throws IOException { Path chunkDir Paths.get(rootDir, temp, safeIdentifier); Path finalFile Paths.get(rootDir, final, buildFinalFileName(identifier, fileName)); if (Files.exists(finalFile)) { return Result.ok(finalFile); // 秒传/已合并场景 } try (OutputStream out new BufferedOutputStream(new FileOutputStream(finalFile))) { for (int i 0; i totalChunks; i) { Path part chunkDir.resolve(i .part); if (!Files.exists(part)) { throw new IllegalStateException(分块缺失: i); } Files.copy(part, out); } } deleteRecursively(chunkDir); return Result.ok(finalFile); }如果分块数量特别大或者未来要支持随机访问读取可以用RandomAccessFile在每个分块写入时seek到offsettry (RandomAccessFile raf new RandomAccessFile(finalFile.toFile(), rw)) { raf.seek((long) chunkIndex * chunkSize); raf.write(chunkBytes); }合并操作的原子性非常重要。我强烈建议用先写临时final文件再rename到最终位置的方式。因为同一文件系统内的rename是原子操作不会出现两个请求同时写同一个最终文件导致内容交错的情况。如果有多个进程同时调用merge只有一个会成功rename另一个会发现目标文件已存在直接返回成功。这比分布式锁简单可靠得多。注意临时final文件必须和最终文件放在同一个磁盘分区跨分区rename会退化成先拷贝再删除原子性就失效了。3.4 完整性校验三查一个都不能少合并后文件损坏是用户最反感的故障。我总结了一套三查流程查数量分块目录中的.part文件数量必须等于totalChunks少一块都不能合并。查大小除最后一块外每个分块大小应等于约定的chunkSize最终文件大小应与前端上传的fileSize一致。查哈希业务有秒传需求时建议在合并完成后对整个最终文件算一次MD5与前端计算的identifier对比。不一致的立即删除文件并返回错误。哈希校验会多花一点时间但对大文件可靠性极有价值。我线上项目会在合并流程后异步补一道全量校验校验失败就隔离文件并告警不影响用户先下载。4. 秒传、临时文件清理与并发一致性4.1 秒传原来和断点续传共用一套设施秒传的本质是文件在服务器上已经存在不需要重复传输。在全文件哈希的加持下前端算出identifier后先调progress接口服务端发现final目录下已经存在该identifier对应的完整文件就能直接返回fileExiststrue和文件访问地址。前端看到这个标记跳过所有分块上传直接进入上传成功界面。严格来说只有完整文件哈希才能保证两个文件完全一致弱指纹存在极小概率撞车。所以工程上可以这样折中弱指纹命中后先不急着秒传后台对已有文件做一次全量哈希校验确认一致才开放秒传结果如果不一致就提示用户重新上传。这样体验和准确性都能兼顾。4.2 临时分块文件必须有过期清理用户传了一半就关掉页面temp目录会积累大量.part文件。几天不清磁盘必定告警。我的清理策略是定时任务扫描temp目录删除超过24小时没有变化的文件。实现时用最后一次更新时间和当前时间比较Scheduled(cron 0 0 3 * * ?) public void cleanExpiredTemp() { long expireThreshold System.currentTimeMillis() - 24L * 3600 * 1000; // 遍历temp目录下每个identifier子目录 // 子目录内所有.part最后修改时间都早于阈值时整目录删除 }有一个细节值得注意大文件有可能被用户断断续续传几天如果只按目录最后修改时间判断目录里某个文件今天被更新过这个目录就不该被清理。所以清理逻辑要考察目录内所有文件的最新时间而不是只看目录本身时间否则可能误删还在传输中的分块。更稳的做法是每次分块上传时更新一个活跃标记文件定时任务只清理没有活跃上传任务且超过保留周期的目录。4.3 处理并发提交与合并冲突同一个identifier有两个终端同时上传的极端场景虽然少见但必须考虑。合并接口并发调用时最安全的方式就是临时final文件rename。我在第四章已经讲过这个思路这里再补充一点分块上传接口本身也要做幂等。分块文件已存在且大小一致时直接返回成功这样前端并发重试同一分块时不会出现两个线程同时写一个.part文件的竞争问题。分布式部署下需要额外注意如果后端有多台节点负载均衡把同一文件的分块请求分发到不同机器合并时在其中一台节点上就找不到其他分块。解决办法是共享存储或对象存储把分块写到同一块共享空间如果只是单机应用这个坑不存在但提前了解总没有坏处。5. 实战中常见的坑与排查思路5.1 分块大小怎么定给一个计算思路分块大小没有绝对标准但可以按场景估算。普通公网环境下2MB到10MB比较合适内网或高带宽环境可以放宽到10MB到50MB弱网移动端1MB到4MB更稳因为单个分块体积小、超时概率低。分块太小HTTP请求数量暴涨每个请求都有建连、请求头、响应解析的开销。5MB分块下1GB文件是200个请求1MB分块就是1024个请求配合3并发虽然能跑但框架处理开销成比例上升。分块太大断点续传的粒度变粗比如用户传到95%断了剩下5%可能还有几十MB需要重传。我的建议是没有特殊要求直接用5MB弱网场景选1MB到2MB对要在服务端做流式处理的大文件选1MB到2MB也更方便后续拼接。5.2 哈希计算一直卡死页面怎么办即使采用增量算法几GB文件完整算一遍哈希也要好几秒。除了前面提到的Web Worker方案我实际项目里用的策略是分层指纹文件超过1GB先取文件首尾各1MB、文件大小和最后修改时间拼接算一个弱指纹几十毫秒完成用于断点续传足够了。秒传校验时再触发完整哈希避免用户无谓地等待。如果业务对精确度要求极高可以做成异步完整哈希上传时用弱指纹合并完成后在服务端异步计算完整哈希并记录到文件元数据。后续再次上传时若弱指纹相同且服务端已经有完整哈希匹配记录再放行秒传。5.3 合并出来的文件内容错位或者损坏这是最让人抓狂的问题。前端明明显示100%上传成功后端合并后文件却打不开。排查优先级如下确认前端chunkIndex从0开始且totalChunks计算正确前后端对这两个值的理解是否一致。后端除了读文件名还要校验每个分块的实际大小除最后一块外都应等于约定chunkSize。大小不符说明传输中文件被截断或写错位置。检查分块上传接口是否有幂等保护。如果两个并发请求同时写同一个.part文件一个写一半、另一个也写一半文件必然损坏。用过存在且大小一致就跳过的逻辑可以有效避免。多机部署时检查所有分块请求是否到了同一台机器。如果负载均衡轮询到不同节点分块散落各地合并时必然缺块。要么用共享存储要么按identifier做粘性路由。5.4 显示已上传但文件大小对不上前端认为某个分块上传成功但服务端实际落盘不完整。常见原因是后端返回响应时连接已经断开或服务端流读取还没读完就返回成功。规避方法很简单上传接口必须等文件完整落盘并校验大小后才返回成功响应。前端重试时也不要完全相信本地记录查询接口要能返回某分块已存在且大小一致的确认结果大小不一致时前端重新传该块。5.5 磁盘被临时分块撑爆某个大文件上传中断后用户直接离开临时分块一直留在磁盘。除了定时清理任务我还建议在合并成功、用户主动取消两个时机主动清理临时目录。同时监控temp目录总大小超过阈值及时告警。如果用户传了一半关掉浏览器保留24小时的分块是合理的第二天再打开页面还能续传超过保留期就删不要手软。6. 能顺手提升体验的几个落地细节6.1 进度条和剩余时间怎么算分块上传的进度不要用文件字节数比例直接用已成功上传的分块数 / 总分块数更准确。前端每成功一个分块就用task.uploadedIndexes.size除以task.totalChunks更新进度条。由于并发上传多个分块会同时成功进度条呈台阶式前进是正常的不用担心。续传恢复时查询接口返回的已上传索引要立即回填进度条直接回到中断前的位置。6.2 接口返回结构统一设计前后端联调时最烦的是每个接口返回格式不一样。我统一使用这样的JSON结构{ code: 0, message: success, data: { identifier: abc123, uploadedIndexes: [0, 1, 2], fileExists: false, fileUrl: } }这样前端无论调进度、上传、合并接口解析逻辑都是同一套。progress接口里带fileExists字段前端可以直接进入秒传流程省掉一次额外的查询请求。另外建议在code非0时附带便于排查的message比如分块缺失: index7线上排错会省很多时间。6.3 上生产前的自测清单给准备落地这个方案的开发者一份检查清单1GB文件传到一半中断恢复后确认只传输缺失分块没有重新传整个文件。故意让某个分块上传失败前端重试后最终文件内容与源文件完全一致。合并接口重复调用两次不会生成两个最终文件或损坏文件。临时目录在合并成功后确实被清理清理任务不会误删活跃上传中的分块。请求带畸形identifier时后端不会出现路径穿越或异常崩溃。多个用户同时上传不同文件identifier不冲突进度互不干扰。弱网环境下模拟断网恢复后上传进度正确。我自己的习惯是每次接到文件上传需求先把分块大小、并发数、临时文件保留周期这三个参数写死在配置中心方便线上动态调整。分块方案真正跑顺之后后端代码基本不用再改反而是前端状态恢复和进度展示反复调了几轮。说到底断点续传的可靠性不靠单个妙招而是前后端对切块、进度、合并、清理这套协议的一致理解。把每个环节都当成有状态、可恢复的过程来设计文件再大也不会成为事故源。