1. 上传慢的真凶:单体上传的三大瓶颈与分片思路
做后端开发的朋友应该都有过这种经历:用户反馈说视频文件传不上去,一看日志,连接断了;或者一个接近200MB的文件,传了十分钟,卡在最后90%的位置一动不动。排查半天发现不是带宽问题,而是整个上传方案从设计上就扛不住大文件。项目标题里的“上传文件慢”,往往不是网络慢,而是单体上传这个方案本身存在硬伤。
单文件整体上传最大的问题有三个。第一个是连接超时风险:一个文件从客户端到服务端要持续占用一个HTTP连接,文件越大,耗时越长,中间路由器、代理、浏览器或服务端任意一方超时断连,整个文件就得重新传。第二个是带宽利用率低:绝大多数HTTP上传是串行传输,单个TCP连接的窗口在弱网环境下很难跑满带宽,尤其跨地域传输时,实际吞吐量可能只有带宽上限的三成。第三个是失败成本极高:没有断点续传机制,一旦中断就从头再来,用户体验和服务器带宽都是双重浪费。
分片上传的思路其实很朴素:把一个大文件在客户端切成多个几MB的小块,分别独立上传,服务端全部接收后再按顺序合并还原。这样做的好处是即时有某个分片失败,只需要重传出错的那一片,而不是整个文件;同时还可以多分片并发上传,把带宽跑满。这个思想在早期的断点续传软件里就有雏形,放在SpringBoot服务里实现起来也并不复杂。
不过在动手写代码之前,得先明确一个边界:分片上传不是银弹。如果只是几十MB的小文件,用分片反而是过度设计,多出来的合并逻辑、分片管理都会变成额外的复杂度。一般建议文件超过100MB或者网络环境明显不稳定时才考虑分片方案。下面我按一个完整的实战项目来拆解整个实现过程,从后端接口设计到前端切片配合,再到生产环境里容易踩的坑,一次性讲清楚。
2. 后端核心实现:分片接口设计与合并流程
2.1 接口规划:三个接口分别承担什么职责
分片上传的后端至少要拆成三个接口来设计,把“初始化、上传分片、合并文件”三个动作分开。有人图省事只写一个上传接口,让前端把所有分片直接POST上来,这种设计在中小文件上勉强能用,但一旦分片多了,缺少初始化环节就没办法做秒传和断点续传,后面想优化体验就得重构。
第一个接口是文件初始化接口,入参包含文件名、总大小、分片大小、总分片数。这个接口的作用是告诉服务端“我要开始传一个文件了”,服务端据此生成一个唯一的文件标识(一般用UUID),预留临时目录。第二个接口是分片上传接口,入参包含文件标识、分片序号、分片文件数据,服务端把每个分片落到临时目录里。第三个接口是合并接口,入参包含文件标识和原始文件名,服务端把所有分片按序号合并成完整文件,并清理临时分片。
上传期间的临时目录结构建议按文件标识隔离,不要所有分片平铺在一个目录里。我见过不少项目把所有文件的上百分片全丢进同一个文件夹,结果清理和排查问题都特别痛苦。按文件标识建子目录,后续做分片不存在检查、垃圾清理都会方便很多。
分片上传还有几个核心参数需要前后端约定好,这里列一个我项目里用的典型配置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| chunkSize | 2MB ~ 10MB | 分片太小会导致请求数过多,太大则失去分片意义 |
| concurrency | 3 ~ 5 | 并发上传的分片数,太多会触发浏览器连接限制 |
| identifier | UUID或文件MD5 | 用MD5可实现秒传,用UUID则存储简单 |
| totalChunks | 由文件大小和分片大小计算 | 前端计算好传给后端,后端做完整性校验 |
2.2 分片上传与合并的SpringBoot实现
先看分片上传的主接口代码。这里我用的是一个相对标准的写法,包含分片文件存档、分片数量校验、断点续传查询三个能力:
@RestController @RequestMapping("/api/file") public class FileUploadController { // 分片上传的根路径,实际部署时建议配置到独立磁盘或分布式存储 private static final String UPLOAD_DIR = "/data/uploads"; // 服务端保存分片的临时目录,按文件标识隔离 @PostMapping("/chunk") public R uploadChunk( @RequestParam("file") MultipartFile file, @RequestParam("identifier") String identifier, @RequestParam("chunkNumber") Integer chunkNumber, @RequestParam("chunkSize") Long chunkSize, @RequestParam("totalChunks") Integer totalChunks) { if (file.isEmpty()) { return R.fail("分片文件为空"); } try { // 按文件标识创建子目录,防止分片混淆 Path chunkDir = Paths.get(UPLOAD_DIR, identifier); if (!Files.exists(chunkDir)) { Files.createDirectories(chunkDir); } // 分片文件的命名规则直接用数字序号,合并时排序更方便 Path chunkPath = chunkDir.resolve(chunkNumber + ".tmp"); file.transferTo(chunkPath.toFile()); return R.success("分片上传成功"); } catch (IOException e) { // 这里要打日志,生产环境排查上传问题全靠它 return R.fail("分片服务端存储失败"); } } // 查询已上传的分片列表,用于断点续传 @GetMapping("/chunk/list") public R chunkList(@RequestParam("identifier") String identifier) { Set<Integer> uploaded = new HashSet<>(); Path chunkDir = Paths.get(UPLOAD_DIR, identifier); if (!Files.exists(chunkDir)) { return R.success(Collections.emptyList()); } try (Stream<Path> stream = Files.list(chunkDir)) { stream.forEach(path -> { String fileName = path.getFileName().toString(); uploaded.add(Integer.parseInt(fileName.replace(".tmp", ""))); }); } catch (IOException e) { return R.fail("分片列表查询失败"); } return R.success(uploaded); } }合并接口的逻辑相对多一些,核心操作是读所有分片、按序号排序、按顺序写入目标文件,然后清理临时分片。这里特别要注意分片排序的问题。如果分片命名用的是String类型,默认按字典序排,“10.tmp”会排在“2.tmp”前面,合并出来的文件必然损坏。所以排序时一定要转成int比较,或者命名时用“00001.tmp”这种补零格式。
// 合并分片为完整文件 @PostMapping("/merge") public R mergeChunks(@RequestParam("identifier") String identifier, @RequestParam("fileName") String fileName) { Path chunkDir = Paths.get(UPLOAD_DIR, identifier); Path targetFile = Paths.get(UPLOAD_DIR, System.currentTimeMillis() + "_" + fileName); try { List<Path> chunkPaths = new ArrayList<>(); try (Stream<Path> stream = Files.list(chunkDir)) { chunkPaths.addAll(stream.collect(Collectors.toList())); } if (chunkPaths.isEmpty()) { return R.fail("分片不存在"); } // 按分片序号排序,int比较是关键 chunkPaths.sort(Comparator.comparingInt(p -> { String name = p.getFileName().toString(); return Integer.parseInt(name.replace(".tmp", "")); })); // 用BufferedOutputStream顺序写入,避免多次随机IO try (BufferedOutputStream outputStream = new BufferedOutputStream( Files.newOutputStream(targetFile))) { byte[] buffer = new byte[8 * 1024]; for (Path chunkPath : chunkPaths) { try (InputStream inputStream = Files.newInputStream(chunkPath)) { int len; while ((len = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, len); } } } } // 清理分片临时目录,否则磁盘会被垃圾分片占满 deleteChunkDir(chunkDir); return R.success(targetFile.toString()); } catch (IOException e) { return R.fail("合并失败"); } }2.3 秒传与断点续传:记忆Identifier背后的细节
做完上面的基础功能后,会发现一个体验问题:如果文件传到一半断了,用户重新打开页面,怎么知道哪些分片已经传过了?断点续传的实现关键就在这里——前端启动上传前先调用分片列表查询接口,后端把已上传的分片序号返回,前端只需要跳过这些序号继续传剩下的部分。
更进一步的体验是秒传。如果文件之前已经上传过,接口直接返回“合并成功”而不需要真正传输。实现方式是把文件标识改成文件的MD5值,上传前先请求一个“检测接口”,后端去已存储文件的索引表里查MD5是否存在。如果存在,直接告诉前端“这个文件已经传过了,不用再传”。这里说的索引表通常是一张数据库表,记录文件MD5、文件路径、文件大小这几个字段。
把identifier从UUID改成MD5,对断点续传也天然友好:同一个文件在不同浏览器或不同时间传入时,identifier是完全一致的,分片列表查询接口能直接找到之前未完成的临时分片,断点续传就成立了。不过MD5计算在高并发场景下要注意一点:大文件的MD5计算耗时较长,前端可以放在Web Worker中异步计算,否则主线程会卡顿。
我在实际项目里通常的做法是前端先用文件大小和文件名生成一个临时标识,上传成功后服务端再计算一次完整文件的MD5存入索引表——这样初始化上传的时候不需要等待MD5计算完成,体验更流畅。
3. 前端切片与并发控制:后端设计得再好,前端不会配合也白搭
3.1 File.slice切割与分片元数据组装
后端接口就绪之后,前端是分片上传真正执行的地方。前端的核心操作是调用File.slice()方法把文件内存映射成多个Blob片段。这里有个很多人一开始会犯的错误:以为slice()会把文件真实切分成多个磁盘文件,实际上浏览器底层只是通过文件字节偏移量生成了多个Blob引用,并没有复制文件内容,所以切片这个过程本身几乎是零成本、瞬时完成的。
// 前端切片核心逻辑 const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB每片 function splitFile(file) { const chunks = []; const totalChunks = Math.ceil(file.size / CHUNK_SIZE); for (let i = 0; i < totalChunks; i++) { const start = i * CHUNK_SIZE; const end = Math.min(file.size, start + CHUNK_SIZE); // slice()不会真实复制文件数据,只是创建了偏移量引用 chunks.push({ chunk: file.slice(start, end), chunkNumber: i + 1 }); } return { chunks, totalChunks, chunkSize: CHUNK_SIZE }; }分片时有两个细节值得注意。第一个是分片大小不能一概而论,需要根据网络环境和文件大小动态调整。局域网内1GB文件可以切成10MB分片并发上传,但公网弱网环境下10MB分片很容易超时,2MB到5MB更稳妥。第二个是分片序号从1开始还是从0开始要前后端统一,我见过前后端一个从0开始一个从1开始导致合并永远失败的案例,这类低级错误往往最耗时间排查。
切片完成后,每个分片要带上元数据一起提交。这里额外注意await并发上传时不要用同一个FormData实例,否则浏览器会报“FormData已经被使用”之类的错误,每个分片都应该是独立的FormData对象。
// 单个分片上传 async function uploadChunk(chunkInfo, identifier, totalChunks) { const formData = new FormData(); // 注意:每个分片都用新的FormData实例 formData.append("file", chunkInfo.chunk); formData.append("identifier", identifier); formData.append("chunkNumber", chunkInfo.chunkNumber); formData.append("chunkSize", CHUNK_SIZE); formData.append("totalChunks", totalChunks); const response = await fetch("/api/file/chunk", { method: "POST", body: formData }); if (!response.ok) { throw new Error(`分片 ${chunkInfo.chunkNumber} 上传失败`); } return response.json(); }3.2 并发控制、失败重试与进度计算
并发上传并不是“把所有分片一次性全发出去”那么简单。浏览器对同一个域名的并发连接数是有限制的,HTTP/1.1下Chrome约6个、Firefox约6个,如果一次性提交10个分片,多出来的请求会排队,反而造成拥塞和超时。所以必须自己实现一个并发池,控制同时上传的分片数。
我用一个简单的思路:维护一个分片队列和一个‘空闲容器’数组,循环取未上传的分片启动上传任务,每完成一个就从空闲容器中补一个分片进来。同时给单个分片加失败重试机制,连续失败三次才放弃,避免因为一次瞬时网络波动导致整个任务失败。
// 带并发限制的分片上传 async function uploadWithConcurrency(chunks, identifier, totalChunks, concurrency = 4) { const results = new Array(chunks.length).fill(false); let successCount = 0; const pool = async () => { while (queueCursor < chunks.length) { const currentIndex = queueCursor++; const info = chunks[currentIndex]; for (let retry = 0; retry < 3; retry++) { try { await uploadChunk(info, identifier, totalChunks); results[currentIndex] = true; successCount++; updateProgress(successCount / totalChunks * 100); break; } catch (e) { // 重试前延迟200ms,避免连发重试请求 await new Promise(resolve => setTimeout(resolve, 200 * (retry + 1))); } } } }; // 创建并发池任务 const workers = []; for (let i = 0; i < concurrency; i++) { workers.push(pool()); } await Promise.all(workers); return { successCount, results }; }进度的计算逻辑要按“已上传成功分片数 / 总分片数”而不是“已发送字节数 / 总字节数”。因为分片大小是均匀的,两者数值上差别不大,但用分片数统计能直接配合断点续传逻辑,展示更稳定的进度条。真实的文件上传项目里,用户对进度条的心理预期很高,如果进度条一直在90%左右徘徊,很大程度上是合并接口耗时而导致前端已经传完分片却仍在等待。这种场景下要把“上传中”和“合并中”两个阶段分开展示,避免用户误以为卡住了。
4. 实测效果与对比:从10分钟到几十秒的真实变化
4.1 单体上传与分片上传的耗时对比
我拿一个真实项目里的场景做测试对比。文件为一个约520MB的视频,网络环境为中等偏上的公网链路测试服务器,带宽约100Mbps。同一台服务器、同一个SpringBoot应用,分别用单体上传和分片上传跑了一轮。
| 上传方式 | 配置 | 总耗时 | 中断重传成本 |
|---|---|---|---|
| 单体上传 | 单连接 | 约8分钟 | 中断后全量重传 |
| 分片上传 | 4并发,5MB分片 | 约1分20秒 | 只需重传失败分片 |
| 分片上传 | 3并发,2MB分片 | 约2分05秒 | 只需重传失败分片 |
这个对比能看出两个结论。第一,分片并发上传在耗时上确实有明显优势,因为多个TCP连接同时传输,吞吐量不再是单连接的瓶颈。第二,分片大小不是越大越好也不是越小越好,5MB在这个网络条件下是更合理的折中点,2MB分片因为请求数多了近两倍,额外的网络往返开销反而拖慢了整体速度。
另外一个隐藏收益是稳定性。单体上传时我故意在传输到一半时断开网络重连,结果全程重传。分片上传则只需重传当时正在传、未完成的那两个分片,其他已经上传成功的分片全部保留。这个差异在生产环境里对用户体验的提升非常明显,用户不会因为一次弱网波动就彻底放弃上传。
4.2 合并耗时的优化空间
分片上传整体跑完后,合并环节同样有讲究。520MB文件、100多个5MB分片,用普通的Files.copy逐文件拼接,耗时大概在8到15秒之间。这个时间对于用户来说是可以接受的,但如果文件到了几个GB级别,合并耗时可能超过一分钟。
我尝试过几种合并优化方案。第一种是用FileChannel.transferTo做零拷贝传输,合并速度比BufferedOutputStream逐字节拷贝明显更快,在机械硬盘上差异尤其明显。第二种是分片合并和文件写入采用大块缓冲,比如把示例里的8KB缓冲换成1MB,减少系统调用次数。第三种是干脆不在应用服务器上合并,而是直接把分片上传到对象存储服务,利用云服务端的合并能力,这是后面进阶部分要展开的内容。
从实践经验看,普通服务器应用内部用FileChannel或者大缓冲的BufferedOutputStream已经足够,没必要为了合并优化引入太复杂的架构。合并耗时只要控制在总上传时长的五分之一以内,用户感知就不会太明显。
5. 生产环境实战:我在分片上传项目里踩过的坑
5.1 分片序号排序的隐藏炸弹
上面合并代码里提到过排序问题,这里想展开专门说一下,因为它确实藏得太深了。正常情况下分片文件命名用数字序号加.tmp后缀,比如1.tmp、2.tmp、10.tmp,如果按String类型排序,结果会变成1.tmp、10.tmp、2.tmp。合并时文件顺序错乱,最终视频播放到某个位置就会花屏或者卡住,而这个位置不固定,排查起来极其困难。
我遇到过一个案例,某开发者的分片上传方案在文件小于20MB时合并总是成功,但一旦超过20MB合并出来的文件就是坏的。原因是小于20MB时分片数量少、恰好字典序和数字序一致,超过20MB之后出现了10.tmp排到2.tmp前面的情况才暴露。排查时需要非常仔细地看排序逻辑,否则很容易忽略。解决方案就是代码里加个Comparator.comparingInt,或者命名时统一补零为固定长度的字符串。
5.2 临时分片清理与磁盘占满问题
分片上传项目上线后要面对的一个实际问题就是磁盘占用。用户上传了一半就关闭页面是常态,临时分片文件会滞留在服务器上。如果并发用户一多,几天不清理,磁盘就满了,服务直接崩溃。
我通常用三招组合处理。第一招是在合并成功后立即删除分片临时目录;第二招是写一个定时任务,扫描上传临时目录,清理超过24小时未被合并的分片文件,因为正常分片上传任务不会持续超过24小时;第三招是给分片上传接口做并发限流,同一IP或者同一identifier的并发请求做限制,防止接口被恶意利用刷爆磁盘。
这第三点特别重要。分片上传接口如果对外暴露且没有限制,攻击者可以伪造大量identifier上传垃圾分片。一个230MB的文件如果不做限制,按5MB分片切就有46个分片请求,每个请求都会在磁盘上留下文件,最终把存储空间耗尽。生产环境一定要在网关或应用层对分片上传做频率限制和总大小限制。
5.3 文件名乱码与路径穿越问题
文件名的处理也是一个容易出问题的点。前端传过来的原始文件名可能包含了中文字符、空格甚至特殊符号,如果不做处理,在合并时直接拼接到文件路径里,轻则文件名乱码,重则出现路径穿越漏洞——比如文件名是../../etc/passwd,拼接到目标路径后可能写到了系统目录。
我的处理方式是对原始文件名先用URLDecoder解码并做Base64或UUID重新命名存储,数据库表单独存原始文件名供下载时使用。这样服务端磁盘上保存的文件名和用户看到的文件名完全隔离,既避免乱码,又消除路径穿越风险。另外还要注意跨域配置里Access-Control-Expose-Headers要包含自定义的响应头,否则前端获取不到后端返回的上传结果。
6. 进阶优化方向:从“能用”到“好用”的几步走
6.1 大文件合并的三种策略选型
分片上传解决了传输粒度和失败重试的问题,但合并这个动作在超大文件场景下始终是个瓶颈。三种常见的合并策略各有侧重,实际选型取决于对性能和一致性的要求。
第一种是应用内相互合并同步返回,适合单体应用、文件大小在几个GB以内的场景,实现最简单。第二种是分片上传完成后触发异步合并任务,前端轮询或通过回调拿到完成状态,适合超大文件且用户可以接受稍等片刻的场景。第三种是把分片上传到对象存储服务,然后利用对象存储自带的合并功能——只需上传分片时指定相同的对象路径,调用一次合并接口即可,服务端不参与文件数据流转,性能和可靠性都更高。
这里要说明的是,在本地SpringBoot应用里模拟对象存储是完全可行的,开源方案里有MinIO这类兼容S3协议的解决方案,支持分片上传的完整生命周期。如果项目已经有对象存储依赖,直接把分片落盘逻辑替换为调用存储SDK的上传接口,代码改动量反而不大。
6.2 上传进度、并发参数与网络环境的自适配
进阶到“好用”这个层级,就不能把所有用户的并发参数都写死。不同网络环境下最优参数差异很大:移动4G网络和千兆局域网的最优并发数是完全不同的。我在项目里采取的做法是提供一个测速接口,前端在正式上传前先请求后端很小的探测文件,测量当前网络延迟和带宽,动态决定分片大小和并发数。
逻辑不复杂:网络延迟高就减小分片大小并降低并发,防止超时;带宽高就增大分片大小并提高并发,充分利用吞吐量。进度的展示也要做两阶段:分片上传阶段按“已完成分片数 / 总分片数”显示进度,合并阶段单独显示“文件合并中”。如果不做区分,用户看到进度条停在95%会非常焦虑。
还有一个非常实用的小细节是给分片上传接口设置合理的超时时间。SpringBoot默认的Tomcat连接超时可能只有几秒到几十秒,2MB分片在弱网下可能传输超过这个时间。如果超时设置不当,分片上传会反复出现超时失败。生产环境一般建议把分片上传接口的读取超时设置到60秒以上,同时结合前端重试机制,基本能覆盖绝大多数弱网场景。
分片上传整体做下来,技术本身并不复杂,真正的复杂度都在各种边界情况和体验细节里。从单体上传到分片上传,最大的变化是把“一刀切”的思维切换成了“拆细、并发、可恢复”的思路,这个思路同样适用于其他大文件传输场景。我自己的体会是:做完这一套以后,再遇到用户反馈上传慢、上传失败的问题,心里就有底了,排查方向也已经非常明确,不会再像之前一样只能让用户换个网络重试。