上周处理了一波线上反馈,用户往系统里传教学视频素材,单个文件普遍2GB到4GB,用常规的multipart上传,十次里有六次在60%到80%之间断掉,断了就得从头再来,用户直接在工单里骂街了。那个项目就是非常典型的JavaWeb架构,前端是浏览器页面,后端是Servlet/Spring那一套,问题暴露之后我才意识到,大文件分片上传不是“锦上添花”的优化功能,而是必须独立设计、独立落地的模块。这篇文章把从零搭建这套分片上传模块的完整步骤、核心代码和我在生产环境踩过的坑全部写出来,给同样在JavaWeb项目里被大文件折磨的人一个可参考的落地路径。
1.1 传统上传卡在哪些具体位置
先说结论:大文件用一次HTTP请求直接传,必挂,只是挂的位置不同。
第一道墙是网关和容器的请求体大小限制。Nginx默认client_max_body_size是1M,Tomcat的maxPostSize默认2M,Spring Boot里还有spring.servlet.multipart.max-file-size,默认也只有1M。你可以把这些配置手工调大,但调到多大算够?4GB?将来用户传8GB呢?总有一个值会再挡住你。
第二道墙是内存。传统的MultipartFile接收上传时,数据要先经过Tomcat的临时文件机制,但如果你在代码里做file.getBytes()这类操作,4GB的文件会直接往堆内存里塞。JVM堆默认几个GB,一次上传就能OOM。有人说“我拿流慢慢读不就行了”,问题是一边读流一边写文件,如果中间网络抖动,Socket超时,整个请求失败,已写入的半个文件既不能用也不想留,完全属于垃圾数据。
第三道墙是弱网环境。移动网络下上传带宽不稳定,一个4GB文件要传半小时甚至一小时,HTTP长连接在中间断一次就全盘结束。用户体验非常差。
所以说,分片上传不是“炫技”,而是大文件场景下绕开这三道墙的常规解法。
1.2 分片上传到底改变什么
分片上传的核心套路并不复杂:前端把文件切成多个大小相同的二进制块,每一块单独发一次HTTP请求到后端;后端把每一块写入临时目录;当所有分片都上传完成后,再由一个合并动作把所有分片按顺序拼成完整文件。图上的链路是:文件切片 -> 分片上传 -> 服务端暂存 -> 分片合并 -> 完整文件落地。
这套机制解决的本质问题有两个。一是单次请求的体量被压到可接受范围,5MB一个分片,即使失败重传一个分片,开销极小;二是可以并发上传多个分片,利用带宽上限,整体传输速度反而可能比单请求更快。再往后衍生出断点续传和秒传,都是建立在分片机制之上的。
1.3 动手之前必须先定四组参数
从零搭建之前,我建议先把下面的参数明确下来,不然后面会反复返工。
- 分片大小:我用的是5MB。太小(比如1MB)会导致请求数量过多,4GB文件有4096个分片,光是HTTP握手和请求头开销就能拖垮服务器;太大(比如50MB)又失去分片的意义,弱网重传成本太高。5MB是一个在多数JavaWeb项目里都比较稳的默认值。
- 并发数:前端同时上传的分片数,我控制在3到6个。浏览器对同一域名的并发连接数本来就有限制,后端也不希望一下子被打满。
- 文件最大限制:比如“单文件最大8GB”,超过就拒绝,这个阈值要提前想好,涉及磁盘规划和Nginx/Tomcat参数调整。
- 临时目录与最终目录:分片临时目录放在
uploads/tmp,最终文件放在uploads/file,两者必须分开,方便定时清理。
2. 前端切片与指纹计算:先把文件变成可上传的最小单元
后端的活先不急,前端是分片上传的入口。这一步做不好,后端做得再稳也没有用。
2.1 用 File.slice 切出二进制块
浏览器自带的File对象继承自Blob,所以可以直接用Blob.prototype.slice方法切片。slice(start, end)返回一个新的Blob对象,这个对象不会复制底层数据,只是引用原文件的某一段字节,所以内存占用很可控。
前端核心切片代码大概是这样的:
const CHUNK_SIZE = 5 * 1024 * 1024; function createChunks(file) { const chunks = []; let start = 0; let index = 0; while (start < file.size) { const end = Math.min(file.size, start + CHUNK_SIZE); chunks.push({ index: index, blob: file.slice(start, end), size: end - start }); index++; start = end; } return chunks; }这里有一个细节:最后一个分片的大小几乎总是小于CHUNK_SIZE,这很正常,但后端合并时不能假设每个分片都等于固定大小,必须以实际文件大小为准。这个坑我后面专门讲。
为何分片索引要从0开始而不是1?因为后端遍历和排序时,从0开始可以跟数组下标天然对齐,避免多一层的索引转换。前后端约定必须明确,不然合并顺序错乱是必然的。
2.2 文件指纹:用 MD5 标识“同一个文件”
分片上传的断点续传和秒传都需要一个稳定的文件标识。用什么?文件路径和文件名都不行,用户改个名、换个目录,标识就变了,最稳的是文件内容指纹,也就是对文件整体算一个MD5或SHA-1。
大文件算MD5是有代价的,尤其是4GB文件,在主线程里直接算会卡死页面,必须用Web Worker做离线计算。我用的库是spark-md5,它的思路是增量读取文件字节,不断把数据送入hash算法,最终得到整个文件的MD5值。
Web Worker里的大致逻辑:
// hash.worker.js importScripts('spark-md5.min.js'); self.onmessage = function (e) { const file = e.data; const chunkSize = 2 * 1024 * 1024; const spark = new SparkMD5.ArrayBuffer(); let offset = 0; function readNext() { if (offset >= file.size) { const hash = spark.end(); self.postMessage({ hash, done: true }); return; } const blob = file.slice(offset, offset + chunkSize); const reader = new FileReader(); reader.onload = function (event) { spark.append(event.target.result); offset += chunkSize; readNext(); }; reader.readAsArrayBuffer(blob); } readNext(); };主线程调用它时,同时显示“正在校验文件信息”,算完后才开始分片上传。实测下来,一个4GB文件在普通台式机上算MD5大概要10到30秒,如果是机械硬盘会更慢,这个体验损失是值得的,因为换回来的是续传和秒传能力。
还有一个工程经验:前端把总文件MD5传到后端之后,后端最好也给每个分片算一个MD5,前端上传时把分片MD5一并带上。这样可以校验单个分片的完整性,防止传输过程中出现数据损坏。如果嫌分片MD5计算麻烦,至少要在合并后做整文件的MD5校验。
2.3 上传状态机:别用一堆 if 慢慢堆
分片多了以后,每个分片都可能处于不同的状态:等待上传、正在上传、上传成功、上传失败。我用一个对象来管理分片状态:
const chunkStates = new Map(); function initChunkState(chunks) { for (const chunk of chunks) { chunkStates.set(chunk.index, { status: 'pending', // pending | uploading | done | error retryCount: 0, chunk }); } }进度条的计算也依赖状态。总进度有两种算法:按分片数量算,或者按字节数算。我更推荐按字节数算,因为最后一个分片可能是小片,按数量算会导致整体进度和实际字节进度偏差较大:
function calcProgress(fileSize) { let uploadedBytes = 0; for (const [index, state] of chunkStates) { if (state.status === 'done') { uploadedBytes += state.chunk.size; } } return (uploadedBytes / fileSize * 100).toFixed(1); }失败重试逻辑也在这里做:单个分片失败后,重试次数小于3就放入待重试队列,超过3次就中止整个上传任务并提示用户“第N块分片上传失败”。
3. 后端存储模型与两个核心接口:上传分片、合并文件
前端准备好了,后端开始接地气的部分。这一步我按“临时文件怎么放、分片怎么收、最终文件怎么合”三层来说。
3.1 临时目录与分片命名规范
后端接收分片前,先把存储目录规划好。直接用“上传接口收到的文件名作为目录名”是很差的设计,因为文件名有各种特殊字符,还可能重名。我用文件MD5作为目录名,它的唯一性和安全性都比文件名好得多。
目录结构如下:
uploads/ tmp/ {md5}/ 0.part 1.part 2.part ... file/ {md5}_{原始文件名}为什么tmp目录下不用再按MD5前两位建立二级散列?因为如果同一时间上传任务非常多,tmp目录下会堆积大量MD5文件夹,单个目录的文件条目过多会影响文件系统性能。简单的方案是按MD5前两位做散列,即tmp/{md5[0:2]}/{md5}/{index}.part,我建议生产环境这么干。
分片文件命名我直接用索引号0.part、1.part,不要加任何冗余业务信息。索引号天然有序,后端合并时只需要遍历0到总数减1,按顺序拼接即可。需要给每个分片额外记录的唯一上传ID(如UUID)不用放在文件名里,放到数据库表里做流水记录即可。
同时,每个上传任务要登记元数据。我用一张数据库表,字段如下:
| 字段 | 说明 |
|---|---|
| upload_id | 主键,UUID |
| file_md5 | 整个文件指纹 |
| file_name | 原始文件名 |
| file_size | 文件总字节数 |
| chunk_total | 总分片数 |
| chunk_size | 每片约定大小 |
| uploaded_chunks | 已上传分片索引集合,如0,1,2,3 |
| status | 0上传中 / 1已合并 / 2已失效 |
| create_time | 创建时间 |
| update_time | 最后更新时间 |
这张表是断点续传和秒传的基础。每次上传分片成功后,往uploaded_chunks里追加索引,这里要注意并发,不能让两个分片的更新互相覆盖,后面会讲到。
3.2 分片上传接口:先查重、再落盘、后登记
后端分片上传接口接收的参数很简单:文件MD5、分片索引、总分片数、原始文件名、分片二进制。Spring Boot写法如下:
@RestController @RequestMapping("/upload") public class ChunkUploadController { private static final String TMP_BASE = "/data/uploads/tmp/"; @PostMapping("/chunk") public R uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("md5") String md5, @RequestParam("index") Integer index, @RequestParam("total") Integer total, @RequestParam("fileName") String fileName) throws IOException { // 1. 按MD5构建分片目录,目录不存在则创建 Path tmpRoot = Paths.get(TMP_BASE, md5.substring(0, 2), md5); Files.createDirectories(tmpRoot); // 2. 目标分片文件 Path partFile = tmpRoot.resolve(index + ".part"); // 3. 如果分片已存在且大小一致,直接返回成功(幂等) if (Files.exists(partFile) && Files.size(partFile) == file.getSize()) { return R.ok("chunk already exists"); } // 4. 写入分片,先写到临时文件再改名,避免半截文件被当成正常分片 Path partTmp = tmpRoot.resolve(index + ".part.tmp"); try (InputStream in = file.getInputStream()) { Files.copy(in, partTmp, StandardCopyOption.REPLACE_EXISTING); } Files.move(partTmp, partFile, StandardCopyOption.REPLACE_EXISTING); // 5. 登记元数据 uploadMetaRepository.appendChunk(md5, index, total, fileName, file.getSize()); return R.ok(); } }第3步是关键:同一个分片因网络问题被前端重试,导致后端收到两次一模一样的请求,这种情况必须幂等处理。不幂等就会出现分片文件被覆盖、记录重复插入等问题。用“存在且大小一致”作为判断依据,简单高效。
第4步的“先写tmp再改名”也有讲究。如果直接写0.part,进程突然被杀或磁盘满了,留下一个写了一半的0.part,后续判断“文件存在即已上传”就会误判。先写0.part.tmp,写完再原子改名,能保证0.part出现时就是完整可用的分片。
分片大小也要校验,防止有人恶意传一个超大“分片”把磁盘打满。我在配置里强制约定分片大小不能超过8MB,超过直接拒绝。
3.3 合并接口:用零拷贝方式把分片拼成一个文件
所有分片都上传成功后,前端调用合并接口。后端合并的核心动作是:按索引从小到大打开每一个分片文件,依次把字节写入最终文件。
常规写法是用FileOutputStream循环读写,但Java里有一个更高效的API:FileChannel.transferTo()。它可以把一个通道的数据直接传输到另一个通道,尽量减少内存拷贝次数,对大文件的合并性能提升非常明显。
合并接口核心代码:
@PostMapping("/merge") public R merge(@RequestParam("md5") String md5, @RequestParam("fileName") String fileName, @RequestParam("total") Integer total) throws IOException { String safeFileName = sanitizeFileName(fileName); Path tmpRoot = Paths.get(TMP_BASE, md5.substring(0, 2), md5); Path finalPath = Paths.get(FILE_BASE, md5 + "_" + safeFileName); // 1. 创建父目录 Files.createDirectories(finalPath.getParent()); // 2. 零拷贝合并 long totalWritten = 0; try (FileChannel out = FileChannel.open(finalPath, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i = 0; i < total; i++) { Path part = tmpRoot.resolve(i + ".part"); if (!Files.exists(part)) { // 缺分片,说明前端没有完整上传,直接中断 throw new IllegalStateException("missing chunk index " + i); } try (FileChannel in = FileChannel.open(part, StandardOpenOption.READ)) { long size = in.size(); long transferred = 0; while (transferred < size) { transferred += in.transferTo(transferred, size - transferred, out); } totalWritten += size; } } } // 3. 校验合并后的文件大小与前端声明是否一致 long expectedSize = uploadMetaRepository.getFileSize(md5); if (totalWritten != expectedSize) { Files.deleteIfExists(finalPath); throw new IllegalStateException("file size mismatch"); } // 4. 清理临时分片目录 deleteDirectory(tmpRoot); // 5. 更新元数据状态为已合并 uploadMetaRepository.markMerged(md5, finalPath.toString()); return R.ok("merged"); }合并接口有两个需要复用的校验点:一是缺失分片时直接抛异常,绝对不能“拼一个残缺文件然后当成成功”;二是合并后的字节数必须等于元数据里登记的文件总大小,这个校验能兜住大多数数据错漏。
合并是在服务端局域网磁盘上做的,速度很快,一个4GB文件通常几秒到十几秒完成,前端只需要展示“合并中”状态即可。
4. 断点续传与秒传:不重传已传过的数据
分片上传解决了单次大请求的问题,但还没有解决“用户传了一部分断网,回头还得重来”的问题。想让处在中间状态的上传任务可以“续上”,需要增加两个能力:一个是上传前查询已上传的分片,另一个是对完整已存在的文件做秒传。
4.1 上传前查询:让前端知道自己该从哪继续
后端提供一个状态查询接口:
GET /upload/status?md5=xxx返回内容:
{ "uploadedChunks": [0, 1, 2, 3, 5], "merged": false, "fileSize": 4294967296 }前端拿到这个列表之后,本地切片时跳过已上传的索引,只传缺失的部分。注意这里有一个非常重要的小问题:已上传的uploadedChunks可能有空隙,比如0,1,2,3,5,说明第4块还没传。前端绝不能因为“最大连续编号到了3”就认为4也传过了,必须逐块判断索引是否在集合中。
这个查询接口也承担秒传判断。如果后端发现这个MD5对应的任务状态已经是1(已合并),并且最终文件还存在,就返回merged: true。前端看到这个信号,直接把进度条拉到100%,不再执行任何分片上传和合并动作。
4.2 并发场景下的幂等与覆盖策略
断点续传的并发坑比很多人想象中多。最常见的是同一个文件被用户在两个浏览器标签页里同时上传,两个页面同时调upload/status,拿到相同的缺口列表,然后同时传第0块。后端就会出现同一个分片文件被并发写入的场景。
解决思路是“乐观判断、悲观写入”:查重发现分片不存在时,不要无条件直接创建文件,而是先把分片写入一个独立的临时文件,然后通过“原子移动”操作让它变成正式分片。Java的Files.move()指定ATOMIC_MOVE原子上可以保证同一路径下只有一个文件最终生效。我用的是:
Files.move(partTmp, partFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);如果两个请求同时写同一个分片,后完成的那次ATOMIC_MOVE会覆盖前一次,但两个请求写入的内容理论上是一致的(都是同一个原始分片),所以覆盖不影响最终正确性。元数据表的更新用数据库行锁或者Redis分布式锁保证uploaded_chunks不会丢失追加记录。
4.3 秒传的哈希碰撞:理论风险与实际取舍
秒传依赖文件MD5。流程是:前端算完整个文件MD5后,先调查询接口,后端在“已上传文件表”里查找相同MD5且状态为已合并的记录,存在则直接返回“秒传成功”。
这里有一个绕不开的问题:MD5存在碰撞可能性。理论上两个内容不同的文件可能拥有相同MD5,如果后端无脑信任,就会导致用户A秒传了文件X,用户B要传不同的文件Y但MD5碰巧相同,结果分到了X的内容。解决方式有几种:
- 在MD5一致且大小一致基础上,抽样校验文件头尾几十字节;
- 用更稳的SHA-256代替MD5作为秒传标识;
- 对安全性要求极高的场景,干脆不允许秒传,只做断点续传。
我的工程建议是:业务场景不是对抗性场景,MD5加文件大小双重校验已经足够安全。极低概率的碰撞在普通业务系统里可以接受,但不要把秒传用在敏感文件场景中。如果做了SHA-256,前端算哈希的时间会更长,取舍由业务决定。
5. 并发控制与性能调优:从“能跑”到“扛住生产流量”
分片上传框架搭好之后,如果不管并发和资源清理,上线之后会被压垮或者把磁盘打满。这部分是生产环境里必须做的一步。
5.1 前端并发池:控制请求洪峰
前端不能一次性把所有分片请求发出去。一个4GB文件分成800个分片,如果800个请求同时发出,浏览器连接数会爆,后端Tomcat线程池会满,普通的4核8G服务根本扛不住。我给前端写了一个极简并发池,把并发数限制在3个:
async function runPool(tasks, limit = 3) { const executing = new Set(); for (const task of tasks) { if (executing.size >= limit) { await Promise.race(executing); } const p = Promise.resolve().then(task); executing.add(p); const clean = () => executing.delete(p); p.then(clean, clean); } await Promise.allSettled(executing); }每个分片上传任务的内部还要有超时控制。我建议给单个分片的HTTP请求设置60秒超时,超过就标记失败并重试。如果分片大小是5MB,60秒足以在极弱的网络里传完,超时时间再大容易让用户长时间卡在无响应状态。
服务端也要有一个隐性的流量控制:分片上传接口接收的直接是MultipartFile,这个对象在Tomcat内部也会写入临时文件,所以不会大片占用堆内存。但Tomcat默认工作线程有限,如果被大量并发分片请求占满,会影响整个应用的其他接口。所以前端并发限制3到6个,对服务端来说基本是安全的。
5.2 合并时的 IO 与内存矛盾
很多人写文件合并时习惯把每个分片读成byte[]再写出去,这在分片很小时可用,但累加起来会吃满内存。正确做法是用transferTo,它走的是内核态的文件通道拷贝,不占用Java堆内存。我在3.3节的代码里已经用了这个方式,强调一下:合并4GB文件时,JVM堆占用几乎不增长,这是它能扛住大文件的根本原因。
磁盘IO是另一个容易被忽视的点。每次合并都是一次大文件顺序写,如果同一秒有多个合并请求同时执行,机械盘或者负载较低的云盘很容易达到IO瓶颈,表现为合并阶段明显变慢。缓解手段有两个:
- 合并任务丢到一个单线程的Executor里串行执行,让同一时间只有一个大合并动作;
- 或者按照MD5哈希,把这些任务分散到多个Executor,避免一把锁把所有请求堵死。
单线程Executor实现起来最简单,如果业务上传并发不高,强烈推荐这种方式。
5.3 临时文件定期清理:防止磁盘被“半成品”占满
分片上传最容易被忽略的生产问题是“死任务”残留。用户传了600个分片,一直不点合并,或者合并请求缺失分片而失败,临时分片就一直躺在磁盘上。高峰时期,这类残留文件一天可以吃掉几十GB。
我在项目里用定时任务处理,每30分钟扫描一次tmp目录,找到创建时间超过24小时的{md5}文件夹并整体删除。这要求写分片时记录每个分片文件的最后修改时间,定时任务里通过Files.getLastModifiedTime()判断即可。
清理任务还要注意:不能删正在被合并处理的目录。简单做法是维护一张“合并中任务”的内存集合,清理任务跳过这些MD5目录。为什么不是数据库状态?因为合并接口本身是短任务,内存集合足够,定时删除扫描时做判断即可。
6. 我在这套模块里踩过的四个典型坑
最后这部分不是凑字数,都是真实环境里排查过的硬问题。我把过程写出来,希望能帮你省掉几天的排错时间。
6.1 坑一:分片索引从1开始导致合并顺序错乱
第一次联调时,前端同事写切片时从1开始编号,后端的合并循环也从1开始,单看都正常,但合并出来的文件一播放就花屏,而且文件大小和原文件不一致。查了好久发现,分片0对应的文件内容被丢掉了,拼接时第一个字节就错位。
排查链路大致是这样的:先比对了总分片数和文件大小,发现都相同,但MD5对不上;然后写了一个脚本把每个分片的头部内容和原文件对应位置的内容做比对,发现整个文件从开头就少了第一个分片,而第0号分片文件却孤零零地留在临时目录里。
解决方案是统一约定索引从0开始,前端chunk.index = 0,1,2,...,后端按for (int i = 0; i < total; i++)遍历。前后端契约文档里必须写明这一点。合并前最好加一道“存在性巡检”,遍历时发现缺文件立刻中断,避免把残缺文件当做成功结果。
6.2 坑二:文件名经过URL编码后还原出错
分片上传接口里,文件名是放在请求参数里的。前端直接把中文文件名放进FormData,到后端取出来是乱码,或者被Tomcat按默认字符集解码成问号。第一次遇到时我还以为是后端request.setCharacterEncoding("UTF-8")没生效,折腾了很久。
后来统一前端在传参数前用encodeURIComponent(fileName)做一次编码,后端再用URLDecoder.decode(fileName, "UTF-8")解码。这套方案对中文、空格、特殊符号都能正确处理。另外后端合并文件落地时,不能直接把原始文件名拼进文件系统路径,至少要做一次sanitizeFileName,把路径分隔符和非法字符替换掉,否则用户在文件名里塞一个../可能造成路径穿越。
6.3 坑三:合并接口一次性读入内存导致OOM
早期版本的合并接口用的是IOUtils.toByteArray(inputStream)把每个分片一次性读到内存,分片5MB时不觉得有问题,但后来支持上传更大文件后,分片大小改成20MB,并发合并两个文件时直接OOM。日志显示java.lang.OutOfMemoryError: Java heap space,排查一看就是byte[]数组把堆塞满了。
解决方式就是我前面写的transferTo流式合并。这不仅是性能优化,更是一个稳定性修复。凡是大文件合并,一定要避免整块读入堆内存,不管当前分片看起来多小,都应该用通道拷贝或者固定缓冲区循环读写。
6.4 坑四:“查已上传分片”和“写分片”之间的竞态
断点续传上线后,偶尔出现分片上传任务状态显示已完成,但合并时又报“缺失分片”。复现路径是:前端查询uploadedChunks时,某个分片正在上传但尚未写入完成,状态查询接口没查到它,于是前端认为这个分片缺失,又并发传了一次。第二次上传与第一次的写入动作交错,最终两个请求都“成功”返回了,但其中一个分片文件只有一半数据,于是合并时要么大小校验失败,要么文件损坏。
修复方式是前端的“状态查询”和“上传”不能完全分离:上传任务发起前,把该分片本地标记为“上传中”,即使服务端还没返回任何记录,前端也不能立刻把它视为缺失;后端则用“先写临时文件再原子移动”的方式避免半截分片被当成正常文件,双端同时处理才能把竞态窗口压到最小。
在这个模块上线的几个月里,用户上传大文件的工单从每天几十条降到了零。回看整个过程,核心不是某个代码片段多高明,而是把分片的存储模型、接口幂等、合并校验和清理机制从头设计清楚。搭建第一步时,先定业务参数、再画目录存储结构、最后写接口,这个顺序能帮你少走很多弯路。如果你准备在自己的JavaWeb项目里动手,建议先拿一个小文件把链路跑通,再逐步加压,不然出了问题时很难判断是前端切片的问题还是后端合并的问题。