Java视频分片上传实战:从分片机制到断点续传与秒传

📅 发布时间:2026/10/13 13:09:53
Java视频分片上传实战:从分片机制到断点续传与秒传
视频分片上传这件事做Java后端的迟早会碰上。尤其是平台里涉及视频课程、监控回放、用户UGC内容这一类大文件上传时如果还在用一个普通文件上传接口硬扛要么请求直接超时要么手机切换到4G信号隧道一抖动几十分钟的视频传了一大半全废了。我自己最早接手这个需求的时候第一版就是“老老实实整文件上传”结果被测试妹妹用一台千元安卓机在弱网环境里把接口打挂了三次。后来彻底改成“切分、传输、合并”的思路才算把问题收住。这篇内容不写概念空谈而是把一套我在Java环境下真正跑过的视频分片上传方案拆开讲分片大小怎么定、核心接口怎么设计、合并怎么保证顺序和完整、断点续传和秒传怎么实现以及实际开发中容易踩的坑。项目里有技术选型需求的或者正在改造上传模块的可以直接参考后面的示例代码去试至少能省不少走弯路的时间。1. 分片上传的核心思路为什么视频不能一次传完1.1 一次上传视频的四大痛点普通单文件上传说白了就是把整个视频文件放进HTTP请求体里一次性发给服务端。这种方式在几MB的小文件上没问题但视频文件动辄几百MB甚至几个GB问题就会集中爆发。我在项目里遇到的情况基本可以总结成四个痛点。第一是超时。无论是Nginx还是应用服务器对请求处理时间都有隐含上限。单个大文件传输时间过长中间任何一层断了连接客户端收到的就是一个上传失败。第二是内存压力。服务端如果用MultipartFile.getBytes()把整个文件读进内存再做处理几十MB也许还行几百MB的文件直接能把堆内存撑爆频繁引发Full GC。第三是网络不可靠性被成倍放大。文件越大传输时间越长中间偶发一次网络抖动就得从头再来用户体验极差。第四是没有断点恢复能力。普通上传接口一旦失败客户端没有“接着传”的概念只能重新传一遍完整文件流量和时间的浪费非常严重。所以解决思路很简单不要让一个请求背负整个文件的重量而是把文件切成若干小块分别上传最后在服务端合并。1.2 分片上传与传统上传的区别分片上传并不是什么黑科技核心就是三个动作切分、传输、合并。前端或客户端先把大文件按照固定大小切成多个分片比如每片5MB然后按分片顺序逐个上传到服务端全部上传完毕后服务端再按照分片序号把这些临时分片合并成完整的原文件。这条链路和传统上传最本质的区别在于单次上传的数据量从“整个文件大小”降到了“单个分片大小”。这意味着即使某个分片传输失败只需要重传这个分片不需要把整个文件重来一遍。同时多个分片可以并发上传理论上能更充分地利用带宽整体速度反而更快。那是不是所有文件都应该无脑分片也不一定。文件很小的时候分片本身带来的额外请求开销和时间还不如下载一个普通上传快所以实际项目里一般会对文件大小做个判断超过阈值才走分片逻辑比如超过100MB或200MB才分片小文件直接走普通上传。2. Java分片上传关键设计接口、分片大小与存储规则2.1 分片大小怎么定才算合理很多入门教程把分片大小写成固定常数但我建议把它设计成可配置参数并且针对不同业务场景动态决策。分片大小受两个因素制约一是网络容忍度二是服务端处理能力。网络容忍度方面分片越小单个分片传输时间越短遇到网络波动重传的成本越低但分片数量增多请求次数增多总耗时和请求开销也会增大。分片越大请求次数少了但单次传输时间变长一旦网络抖动重传浪费的流量和重试成本就高了。我实际使用下来5MB到10MB是相对均衡的一个区间。如果服务端带宽充足、内网环境建议用10MB如果面向移动端弱网场景2MB到5MB更稳妥。服务端处理能力方面每个分片都要写入磁盘如果并发数很高磁盘IO会成为瓶颈。分片大写入操作少磁盘压力小分片小写入操作频繁但每个文件更小写入更快。这里面没有一个绝对最优解。我的做法是把分片大小做成配置项在上线前用真实的带宽和模拟并发测一次找到一个“单位时间上传成功率最高”的值通常最终会落在5MB附近。2.2 分片任务ID与命名规则分片上传必须让服务端知道“哪些分片属于同一个文件”。所以整个上传过程需要引入一个贯穿全局的任务ID。我自己比较喜欢用UUID生成一个uploadId每次初始化上传任务时返回给前端前端在后续上传每个分片时都带上这个ID。服务端接收分片后要在磁盘上为每个任务建一个独立的临时目录命名规则遵循“任务ID/分片序号”即可比如/upload_tmp/{uploadId}/{index}.part这里有一个很容易踩的坑分片序号千万不要用字符串直接拼目录名。比如第10个分片会变成10.part第2个会变成2.part。如果做排序的时候用字符串Collections.sort()去排序得到的是1.part、10.part、2.part、3.part合并时顺序全乱了。所以分片序号一定要用数字解析后再排序。2.3 三个核心接口怎么设计一个完整的分片上传流程最少需要三个接口这也是我在项目里最常用的设计方式。第一个是初始化上传接口。前端在开始上传前先调用这个接口把文件名、文件总大小、预估总分片数、文件的整体MD5值传给服务端。服务端在这里生成uploadId并在缓存或数据库里记录本次上传任务的元信息。这个接口还有个重要作用如果服务端发现整体MD5已经存在说明之前有人传过一模一样的文件就可以直接返回“秒传成功”前端连分片都不用传了。第二个是分片上传接口。这是真正接收分片数据的接口前端每次调用时上传一个分片同时带上uploadId、当前分片序号、分片MD5。服务端要做的事情包括检查uploadId是否存在、把分片保存到临时目录、记录该分片序号比如放入Redis集合、校验分片MD5防止数据损坏。第三个是合并接口。当前端把所有分片都传完后通知服务端可以合并了。服务端检查已上传的分片数量是否等于总分片数如果一致按分片序号从小到大读取分片写入最终文件如果不一致直接返回“分片缺失请继续上传”。除了这三个接口我还会加一个“查询已上传分片列表”的接口用来支持断点续传。前端在初始化完成后可以调用这个接口拿到已经上传成功的分片序号然后跳过这些分片只传剩下的。2.4 秒传与断点续传的实现思路秒传的原理其实很简单文件内容没有变化时两个不同用户上传同一个视频它们的整体MD5值是一样的。那么在初始化接口里前端除了传文件名和大小再传一个文件整体的MD5。服务端拿这个MD5去数据库或缓存里查如果已经存在完整文件记录就直接把该记录关联到当前用户返回“秒传成功”的标识前端直接跳转到上传完成页面。这里的MD5计算可以在前端用JavaScript完成也可以让后端通过分片上传完后整体计算但秒传场景必须在初始化阶段就拿到整体MD5所以基本都是前端先算好的。断点续传则是依赖前面提到的“查询已上传分片列表”接口。前端拿到已上传序号之后把剩余分片继续上传即可。同时后端在保存分片时要对同一个分片序号的重复上传做幂等判断。我一般用Redis的Set来记录已经上传成功的分片序号每次上传分片前先判断序号是否已存在如果存在就认为该分片已经传完直接返回成功不再重复保存文件。这样即便前端因为网络原因重试了同一个分片也不会产生脏数据。3. 可直接复用的Java后端示例代码3.1 工程依赖与基础配置我用的是Java 8 Spring Boot文件分片处理本身不需要额外的重量级依赖只需引入Web模块即可。如果你的项目还没有加在pom.xml或build.gradle里加入Spring Web依赖就行。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency如果你打算用Redis来记录已上传分片和任务元信息还需要引入Redis依赖但这不是必须的。在最简化实现里完全可以用内存中的ConcurrentHashMap代替Redis。不过生产环境中多实例部署时内存方案会导致不同实例之间数据不一致所以我建议正式项目用Redis示例代码为了便于理解我会直接用HashMap和Set说明主流程。上传文件需要落盘建议在application.yml里单独配置一个上传临时根目录upload: temp-dir: /data/upload_tmp为了可读性我用一个工具类模拟简单的Result返回结构读者可以根据自己的项目统一返回类型替换。3.2 初始化上传任务接口实现初始化接口的核心逻辑是接收文件名、文件大小、总分片数和文件整体MD5生成uploadId并把任务元信息保存下来。这里我把元信息放在一个UploadTask对象里方便后续序列化到Redis或数据库。Data public class UploadTask { private String uploadId; private String fileName; private Long totalSize; private Integer totalChunks; private String fileMd5; private Long chunkSize; private String uploadDir; }初始化接口的Controller实现RestController RequestMapping(/api/upload) public class VideoUploadController { private final MapString, UploadTask taskHolder new ConcurrentHashMap(); PostMapping(/init) public Result init(RequestParam String fileName, RequestParam Long totalSize, RequestParam Integer totalChunks, RequestParam String fileMd5) { // 模拟秒传如果已有相同MD5的文件记录则直接秒传 if (taskHolder.values().stream().anyMatch(t - t.getFileMd5().equals(fileMd5))) { return Result.ok(上传秒传成功, null); } String uploadId UUID.randomUUID().toString().replace(-, ); String uploadDir uploadTempDir File.separator uploadId; UploadTask task new UploadTask(); task.setUploadId(uploadId); task.setFileName(fileName); task.setTotalSize(totalSize); task.setTotalChunks(totalChunks); task.setFileMd5(fileMd5); task.setUploadDir(uploadDir); taskHolder.put(uploadId, task); MapString, Object data new HashMap(); data.put(uploadId, uploadId); data.put(uploadAddr, uploadDir); return Result.ok(初始化成功, data); } }注意实际生产环境里秒传的判断不能这样遍历内存而是要查数据库中文件的MD5索引表。示例代码只演示流程。3.3 分片上传接口实现分片上传接口接收五个参数uploadId、分片序号index、分片MD5、分片文件本身。第一步通过uploadId找到对应的UploadTask第二步判断该分片是否已经上传过第三步将分片保存到临时目录第四步记录已上传的分片序号。PostMapping(/chunk) public Result uploadChunk(RequestParam String uploadId, RequestParam Integer index, RequestParam String chunkMd5, RequestParam(file) MultipartFile file) throws IOException { UploadTask task taskHolder.get(uploadId); if (task null) { return Result.error(上传任务不存在请重新初始化); } File dir new File(task.getUploadDir()); if (!dir.exists()) { dir.mkdirs(); } // 幂等判断这个分片已经上传过了 String indexKey uploadId :indexes; if (uploadedIndexes.getOrDefault(indexKey, Set.of()).contains(index)) { return Result.ok(该分片已上传跳过, null); } File chunkFile new File(task.getUploadDir(), index .part); // 保存分片文件 try (FileOutputStream fos new FileOutputStream(chunkFile)) { fos.write(file.getBytes()); } String localMd5 DigestUtils.md5DigestAsHex(file.getBytes()); if (!chunkMd5.equalsIgnoreCase(localMd5)) { // 分片MD5不一致删除文件返回错误 chunkFile.delete(); return Result.error(分片数据校验失败); } // 记录已上传分片序号 uploadedIndexes.computeIfAbsent(indexKey, k - ConcurrentHashMap.newKeySet()).add(index); taskHolder.get(uploadId).setUploadDir(task.getUploadDir()); return Result.ok(分片上传成功, null); }这里有一个细节我在保存分片文件之后才计算并校验MD5。如果校验失败要把刚保存的文件删除掉避免残留脏数据。另外setUploadDir这行在示例里显得多余只是为了强调task对象是共享的可变引用生产环境如果多线程操作同一个task要注意并发安全的隔离。3.4 分片合并接口实现合并接口是流程里最容易出问题的一步。核心逻辑是先通过uploadId拿到任务信息然后检查已上传的分片数量是否达到总分片数。如果没达到就返回“缺少分片”的明确提示如果达到了就按数字序号排序依次将分片文件写入最终文件。PostMapping(/merge) public Result merge(RequestParam String uploadId) throws IOException { UploadTask task taskHolder.get(uploadId); if (task null) { return Result.error(上传任务不存在); } SetInteger uploaded uploadedIndexes.getOrDefault(uploadId :indexes, Collections.emptySet()); if (uploaded.size() task.getTotalChunks()) { return Result.error(分片未传完缺少 (task.getTotalChunks() - uploaded.size()) 个分片); } File dir new File(task.getUploadDir()); if (!dir.exists()) { return Result.error(临时目录不存在); } // 按文件名的数字序号排序不要使用字符串排序 File[] chunkFiles dir.listFiles((d, name) - name.endsWith(.part)); if (chunkFiles null || chunkFiles.length 0) { return Result.error(没有找到任何分片文件); } ListFile sortedChunks Arrays.stream(chunkFiles) .sorted(Comparator.comparingInt(f - Integer.parseInt(f.getName().split(\\.)[0]))) .collect(Collectors.toList()); File finalFile new File(uploadFinalDir, task.getFileName()); try (FileOutputStream fos new FileOutputStream(finalFile)) { for (File chunk : sortedChunks) { try (FileInputStream fis new FileInputStream(chunk)) { byte[] buffer new byte[1024 * 1024]; int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } } fos.flush(); } // 校验最终文件大小 if (finalFile.length() ! task.getTotalSize()) { finalFile.delete(); return Result.error(合并文件大小校验失败); } // 清理临时分片文件和任务记录 for (File chunk : sortedChunks) { chunk.delete(); } dir.delete(); uploadedIndexes.remove(uploadId :indexes); taskHolder.remove(uploadId); return Result.ok(合并成功, null); }合并的时候我把buffer设成了1MB而不是直接把整个分片读入内存。这样即使某个分片有10MB也不会占用太多内存。合并完成后做一次文件大小校验这是在早期项目里帮助我发现“某个分片没传完也进入了合并流程”这类问题的重要手段。3.5 前端配合的几个要点后端接口写好了前端配合同样不能马虎。大多数前端框架都支持分片操作但有几个要点需要提醒一下。第一个要点是分片切割。前端需要按固定大小切分文件比如每5MB一个分片最后一小分片可能不足5MB也正常。第二个要点是并发数控制。不要把所有分片一次性全部并发上传我建议控制在3到5个并发既能提升速度又不会把带宽打满导致失败率上升。第三个要点是失败重试。前端对失败的分片要做重试机制最多重试3次重试仍失败就停止上传并提示用户。第四个要点是断点续传的恢复逻辑。先调用“查询已上传分片列表”接口把已经存在的分片过滤掉再继续传剩余部分。前端这里可以用XMLHttpRequest或fetch实现没有太高的技术门槛。4. 常见问题与排查技巧实录4.1 分片重复提交与幂等处理分片上传中前端为了保证成功率通常会设置重试机制。这意味着同一个分片可能被提交两次甚至多次。如果不做幂等处理第二次提交时会在临时目录写入一个覆盖文件逻辑上问题不大但如果你用的是追加写入的方式就会把同一分片的数据写进两次合并出来的文件直接损坏。我的建议是固定使用“先检查序号是否存在再保存文件”的顺序并且在保存文件时用FileOutputStream直接覆盖而不是追加模式。另外在Redis或内存Set中记录已上传序号时要注意原子性这里是“检查再写入”严格来讲有并发窗口实际并发量不大时可接受如果并发要求高建议用Redis的sismember和sadd组合或者给分片保存操作加分布式锁。4.2 合并顺序错乱与分片缺失合并顺序错乱是我身边同事最容易踩的坑。很多人在合并时直接用File.listFiles()返回的文件列表然后按文件名自然顺序合并。得到的顺序极有可能是1.part、10.part、11.part、2.part……最终文件完全无法播放。这里唯一的正确做法就是把分片名中的序号解析成整数再排序代码里我已经展示过。还有一个隐蔽问题某些分片虽然上传成功但由于前端的并发超时误判界面提示失败用户重新点击上传后又传了一遍导致服务端记录的序号集合里少了一个索引合并时永远凑不齐。遇到这种问题第一步查服务端已上传序号列表第二步再和前端日志核对哪些分片没到。如果数量对不上最简单的办法是把临时目录里的分片文件按序号列出来清单序号缺失一目了然。4.3 临时文件堆积与磁盘空间告警分片上传会在服务端临时目录里积累大量分片文件如果用户上传到一半就取消了这些临时文件不会被合并接口清理会一直残留在磁盘上。时间一长几十个未完成任务就能占用好几个GB的空间。我的排查经验是单独划分一个磁盘分区给临时目录设置磁盘水位监控达到85%时触发告警另外开发一个定时任务定期清理超过24小时未完成的分片任务和对应目录。上面示例代码中我把临时目录放在/data/upload_tmp就是为了和生产目录隔离方便做定期清理。4.4 大文件合并内存溢出合并大文件时最容易出现的问题就是内存溢出。很多初学者会写一个getBytes()或FileUtils.readFileToByteArray()把整个分片读进内存如果分片是10MB并发合并几个文件几十MB很快就没了堆内存再小一点GC根本来不及回收。正确做法是无论分片还是合并都用流式处理。我上面的合并代码已经用1MB的缓冲数组循环读写即使合并一个1GB的文件内存占用也是固定的不会因为文件变大而增长。另外如果上传的视频量很大合并操作本身是IO密集型的不要在主线程里同步执行最好丢到独立线程池否则所有用户都会卡在“合并中”的状态上。5. 实战中的调优与个人心得5.1 分片大小与并发数的平衡我在不同的服务器配置上做过对比测试当分片大小是5MB、前端并发数是3的时候弱网环境下的失败率和总体耗时相对平衡。分片太小比如1MB虽然重传代价很小但请求频率太高服务端连接数会快速增长还可能触发网关的频率限制。分片太大比如50MB单次请求传输时间变长一旦路由器或者网络代理卡住整个分片就得重传。如果你有充足时间做压测我建议记录两个指标单位时间成功上传的分片数以及每个分片的平均耗时。以这两个指标为依据去调整分片大小远好过拍脑袋定一个值。5.2 临时文件目录的规划与清理临时目录我强烈建议和正式存储目录分开原因很简单临时目录随时清空不会影响线上文件而正式存储目录一旦被误删就是灾难。实际操作中我以天为单位建一层目录比如/data/upload_tmp/20250311/比把所有文件堆在一个目录里更方便排查问题和清理过期数据。清理逻辑也不能瞎写至少要区分“上传完成”“上传中”“已放弃”三类状态。我自己的定时任务逻辑是查询所有超过24小时没有更新且没有完成合并的uploadId先确认该任务的所有分片文件都没有被引用然后删除目录。脚本很简单但要留一份人工确认的开关因为总会有用户传了一半去开会回来又继续传的情况24小时其实不算长。5.3 安全性、校验与日志监控视频文件上传接口是最容易被恶意利用的入口之一。尤其分片上传每个分片都是直接写入磁盘的如果不做身份校验任何一个匿名用户都可以通过循环调用分片接口把服务器磁盘写满。我在项目里至少要保证三点第一上传接口必须经过登录态校验不能匿名访问第二限制单用户同时进行的初始化任务个数避免一个用户创建几十个任务第三校验文件类型后缀和文件头内容视频文件至少检查扩展名和MIME type有条件的话再用专门的工具解析文件头。日志和监控方面每次初始化、分片上传、合并成功或失败都要记录uploadId、来源IP、耗时、文件大小和分片序号。这些日志是排查“用户说上传失败但其实分片已经到齐”这类问题时的救命稻草。我在实际维护中遇到过很多次用户反复投诉上传失败最后一看日志分片全部传完了只是合并接口返回超时被前端当成失败。如果日志里没有这些信息排查时间会成倍增加。一路做下来我觉得视频分片上传最难的地方不再是“怎么把文件切开再合并”而是工程化地把异常情况处理干净。分片重复、断点续传、临时文件清理、合并顺序、校验机制、内存控制每一样都是边角料但每一样都能把一个看似跑通的流程打回原形。上面这套方案是我在多个真实项目里反复调整过的整体逻辑比较稳代码也不复杂建议你从一个小视频文件开始逐步跑通三条核心链路再考虑高并发和秒传这些增强能力。如果你中途遇到什么问题直接对照第四部分排查就好很多都是同类问题。