做视频上传这个需求第一版我直接用的表单POST整个文件文件一旦到了几百MB甚至GB级别前端等响应等到超时后端的Tomcat连接也被长时间占住别的请求跟着遭殃。后来我把视频按固定大小切成小块一块一块传最后在服务端拼起来这个问题才算彻底解决。这篇文章是整套分片上传流程的完整复盘后端用Java实现初始化、分片接收和合并前端用原生JavaScript做文件切片、并发控制和断点续传不依赖任何付费服务代码可以直接拿来改。适合正在做视频、音频、压缩包这类大文件上传功能的Java开发也适合想把现有上传功能升级成断点续传的全栈同学参考。看完你会对整个分片上传的实现路径有清晰的认识知道每个接口为什么这么设计踩坑之后应该从哪里开始查。1. 为什么视频文件非得分片上传三个绕不开的痛点1.1 整体上传的瓶颈到底在哪先说最直观的问题HTTP连接占用。浏览器对同一个域名的并发连接数量是有限制的HTTP/1.1下一般只有6个左右。一个视频文件在上传过程中这个连接会一直保持占用状态前端页面里其他资源请求、接口请求都等着排队。我在项目里遇到的情况是一个大文件传上去之后页面上的其他请求全部卡住用户直观感受就是“页面死了”。第二个是失败成本。传了二十分钟的网络中途断一下整个文件要重新上传。这个体验可以说是灾难级别的。尤其是在移动网络环境下一个200MB的视频传了80%断掉用户基本没有耐心再来一次。第三个是服务器压力。单个大文件上传时框架层要么把文件先写临时文件要么是缓冲到磁盘占用大量磁盘空间和带宽。而且如果一个请求长时间占用Tomcat线程本身就没有多少线程的Tomcat会很快被大文件上传拖垮其他正常的小请求也进不来了。1.2 分片上传到底解决了什么分片上传的思路特别朴素把一个完整的视频文件按照固定的字节数切成多块每块单独上传最后在服务端把所有的块按顺序合并回完整文件。这就像寄一批货物你不会全部塞进一个大集装箱里发走那样路上任何一环出问题整个车就废了你会拆成多个小包裹走多个车甚至多个渠道丢了一件补寄一件就行。换个说法整体上传的失败粒度是“整个文件”分片上传的失败粒度是“一个小分片”。一个2GB的视频切成512个4MB的分片有一个分片传失败了只重新传那一个分片就够了。分片之间的并发上传还能明显拉高整体的吞吐速度用户体验从“等半天看不到进度”变成“进度条稳定上涨”。分片上传天然是断点续传的基础后续我会详细讲怎么基于分片状态恢复任务。2. 整体流程与核心设计先把地图画出来再动手2.1 分片上传的三个阶段和关键路径很多文章一上来就贴代码我觉得还是先把流程讲透。分片上传整体可以拆成三个阶段初始化、上传分片、合并文件。初始化阶段前端把文件名、文件大小、总分片数、分片大小这些元数据告诉后端后端生成一个本次上传任务的唯一ID我们通常叫它uploadId同时在后端创建好这个任务对应的临时目录。这个阶段的本质是“提前占坑”让后端知道接下来会有一批分片要过来。上传分片阶段前端把文件用File.slice()切成blob把每一个分片带上uploadId和分片序号通过FormData POST给后端。后端收到之后校验参数然后把分片写入当前uploadId对应的临时目录。这个阶段是最耗时的也是前端进度条、并发控制的主战场。合并阶段前端确认所有分片都已经传成功了就请求一次合并接口。后端检查分片数量是否齐全如果齐全就按分片序号从小到大依次读取写到一个完整的目标文件里最后校验合并后的文件大小是否和初始化时的fileSize一致一致则清理临时分片文件返回最终文件路径。2.2 uploadId和核心参数的意义uploadId是整个过程中的黏合剂它连接了三次请求初始化拿到它上传分片带上它合并时用它确认任务。每个uploadId对应一个独立的上传上下文后端用它在临时目录里隔离不同任务的文件避免互相串数据。还需要几个关键参数这里用一个表格说明它们的分工参数作用说明uploadId任务唯一标识服务端生成全局唯一fileName原始文件名合并时用于生成目标文件名注意做安全处理fileSize文件总大小合并后校验用防止分片缺失或损坏chunkSize单个分片大小前端切片时使用后端也用它做基础校验totalChunks总分片数前端根据文件大小计算后端合并时循环依据chunkIndex当前分片序号从0开始后端用于命名临时分片文件taskKey断点续传任务标识前端生成同一文件固定不变后端据此复用uploadId其中taskKey是断点续传的关键后面第四节会专门展开。2.3 技术选型的思路与理由后端我用的Spring Boot因为MultipartFile接收文件分片非常直接一个接口就能搞定。临时分片我建议直接落到本地磁盘不建议把分片内容存内存里。网上有人把每个分片转成byte[]再存Redis或者塞到Map里一旦文件多了内存直接扛不住而且Redis存大对象也不划算。磁盘文件的方式最简单一是不占JVM堆内存二是断点续传时直接检查文件是否存在就行。前端用原生File对象加File.slice()切片配fetch或者XMLHttpRequest都可以。不需要引入第三方上传库核心逻辑也不复杂。为什么不用WebSocket或者别的协议HTTP分片上传最大的好处是兼容性好浏览器、运维、CDN层都能直接处理出现故障时排查起来也简单。3. 后端实现Spring Boot 下的初始化、分片接收与合并3.1 初始化上传接口占住坑位建立任务上下文后端第一个接口是初始化。前端会带着文件名、文件大小、总分片数、分片大小信息来请求后端校验参数之后生成uploadId创建临时目录并把任务状态存起来。先看代码RestController RequestMapping(/api/upload) public class UploadController { private static final String UPLOAD_ROOT /data/upload; // 生产环境请替换为 Redis这里仅做单机演示 private final MapString, UploadInfo uploadInfoMap new ConcurrentHashMap(); // taskKey - UploadInfo用于断点续传时复用任务 private final MapString, UploadInfo taskKeyToUploadInfo new ConcurrentHashMap(); /** 1. 初始化上传任务 */ PostMapping(/init) public ResultString init(RequestBody InitReq req) { if (!StringUtils.hasText(req.getFileName()) || req.getFileSize() null || req.getFileSize() 0) { return Result.error(文件信息不完整); } System.out.printf(初始化任务: file%s, size%d, chunks%d%n, req.getFileName(), req.getFileSize(), req.getTotalChunks()); // 如果 taskKey 已有对应任务直接复用旧 uploadId实现断点续传 if (StringUtils.hasText(req.getTaskKey())) { UploadInfo existInfo taskKeyToUploadInfo.get(req.getTaskKey()); if (existInfo ! null) { return Result.success(existInfo.getUploadId()); } } // 生成新任务 String uploadId UUID.randomUUID().toString().replace(-, ); UploadInfo info new UploadInfo(); info.setUploadId(uploadId); info.setFileName(req.getFileName()); info.setFileSize(req.getFileSize()); info.setChunkSize(req.getChunkSize()); info.setTotalChunks(req.getTotalChunks()); info.setTaskKey(req.getTaskKey()); try { Path dir Paths.get(UPLOAD_ROOT, uploadId, chunks); Files.createDirectories(dir); } catch (IOException e) { return Result.error(服务器创建目录失败); } uploadInfoMap.put(uploadId, info); if (StringUtils.hasText(req.getTaskKey())) { taskKeyToUploadInfo.put(req.getTaskKey(), info); } return Result.success(uploadId); } }uploadId我用的UUID去掉横杠。这里有个容易被忽略的点临时目录是按uploadId隔离的每个任务一个目录分片文件统一放在chunks子目录下这样后面合并和清理都很方便。taskKey复用逻辑非常关键。前端刷新页面之后如果同一个文件再用同样的taskKey来初始化后端直接返回原来的uploadId不用重新创建任务。已存在于磁盘的分片文件也不会被覆盖这样续传就不需要额外做任何搬移工作。3.2 接收分片接口校验、落盘与进度记录分片上传接口是核心中的核心每个分片到达后端之后要做的事情就是校验任务是否存在、校验参数、把分片文件落盘、记录当前分片序号。代码在这里/** 2. 上传单个分片 */ PostMapping(/chunk) public ResultVoid uploadChunk(RequestParam String uploadId, RequestParam Integer chunkIndex, RequestParam Integer chunkTotal, RequestParam MultipartFile file) { UploadInfo info uploadInfoMap.get(uploadId); if (info null) { return Result.error(上传任务不存在或已过期); } // 防御防止传进来的分片内容和申明的分片大小明显不一致 if (file.isEmpty() || file.getSize() info.getChunkSize() * 2) { return Result.error(非法分片内容); } try { Path chunkFile Paths.get(UPLOAD_ROOT, uploadId, chunks, chunkIndex .part); file.transferTo(chunkFile); // 记录已上传分片 info.getUploadedChunks().add(chunkIndex); } catch (IOException e) { return Result.error(分片保存失败); } return Result.success(); }这里有几个细节值得注意。分片文件命名直接用的chunkIndex比如0.part、1.part。由于合并的时候是按数字顺序循环读取的不会像String排序那样出现“10排在2前面”的乱序问题。有人喜欢用带前导零的名字比如000001.part其实没必要只要合并时按数字索引读就不会错。顺便提醒一下配置文件里的multipart限制。Spring Boot默认的单文件上传下线是1MB如果分片大小是5MB那不调配置直接上传就会报错。建议在application.yml里这样设置spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MBmax-file-size要大于等于前端设定的分片大小max-request-size要覆盖整个请求体大小一般设为分片大小的两倍够用了。上传完每个分片之后我会把这个分片序号加进info的uploadedChunks集合里。这个集合的意义在于断点续传时后端能够告诉前端“哪些分片已经有了”前端直接跳过这些分片只传缺失的。3.3 合并文件接口顺序拼接、完整性校验与清理当前端确认所有分片都上传完成后就会触达合并接口。这个接口做的事情是检查分片是否齐全按序号顺序把分片文件合并成一个完整视频校验大小最后清理遗留的分片文件。/** 3. 合并分片 */ PostMapping(/merge) public ResultString merge(RequestParam String uploadId) { UploadInfo info uploadInfoMap.get(uploadId); if (info null) { return Result.error(上传任务不存在); } if (info.getUploadedChunks().size() ! info.getTotalChunks()) { return Result.error(分片不完整已上传: info.getUploadedChunks().size() / info.getTotalChunks()); } Path chunksDir Paths.get(UPLOAD_ROOT, uploadId, chunks); String safeFileName Paths.get(info.getFileName()).getFileName().toString(); Path targetFile Paths.get(UPLOAD_ROOT, uploadId, safeFileName); try (FileOutputStream out new FileOutputStream(targetFile.toFile())) { for (int i 0; i info.getTotalChunks(); i) { Path partFile chunksDir.resolve(i .part); if (!Files.exists(partFile)) { return Result.error(缺少分片: i); } try (InputStream in Files.newInputStream(partFile)) { byte[] buf new byte[8192]; int len; while ((len in.read(buf)) ! -1) { out.write(buf, 0, len); } } } } catch (IOException e) { return Result.error(合并失败); } // 大小校验防止分片损坏或漏传 try { if (Files.size(targetFile) ! info.getFileSize()) { return Result.error(合并后文件大小与预期不符); } } catch (IOException e) { return Result.error(文件校验失败); } // 清理临时分片目录 deleteDirectory(chunksDir); return Result.success(targetFile.toString()); }合并过程就是简单的字节流顺序写入。8KB的缓冲区是老写法实际用的时候可以放大一点比如64KB减少系统调用的次数合并速度会明显提升。分片文件本身就是二进制字节不是文本文件所以不需要加任何分隔符直接拼一起就是原始视频的完整文件。关于文件名这里用Paths.get(fileName).getFileName()取了一下是为了防止用户上传的原始文件名里带了路径比如../../etc/passwd这种恶意构造。更严谨的做法是服务端完全重新生成文件名把原始文件名存到数据库的元数据表里静态访问时再做映射。如果业务上不要求保留原始文件名我建议直接随机生成文件名彻底杜绝路径穿越风险。合并完之后的分片清理也是必须做的工作。很多任务中断之后磁盘上的临时分片不会自己消失要加一个定时任务去扫描过期的uploadInfo把超过24小时还没合并的目录清掉。这个可以在后面提到。3.4 状态查询接口给断点续传明确依据合并之前前端需要知道哪些分片已经上传成功了。所以状态查询接口非常简单就是把uploadInfo里的uploadedChunks集合返回给前端。/** 4. 查询上传状态已上传的分片序号集合 */ GetMapping(/status) public ResultSetInteger status(RequestParam String uploadId) { UploadInfo info uploadInfoMap.get(uploadId); if (info null) { return Result.error(上传任务不存在); } return Result.success(info.getUploadedChunks()); }这个接口看起来简单但在断点续传方案里地位很高。第一次上传时前端初始化完拿到uploadId马上拉一下状态集合通常是空的没问题。刷新页面或者网络恢复之后前端带着同样的uploadId再来查一次后端把已经成功落盘的分片序号返回前端就能快速跳过这些分片只传剩下的。4. 前端实现File.slice 切片、并发控制与进度展示4.1 切片逻辑把大视频裁成均匀的块前端切片的API是File.slice()用法是把一个File对象按起始字节和结束字节切出一段新的Blob。const CHUNK_SIZE 5 * 1024 * 1024; // 5MB const totalChunks Math.ceil(file.size / CHUNK_SIZE); function getChunkBlob(file, chunkIndex) { const start chunkIndex * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); return file.slice(start, end); }注意最后一个分片可能小于CHUNK_SIZE直接用Math.min兜底就行不需要单独判断。这是切片逻辑里最容易写错的地方如果直接用start CHUNK_SIZE作为结束位置最后一个分片的end会超出文件大小导致切出来一个空blob。用Math.min就能避免。关于分片大小的选择我建议视频上传从5MB起步。分片太小比如256KB请求数量多网络往返次数多总吞吐反而下降分片太大比如50MB失败重传的成本又偏高。5MB是一个均衡值在弱网环境下也能保证每个分片在一两分钟内完成传输。如果网络状况好、文件巨大可以适当提高到10MB。4.2 并发上传控制既要有速度又不能把服务器压垮分片都切好之后接着上传。如果使用for循环一个接一个传速度太慢如果一次性把所有分片全部发出去浏览器连接数和服务器压力又扛不住。所以这里需要一个固定并发数的worker模型。我用的是一个极简实现维护一个全局的当前分片索引然后启动固定数量的worker每个worker循环取任务。async function uploadChunk(file, uploadId, chunkIndex) { const blob getChunkBlob(file, chunkIndex); const formData new FormData(); formData.append(uploadId, uploadId); formData.append(chunkIndex, chunkIndex); formData.append(chunkTotal, totalChunks); formData.append(file, blob, chunk-${chunkIndex}); const response await fetch(/api/upload/chunk, { method: POST, body: formData }); return response.json(); } async function uploadWithConcurrency(file, uploadId, uploadedSet, concurrency 3) { let currentIndex 0; let uploadedCount uploadedSet.size; const total Math.ceil(file.size / CHUNK_SIZE); async function worker() { while (currentIndex total) { const idx currentIndex; if (uploadedSet.has(idx)) { continue; } // 简单重试失败最多重试3次指数退避 let retry 0; while (retry 3) { try { const data await uploadChunk(file, uploadId, idx); if (data.code 200) { uploadedSet.add(idx); uploadedCount; updateProgress(uploadedCount, total); break; } else { retry; await sleep(1000 * Math.pow(2, retry)); } } catch (e) { retry; await sleep(1000 * Math.pow(2, retry)); } } } } const workers []; for (let i 0; i concurrency; i) { workers.push(worker()); } await Promise.all(workers); }这里有一个非常核心的点不能用Promise.all把所有分片直接丢进去跑因为那等于没有限制并发。超出浏览器连接上限之后多出来的请求并不会立刻发送而是等前面的连接释放整体反而更慢服务器端也会突然涌进大量并发请求。并发数取3到5比较合理Chrome对同一域名的连接限制在6个左右取3是给页面其他请求留出余量。这个worker写法还有一点好处就是任务分配是动态的。有的分片因为网络原因传得比较慢快的worker会继续拿新任务不会出现固定分配导致某一组worker闲置的情况。4.3 断点续传与秒传的完整闭环断点续传听起来很高端实现起来核心就两步前端生成稳定的taskKey后端能根据taskKey找回上传状态。taskKey的生成我用的常规做法是拼接文件的名字、大小、修改时间这三个字段function getFileTaskKey(file) { return ${file.name}_${file.size}_${file.lastModified}; }这三个字段组合出来的key在绝大多数场景下是稳定的。同一个文件即使换了几次浏览器重启这几个字段也不会变。如果你对准确率有更高要求可以去算文件内容的MD5但300MB级别的大文件MD5计算时间很长用Web Worker来做的话又会引入额外复杂度所以一般业务没人用MD5当taskKey反而是把MD5用在秒传场景里。后端在初始化接口里已经实现了taskKey复用逻辑。前端流程变成这样async function startUpload(file, onProgress) { const taskKey getFileTaskKey(file); const totalChunks Math.ceil(file.size / CHUNK_SIZE); const initRes await fetch(/api/upload/init, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, totalChunks, chunkSize: CHUNK_SIZE, taskKey }) }); const initData await initRes.json(); const uploadId initData.data; // 查询已上传分片 const statusRes await fetch(/api/upload/status?uploadId${uploadId}); const statusData await statusRes.json(); const uploadedSet new Set(statusData.data); await uploadWithConcurrency(file, uploadId, uploadedSet, 3); // 所有分片传完后合并 const mergeRes await fetch(/api/upload/merge?uploadId${uploadId}, { method: POST }); return mergeRes.json(); }用户刷新页面之后重新选择同一个视频taskKey一算还是那个值后端返回的还是同一个uploadIdstatus接口带回了已上传的分片序号前端从断点位置继续传用户看到的进度直接就是从之前的位置起步。秒传的逻辑就更简单了它本质上是查重。比如视频平台的用户上传了同一个片源后端可以先查一次是否有MD5相同的完整文件如果有就直接返回已存在文件的访问地址不再走分片上传流程。这个功能不是分片上传的必选项但如果业务上大量重复上传这个优化能节省非常可观的存储和带宽。4.4 进度展示用已上传分片数代替字节数更靠谱进度条实现上有两种思路一种是用已上传的分片数除以总分片数另一种是累计已上传的字节数除以总字节数。在分片并发上传的场景下我建议直接用分片数算实现简单且稳定。function updateProgress(uploadedCount, totalChunks) { const percent Math.round((uploadedCount / totalChunks) * 100); const progressText ${uploadedCount}/${totalChunks} 分片${percent}%; document.getElementById(progress).textContent progressText; }为啥不建议统计字节数因为总字节数需要在每次上传分片后累加一旦有分片失败重试累加逻辑就要处理重复计算搞不好进度条还会往回跳。用分片数计算就没有这个问题只要一个分片上传成功计数加一进度只会往前走。真实业务里如果在并发上传进度条是按“已成功分片数”跳动的5MB一个分片在一个100MB的文件里进度条会跳20次视觉上已经很平滑。只有在1MB以下的极小额分片里才会感觉到进度条顿挫那可以前端切小一点分片。5. 常见问题与排查技巧实录5.1 合并后的视频打不开或者花屏这类问题优先怀疑三件事分片缺失、顺序错乱、文件损坏。分片缺失好查看后端日志里merge接口有没有报“分片不完整”。如果前端传了512个分片后端uploadedChunks的size只有510说明有2个分片在传输过程中失败了前端重试没生效。排查点先看浏览器的network面板确认那些失败的请求有没有走重试逻辑。顺序错乱的情况很少见因为merge接口是按数字索引循环读取的。但如果你不是用chunkIndex命名临时文件而是用分片文件名自带的某个字符串排序就非常容易乱。我自己踩过一次把分片名叫成part-1、part-2、part-10用字符串排序后10排在2前面合出来的视频自然播不了。解决办法就是统一用数字索引命名按数字循环读。文件损坏的话可以去比较一下合并后文件大小和源文件大小。文件大小一致不代表内容一定正确但大小不一致一定有问题。如果大小一致还是播放不了可以先随便挑一个已知的MP4文件小一点的几十MB手动切分成片再合并一次排除是源视频本身格式的问题。5.2 服务器内存暴涨、连接超时线上服务器突然CPU升高、Tomcat线程被打满八成是并发数调太高了。前端如果一次性把所有分片全发出去一个视频几百个请求同时涌过来Spring Boot的内嵌Tomcat默认线程池200左右一下子就会被同时涌入的multipart请求占满每个请求又在等待IO处理整个服务对其他业务接口的响应就全卡住了。正确的做法是严格控制并发数3到5个足够。同时可以在网上部署一台Nginx做缓冲Nginx的client_max_body_size要调成大于分片大小的值不然所有分片请求都会被Nginx直接拦下来。还有一个隐藏点如果你用的是多实例部署同一个uploadId的分片请求要通过负载均衡路由到同一台机器或者像前面说的把分片状态放到Redis、文件放到共享存储否则就会出现“机器A收了2号分片机器B收3号分片时查不到任务”这种诡异问题。5.3 刷新页面后断点续传失效断点续传失效主要有两个原因。第一个是前端生成的taskKey前后不一致比如你在init时用了file.name加file.size刷新后又加了file.type进去key一变后端把它当成全新任务。这个很好排查在浏览器console里打印一下taskKey对比两次是否一样就行。第二个原因是后端服务重启导致内存中的uploadInfoMap丢了。演示代码里用的ConcurrentHashMap存状态服务进程一重启所有上传任务全部消失了前端拿着同样的uploadId去查询后端直接返回“任务不存在”前端只能重新初始化。不考虑生产环境的话单靠内存Map是能跑的。生产环境必须把UploadInfo转成结构化数据存到Redis里Redis的TTL还能顺带处理超时清理比Spring的定时任务优雅得多。5.4 本地一切正常线上却传不上去这个问题我遇到不下三次了。本地开发用的是Spring Boot默认配置没有Nginx层一个5MB的分片轻轻松松到了后端。线上环境前面挂了Nginx默认的client_max_body_size是1MB任何一个分片超过1MB就直接被Nginx返回413前端看到的是“Request Entity Too Large”但本地根本复现不了。遇到这种问题先看响应状态码413基本就是Nginx的body大小限制如果是一般的500错误再去看后端日志。Nginx配置我会写成这样server { listen 80; server_name yourdomain; client_max_body_size 20m; location /api/upload/ { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_pass http://backend; } }等等这里需要补充一个点。如果只有client_max_body_size改大了但后端Spring的spring.servlet.multipart.max-file-size还是默认1MB请求通过了Nginx在后端依然会被拒。所以两头都要改前端分片大小、Nginx的client_max_body_size、Spring的max-file-size这三个值必须保持一个合适的递进关系。6. 我踩过几次坑之后的一些体会最后分享一下我对这套方案的实际手感。分片大小、并发数这些参数别迷信网上说什么就是什么最好在你的目标环境里实测一轮。我用5MB分片和3个并发在普通4G网络环境下传一个800MB的视频整体耗时大概在十来分钟左右服务器CPU和内存都稳定。分片调到20MB、并发拉到6之后传输速度确实快了一点但线上服务器的连接数明显飙升失败重试的频率也高了最后我还是回到5MB和3并发。还有一个小建议合并动作放在上传线程里做遇到大文件合并会比较吃IO如果并发合并的任务多服务可能出现短暂的IO瓶颈。有条件的话可以把合并操作丢到消息队列里异步执行前端轮询合并状态整体健壮性会好很多。这整套分片上传方案核心代码并不复杂真正麻烦的是各种边界情况网络中断、服务重启、分片丢失、并发失控。把这几个场景一条一条在本地用程序模拟一遍可比直接上线之后再排查要省心得多。希望这篇文章能帮你把自己的上传模块跑得更稳。