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 为什么不能用一把梭的方式提交整个文件
假设你没有做任何分片处理,用<form>+multipart/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 Result<String> uploadChunk(@PathVariable String fileId, @PathVariable int index, @RequestParam("chunk") MultipartFile chunk) { uploadService.saveChunk(fileId, index, chunk); return Result.success("分片上传成功"); } @PostMapping("/merge") public Result<String> 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。每当收到一个分片,就更新该文件的已上传分片序号集合。
一个直白但有效的方法:用一个Set<Integer>记录已上传分片序号,序列化成 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 字节上传。
- 前端把未上传的分片列表交给 Worker,Worker 依次上传分片。
- 每上传一片,服务端写入分片文件、更新分片状态。
- 上传过程中出现网络错误,Worker 记录失败的分片序号,短暂重试后仍失败则暂停。
- 用户点击"继续上传",前端重新调用 init 接口,拿到已上传的分片列表,只补传缺失的分片。
- 所有分片完成后,前端调
/api/upload/merge,服务端合并文件、删除临时分片、更新状态。
这套链路最妙的地方在于:每一步的失败都是可恢复的,没有任何一个环节需要"从头再来"。这在实际交付项目时是决定用户去留的关键体验因素。
5. 性能调优与踩坑实录:那些文档里查不到的细节
断点续传的原理搞清楚之后,真正考验人的是把它跑稳定。我整理了几个实际项目中大概率会遇到的问题和我的处理方式,供各位参考。
5.1 SpringMVC 上传大小配置:不改这个,前面的努力全白费
SpringMVC 默认的上传大小限制因版本而异,但都很小,必须显式修改。如果用的是 Servlet 3.0 +StandardServletMultipartResolver,需要在web.xml或者配置类中设置maxFileSize和maxRequestSize:
@Bean public MultipartResolver multipartResolver() { StandardServletMultipartResolver resolver = new StandardServletMultipartResolver(); return resolver; }对应的 Servlet 配置需要设置 multipart 参数:
@Bean public ServletRegistrationBean<DispatcherServlet> dispatcherServletRegistration(DispatcherServlet dispatcherServlet, WebApplicationContext context) { ServletRegistrationBean<DispatcherServlet> 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)而非本地磁盘,合并动作改为调用对象存储的分片合并接口——这些都是相对平滑的演进。核心的分片校验、状态记录、完整性问题,跟单机版本没有本质区别,仍然可以用前面这套设计来支撑。
我在实际项目中还遇到过"前端上传完所有分片但后端因断电丢了一部分分片"的情况,处理方式是合并前增加一步"服务端扫描磁盘上的实际分片文件数"的校验,要和数据库记录对比,以磁盘实际存在为准。数据库里的标记可能因为未提交事务丢失,但落盘的分片文件不会说谎。这一步校验虽然简单,但能避免不少怪问题。