SpringBoot大文件分片上传实战:解决视频上传卡顿与断点续传 1. 视频大文件直传的三个死穴先把它聊透之前给一个后台管理系统做视频上传优化当时的功能很简单前端拿到 File 对象用 XMLHttpRequest 直接把整个文件 POST 给 SpringBoot接口这边用 MultipartFile 接收落盘到本地。单文件 100MB 以内的测试一切正常一旦换成 800MB 的录像进度条走到 60% 就原地卡住过几分钟浏览器直接报网络错误后台日志里看不到半点异常因为请求根本没到达应用层就被掐断了。这个现象背后其实是三座大山要么不碰一碰就翻车。第一座山是服务器组件的默认限制。SpringBoot 的 multipart 默认只能接收最大 1MB 的文件请求总大小默认 10MB这个参数在application.yml里靠spring.servlet.multipart.max-file-size和max-request-size调整。但就算你把这两个值改到 5GB前面还有一道关卡如果你通过 Nginx 做反向代理它的默认client_max_body_size只有 1MB上传一个 800MB 的整文件请求体在 Nginx 层就会被直接拒绝返回 413 Request Entity Too Large。你排查的时候往往发现应用日志一片空白就是因为根本没走到后端。第二座山是传输不可控。一个 800MB 的请求体要经过用户到 Nginx、Nginx 到应用服务器、应用服务器落盘这三段链路任何一段出现网络抖动、连接超时、代理超时整个请求就废了。更难受的是这种失败没有局部性文件传了 300MB断了你没有任何办法只续传剩下的 500MB只能把 800MB 重新传一遍。视频文件越大重传成本越高体验差到用户直接删除应用。第三座山是服务端内存压力。SpringBoot 接收 MultipartFile 时默认先把整个请求体存到临时目录再交给业务层处理这个临时文件不算内存问题。真正的压力在于很多初版代码为了校验内容会直接file.getBytes()把整个文件加载进内存或者用 byte[] 数组做转换。一个 1.2GB 的视频堆内存稍微小点就直接 OOM。就算你规范地用流去拷贝串行处理一个大文件的写盘时间也会把 Tomcat 线程占住线程一占满其他小请求跟着排队。切片上传的核心思路说白了就是把“一个人扛 800MB”拆成“多个人各扛一小段”把整体失败变成局部失败。前端把一个文件按固定大小切成若干分片逐个上传后端按顺序接收并最终合并。这样每一段请求都很小服务器限制容易满足某一片失败只要重传那一片可以和断点续传无缝结合。更重要的是每个分片的上传是独立的可以并发执行总耗时往往比单文件直传要短得多。你把这个机制想明白之后再去看网上一堆切片上传教程就不会被绕晕。它们本质上都在做同一件事定义分片、上传分片、合并分片只是前端框架和中间件代工了其中一部分。接下来我从参数设计讲起把这条链路从头拆到尾。2. 分片机制拆解先解决分片大小、文件标识和幂等这三个关键点切片上传在动手写接口之前有一个必须想清楚的问题一片切多大这个问题没有标准答案但有一个合理的经验区间。常见文件系统 I/O 的粒度在 4KB 到 64KB网络层面上 5MB 左右的分片是比较中庸的选择。我做过对比测试分片切到 1MB 时一个 800MB 的文件要发 800 个请求握手开销和请求头占的比重太大上传速度反而变慢切到 50MB 时请求数量少但单片失败后的重传代价大进度条也显得很迟钝。实际项目中我通常取 5MB 到 10MB既不会把服务器打爆又能让失败重传的代价控制在可接受范围。分片大小和并发数是配套设计的一对参数。并发数不宜太高我习惯控制在 3 到 6 路。并发太猛浏览器同一时间发起十几个请求服务器每个请求都在写临时文件磁盘 I/O 容易被拖垮并发太低又没有利用带宽。还有一个不能忽略的点每个分片上传完成后前端要单独记录该分片的状态并发数意味着同时维护多个上传任务的进度。这个逻辑后面写前端实战时会详细展开。第二个必须想清楚的是文件标识。这个标识就是要能唯一代表当前上传任务的字符串通常由前端计算文件内容摘要得到。做切片上传时前端切完片之后必须把文件标识一起传给后端后端用这个标识作为本次上传任务的唯一凭证。为什么不用随机 UUID因为文件内容摘要可以天然去重同一份视频上次已经传过了再次选择时前端在初始化阶段就可以告诉后端“这个文件我见过”直接进入秒传流程。另外这个标识也用于后端组织临时目录比如按标识建目录再在目录下存放各分片这样同一个文件的所有分片都在一个目录里合并时非常方便。第三个关键点是幂等。分片上传接口的天然场景就是“同一片可能被传两次”原因可能是网络超时后前端重试也可能是并发上传时两个分片交错到达。服务端对分片的保存策略必须支持覆盖写同一个分片索引如果已经有落盘文件直接用新的覆盖掉。这样即使前端重试三五次最终落到磁盘上的分片数据依然是完整可用的。幂等这件事不需要复杂设计保存分片时用覆盖写入的选项就够了但如果你不做这个处理合并时可能拿到半个新分片、半个旧分片导致最终视频文件损坏。分片机制里还有一个常见的坑分片总数不要信任前端传值。前端可能因为代码 bug 或网络问题传一个错误的totalChunks服务端应该根据文件大小和约定好的分片大小自行计算总片数和最后一片的偏移。前端传的参数只作为辅助信息核心依据必须在后端自己算否则合并时轻则生成坏文件重则产生越界问题。这部分逻辑我在第三个章节写接口时会具体给出代码。3. SpringBoot 后端核心接口设计初始化、分片落盘、合并文件后端我建议拆成三个接口职责分开后续做断点续传和秒传时更容易扩展。第一个是初始化接口负责接收文件名、文件大小、文件标识返回上传令牌和分片参数。第二个是分片上传接口负责接收单个分片并落盘。第三个是合并接口负责把所有分片按顺序拼成完整视频文件。下面我把每个接口的关键代码和设计意图拆开讲。3.1 初始化接口生成上传任务并计算分片元数据初始化接口的入参是fileName、fileSize、identifier这三样。identifier就是前面说的文件标识也可以是后端主动计算的结果。接口收到请求后要做三件事计算分片总数、创建临时目录、返回分片信息给前端。PostMapping(/upload/init) public UploadInitVO init(RequestBody UploadInitDTO dto) { long fileSize dto.getFileSize(); long chunkSize 5 * 1024 * 1024L; long totalChunks (fileSize chunkSize - 1) / chunkSize; String uploadId dto.getIdentifier(); Path dir Paths.get(uploadRoot, uploadId); if (!Files.exists(dir)) { Files.createDirectories(dir); } UploadInitVO vo new UploadInitVO(); vo.setUploadId(uploadId); vo.setChunkSize(chunkSize); vo.setTotalChunks(totalChunks); vo.setExists(checkIfFileExists(uploadId)); return vo; }注意这里我用(fileSize chunkSize - 1) / chunkSize来计算分片总数这是向上取整的写法最后一个分片可能不足 5MB这是正常的。exists字段是给秒传预留的如果后端发现同一标识已经有完整合并文件前端拿到这个标记就直接跳过上传。还有一个细节临时目录一定要确保创建成功而且目录名不能直接用原始文件名因为文件名可能包含斜杠、中文、特殊字符直接拼接路径会出大问题。用identifier做目录名是安全的选择。3.2 分片上传接口按索引落盘支持覆盖与校验分片上传接口是核心中的核心。它接收上传令牌、分片索引以及分片文件本身。我习惯用两部分数据分片索引和总分片数放在请求参数里文件内容用 multipart 传输。PostMapping(/upload/chunk) public void uploadChunk(RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) int chunkIndex, RequestParam(file) MultipartFile file) throws IOException { Path dir Paths.get(uploadRoot, uploadId); if (!Files.exists(dir)) { throw new RuntimeException(上传任务不存在请先调用初始化接口); } Path target dir.resolve(chunkIndex .part); file.transferTo(target.toFile()); chunkRegistry.markUploaded(uploadId, chunkIndex); }这段代码做了两件很重要的事。第一分片文件名直接使用chunkIndex.part比如0.part、1.part、2.part完全按索引命名。这样合并时只需要从 0 到 totalChunks-1 按顺序读文件即可与分片到达顺序无关。第二file.transferTo(target.toFile())内部做的事就是把 MultipartFile 拷贝到指定路径框架本身会处理临时文件的清理。如果你重复上传同一个chunkIndex会直接覆盖旧文件天然幂等。但有个地方需要小心transferTo要求目标文件的父目录必须存在否则抛 IOException。所以必须确保初始化时目录已经创建或者在这个接口里再兜底Files.createDirectories(dir)。我建议兜底因为前端可能不按顺序调用或者初始化后目录被其他逻辑清理过。分片大小校验也放在这个接口里。后端可以依据初始化时分片大小判断每个分片的预期长度除最后一片外每个分片的大小应该等于 chunkSize最后一片大小等于文件总大小减去前面所有分片之和。如果实际收到的分片与预期不符直接拒绝防止前端代码被篡改或文件被意外截断。这个校验放在初始化阶段算好各个分片的偏移量上传时比对 MultipartFile 的getSize()即可。3.3 合并接口顺序拼接、文件校验、清理临时目录合并接口做的事情最直接遍历分片索引把所有.part文件按顺序写入最终文件然后再校验总大小最后删除临时目录。PostMapping(/upload/merge) public void merge(RequestParam(uploadId) String uploadId, RequestParam(totalChunks) int totalChunks) throws IOException { Path dir Paths.get(uploadRoot, uploadId); Path merged Paths.get(uploadRoot, uploadId .mp4); try (FileChannel out FileChannel.open(merged, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i 0; i totalChunks; i) { Path part dir.resolve(i .part); if (!Files.exists(part)) { throw new RuntimeException(第 i 个分片缺失合并失败); } try (FileChannel in FileChannel.open(part, StandardOpenOption.READ)) { long position 0; long size in.size(); while (position size) { position in.transferTo(position, size - position, out); } } } } Files.walk(dir) .sorted(Comparator.reverseOrder()) .forEach(p - { try { Files.deleteIfExists(p); } catch (IOException e) { throw new RuntimeException(e); } }); }这里有几个值得展开的细节。第一合并时为什么要用FileChannel而不用一次性把文件都读进内存的 byte[]因为视频文件动辄几百 MB 甚至上 GB一次性全量读取会把整个文件加载到堆内存服务器内存再大也不够折腾。用 Channel 做流式搬运IO 效率高内存占用几乎为零。第二transferTo的返回值不一定等于剩余字节数所以要用 while 循环处理直到当前位置推进到文件末尾。如果只调一次in.transferTo(0, in.size(), out)在某些文件系统或网络文件系统上可能只搬了一部分数据最终合并出来的视频就会在中途断裂。第三合并完成后的清理不能省略。分片文件占用的磁盘空间加起来通常比合并视频本身还要多如果每次上传都不清理用不了几个视频磁盘就满了。清理时用Files.walk从子文件到父目录逐级删除比单独删每个文件更稳妥因为删除操作无法直接删除非空目录必须先删分片再删目录。合并接口还有一个值得做但常常被忽略的判断文件完整性校验。合并后可以用合并文件的大小与初始化阶段拿到的文件大小做比对不一致直接删除合并文件并报错这条规则在视频场景下尤其管用。视频文件只要缺一段哪怕只有几百字节播放时都会在特定位置花屏或卡死靠大小校验能挡住大部分不完整情况。4. 前端配合实战原生切片、并发控制与进度计算后端三个接口已经准备好前端只要按照协议把文件切成块、按顺序发上去就行。我用原生 JavaScript 实现先不看任何框架因为这样你能看清楚每一步在干什么后面换框架时思路也很容易迁移。4.1 使用 Blob.slice 切分视频文件前端切片的入口是 File 对象的slice方法。它和字符串的 substring 类似传起始字节和结束字节返回一个 Blob。const CHUNK_SIZE 5 * 1024 * 1024; const file document.querySelector(#videoFile).files[0]; const identifier await calculateFileFingerprint(file); const totalChunks Math.ceil(file.size / CHUNK_SIZE); let chunks []; for (let index 0; index totalChunks; index) { const start index * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); chunks.push({ index, blob: file.slice(start, end) }); }切完之后chunks数组里的每个元素就是一个独立分片。blob可以直接放进 FormData 作为文件字段上传后端拿到就是一个 MultipartFile。totalChunks用Math.ceil(file.size / CHUNK_SIZE)向上取整和刚才后端公式保持一致最后一个分片的大小自然小于等于 CHUNK_SIZE。使用slice时有个小坑不同浏览器的老版本 API 名称不一样。现代浏览器都支持file.slice(start, end)但如果你需要兼容老版本浏览器可能还得判断file.webkitSlice是否存在。现在的项目基本不需要太担心这个但代码里做一层兼容判断是防御性编程的好习惯。切完片先不急着发请求我建议先调用后端的初始化接口把文件大小、文件标识传过去拿到后端返回的chunkSize、totalChunks和exists标记。如果exists为 true说明服务器已经有完整文件直接显示“秒传成功”整个上传流程就结束了一个分片都不用发。4.2 并发控制与失败重试策略分片切好之后最朴素的玩法是把所有分片一次性全部通过Promise发出去。这个做法在小文件上没问题但如果是上百个分片同时请求服务器会瞬间收到几十个并发写磁盘的请求磁盘队列直接被打满结果不仅没有提速反而比串行更慢。我的做法是维护一个固定大小的并发池。用一个数组管理待上传分片同时最多执行 3 个上传任务每完成一个就从队列里取下一个。const CONCURRENCY 3; let completedCount 0; let failedChunks []; async function run() { while (chunks.length 0) { const current chunks.shift(); await uploadSingle(current); } } async function uploadSingle(chunk) { try { const formData new FormData(); formData.append(uploadId, uploadId); formData.append(chunkIndex, chunk.index); formData.append(file, chunk.blob, chunk- chunk.index); const resp await fetch(/upload/chunk, { method: POST, body: formData }); if (!resp.ok) { throw new Error(upload failed: resp.status); } completedCount; updateProgress(); } catch (e) { failedChunks.push(chunk); } } const workers Array.from({ length: CONCURRENCY }, () run()); await Promise.all(workers);run函数是每个并发工人的工作循环从队列头部取一个分片上传成功则计数更新进度失败则把分片放回失败列表。这里有几个问题要面对失败的分片要不要立即重试重试几次我一般会在uploadSingle里对单分片做 3 次重试重试之间间隔按 1 秒、2 秒、4 秒递增退避避免失败后的连续重试进一步压垮服务器。三次仍失败则放入failedChunks等待一键重新上传剩下的分片。还有一个实际开发中会遇到的细节fetch一旦发起请求即使服务器已经返回 500网络层也可能不报错只是 response.ok 为 false。所以判断成功不能只看是否收到了响应还要检查response.ok。上面代码里我已经写了这个判断但很多人第一次写时会漏掉结果就是服务器明明拒绝了分片前端还傻乎乎地认为上传成功并推进度。4.3 进度计算以字节为准还是以分片为准进度条是上传体验的直观体现。常见做法有两种按分片个数计算、按字节数计算。按分片个数计算最简单已上传分片数除以总分片数就是进度但它的缺陷是最后一个分片往往不到 5MB如果前面 99 个分片都是 5MB最后一片只有 3KB进度条会卡在 99% 很久体验很奇怪。按字节计算更准确每个分片上传成功时累加该分片的实际大小然后除以文件总大小function updateProgress() { const uploadedBytes completedChunks.reduce((acc, chunk) acc chunk.blob.size, 0); const percent Math.floor((uploadedBytes / file.size) * 100); progressBar.style.width percent %; }这样做还有一个额外的价值当某个分片上传失败时你要把它对应的字节数也从已完成里扣掉才能保证进度条只增不减但不虚高。如果不扣失败重传等于重复计算最终 100% 了文件却还没传完用户就会产生误导。前端这块还有一个值得提的细节视频文件通常很大切分 5MB 一片产生上百个分片时如果直接在主线程里循环创建 chunks 数组UI 会短暂卡顿。现代浏览器可以用 Web Worker 在后台线程做切分主线程只负责把切好的 Blob 取过来发出请求。不过实际使用中切分本身是内存操作除非文件超过 2GB否则卡顿不太明显可以先不做这个优化。5. 实际开发中踩过的五个坑从“初见 413”到合并性能切片上传的核心接口都写完之后真正折磨人的反而是那些边界情况。我把自己实际项目里踩过的坑整理了一下希望能帮你提前绕开。第一个坑就是 413 错误。明明 SpringBoot 已经配置了max-file-size为 10MB上传单片 5MB 的时候还是返回 413排查半天才发现是 Nginx 的client_max_body_size没有同步改大。这个问题的隐蔽之处在于开发环境直连应用服务器时一切正常一上测试环境走 Nginx 代理就出问题。如果你用了网关、负载均衡这类中间层每一层都要确认上传大小限制链条上的任何一环都会掐掉大请求。第二个坑是分片上传完成速度很快但合并接口特别慢。这种现象通常出现在分片数量巨大且合并时没有用流式管道而是用一次性读取全部字节的场景。我曾经见过合并逻辑先把所有分片读进内存数组再拼接起来最后一次性写出。8 个分片时没感觉切到 80 个分片时直接内存爆炸。合并操作一定要用流式或通道的方式按索引一个接一个读、一个接一个写内存占用保持在一个分片大小以内就不会出问题。第三个坑是乱序。前端使用并发 5 路上传时分片到达后端的时间完全不确定可能是3.part先到0.part最后到。如果后端接口是按“到达顺序写入合并文件”那合并出来的视频一定是乱序的播放起来就是花屏、跳帧、时长不对。解决思路刚才已经讲过了分片文件按照索引独立命名合并时按索引升序遍历而不是按上传时间顺序追加。这个设计从一开始就要定不要等到并发功能做了一半再来改。第四个坑是重复分片造成的文件损坏。前端在超时后重试分片时如果后端接收逻辑是“第一次收到就写入第二次收到就直接忽略”那么第一次写入的数据如果是不完整的、网络传输中断的数据第二次重试传输的完整数据反而被丢弃最终文件损坏。正确策略是覆盖写重试的分片直接覆盖旧文件。覆盖写虽然会多写一次磁盘但它保证了最终留在磁盘上的分片一定是最新一次成功传输的完整数据。第五个坑是临时目录的磁盘空间。视频上传系统上线后磁盘被占满过排查发现是之前的合并流程缺少清理临时文件的步骤导致每个上传过的视频都留下了几十个分片文件躺在磁盘上。如果合并时发现磁盘空间不够合并失败那后果更麻烦原始分片还在但合并文件没生成用户反复点击上传每次都报错。所以合并接口里删除临时目录之外还要考虑一个兜底方案定时任务每天清理超过 24 小时未合并的上传目录避免那些初始化了但没传完的文件长期占着磁盘。除了这五个坑还有一个值得做的优化在分片上传接口加一个简单的并发控制单上传任务的分片同时只能处理有限个防止异常请求导致整个接口被拖垮。可以做一个基于内存或分布式的计数每个分片处理完释放。这个措施在内部项目里看起来多此一举但一旦服务暴露到公网它就能挡住很多异常流量。6. 进阶玩法断点续传、文件指纹去重与异步合并切片上传基础版本跑通之后能很容易延伸出几个“看起来很高级”的能力断点续传、秒传、异步合并。这些能力不是额外功能而是切片上传的数据结构天然支持的扩展。先说断点续传。断点续传的核心是初始化接口返回的信息要升级为“已上传分片列表”。如果用户把文件传到一半关掉了浏览器再次选择同一个文件时前端初始化后会拿到后端返回的已上传分片索引比如 [0, 1, 2, 5, 6]说明 3 和 4 没传成功。前端只需要跳过已存在的分片把缺失的分片重新上传一遍然后调用合并接口即可。实现这个能力后端只需要在两个地方改动初始化阶段扫描临时目录下的.part文件把存在的索引返回给前端分片上传接口维持现有的覆盖写逻辑因为重复上传已存在的分片不会产生副作用。前端则只需要判断某个分片索引是否已存在于已上传集合中存在就跳过。再说秒传。秒传的本质是文件标识复用。前端每次上传前计算文件标识初始化接口先查这个标识对应的合并文件是否已存在如果存在就直接返回存在标记前端显示上传完成且不发任何分片。这个能力看起来很神奇但它依赖一个前提文件标识必须足够可靠。如果你只拿文件名和文件大小做标识两个不同的视频恰好大小相同文件名相同就会被误判为同一文件哪怕内容不同也只能被当作已存在这是灾难。稳妥做法是取文件内容的前 N 个字节、中间 N 个字节、末尾 N 个字节加文件大小拼接成标识这类标识的碰撞概率在实际场景下已经很低。如果想做到更严格可以算整个文件的 MD5 或 SHA-1但大文件全量计算哈希本身也需要时间可能用户都开始上传了还没算完所以实践中常采用抽样计算的方式。最后说异步合并。基础版本中合并接口是同步执行的一个几 GB 的视频合并可能需要十几秒同步接口会让前端一直等待网关超时的风险高。进阶做法是合并接口收到请求后立即返回合并中后台用线程池处理合并任务前端通过轮询一个查询接口了解合并状态。查询接口的数据来源可以是内存状态表也可以是数据库表中的记录状态包括处理中、成功、失败。异步合并还需要考虑并发问题同一个上传任务的合并操作不能同时执行两次否则两个线程同时写同一个合并文件会发生错乱。用分布式锁或者数据库唯一约束都能避免这种问题。还有一个可选但值得做的扩展不自己合并直接把分片对接对象存储。很多云服务的对象存储本身就支持分片上传能力它可以接收你切好的分片服务端自动完成合并省去了自己管理临时目录、合并文件、磁盘清理这一整套逻辑。如果你的项目本来就用了云存储用它的分片上传接口代替自研合并接口会更稳毕竟云厂商对并发和大文件的处理经验比我们自己临时写的代码要成熟得多。但自研方案的优点是完全可控不依赖外部服务部署环境更轻。根据我自己的使用感受断点续传、秒传、异步合并这三个能力如果都能加上用户体验会有质的提升。尤其是秒传配合断点续传用户会感觉上传几乎是瞬时的哪怕传输网络很一般也不会反复折腾。建议按“基础切片上传 - 断点续传 - 秒传 - 异步合并”这个顺序逐步升级每一步都建立在已稳定的架构上出问题时排查范围也会小很多。最后分享一个个人习惯我在写这类上传模块时会单独建一张上传元数据表记录文件标识、上传状态、合并状态、创建时间这些信息分片文件只作为临时产物放在磁盘上。这张表对后续做统计、清理、溯源都很有用成本却极低。等到再被问到进度条为什么卡住的时候查一下这张表就能定位问题而不是靠猜。