视频文件分片上传在 Java 项目里属于那种“看起来不难、做起来全是坑”的功能。你给用户一个上传入口,他传一个 300MB 的视频,Tomcat 的默认请求大小没调,直接给你抛一个 413,前端页面转圈半天最后告诉你上传失败,用户当场想卸载应用。我做过几个涉及视频上传的后端服务,最早都是接收完整文件,后来发现这条路根本走不通,才开始系统接触分片上传。
分片上传的核心价值其实就一句话:把一个大问题拆成多个小问题,每个小问题都能独立失败、独立重试。视频文件少则几十MB,多则几个GB,网络稍微抖动一下,整包传输直接断掉;而分片之后,一个分片传失败只重传那一片,不需要从头再来。这篇文章我直接把一套可落地的方案写出来,包括前端怎么切片、Java 后端怎么接收和合并、断点续传怎么做,以及我在生产环境里踩过的几个典型问题。适合正在做视频上传、网盘类功能,或者被大文件上传接口搞到头大的 Java 后端同学参考。
1. 大视频上传的三个死穴,以及分片为什么能救场
1.1 传统单文件上传为什么扛不住大视频
先说一个最简单的场景:前端一个<input type="file">,用户选了一个 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 ArrayList<Integer>()); } File[] files = chunkDir.listFiles(); List<Integer> 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); } // 只保留白名单内的视频扩展名 Set<String> 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. 最后分享一点生产环境的体会
这套分片上传方案我在好几个项目里用过,从最早单纯的接收合并,到后来加上断点续传、秒传、并发控制,最深的体会是:真正难的不是代码,而是把你的流程想清楚,让每个环节都具备自校验能力。前端切片、后端接收、合并清理,这三步环环相扣,任何一环只想着“反正前端会处理”就会出问题。
另一个实在的经验是:上线之后先别急着追求并发优化,建议先跑通一条完整链路——传一个真实的大视频,从开始上传到进度查询,到断点续传,到合并,到临时目录清理,全走一遍。半小时的联动测试,能替你省下将来在用户反馈里排查“为什么传不上去”的好几天。
如果后续计划把文件迁移到对象存储,分片的思想也建议保留下来。对象存储提供的分片上传接口本质上就是同一套流程,只是把临时目录换成了存储服务端的分片缓存;把你这套自研方案吃透,换到任何存储平台都不会觉得吃力。