资讯动态

Java视频文件分片上传实战:切片、合并与断点续传全解析

发布时间:2026/10/13 12:06:57 来源:尧图企业网站定制
视频文件分片上传在 Java 项目里属于那种“看起来不难、做起来全是坑”的功能。你给用户一个上传入口他传一个 300MB 的视频Tomcat 的默认请求大小没调直接给你抛一个 413前端页面转圈半天最后告诉你上传失败用户当场想卸载应用。我做过几个涉及视频上传的后端服务最早都是接收完整文件后来发现这条路根本走不通才开始系统接触分片上传。分片上传的核心价值其实就一句话把一个大问题拆成多个小问题每个小问题都能独立失败、独立重试。视频文件少则几十MB多则几个GB网络稍微抖动一下整包传输直接断掉而分片之后一个分片传失败只重传那一片不需要从头再来。这篇文章我直接把一套可落地的方案写出来包括前端怎么切片、Java 后端怎么接收和合并、断点续传怎么做以及我在生产环境里踩过的几个典型问题。适合正在做视频上传、网盘类功能或者被大文件上传接口搞到头大的 Java 后端同学参考。1. 大视频上传的三个死穴以及分片为什么能救场1.1 传统单文件上传为什么扛不住大视频先说一个最简单的场景前端一个input typefile用户选了一个 500MB 的视频请求体直接把这个文件塞给后端接口。这一步会同时踩中几个问题。第一个是 HTTP 连接长时间占用。视频上传不是瞬时完成500MB 的数据在普通家用宽带的弱网环境下可能要好几分钟。连接只要在这期间断一次整次请求就废了。后端的 Servlet 容器一般都有连接超时时间就算你自己没配前面的负载均衡器、网关层也都会设置超过一定时长就会主动断开用户必然失败。第二个是请求体大小限制。Tomcat 的maxPostSize、Spring Boot 内置容器对表单提交的大小限制默认值都不高。在我印象里不少版本默认才几MB不调配置的话大文件根本过不去。很多人第一次踩坑就是在这里排查半天才发现是容器限制。第三个是服务端内存压力。如果把一个 500MB 的文件一次性读入内存再做处理高并发场景下就是灾难。两个用户同时传1GB 内存就没了服务很容易被拖垮。你要是用byte[] bytes file.getBytes()这种写法那是真没跑过生产环境。所以单文件上传这条路对大视频基本是堵死的。要么改配置硬扛要么换思路。1.2 分片上传的核心思路化整为零逐块传输分片上传的思路其实特别朴素把视频文件按固定大小切成若干块每一块单独发起一次 HTTP 请求上传到服务端等所有分片都传完了再让服务端按顺序把分片合并成完整文件。你可以把它理解成搬家。一个大衣柜直接搬不下电梯就拆成柜门、侧板、层板分别运上楼到了家里再按照图纸装回去。视频文件就是那个大衣柜分片就是拆下来的零件最后合并就是重新组装。这个思路带来的最大好处是每个分片之间完全独立。一个分片传失败了只需要重传这一片其他已传成功的分片不会受影响。配合每个分片的状态记录用户中途退出、网络断开、甚至浏览器关闭下次进来都能从断点继续传而不是整个文件推倒重来。1.3 分片之后断点续传、并发加速、秒传都顺理成章分片上传不只解决了“传不动”的问题它顺带把另外三个常见的体验问题也给解了。断点续传是天然的。服务端只要有一个接口能告诉前端“哪些分片已经传过了”前端就能跳过错过的分片只补传缺失的部分。这个功能在单文件上传里几乎没法做因为你没法确定服务端到底收到了多少字节。并发加速也很容易实现。分片之间没有依赖关系前端可以同时发起多个上传请求。比如一次传 3 个分片就好比搬家时安排了 3 个人分别搬不同的箱子整体传输速度比串行快不少。这个并发数不能无脑调大控制好度就行后面细说。秒传则是分片方案的一个进阶玩法。前端在上传前先对文件算一个 MD5把这个值发给后端查询。如果后端发现同样 MD5 的文件已经存在就不用真的上传直接告诉前端“秒传成功”。这是很多网盘类产品“上传秒完成”背后的原理。不过话说回来分片方案也不是没有代价。分片的上传状态要管理合并流程要保证顺序正确合并完的临时文件要清理否则磁盘会被垃圾文件堆满。复杂度比单文件上传高了一个量级但相比大视频上传失败率这点复杂度完全值得。2. 动手前的方案设计分片大小和接口约定2.1 分片大小怎么定才合理分片大小是方案设计里第一个要决策的事。它不是越大越好也不是越小越好。分片太大就退化成单文件上传了。一个分片 200MB传输时间依然很长失败重传的成本还是很高并发加速的优势也没了。分片太小则会让请求数量爆炸。1MB 的分片2GB 的视频要拆成 2048 片每片都要走一次 HTTP 请求、服务端要创建一次临时文件、合并的时候要打开 2048 次文件句柄这些开销全被浪费掉了。我在实际项目里常用的平衡点是 10MB 到 20MB。以 20MB 分片为例一个 2GB 的视频会被拆成 102 片左右这个数量服务端完全能扛住单片传输时间在正常网络下也就几秒失败重传的代价也很小。弱网环境另说。如果你的用户大量使用移动网络、信号不稳定建议把分片调小到 2MB 到 5MB。分片越小单次请求耗时越短遇到网络抖动导致失败的概率就越低。内网环境下可以反着来50MB 甚至 100MB 一个分片效率更高。这没有绝对标准需要根据你的实际用户场景来定。选好分片大小之后前端代码里通过Math.ceil(file.size / CHUNK_SIZE)算出总分片数后端合并时拿这个数判断分片是否到齐。这个字段很关键后面合并逻辑全靠它。2.2 三个核心接口把职责切清楚分片上传的后端接口最核心的就三个。第一个是接收分片接口接收单个分片文件并保存到临时目录。前端每传一个分片都会调一次这个接口。第二个是合并接口当前端确认全部分片都传完了调这个接口触发后端合并。后端把这个任务对应的所有分片按顺序拼接成一个大文件。第三个是进度查询接口返回某个上传任务当前已收到的分片索引列表。这个接口是断点续传的基础前端重新上传时先查一次直接跳过已存在的分片。整体流程串起来看是这样的前端拿到文件后先调进度接口得到uploadedIndexes然后遍历所有分片跳过已上传的剩下的分批次上传全部传完后调合并接口后端合并完成后把临时目录清理掉。这个流程里最关键的点是前端不主动告诉后端“我这个文件传完了”以外的东西后端通过“分片数量够不够”来判断是否可以合并。分片数量由上传分片时携带的totalChunks字段决定不依赖前端单独传一个文件大小来对比更可靠。2.3 文件校验不要只相信前端上传这件事永远不要只相信前端传来的数据。文件大小、分片数量、文件名这些字段都可能因为各种原因不准确服务端必须有自校验的能力。一个简单的做法是后端在每个分片保存时判断其大小是否符合预期。除了最后一片允许小于分片大小其他分片的大小应该严格等于CHUNK_SIZE。如果大小不对直接拒绝这一片。合并之前再校验一遍分片数量。遍历临时目录看看从 0 到totalChunks - 1的分片文件是否全部存在。缺一个就返回失败并且最好在响应里告诉前端缺的是哪几个索引这样前端可以精准补传。严谨一点的话可以为每个分片记录 MD5 值上传时分片参数里带上分片 MD5后端保存前校验防止分片在传输过程中被截断或损坏。全部分片合并完之后再对完整文件算一次 MD5和前端传的整体 MD5 对比。这属于锦上添花面试时是加分项生产环境里对视频类文件也很值得做毕竟一个损坏的视频文件比上传失败更让人抓狂。2.4 临时目录怎么架构才能兼顾性能和清理分片文件不能直接扔到系统临时目录否则合并之前就可能被系统自动清理掉或者和别的任务混在一起。我常用的架构是为每个上传任务建一个独立目录uploadRoot/ fileId_1/ 0.part 1.part 2.part fileId_2/ 0.part 1.partfileId由前端生成一般用时间戳加随机字符串保证全局唯一。每个分片文件直接用分片索引命名比如0.part、1.part。这个命名方式特别重要因为它天然有序合并时按文件名排一下就是正确的分片顺序天然避免了“并发上传导致到达顺序错乱”的问题。临时目录必须和正式存储目录分开。合并完成后把临时目录整个删掉。上传中断遗留下来的目录则需要一个定时任务定期清理比如每 6 小时扫描一次删除超过 24 小时没有更新的临时目录。这个清理步骤很多人会漏掉等磁盘满了才来排查查来查去结果发现全是.part文件。目录放在哪块盘上也有讲究。分片写入是高频小文件 IO机械盘性能很差。如果条件允许把临时目录放到 SSD 上或者单独挂一块盘合并完成后再搬到目标存储。这个细节在并发用户数量上来以后体会特别明显。3. 可以直接抄的示例代码前端切片 Java 后端接收与合并3.1 前端切片核心代码先看前端。这里不用任何框架用原生 JavaScript 加 axios 写一套最通用的版本。核心就三件事切片、跳过错过的分片、控制并发。const CHUNK_SIZE 20 * 1024 * 1024; // 20MB const CONCURRENCY 3; // 并发数 function createFileId(file) { return ${Date.now()}_${file.name}; } async function handleUpload(file) { const fileId createFileId(file); const totalChunks Math.ceil(file.size / CHUNK_SIZE); let uploadedIndexes []; try { const progressRes await axios.get(/upload/progress, { params: { fileId } }); uploadedIndexes progressRes.data.data || []; } catch (e) { // 第一次上传fileId 不存在后端会返回空列表 uploadedIndexes []; } let uploadedSet new Set(uploadedIndexes); let queue []; for (let i 0; i totalChunks; i) { if (uploadedSet.has(i)) continue; const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const blob file.slice(start, end); const formData new FormData(); formData.append(fileId, fileId); formData.append(index, i); formData.append(totalChunks, totalChunks); formData.append(fileName, file.name); formData.append(chunk, blob, chunk_${i}); queue.push({ formData, index: i }); } for (let i 0; i queue.length; i CONCURRENCY) { const batch queue.slice(i, i CONCURRENCY); await Promise.all(batch.map(item axios.post(/upload/chunk, item.formData) )); // 这里可以更新进度条已上传分片数 / totalChunks } await axios.post(/upload/merge, null, { params: { fileId, fileName: file.name, totalChunks } }); }这段代码里的file.slice()是浏览器自带的 API不需要引入额外的库它会把File对象按字节范围切出一块Blob。把blob放进FormData里axios上传时会自动编码为multipart/form-data。CONCURRENCY设成 3 是我比较常用的值。并发太高会占满用户的带宽导致浏览器其他请求都卡顿服务端也会同时收到很多写磁盘请求。并发太低又体现不出分片优势。3 到 5 是一个平衡区间宽带好的用户可以适当调高。3.2 Java 后端接收分片后端我用 Spring Boot。接收分片的接口很简单但有几个容易被忽略的细节。PostMapping(/upload/chunk) public Result uploadChunk( RequestParam(fileId) String fileId, RequestParam(index) int index, RequestParam(totalChunks) int totalChunks, RequestParam(value fileName, required false) String fileName, RequestPart(chunk) MultipartFile chunk) throws IOException { if (!isValidVideoExtension(fileName)) { throw new ApiException(不支持的文件类型); } String chunkDirPath uploadRoot File.separator fileId; File chunkDir new File(chunkDirPath); if (!chunkDir.exists() !chunkDir.mkdirs()) { throw new IOException(创建分片目录失败); } File chunkFile new File(chunkDir, index .part); // 幂等处理分片已存在则直接跳过 if (chunkFile.exists() chunkFile.length() 0) { return Result.success(分片已存在跳过); } int expectedSize (index totalChunks - 1) ? (int) (getTotalFileSizeFromContext() % CHUNK_SIZE ? CHUNK_SIZE : CHUNK_SIZE) : CHUNK_SIZE; // 实际项目中 totalFileSize 由前端额外传入更方便这里简化写法严谨做法是前端再传一个 totalSize 字段 if (chunk.getSize() CHUNK_SIZE || (index totalChunks - 1 chunk.getSize() 0)) { throw new ApiException(分片大小异常); } chunk.transferTo(chunkFile); return Result.success(分片接收成功); }这里有两个关键点。第一是幂等。前端控制并发时如果用 Promise.all 同时发多个请求理论上不会有重复分片的问题。但断点续传场景下前端可能从获取进度到这个分片上传完成之间有重复的请求穿插进来更常见的是用户手动刷新页面重传进度接口返回的列表延迟导致某些分片被重复上传。后端发现分片文件已存在且大小正常直接返回成功不重复写入能避免很多脏数据。第二是transferTo的坑。MultipartFile 的transferTo在跨磁盘移动时其实不是简单的文件移动它是把临时文件复制到目标位置。如果目标文件已存在某些容器实现会直接覆盖或报错。所以我在写之前一定要先做存在性判断并确保目标文件的父目录存在。3.3 合并分片的实现合并逻辑是整个方案的压轴。关键点是永远不要按照分片到达的顺序合并必须按照分片索引的顺序合并。因为并发上传时先发的请求不一定先到后发的反而可能先被服务端接收到。PostMapping(/upload/merge) public Result mergeChunks( RequestParam(fileId) String fileId, RequestParam(fileName) String fileName, RequestParam(totalChunks) int totalChunks) throws IOException { File chunkDir new File(uploadRoot File.separator fileId); if (!chunkDir.exists() || !chunkDir.isDirectory()) { throw new ApiException(上传任务不存在); } // 合并前校验分片完整性 for (int i 0; i totalChunks; i) { File chunkFile new File(chunkDir, i .part); if (!chunkFile.exists() || chunkFile.length() 0) { throw new ApiException(分片缺失索引: i); } } String safeFileName sanitizeFileName(fileName); File mergedFile new File(uploadRoot File.separator fileId _ safeFileName); try (FileOutputStream fos new FileOutputStream(mergedFile)) { for (int i 0; i totalChunks; i) { File chunkFile new File(chunkDir, i .part); try (FileInputStream fis new FileInputStream(chunkFile)) { byte[] buffer new byte[8192]; int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } } } // 合并成功清理临时分片目录 deleteDirectory(chunkDir); return Result.success(mergedFile.getAbsolutePath()); }这段代码的核心是一个从 0 到totalChunks - 1的 for 循环完全依赖索引顺序。每次循环读取一个.part文件通过FileInputStream边读边写而不是一次性把整个分片读进内存。缓冲区byte[] buffer new byte[8192]8KB 一次写出性能完全够用。合并时最怕的就是循环中途遇到缺失的文件。所以我在开始写目标文件之前先做了一次完整性遍历。这相当于把“准备”和“执行”两个阶段分开准备阶段发现问题直接失败退出不会产生半成品文件。3.4 断点续传核心进度查询接口进度查询接口的逻辑很直接查一下这个fileId对应的临时目录里存在哪些.part文件把索引号返回给前端。GetMapping(/upload/progress) public Result getProgress(RequestParam(fileId) String fileId) { File chunkDir new File(uploadRoot File.separator fileId); if (!chunkDir.exists()) { return Result.success(new ArrayListInteger()); } File[] files chunkDir.listFiles(); ListInteger uploadedIndexes new ArrayList(); if (files ! null) { for (File f : files) { String name f.getName(); if (name.endsWith(.part)) { try { uploadedIndexes.add(Integer.parseInt(name.substring(0, name.length() - 5))); } catch (NumberFormatException ignored) { // 忽略异常命名的文件 } } } Collections.sort(uploadedIndexes); } return Result.success(uploadedIndexes); }这个接口不需要知道前端传了什么文件的 MD5只要fileId一致就能找到同一个上传任务。前端拿到索引列表后在真正上传之前构建一个 Set遍历分片时直接continue就完成了断点续传的逻辑。秒传的扩展也不难。如果后续要支持秒传额外加一个查询接口前端把文件的整体 MD5 传过去后端查一张上传记录表只要 MD5 和文件大小都一致就直接返回“已存在秒传成功”前端根本不用进入切片上传流程。这张记录表在合并成功后写入每次上传前查询成本很低收益却很明显。3.5 文件名安全处理合并时一定要对文件名做特殊处理这个点很多人会忽略。文件名可能包含路径分隔符、特殊字符比如../../etc/passwd如果直接拼到文件路径里就是典型的路径穿越漏洞。private String sanitizeFileName(String fileName) { if (fileName null || fileName.trim().isEmpty()) { return upload_ System.currentTimeMillis(); } String safeName new File(fileName).getName(); safeName safeName.replaceAll([\\\\/:*?\|], _); if (safeName.length() 100) { safeName safeName.substring(safeName.length() - 100); } String ext ; int dotIndex safeName.lastIndexOf(.); if (dotIndex 0) { ext safeName.substring(dotIndex).toLowerCase(); safeName safeName.substring(0, dotIndex); } // 只保留白名单内的视频扩展名 SetString allowedExt new HashSet(Arrays.asList( .mp4, .avi, .mov, .mkv, .flv, .wmv, .webm )); if (!allowedExt.contains(ext)) { ext .mp4; } return safeName ext; }这个方法的思路是先用new File(fileName).getName()把纯文件名提取出来剔除路径部分再替换掉 Windows 和 Unix 下的非法字符最后强制校验扩展名只保留视频格式白名单。这么做之后即使前端传了恶意文件名后端也不会写出越界路径更不会存储一个危险后缀的文件。关于扩展名校验多说一句MIME 类型和 Content-Type 都是前端很容易伪造的字段不能作为安全依据。扩展名白名单是最基础的一层更严格的方案是合并完成后用专业的视频识别工具读取文件头判断实际编码格式。如果只是普通业务扩展名白名单基本够用。4. 实际踩坑记录并发乱序、重复分片、磁盘清理4.1 不要按到达顺序合并并发上传一定会乱序我最早实现合并逻辑时用了“边接收边追加”的思路来一个分片就往目标文件后面追加一段。结果发现合并出来的视频文件播放到一半就花屏或者直接卡死。原因很简单并发上传时分片到达后端的时间和索引完全无关。索引 5 的分片可能最后一个到达你按到达顺序拼接视频数据就错乱了。视频文件对字节顺序极其敏感任何一个字节的顺序错了整个文件就废了。这个问题本质上不是网络问题而是并发导致的调度差异。只要用了 Promise.all 或任何并发上传方式到达顺序就不可控。正确做法永远是把分片先存成独立的临时文件等全部分片到齐后再按索引排序统一合并。临时文件命名用index.part就能在合并时保证天然有序。4.2 重复分片处理失误文件被写坏第二个坑发生在断点续传和并发控制叠加时。前端两个请求同时上传同一个分片后端transferTo目标文件时由于文件已存在且非空直接把之前的完成数据覆盖掉了。如果第二次写入的数据因为网络原因不完整这个分片就永久损坏了即使后续其他分片全齐合并出来的文件也是坏的。解决方式就是幂等接收分片时先判断文件是否存在且大小正确是就直接返回成功不再执行transferTo。这个判断必须放在磁盘写入之前。你可能觉得概率很低但在真实网络环境下用户刷新页面、重复点击、前端重试机制触发重复请求出现的频率比你想象的高得多。防患于未然这个判断值得写。4.3 临时目录成了磁盘杀手分片上传跑起来之后我第一次遇到生产环境磁盘告警检查一看临时目录里堆积了十几个 GB 的.part文件。原因就是用户上传到一半放弃了或者上传中断后前端一直没有重试后端也没人清理这些孤儿目录。解决要从两个方向入手。第一个方向是合并成功后立刻删这个代码里已经有了。第二个方向是定期清理任务我写了一个简单的定时任务每天凌晨扫描临时目录下的所有子目录检查最后修改时间超过 24 小时没有变化的直接整个目录删掉。这样即使上传中断磁盘也不会被长期占用。另一个相关问题是清理任务可能会误删正在上传的任务。如果一个用户上传很慢一个分片传了半小时目录的最后修改时间停留在半小时前24 小时的时间窗口就不会误伤。如果你担心超长任务被误删可以把时间窗口调成 48 小时或者在目录里放一个轻量级的锁文件清理前检查锁是否有效。4.4 路径分隔符和文件名别在这上面栽跟头这个坑属于那种“本地跑得好好的一上 Linux 就崩”的经典问题。本机是 Windows路径拼接用了\代码里写死uploadRoot \\ fileId一部署到 Linux 服务器上目录创建失败分片全部接收不了。正确做法是用File.separator或者干脆用 Java 7 的Paths.get(uploadRoot, fileId, index .part)它会根据运行平台自动选择正确的分隔符。这一点写 Java 的人都知道但实际开发里还是经常有人写死。文件名安全前面已经说过了。这里还要再强调一遍合并后的文件名不要直接使用前端传过来的原始文件名否则可能出现文件名编码问题、非法字符问题、路径穿越问题。必须做清洗和扩展名白名单校验。这个步骤虽然代码就十几行但对系统稳定性和安全性都很关键。4.5 分片丢失但合并却成功了这种假象最坑还有一种情况合并时完整性校验通过文件也能正常合并但合并出来的视频文件播放时缺失片段。排查之后发现前端在切片时用了file.slice(start, end)但end计算错了导致最后几片是空的 Blob后端接收时又没做空文件校验于是问题被一路放行到合并阶段。这种问题很难排查因为它不报错文件看起来也正常但内容不完整。我的建议是接收分片时做一个硬性校验除最后一片外分片大小必须严格等于CHUNK_SIZE最后一片大小必须大于 0。前端切片逻辑如果有偏移后端就能第一时间拦住把错误暴露在接收阶段而不是合并阶段。这个校验同时也保护了合并逻辑。因为你永远不知道前端代码是不是别人改过一版传上来的totalChunks、分片大小和各种参数是否还和约定一致。后端保持怀疑一切的态度问题就会少一半。5. 最后分享一点生产环境的体会这套分片上传方案我在好几个项目里用过从最早单纯的接收合并到后来加上断点续传、秒传、并发控制最深的体会是真正难的不是代码而是把你的流程想清楚让每个环节都具备自校验能力。前端切片、后端接收、合并清理这三步环环相扣任何一环只想着“反正前端会处理”就会出问题。另一个实在的经验是上线之后先别急着追求并发优化建议先跑通一条完整链路——传一个真实的大视频从开始上传到进度查询到断点续传到合并到临时目录清理全走一遍。半小时的联动测试能替你省下将来在用户反馈里排查“为什么传不上去”的好几天。如果后续计划把文件迁移到对象存储分片的思想也建议保留下来。对象存储提供的分片上传接口本质上就是同一套流程只是把临时目录换成了存储服务端的分片缓存把你这套自研方案吃透换到任何存储平台都不会觉得吃力。

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

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

免费获取报价 →
↑