1. 为什么大文件上传必须走分块与断点续传
1.1 大文件上传的核心痛点
入行Java后端第七年,我最怕听到的一句话就是“帮我在网页上加个上传功能,文件也不大,就几个GB”。一个普通的文件上传接口,传几十MB问题不大,一旦体积上升到GB级,你会发现浏览器的上传请求几乎必挂:要么等上十几分钟最后告诉你“连接已断开”,要么服务器直接返回一个让人摸不着头脑的502,要么后端在读取流的时候内存撑不住直接OOM。
很多人第一反应是“把服务器的超时时间调大,把Tomcat的maxPostSize调大”,调完确实能撑一会儿,但用户一旦中途切个Wi-Fi、锁个屏、或者不小心点了刷新,一切就得从头再来。我见过最绝望的场景是一个设计团队传一段2.7GB的视频素材,传到98%的时候网断了,然后他们又花了一个多小时重新传第二遍。这种体验放在任何一个面向真实用户的系统里,都是灾难级的。
根本原因在于,传统表单上传把整个文件当成一个连续的字节流,一次HTTP请求吞吐全部内容。网络只要抖动一下,请求上下文丢失,服务端拿到的就是残缺数据,而且无从恢复。要解决这个问题,思路必须改变:既然一个文件传不完,那就把它切成很多个小块,每一块独立上传、独立确认;哪一块失败了,只重传那一块,而不是整个文件重新来一遍。这就是分块上传。在分块基础上,再记录哪些块已经传成功,下一次再从失败的块继续传,这就是断点续传。
1.2 分块上传与断点续传的关系
这两个概念经常被混着说,但我习惯这样区分:
- 分块上传解决的是“怎么把一个大的上传任务拆成若干个小任务,并最终拼回原文件”的问题。它负责切分、传块、合并这三件事。
- 断点续传解决的是“在分块上传过程中,如何记住进度,在任务中断后接着传”的问题。它负责进度的记录、查询、以及跳过已传分块。
所以断点续传是建立在分块上传之上的。如果你只做分块,但每次重传都让用户重新选文件、然后所有分块全部重传,那依然没有解决“白干”的问题。只有把分块状态持久化到服务端,客户端在发送分块之前先问一次“我已经传到哪了”,才能真正意义上做到断点续传。
接下来我会用一套我实际在项目中验证过的方案,从前端切分到后端合并,完整讲一遍代码实现和踩坑记录。技术选型上,前端使用原生JavaScript的File API,后端使用Spring Boot。不引入极端复杂的框架,但代码可以直接抄进你的项目里改改就能跑。
2. 前端分块策略:切片、进度管理与重试
2.1 使用File.slice()切割文件
浏览器端的File对象继承了Blob,Blob自带一个slice()方法,可以按字节范围切出一段新的Blob。这是实现分块上传的基础,不需要任何第三方插件。
我习惯定义一个CHUNK_SIZE常量,单位是字节。实际项目中我常用5 * 1024 * 1024,也就是5MB一块。为什么选5MB,后面第五部分我会专门讲,现在先看切块代码。
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB function sliceFile(file) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + CHUNK_SIZE, file.size); const blob = file.slice(start, end); chunks.push({ blob: blob, index: chunks.length, start: start, end: end, size: blob.size }); start = end; } return chunks; }这里有个容易被忽略的细节:file.slice(start, end)的第二个参数是结束位置,但它是“不包含end位置”的切片,所以最后一块的end直接用file.size不会越界。计算分块总数时,可以不用循环里数个数,直接Math.ceil(file.size / CHUNK_SIZE)更快,但用循环顺便把每块的元数据准备好更常规。
切出来的blob不能直接拿来当普通字符串放进JSON里,它必须作为FormData的一个部分发送。也就意味着每个分块对应一个独立的multipart请求。
2.2 并发控制与进度上报
把所有分块一次性全部发出去,浏览器和服务器都会很痛苦。正确的做法是限制同时上传的并发数。我有一个常用的轻量级并发控制器,一次最多同时传3个分块:
async function uploadChunksWithConcurrency(chunks, fileId, concurrency = 3) { let index = 0; let uploaded = 0; async function worker() { while (index < chunks.length) { const current = index++; const chunk = chunks[current]; const formData = new FormData(); formData.append('fileId', fileId); formData.append('chunkIndex', current); formData.append('totalChunks', chunks.length); formData.append('chunk', chunk.blob, 'chunk-' + current + '.part'); try { const resp = await fetch('/api/upload/chunk', { method: 'POST', body: formData }); if (!resp.ok) throw new Error('HTTP ' + resp.status); uploaded++; const percent = Math.round((uploaded / chunks.length) * 100); updateProgress(percent); } catch (err) { // 上传失败,这里需要根据失败原因决定重试还是放弃 await retryChunk(chunk, current, fileId); } } } const workers = Array.from({ length: concurrency }, () => worker()); await Promise.all(workers); }表面上我们用fetch发请求,但其实很多细节都藏在retryChunk里。重试不能无脑重试,建议区分错误类型:
- 网络错误(TypeError、AbortError):可以重试,通常间隔1秒、2秒、4秒这样的指数退避。
- 服务器返回4xx错误:通常不要重试,因为很可能是参数写错或者文件状态有问题。
- 服务器返回5xx错误:可以重试,但也要限制次数,我一般最多重试3次。
下面是一个简化但可用的重试实现:
async function retryChunk(chunk, index, fileId, maxRetries = 3) { let attempt = 0; while (attempt < maxRetries) { const delay = Math.pow(2, attempt) * 1000; await sleep(delay); try { const formData = new FormData(); formData.append('fileId', fileId); formData.append('chunkIndex', index); formData.append('totalChunks', chunkCount); formData.append('chunk', chunk.blob, 'chunk-' + index + '.part'); const resp = await fetch('/api/upload/chunk', { method: 'POST', body: formData }); if (resp.ok) return; } catch (e) { attempt++; } } throw new Error('分块上传失败,超出最大重试次数: ' + index); }进度上报不要每次都去算文件整体百分比,因为断点续传场景下,已经传过的分块不需要再传,直接从服务端查一个“已上传分块数”,加上当前本次新传的数量,再除以总量,才是最准确的。
2.3 断点续传的前置操作:秒传与已传分块查询
断点续传不是“接着上次的进度继续传”这么简单,它背后有两个前置接口:
- 校验文件是否已完整上传(秒传):用户选了一个文件,如果服务器上已经有完全相同的文件内容,那就直接提示“上传成功”,一秒完成。
- 查询该文件已上传了哪些分块:如果文件没传完,返回已成功接收的分块索引列表,前端跳过这些块,只传缺失的块。
要做到这两件事,首先需要给文件算一个唯一标识。我用的方案是:取文件的名称、大小、最后修改时间,拼在一起后用SHA-256生成一个字符串作为fileId。这种方式不要读整个文件算MD5,否则大文件在算MD5的时候就会卡住。虽然理论上有哈希冲突,但文件名加大小加修改时间的组合,在业务中已经足够稳。
伪代码如下:
async function generateFileId(file) { const text = `${file.name}-${file.size}-${file.lastModified}`; const bytes = new TextEncoder().encode(text); const digest = await crypto.subtle.digest('SHA-256', bytes); return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join(''); }然后在上传前调用查询接口:
async function checkUploadStatus(fileId) { const resp = await fetch(`/api/upload/status?fileId=${fileId}`); const data = await resp.json(); return { uploaded: data.uploadedChunks, // 已上传的分块索引数组 completed: data.completed // 是否已经完整上传 }; }如果completed为true,直接显示“秒传成功”。否则,把uploaded当成一个Set,在切块完成后过滤掉已存在的索引,剩下的就是要传的。
到这一步,前端的分块逻辑已经成型。但前端这一切,都要依赖后端的一套配套接口,接下来看Java后端怎么写。
3. Java后端的分块接收与合并实现
3.1 分块接收接口设计
后端我用Spring Boot,MultipartFile本身就是针对文件上传的封装,tomcat默认最大单个文件大小是1MB,Spring Boot的spring.servlet.multipart.max-file-size默认是1MB。所以第一步必须是放开限制:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 100MB这里max-file-size要大于你定的分块大小,我前端切5MB,后端就配10MB,留足余量。但max-request-size不能跟着前面一起改得特别大,因为一旦某个分块请求体超过限制,整个请求会被拒绝,而我们要的是“单个请求只承载一个块”,100MB的请求上限在并发场景下其实是对服务的一种保护。
接收分块的接口,我用了最直接的写法:
@RestController @RequestMapping("/api/upload") public class ChunkUploadController { private final ChunkStorageService storageService; public ChunkUploadController(ChunkStorageService storageService) { this.storageService = storageService; } @PostMapping("/chunk") public ResponseEntity<Map<String, Object>> uploadChunk( @RequestParam("fileId") String fileId, @RequestParam("chunkIndex") int chunkIndex, @RequestParam("totalChunks") int totalChunks, @RequestParam("chunk") MultipartFile chunk) throws IOException { storageService.saveChunk(fileId, chunkIndex, chunk); Map<String, Object> result = new HashMap<>(); result.put("received", chunkIndex); result.put("completed", storageService.isAllChunksUploaded(fileId, totalChunks)); return ResponseEntity.ok(result); } }这个接口做的事情很单纯:把传上来的块存起来,然后判断是不是所有块都已经齐了。fileId、chunkIndex、totalChunks这三个参数是前端在FormData里带上来的,缺一不可。如果前端不带totalChunks,合并时就不知道到底该等多少个块。
3.2 分块临时存储与磁盘空间管理
分块不能直接存内存,二话不说先落盘。我在ChunkStorageService里维护一个临时目录,结构大概是:
tempDir/ {fileId}/ 0.part 1.part 2.part每个文件一个目录,目录名就是fileId,目录下存放的是按索引命名的.part文件。主要代码如下:
@Component public class ChunkStorageService { private final Path tempRoot; public ChunkStorageService(@Value("${upload.temp-dir}") Path tempDir) { this.tempRoot = tempDir; Files.createDirectories(tempRoot); } public void saveChunk(String fileId, int chunkIndex, MultipartFile chunk) throws IOException { Path chunkDir = tempRoot.resolve(fileId); Files.createDirectories(chunkDir); Path target = chunkDir.resolve(chunkIndex + ".part"); chunk.transferTo(target.toFile()); } }注意一个很重要的点:MultipartFile.transferTo()在传一个已有同名文件时,不同平台行为不同。Windows下如果目标文件已被占用会抛异常,Linux下会直接覆盖。为了避免这种不确定性,我更建议先Files.createDirectories,然后transferTo之前删掉已存在的目标文件:
Files.deleteIfExists(target); chunk.transferTo(target.toFile());至于磁盘空间管理,这是很多人忽略的。一个大文件切成了几百个块,每个块5MB,完整传完之前临时目录里全是碎文件。如果你在做批量导入功能,多人同时上传,磁盘瞬间塞满的例子我见过不少。我建议做两件事:
- 每个
fileId目录下放一个.meta文件,记录块总数、原始文件名、最后更新时间。 - 用一个定时任务,扫描临时目录,超过24小时没有新分块写入的目录直接整体删掉。
定时清理代码大概这样:
@Scheduled(cron = "0 0 3 * * ?") public void cleanExpiredUploads() throws IOException { long maxAge = TimeUnit.HOURS.toMillis(24); try (DirectoryStream<Path> dirs = Files.newDirectoryStream(tempRoot)) { for (Path fileDir : dirs) { if (Files.isDirectory(fileDir)) { long lastModified = Files.getLastModifiedTime(fileDir).toMillis(); if (System.currentTimeMillis() - lastModified > maxAge) { deleteDirectoryRecursively(fileDir); } } } } }3.3 文件合并与完整性校验
所有分块都传完之后,就进入合并环节。合并的原理很简单:按索引顺序,把所有.part文件的内容追加到最终文件里。但在合并之前,必须校验分块数量是否和totalChunks一致,少一个都不能合并。
合并代码:
public Path mergeChunks(String fileId, String originalFilename, int totalChunks) throws IOException { Path chunkDir = tempRoot.resolve(fileId); Path target = tempRoot.getParent().resolve("uploads").resolve(fileId + "_" + originalFilename); Files.createDirectories(target.getParent()); try (OutputStream out = Files.newOutputStream(target, CREATE, TRUNCATE_EXISTING)) { for (int i = 0; i < totalChunks; i++) { Path chunk = chunkDir.resolve(i + ".part"); if (!Files.exists(chunk)) { throw new IOException("缺少分块: " + i); } Files.copy(chunk, out); } } // 合并完成后清理分块目录 deleteDirectoryRecursively(chunkDir); return target; }合并后还要做一次完整性校验。既然前端给了totalChunks,且每块大小基本固化,那么最终文件大小理论上应该等于所有分块大小之和。更严谨的做法是前端在上传第一个分块时把整个文件的MD5传过来,合并后计算MD5比对。不过刚才说了,计算大文件MD5很慢,所以我在实际业务里采用“分块级MD5校验 + 文件大小校验”的组合方式:
- 前端每传一个分块,可以顺便把这一块的MD5传上来,存到
.meta文件里,合并前逐个校验。 - 合并后只对比最终文件大小和原始文件大小,差一个字节都视为异常。
这一步能把绝大多数磁盘写坏、并发干扰、网络丢包引发的合并问题拦住。
4. 断点续传的落地细节:状态记录与异常恢复
4.1 上传任务状态记录方案
断点续传能否成立,核心在于“状态是否存在、是否可靠”。我用的是最轻量的方案:直接把状态记录在临时目录的文件名和meta文件里。
每个分块文件存在,就代表这个分块已上传成功。那么“已上传分块索引列表”就可以直接通过遍历目录下的.part文件得到:
public List<Integer> getUploadedChunks(String fileId) throws IOException { Path chunkDir = tempRoot.resolve(fileId); if (!Files.exists(chunkDir)) return Collections.emptyList(); List<Integer> indexes = new ArrayList<>(); try (DirectoryStream<Path> stream = Files.newDirectoryStream(chunkDir, "*.part")) { for (Path path : stream) { String fileName = path.getFileName().toString(); indexes.add(Integer.parseInt(fileName.substring(0, fileName.indexOf('.')))); } } Collections.sort(indexes); return indexes; }这种方案的好处是不需要额外引入Redis或者数据库,坏处是如果服务实例有多个,临时目录各自独立,状态就不共享。如果项目已经上了多节点部署,建议把分块状态存到Redis,fileId为key,已上传块索引用Set结构存储。数据结构不影响整体逻辑,只影响你“查询状态”那一步的写法。本篇示例聚焦单机方案,多节点的扩展我放到第五部分讲。
meta文件的内容可以是一个JSON:
{ "originalFilename": "demo.mp4", "totalChunks": 200, "fileSize": 1048576000, "createTime": "2025-01-15T10:00:00" }我一般使用Jackson来读写这个文件,保存时机是在第一个分块上传时初始化meta,之后每次上传分块只更新分块自身的最后访问时间,避免频繁写meta造成IO开销。
4.2 分块去重与并发安全
前端在断点续传时会跳过已传分块,但网络丢包后前端重试,或者用户连续点了两次上传,都可能导致同一个分块被重复提交。后端必须处理重复分块,否则合并时会出问题。
最简单的去重方式就是在saveChunk方法里无条件覆盖同名.part文件。同一个分块的索引相同,内容也相同(因为前端切块稳定),覆盖不会产生数据错误。但如果两个请求同时写同一个分块文件,可能会产生“半个文件覆盖半个文件”的情况。为了解决这个问题,我给保存分块的方法加了一个“按分块文件加锁”的机制。
在单机环境里,可以用synchronized锁一个字符串,但直接锁整个方法会降低并发性能。更稳妥的是使用ConcurrentHashMap做细粒度锁:
private final ConcurrentMap<String, Object> chunkLocks = new ConcurrentHashMap<>(); public void saveChunk(String fileId, int chunkIndex, MultipartFile chunk) throws IOException { String lockKey = fileId + ":" + chunkIndex; Object lock = chunkLocks.computeIfAbsent(lockKey, k -> new Object()); synchronized (lock) { Path chunkDir = tempRoot.resolve(fileId); Files.createDirectories(chunkDir); Path target = chunkDir.resolve(chunkIndex + ".part"); Files.deleteIfExists(target); chunk.transferTo(target.toFile()); } }锁的粒度是“同一个文件的同一个块”,不同文件、不同块之间完全不互相阻塞。上传结束后,要及时从chunkLocks里移除不再需要的锁对象,否则长时间运行会有内存泄漏风险。我通常会在合并完成后执行chunkLocks.remove(fileId + ":" + chunkIndex),或者干脆在清理临时目录的时候一并清理。
4.3 中断恢复流程
把中断恢复的整套流程串起来,前端逻辑应该是这样的:
- 用户选择文件,前端计算
fileId。 - 请求
/api/upload/status,拿到uploadedChunks数组和completed状态。 - 如果
completed为true,直接结束。 - 否则,过滤掉已上传分块,并发上传剩余分块。
- 当最后一个分块上传成功后,前端额外调用
/api/upload/merge触发合并。 - 合并成功后,服务端清理临时分块,返回最终文件地址。
这里有个细节需要强调:不要在分块上传接口里自动触达合并。原因是,如果前端有多个请求并发上传,最后一个请求到达的时候,其他分块可能还没写盘完成,这个“最后一个”只是逻辑上的最后顺序,物理上其他块的写入可能还在缓存里没刷盘。如果此时合并,极容易发现缺块。稳妥做法是:前端在所有上传任务Promise.all结束后,显式调用一次merge接口。merge接口内部先检查所有块是否存在,缺块直接返回明确错误,前端收到错误后可继续补传缺失块再重新合并。
5. 生产环境级优化与踩坑记录
5.1 分块大小与并发数怎么定
分块大小和并发数没有标准答案,但有几个约束条件需要权衡:
- 分块太小:请求数量巨大,HTTP握手和multipart解析的开销会占很大比例,整体速度反而慢。比如1GB文件,切成1MB就有1024个请求,每个请求几个毫秒的固定开销累积起来非常可观。
- 分块太大:失去了分块的意义,网络抖动的重试成本变高,服务端内存压力也会增大。
- 并发太高:浏览器TCP连接数有限,服务器线程被占满,带宽被争抢,反而导致重传增多。
我测试下来的经验值:
| 文件大小 | 推荐分块 | 推荐并发 |
|---|---|---|
| 100MB ~ 500MB | 5MB | 3 |
| 500MB ~ 2GB | 10MB | 3 |
| 2GB以上 | 20MB | 2 ~ 3 |
并发数不建议超过5。超过5之后,绝大多数家庭宽带和普通服务器都会成为瓶颈。很多开发者会为了“更快”把并发调到10,结果上传总耗时反而增加了30%,因为大量分块在排队等待TCP窗口,超时重试又占了额外带宽。
5.2 经典问题:内存溢出、超时、重复块、合并失败
内存溢出。这是最常见的。Spring Boot接收multipart请求时,如果没限制max-file-size,Tomcat会把整个请求体先读进内存。即使你限制每个分块5MB,如果并发上传10个,内存占用也能到50MB。更大的隐患是某些代码里习惯把MultipartFile.getBytes()拿出来再处理,一个5MB的块瞬间变成两个5MB的字节数组在内存里。我建议所有对分块的处理都直接走transferTo()落盘,永不调用getBytes()。
前端请求超时。不要让fetch等一个分块超过2分钟。如果服务器处理慢,前端会超时中断,然后触发重试。实际上大部分上传慢是因为带宽,分块传输本身不会在服务器端停留太久。如果发现单个分块上传经常超过60秒,说明分块大小或带宽有问题,优先调整分块大小而不是调超时时间。
重复块导致合并后文件损坏。这个我在排查一个线上问题时遇到过。前端把已传块查询接口返回的索引处理成了字符串类型的Set,和后端返回的整数类型对不上,导致已传块被误判为未传,整个文件重新传了一遍,但合并时某个块因为并发覆盖写坏了。最后解决办法是统一用整数,并在合并前校验每个文件的大小是否符合预期。
合并失败。除了缺块,另一个隐蔽原因是磁盘满了。合并时如果目标盘剩余空间不够,Files.copy会在途中抛IOException,此时temp目录里的分块还在,但最终文件只有残缺的前半部分。我在合并代码里做了“先写临时合并文件,成功后原子改名”的策略:
Path tempResult = target.resolveSibling(target.getFileName() + ".tmp"); // 写入tempResult Files.move(tempResult, target, StandardCopyOption.ATOMIC_MOVE);这样即使合并中途崩了,也不会留下一个看起来正常但内容不完整的最终文件。
5.3 我实测后的建议与后续扩展
如果这个功能要长期演进,有几个方向值得考虑:
- 接入Redis保存上传进度。单机方案用文件系统,简单但无法支撑多实例。改成Redis后,分块状态查询速度快一个数量级,且天然支持多节点共享。
- 合并后同步到对象存储。我现在项目中最终文件不会留在应用服务器本地,而是合并完成后立刻上传到MinIO或OSS,应用服务器只保留一卷临时数据。上传完成后再把临时目录清掉,磁盘压力可控。
- 视频转码等后处理。大文件上传往往伴随着后续处理,比如视频转码、图片压缩。合并完成后会往消息队列投递一条任务,异步处理,避免接口长时间占用。
最后再分享一个我自己踩过的坑:不要相信浏览器的progress事件里的total。在分块上传模式下,progress事件给的是当前请求的进度,不是整个文件的进度。整体进度只能由“已成功分块数 / 总分块数”计算,否则用户看到进度条卡在99%不动,然后又跳回50%,体验极差。我自己在早期版本就犯过这个错,改成分块计数后,进度条平滑多了。
大文件上传这个场景,代码写起来不难,但想在生产环境稳如老狗,还是得把细节抠细。上面这套方案我在内部系统里跑了两年,传过最大的文件是8.7GB的设计源文件,再也没有出现过从头再传的投诉。你完全可以照着这个思路搭一版,然后把分块大小、并发数根据你的网络环境重新调一调,就能跑起来了。