
1. 大文件上传为什么会失败——先认清要解决的问题做 Web 开发的人迟早会碰到这个需求用户要上传一个几个 GB 的视频素材、一份完整的数据库备份或者一套包含大量高清图片的设计稿压缩包。小文件随便传没问题一到大文件就各种翻车传了 40 分钟在 98% 的时候连接断开了重新来一遍办公室网络稍微波动一下上传进度条回退到零领导在旁边等着你盯着屏幕上的网络错误四个字血压直接拉满。先别急着写代码我们得搞清楚大文件上传到底难在哪里。很多人第一反应是加大服务器配置、调大超时时间但这个思路从一开始就错了。1.1 网络不是唯一瓶颈时间窗口才是HTTP 上传大文件的失败率之所以高本质上是因为它把一个需要长时间持续进行的操作押注在一条随时可能出问题的链路上。TCP 连接本身有超时机制Nginx 默认的proxy_read_timeout是 60 秒Servlet 容器也有自己的 session 和连接管理策略。哪怕这些都是可以调的但用户那边断网、电脑休眠、Wi-Fi 切换、公司出口带宽抖动这些都不是服务端能控制的。换个角度看一个 2GB 的文件算你上行带宽有 20Mbps理论耗时也要 13 分钟以上。这 13 分钟里只要有一次网络抖动导致连接中断如果没有断点续传机制前面所有的数据全部作废。这就像你从一楼往楼顶搬砖每趟只能搬一块中途摔了一跤砖碎了就得重新从一楼开始搬——这不是效率问题是设计问题。1.2 断点续传的本质切分、记录、重传、合并断点续传的核心思想不复杂把一个大文件切成 N 个分片逐个上传服务端记录哪些分片已经收到了哪个分片传失败了就单独重传那一片等所有分片都到了之后服务端再把它们拼回完整的文件。这里面有三个关键问题必须想明白上传到哪了服务端需要知道某个文件当前已经收到了哪些分片这个信息必须持久化不能放在内存里因为服务器重启、接口异常都会导致状态丢失。传的是什么每个分片必须有明确的标识至少包含文件唯一标识、分片序号、分片数据这三样信息服务端才能把分片对号入座。怎么拼回去最后一个分片到达后服务端要把所有分片按顺序拼接同时验证完整性防止某个分片内容损坏。这三件事想清楚了断点续传的骨架就有了。后面所有的代码、配置、优化都是为这三个问题服务的。1.3 技术选型的边界为什么选 SpringMVC 而不是别的既然要做断点续传就绕不开技术选型。目前市面上有几种做法用 Hadoop 生态的 HDFS适合海量数据分布式存储、用 Nginx 的upload_progress_module只解决进度显示不解决断点、用第三方云存储 SDK如阿里云 OSS 分片上传但要走外网流量就得付费。如果你的项目本身就是 Java 技术栈、后端框架是 SpringMVC那在它之上自己实现一套断点续传是最务实的方案——不引入额外组件、不产生额外费用、完全可控还能跟现有的权限体系和拦截器无缝集成。SpringMVC 处理这个问题有一个天然优势它的DispatcherServlet和MultipartResolver机制已经把 HTTP 请求中的文件解析、参数绑定这些脏活累活做了封装我们只需要在 Controller 层定义好接收分片的接口专注于业务逻辑即可。接下来我会从原理到代码把整条链路完整走一遍。2. 前端分片与 Worker怎么做到不卡页面还能记住进度断点续传不是后端单方面的事前端是关键的第一公里。你在页面上选了一个大文件浏览器要把这个文件拆开、挨个传、记录进度、断线之后接着传这些动作如果全在主线程里做用户会看到一个无响应的标签页。2.1 为什么不能用一把梭的方式提交整个文件假设你没有做任何分片处理用formmultipart/form-data直接提交一个 2GB 文件会发生什么第一浏览器需要把整个文件读入内存再作为请求体发送消耗的是客户端设备的内存资源。第二一旦请求发出整个上传过程对前端来说是一个黑盒——你拿不到中间状态无法知道传了多少、还剩多少进度条只能靠假数据蒙。第三连接一断整个请求体作废一切从头再来。分片之后这三个问题全部解决内存占用可控每次只读一个分片比如 5MB进内存传完就释放。进度可控每个分片有独立的上传状态已传片数/总片数就是精确进度。断点可续只需要从失败的分片序号继续上传前面成功的分片不用动。2.2 File.slice 分片与 Worker 线程的落地实现在浏览器端切分文件用的是File对象的slice()方法它和Array.prototype.slice的理念类似返回的是原文件的一个二进制片段。假设分片大小为 5MB总共要传 2GB 的文件算一下就是 4096 1 个分片因为 2GB / 5MB 409.6最后一个分片不满 5MB 也很正常按实际大小传就行。前端代码的核心逻辑大概是这个形态const CHUNK_SIZE 5 * 1024 * 1024; // 5MB const file fileInput.files[0]; const totalChunks Math.ceil(file.size / CHUNK_SIZE); const fileId generateFileId(file); // 基于文件内容生成唯一标识用于合并和续传 for (let i 0; i totalChunks; i) { const start i * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); const chunk file.slice(start, end); await uploadChunk(fileId, i, totalChunks, chunk); }这里面有一个细节容易踩坑fileId的生成方式。如果简单用文件名加时间戳同一个文件上传两次会生成不同的 ID断点续传就找不到之前的分片记录。我一般用文件大小 文件名 文件最后修改时间的组合做一个 hash或者直接用文件的 MD5文件大的话算 MD5 比较耗时可以只在后台算前端用轻量策略。实际项目中更推荐先用文件元信息生成 id让用户先发一个初始化请求由服务端决定是否已存在相同文件如果存在且完整就直接返回秒传结果。2.3 用 Worker 把上传任务推到后台线程分片上传本身也是耗时的循环操作如果放在主线程里跑页面会一直处于忙碌状态用户点击其他按钮没反应体验很不友好。这里引出一个热词提到过的方案前端使用 Worker 上传大文件。Web Worker允许你在后台线程中执行 JavaScript主线程和 Worker 之间通过postMessage通信。把分片上传的逻辑搬进 Worker 之后主线程可以保持流畅响应用户甚至可以在上传过程中继续浏览页面、做其他操作。Worker 里处理完某个分片就把进度信息postMessage回主线程更新进度条 UI。// main.js const worker new Worker(/js/upload-worker.js); worker.postMessage({ file, fileId, totalChunks, chunkSize: CHUNK_SIZE }); worker.onmessage (e) { const { type, data } e.data; if (type progress) { updateProgressBar(data.done, data.total); } else if (type success) { notifyUploadComplete(data.fileUrl); } };Worker 内部逐个处理分片每次上传前先向后端确认该分片是否已存在这个动作叫校验分片后面后端部分会细讲已存在的直接跳过不存在的才真正上传。这样即使页面刷新、浏览器关闭重新打开之后只要文件还在就能从中断的位置继续传。2.4 前端状态管理进度记忆的三层方案断点续传要记住传到哪里了前端可以做三层状态保底内存变量最基础的一层记录当前已成功上传的片数适用于页面不刷新的场景。localStorage/sessionStorage把已传分片序号列表持久化到浏览器本地存储页面刷新后还能读取。服务端存储最靠谱的一层前端每次启动上传任务时询问服务端这个文件已经收到哪些分片了以服务端的记录为准前端只负责补齐缺失的分片。推荐的做法是第三层为主前两层作为辅助。原因很简单用户可能换了浏览器、清了缓存也可能后端做了文件清理前端的记录可能过期服务端的记录才是唯一的权威状态。所以前端每次开始上传前先调用一个查询上传状态的接口让后端告诉你已经有哪些分片前端再决定从哪个分片继续。3. SpringMVC 后端接口设计Restful 风格下的分片接收后端承接了整个断点续传的记账和拼图职责接口设计是否合理直接决定了系统的稳定性和扩展性。我在实际项目中把断点续传相关的接口按 Restful 风格拆分成四个各有各的分工。3.1 接口语义与分片信息约定先看一下四个接口的整体设计接口方法路径作用初始化上传POST/api/upload/init告知服务端要上传的文件信息获取 fileId返回分片大小、是否已存在秒传判断校验分片GET/api/upload/{fileId}/chunk/{index}查询某个分片是否已上传成功已存在则跳过上传分片POST/api/upload/{fileId}/chunk/{index}上传单个分片数据合并文件POST/api/upload/{fileId}/merge所有分片就绪后触发服务端合并文件这套接口设计有几个好处语义清晰每个接口只干一件事符合 Restful 风格对资源操作的定义接口路径中直接携带 fileId 和分片序号不需要在请求体里额外传一堆业务字段上传分片用 POST校验分片用 GET合并动作用 POST符合 HTTP 方法语义。3.2 Controller 层MultipartFile 的接收原理在 SpringMVC 中接收分片的 Controller 方法长这样RestController RequestMapping(/api/upload) public class FileUploadController { private final UploadService uploadService; public FileUploadController(UploadService uploadService) { this.uploadService uploadService; } PostMapping(/{fileId}/chunk/{index}) public ResultString uploadChunk(PathVariable String fileId, PathVariable int index, RequestParam(chunk) MultipartFile chunk) { uploadService.saveChunk(fileId, index, chunk); return Result.success(分片上传成功); } PostMapping(/merge) public ResultString merge(RequestParam String fileId, RequestParam String fileName, RequestParam int totalChunks) { String fileUrl uploadService.mergeFile(fileId, fileName, totalChunks); return Result.success(fileUrl); } }这里MultipartFile是 Spring 框架对文件上传的抽象封装底层由CommonsMultipartResolver或StandardServletMultipartResolver解析 HTTP 请求中的multipart/form-data数据体。需要注意的是SpringMVC 默认只支持上传单个文件不超过 1MB我们必须在配置里修改这个限制否则分片稍大一点就会被拒收。RequestParam(chunk)绑定的是前端表单里FormData中字段名为chunk的文件对象。前端的FormData构造大概是这样const formData new FormData(); formData.append(chunk, chunkBlob, chunk_${index}.part); // 然后 axios 或 fetch 上传Controller 收到分片后调用uploadService.saveChunk进入业务层处理。这里有一个容易忽略的点Controller 层只负责参数绑定和校验不要把任何文件存储逻辑写在这里。分片存储涉及的目录创建、路径拼接、磁盘写操作全部下沉到 Service 层保持 Controller 轻量。3.3 服务层分片信息登记与磁盘存储分片的存储策略没有想象中那么复杂核心是给每个文件建一个专属目录目录名用 fileId分片按序号存放。服务端的文件目录结构大致如下/upload_temp/ 20240515163012_a1b2c3d4e5f6/ 0.part 1.part 2.part ...每个分片就是一个独立的.part文件。这里的关键在于分片信息的记账——你总得知道自己收到了哪些分片合并的时候才知道全不全。我把分片记录表设计在数据库中字段包括fileId、fileName、fileSize、totalChunks、uploadedChunkIndex、status、createTime、updateTime。每当收到一个分片就更新该文件的已上传分片序号集合。一个直白但有效的方法用一个SetInteger记录已上传分片序号序列化成 JSON 字符串存到一个字段里或者用一张分表存储。分片数量不会太大2GB 按 5MB 切是 400 多个分片直接在记录表里加一个uploaded_chunks字段存 JSON 也完全够用查询和更新都方便。public void saveChunk(String fileId, int index, MultipartFile chunk) throws IOException { String dirPath STORAGE_PATH File.separator fileId; File dir new File(dirPath); if (!dir.exists()) { boolean created dir.mkdirs(); if (!created) { throw new IOException(创建分片目录失败: dirPath); } } File dest new File(dir, index .part); chunk.transferTo(dest); // 更新数据库中的分片状态 uploadRecordMapper.markChunkUploaded(fileId, index); }这里有一个并发问题的坑必须重点说前端如果同时发起多个分片的并发上传比如一次并发 3 个分片后端可能同时收到同一个 fileId 下的多个分片写入请求。数据库的markChunkUploaded操作必须做好幂等控制——同一个分片序号重复标记不能报错否则分片重传会导致客户端误判为失败。我在实现时给(file_id, chunk_index)加了唯一索引用INSERT ... ON DUPLICATE KEY UPDATE或者先查询再插入的方式保证幂等。3.4 SpringMVC 拦截器身份验证与流量控制的哨兵断点续传接口和普通接口一样需要权限控制SpringMVC 的HandlerInterceptor在这个场景里派上了用场。上传接口不能裸奔至少要做两件事验证用户身份、校验上传配额。我在项目里写了一个UploadAuthInterceptor在preHandle阶段做三件事从请求头中提取 token调用用户服务校验身份未登录直接返回 401。校验该文件是否属于当前用户、fileId 是否合法防止用户恶意构造 fileId 上传任意内容。检查用户当日上传配额是否超限比如免费用户单日上传上限 5GB超出则提示升级。拦截器注册时只拦截/api/upload/**路径不影响其他业务接口Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new UploadAuthInterceptor(uploadAuthService)) .addPathPatterns(/api/upload/**); } }这个拦截器还有一个隐藏价值它可以在分片上传期间维持用户会话不被回收。默认的 Session 超时一般是 30 分钟如果大文件上传时间超过这个阈值可能导致会话失效后面的分片被拒之门外。在拦截器中手动更新时间戳、或者使用 token 机制而不是 Session 机制能规避这个隐患。4. 文件合并与秒传最后的临门一脚分片全部上传完成后后端要执行合并操作。这一步没有太多花活但做好了跟做差了差别很大。4.1 合并策略顺序拼接也要讲技巧合并的最粗暴方式是按分片序号从小到大依次读取.part文件追加写入目标文件。Java 里用Files.write配合StandardOpenOption.APPEND或者FileChannel都能实现。public String mergeFile(String fileId, String fileName, int totalChunks) throws IOException { String dirPath STORAGE_PATH File.separator fileId; String mergedPath STORAGE_PATH File.separator fileId _ fileName; File mergedFile new File(mergedPath); // 先校验分片完整性 UploadRecord record uploadRecordMapper.findByFileId(fileId); if (record null || isChunksIncomplete(record, totalChunks)) { throw new BusinessException(分片不完整无法合并); } try (FileChannel outChannel FileChannel.open(mergedFile.toPath(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i 0; i totalChunks; i) { File partFile new File(dirPath, i .part); try (FileChannel inChannel FileChannel.open(partFile.toPath(), StandardOpenOption.READ)) { long size inChannel.size(); inChannel.transferTo(0, size, outChannel); } } } // 合并完成删除分片目录 deleteDir(new File(dirPath)); // 更新记录状态为已合并 uploadRecordMapper.markMerged(fileId); return mergedPath; }这里FileChannel.transferTo是零拷贝实现性能远高于字节逐段复制在 Linux 上底层走的是sendfile系统调用不会把文件数据拷贝到 JVM 堆内存避免了大文件合并时的内存压力。这个细节在合并几十 GB 文件时差别非常明显——用普通 IO 流合并容易导致 GC 频繁甚至 OOM用 FileChannel 合并不管文件多大都不会有堆内大对象。4.2 秒传是怎么做的秒传本质上是文件指纹比对的产物。用户在初始化上传时前端把文件的大小、文件名、修改时间等元信息发给服务端服务端在已有的文件记录中查找是否存在相同特征的文件。如果存在且文件完整直接返回一个该文件已存在不需要上传的标识前端直接跳过全部分片这就是所谓的秒传。更严谨的做法是计算文件的 MD5 值做精确匹配。但 MD5 计算在大文件上耗时明显——一张 1GB 的文件前端用 Web Worker 算 MD5 大约需要几秒到十几秒这个时间换来的精确度到底值不值取决于你的业务场景。如果文件都是视频素材、设计稿这类大文件用户等待几秒换秒传体验是可以接受的如果文件普遍几十 MB 以内直接用文件大小 文件名做粗略判断就够了。我现在的做法是分两步先用文件大小和文件名做快速筛查能匹配上就进入 MD5 校验匹配不上就直接走正常上传流程不用把 MD5 计算塞给所有用户。4.3 断点续传的完整工作链路把前面所有环节串起来看一次完整的断点续传流程是这样的用户选择文件前端生成 fileId调用/api/upload/init服务端返回分片大小、已存在的分片列表、是否支持秒传。如果秒传命中直接跳转成功页整个过程 0 字节上传。前端把未上传的分片列表交给 WorkerWorker 依次上传分片。每上传一片服务端写入分片文件、更新分片状态。上传过程中出现网络错误Worker 记录失败的分片序号短暂重试后仍失败则暂停。用户点击继续上传前端重新调用 init 接口拿到已上传的分片列表只补传缺失的分片。所有分片完成后前端调/api/upload/merge服务端合并文件、删除临时分片、更新状态。这套链路最妙的地方在于每一步的失败都是可恢复的没有任何一个环节需要从头再来。这在实际交付项目时是决定用户去留的关键体验因素。5. 性能调优与踩坑实录那些文档里查不到的细节断点续传的原理搞清楚之后真正考验人的是把它跑稳定。我整理了几个实际项目中大概率会遇到的问题和我的处理方式供各位参考。5.1 SpringMVC 上传大小配置不改这个前面的努力全白费SpringMVC 默认的上传大小限制因版本而异但都很小必须显式修改。如果用的是 Servlet 3.0 StandardServletMultipartResolver需要在web.xml或者配置类中设置maxFileSize和maxRequestSizeBean public MultipartResolver multipartResolver() { StandardServletMultipartResolver resolver new StandardServletMultipartResolver(); return resolver; }对应的 Servlet 配置需要设置 multipart 参数Bean public ServletRegistrationBeanDispatcherServlet dispatcherServletRegistration(DispatcherServlet dispatcherServlet, WebApplicationContext context) { ServletRegistrationBeanDispatcherServlet registration new ServletRegistrationBean(dispatcherServlet, /); registration.setMultipartConfig(new MultipartConfigElement(null, 1024 * 1024 * 1024L, 1024 * 1024 * 1024L, 0)); return registration; }MultipartConfigElement的三个关键参数分别是临时目录位置、单个文件最大字节数、整个请求最大字节数。如果你的分片大小定的是 5MB那么单文件限制只要大于 5MB 即可但因为有些用户会退化到整个文件一把梭的方式上传我一般会把单文件限制调到不小于 1GB同时配合前端分片逻辑双保险。如果你还用了 Nginx 做反向代理client_max_body_size这个参数同样要调大否则你的请求会在到达 SpringMVC 之前就被 Nginx 拦下返回 413 错误。5.2 分片大小怎么定理论计算与实际测试分片大小没有放之四海而皆准的标准答案要根据网络环境和业务场景来定。我整理了一个粗略的参考表分片大小适用场景优缺点1MB网络不稳定、弱网环境单个分片失败重传成本低但分片数量多请求开销大5MB通用场景我常用的默认值平衡了重传成本与请求次数兼容性好10MB内网环境、带宽充足请求次数少但弱网下失败重传成本偏高50MB以上局域网、专线传输分片少、效率高但网络抖动时重传代价大从数学角度算一笔账2GB 文件5MB 分片是 410 个请求10MB 分片是 205 个请求。每个 HTTP 请求有固定开销TCP 握手、请求头、响应头分片太小会导致总耗时被请求开销稀释分片太大则失去了断点续传的粒度优势。实际测试中在常规宽带环境下 5MB~8MB 是一个甜点区间弱网环境建议降到 2MB 以下。5.3 合并时的临时磁盘空间规划这个坑我踩得比较惨。分片上传时所有分片文件都存储在临时目录合并时临时目录里同时存在分片文件和合并后的完整文件。1GB 的分片集意味着临时目录需要额外 1GB 空间来存放合并结果峰值占用接近 2GB。如果服务器磁盘空间只有 10GB而用户上传了几个 5GB 的大文件磁盘很快就满了。合并完成后虽然会删除分片目录但合并过程可能持续几十秒甚至几分钟这段时间内磁盘可能被占满直接导致文件写入失败。我的处理方案在合并前检查磁盘剩余空间如果剩余空间 文件大小 * 1.5提前返回空间不足请清理后重试合并完成后立即删除分片目录释放临时空间。定期写家常任务扫描超过 24 小时未合并的临时目录自动清理。5.4 并发分片上传的分片接收顺序问题前端为了加速可以一次并发多个分片比如同时传 5 个。这带来一个隐患分片到达后端的顺序是乱的可能第 5 片先到、第 2 片后到。但我们的目录结构天然支持乱序存储——每个分片写入自己独立的.part文件互不干扰。合并阶段按序号顺序读取文件体所以乱序到达不影响最终合并结果。唯一需要注意的是数据库里的分片状态更新要支持并发写入。我用ConcurrentHashMap做内存级别的分片状态缓冲再加上数据库表唯一索引兜底双保险保证不会出现状态错乱。5.5 大文件上传与容器线程池不要让长请求占满 Tomcat 线程分片上传接口本身耗时不算长但如果有人恶意上传超大分片绕过前端限制直接调接口、或者合并接口执行时间太慢会一直持有 Tomcat 工作线程。Tomcat 默认maxThreads是 200如果 100 个用户同时在上传大分片或者等待合并线程池可能被打满其他所有接口都变成排队中。对策有两个方向一是给上传接口设置合理的超时时间控制单个请求的耗时上限二是把合并这类耗时长的操作放到异步线程池执行Controller 立即返回合并中状态前端轮询合并结果。后者对合并大文件尤其重要——我遇到过合并 10GB 文件耗时 3 分钟的情况同步等待完全不现实。6. Linux 部署环境下的额外注意事项热词里有一条linux网络大文件上传一般采用哪种方式说明大家确实关心 Linux 环境下的部署差异。SpringMVC 上传文件在 Linux 上跑和本机 Windows 跑有几个点不太一样。6.1 文件路径分隔符与权限问题Windows 下用File.separator自动处理\和/差异代码层面不需要额外注意。但 Linux 下对目录写权限要求很严格/tmp目录虽然任何人都能写但不建议把上传临时目录放在/tmp因为系统可能会定时清理这个目录正在上传的分片可能被系统任务删掉。我在部署时通常创建一个专门的目录例如/data/upload/tmp并确保运行 Java 进程的用户一般是tomcat或www-data有该目录的读写权限。chown -R tomcat:tomcat /data/upload这类的命令是部署清单里必备的。6.2 文件描述符数量限制每个分片上传都会打开一个文件句柄合并时要打开多个分片文件进行 Channel 操作。如果并发上传的用户多Linux 系统默认的 1024 文件描述符限制很容易被耗尽表现为Too many open files异常。解决方式是把服务的ulimit -n调大比如ulimit -n 65535同时确认 Tomcat 运行脚本里也正确继承了该配置。这个参数很多人容易忽略直到线上报错才回头排查。6.3 云服务器带宽与上传速度的匹配还有一个经常被忽略的运营维度很多云服务器的下行带宽很大、上行带宽却很小。用户上传数据走的是服务器的下行带宽如果带宽只有 5Mbps就算代码写得再高效大文件上传依然会很慢。这时候断点续传的价值就体现在慢但不失败——每次断线重传只需要补发几个分片而不是整个文件。如果是公司内部系统跑在局域网内这个带宽问题会小得多分片大小可以适当调大降低请求往返次数提升整体吞吐。7. 结合 Restful 风格在微服务架构下的扩展思路如果你的项目不是单机部署而是 SpringCloud 微服务架构断点续传的改造思路会有一些调整但基础原理完全复用。把上传服务和业务服务拆开上传服务独立部署、独立扩缩容存储层对接对象存储MinIO、OSS而非本地磁盘合并动作改为调用对象存储的分片合并接口——这些都是相对平滑的演进。核心的分片校验、状态记录、完整性问题跟单机版本没有本质区别仍然可以用前面这套设计来支撑。我在实际项目中还遇到过前端上传完所有分片但后端因断电丢了一部分分片的情况处理方式是合并前增加一步服务端扫描磁盘上的实际分片文件数的校验要和数据库记录对比以磁盘实际存在为准。数据库里的标记可能因为未提交事务丢失但落盘的分片文件不会说谎。这一步校验虽然简单但能避免不少怪问题。