做后台管理系统的人,十有八九会被“大文件上传”这个需求折磨过。文件稍微大一点,比如几百MB甚至上GB,用户传了半个多小时,一个网络波动全白费,重新点上传又是从零开始。Java后端做断点续传的大文件上传,不是一个新话题,但很多人搜到的示例要么只讲原理不给代码,要么代码写得跑不起来。这篇文章会把一套能直接落地的方案拆开讲:后端Spring Boot怎么写、前端怎么配合切片、断点续传的本质到底是什么,以及实际操作中那些不踩一次根本发现不了的坑。适合正在做大文件上传功能、被用户投诉“传一半又断了”的开发者参考。
1. 大文件上传为什么会废:四个环节同时施压
1.1 失败不是玄学,是超时、限流、波动、内存叠加出来的
先看一个典型场景:用户上传一个500MB的视频,前端直接拿一个input表单,把整个文件POST到后端。后端用MultipartFile接收,Spring Boot默认的max-file-size是1MB,本质上这么大的请求根本进不来,直接被拦截。就算你手动调大了限制,前端到后端的这条链路上还有很多环节能让你翻车。
第一个是HTTP连接超时。浏览器和服务器之间的连接,尤其是经过网关、负载均衡器的时候,通常有几十秒到几分钟的空闲/传输超时配置。一个文件传了10分钟,传输中途某个节点断开,服务端收到的请求不完整,直接抛异常。第二个是服务端请求体大小限制,不只是Spring,常见的Web容器、反向代理都有自己的报文大小上限,超了直接返回413。第三个是网络波动,Wi-Fi切换、信号不好、公司网络断一下,TCP连接就断了,整次上传报废。第四个是内存问题,如果用byte[]接收整个文件,500MB的文件直接堆到内存里,并发一高,OOM就跟吃饭一样频繁。
这四件事叠加在一起,结论就是:把整个文件做成一次HTTP请求,在大文件场景下基本站不住。不是代码写得不好,是架构思路就不对。
1.2 断点续传的思路:把大任务拆成小任务,然后记住做到哪了
断点续传的核心思想特别朴素,一句话就能说清楚:不传整个文件,而是把文件切成N个小分片,后端一个一个接收;前端上传前先问一下后端“我之前传到几号分片了”,从断掉的位置往后继续传,全部传完再通知后端合并。
举个例子你就明白了。你要把一部长篇小说抄到本子上,抄到第47页停电了,电来了以后最聪明的做法是翻开第48页继续抄,而不是把第1页重新抄一遍。断点续传要解决的就是“记住抄到第47页了”这件事。具体到技术实现,就是两件事:文件切成多块;记录并查询哪些块已经成功。
文件切块由前端完成,浏览器里的File对象支持slice()方法,可以把文件按字节切段。服务端要做的,则是提供一个查询接口,告诉前端哪些分片已经收到,前端拿到这个清单后跳过已传的块。所有分片都传完了,最后再调一个合并接口,服务端把零散的分片按顺序拼回完整文件。
1.3 为什么不能用HTTP Range实现上传续传
有人会问:HTTP不是有Range头吗,可以做断点续传吗?这个问题值得展开讲清楚。
Range头确实用于断点续传,但它主要适用于下载场景:客户端告诉服务端“我要从第xx个字节开始读”,服务端返回206 Partial Content。这是服务端按照Range参数返回文件的局部内容。但上传是反过来的,客户端要把数据写给服务端,服务端怎么处理请求体里的Range,HTTP协议并没有统一标准,各家服务器、框架、中间件对这个行为的解析完全不一样。
所以现实情况是:直接用Range头做上传续传,没有可靠的跨平台方案,甚至连浏览器发请求都不方便控制。更靠谱的做法是应用层自己定义协议,也就是前面说的“切片+上报已传分片+合并”。这套方案前后端都由自己控制,兼容性最好,行为也可预期。Range头可以留着做上传完成后的下载断点续传。两件事别搞混。
2. 方案架构与四个接口:先把协议定清楚
2.1 整体流程与目录设计
这套方案设计成四个接口就够了:
| 接口 | 方法 | 作用 |
|---|---|---|
| /upload/init | POST | 初始化上传:前端告知文件名、总大小、分片大小、文件MD5,后端创建存储目录和元数据 |
| /upload/check | GET | 查询已上传的分片编号列表,供前端续传时跳过 |
| /upload/chunk | POST | 上传单个分片文件,携带分片索引 |
| /upload/merge | POST | 所有分片完成后,通知后端按顺序合并 |
后端的存储结构设计成目录树,一个文件对应一个目录:
/data/upload/ └── 7f8a9b3c...(文件MD5) ├── chunks/ │ ├── 0.part │ ├── 1.part │ ├── 2.part │ └── ... ├── meta.properties └── 原文件名.mp4(合并后的最终文件)目录名直接用文件的MD5值,好处是同一个文件再次上传时,后端能秒级判断“这文件已经传过了”,直接返回“已完成”,这就是秒传。meta.properties保存这个文件的原始信息,包括文件名、总大小、分片大小、总分片数。这里特意用Properties文件而不用数据库,是为了让中小项目零依赖也能跑起来,重启服务状态还在。
2.2 文件标识为什么选MD5而不是UUID
做断点续传,需要解决一个核心问题:服务端怎么识别“这是同一个文件”。最简单的方案是UUID,前端每次上传都生成一个随机ID,但这样同一个文件换个会话再上传就是全新任务,秒传和断点续传都无从谈起。
用MD5做标识,服务端看到同样的MD5就知道是同一个文件。有些读者觉得大文件算MD5很慢,这点确实存在。1GB的文件用浏览器增量算法计算,通常只需要几秒到十几秒,换来的好处是:
- 秒传:文件已存在时,init阶段直接返回“不用传了”;
- 去重:同一文件重复上传不占额外空间;
- 校验:合并后用MD5比对,能发现分片丢失或损坏。
生产环境如果实在觉得全文件MD5太重,可以退而求其次用“文件大小+文件首尾分片的MD5”做弱标识,但本文示例用完整的MD5,逻辑最干净。
2.3 分片大小与并发数怎么定
分片大小我建议选2MB到5MB之间,别去选512KB这种极端值,也别选几十MB。分片太小,1GB的文件要切成几百上千片,每分片都产生一次HTTP请求,握手开销和网络往返时间会把速度拖慢。分片太大,网络波动一次重传的代价太高,也失去了切片的意义。我自己一般习惯用2MB,配合3~5个并发请求,整体速度和稳定性比较平衡。
并发达成了什么效果:假设一次HTTP往返需要200ms,并发数是4,那么理论上每秒钟能传4个分片,按2MB一片算是8MB/s左右。并发数再往上加,提升开始变得不明显,反而会加剧服务端压力和前端内存占用。3到5个并发是实践中比较稳的区间。
服务端的超时和限制也要同步调整。后端接口接收的是一个分片,但Spring Boot默认的最大上传大小是1MB,不调的话第一个分片就挂了:
spring.servlet.multipart.max-file-size=5MB spring.servlet.multipart.max-request-size=30MBmax-file-size要大于单个分片的大小,max-request-size要能容纳并发上传的总量。上面并发4、分片2MB的情况下,max-request-size设成30MB是够用的。
3. Java后端核心实现:Spring Boot分片上传完整代码
3.1 项目依赖与配置
后端用Spring Boot,核心依赖只需要spring-boot-starter-web,文件合并时用NIO的Files类和Spring自带的DigestUtils,不需要额外引第三方库。如果要用DigestUtils.md5Hex,Spring的spring-core里已经自带。
首先在application.properties里把上传限制放出来:
spring.servlet.multipart.max-file-size=5MB spring.servlet.multipart.max-request-size=30MB然后写一个配置项,定义上传根目录:
upload.root=/data/upload用@Value注入即可。生产环境建议把这个目录放到独立数据盘,别和系统盘混在一起,磁盘满了系统会出各种莫名其妙的问题。
3.2 初始化上传与元数据持久化
init接口接收前端传过来的文件MD5、原始文件名、总大小、分片大小。后端做的事,是创建目录并保存元数据。这里有个细节:如果元数据文件已存在,不要重复创建,因为断点续传时用户可能重新点了上传,信息应该复用。
@Service public class UploadService { @Value("${upload.root}") private String uploadRoot; public void init(String fileMd5, String fileName, long fileSize, int chunkSize) { try { Path uploadDir = Paths.get(uploadRoot, fileMd5); Path chunksDir = uploadDir.resolve("chunks"); Files.createDirectories(chunksDir); Path metaFile = uploadDir.resolve("meta.properties"); if (!Files.exists(metaFile)) { Properties props = new Properties(); props.setProperty("fileName", cleanFileName(fileName)); props.setProperty("fileSize", String.valueOf(fileSize)); props.setProperty("chunkSize", String.valueOf(chunkSize)); props.setProperty("totalChunks", String.valueOf((fileSize + chunkSize - 1) / chunkSize)); try (OutputStream out = Files.newOutputStream(metaFile)) { props.store(out, "upload meta"); } } } catch (IOException e) { throw new RuntimeException("初始化上传失败", e); } } private String cleanFileName(String fileName) { // 防止路径穿越:只取最后一个路径分隔符之后的内容 int idx = Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\')); return idx >= 0 ? fileName.substring(idx + 1) : fileName; } }分片总数不是前端告诉我,而是后端通过(fileSize + chunkSize - 1) / chunkSize算出来的。这个公式是向上取整的意思,避免出现最后一个分片没被算进去的问题。前端和后端算出来的总数必须一致,合并前还要拿这个数做校验。
cleanFileName是必须做的一步,直接把用户传的文件名拼到路径里,一旦里面带../就可能把文件写到任意位置,这是非常基础的路径穿越防御。
3.3 查询已上传分片:断点续传的关键实现
断点续传之所以能成立,靠的就是这个check接口。前端打开页面准备上传时,先调它获取“已传分片编号列表”,然后跳过这些分片。
public List<Integer> check(String fileMd5) { Path chunksDir = Paths.get(uploadRoot, fileMd5, "chunks"); if (!Files.exists(chunksDir)) { return new ArrayList<>(); } try (Stream<Path> stream = Files.list(chunksDir)) { return stream .filter(p -> p.getFileName().toString().endsWith(".part")) .map(p -> Integer.parseInt( p.getFileName().toString().replace(".part", ""))) .sorted() .collect(Collectors.toList()); } catch (IOException e) { throw new RuntimeException("查询分片状态失败", e); } }列出目录下的part文件,把文件名里的索引号解析出来再排序。比如返回[0, 2, 3],就说明1号分片还没成功。为什么这个接口能提供断点续传的能力?因为它是按照“服务端实际存储了什么”来回答的,不是按照前端上传了什么。网络断了,前端不知道哪些分片真正落盘,但文件系统知道。所以用文件系统作为事实来源,是最简单也最可靠的做法。
3.4 分片接收与落盘:幂等处理很关键
chunk接口是接受频率最高的入口。前端并发上传多个分片,怎么保证并发安全?这里有个重要的设计:每个分片写独立的part文件,天然没有写冲突。不需要锁,不需要事务,文件系统自己处理。
public void uploadChunk(MultipartFile file, String fileMd5, int chunkIndex) { try { Path chunksDir = Paths.get(uploadRoot, fileMd5, "chunks"); Files.createDirectories(chunksDir); Path chunkFile = chunksDir.resolve(chunkIndex + ".part"); // 幂等处理:同一个分片可能因为前端重试被重复提交 // 如果已存在且大小一致,直接视为成功,避免覆盖写造成异常 if (Files.exists(chunkFile) && Files.size(chunkFile) == file.getSize()) { return; } file.transferTo(chunkFile.toFile()); } catch (IOException e) { throw new RuntimeException("分片保存失败: " + chunkIndex, e); } }幂等这一步容易被忽略。前端做失败重试时,同一个分片可能被提交两次。如果不做任何判断就transferTo覆盖,只要两次写入之间没有并发问题倒还好,但假如第一次写了一半网络断了,第二次覆盖是没问题的。如果文件已存在而且大小也一致,说明上一次写入已经完整落盘,直接跳过能省一次IO。这里没有校验分片内容,因为合并完成后会做全文件的MD5校验,那时才能真正发现数据完整性有没有问题。
3.5 合并分片与完整性校验
所有分片都传完后,前端调merge接口。后端先读取元数据里的总分片数,再检查实际分片数量是否一致,然后按分片索引顺序写入最终文件。
private final ConcurrentHashMap<String, ReentrantLock> lockMap = new ConcurrentHashMap<>(); public boolean merge(String fileMd5) { // 防止用户连续点击或并发调用merge导致重复合并 ReentrantLock lock = lockMap.computeIfAbsent(fileMd5, k -> new ReentrantLock()); lock.lock(); try { Path uploadDir = Paths.get(uploadRoot, fileMd5); Path chunksDir = uploadDir.resolve("chunks"); Path metaFile = uploadDir.resolve("meta.properties"); Properties props = new Properties(); try (InputStream in = Files.newInputStream(metaFile)) { props.load(in); } int totalChunks = Integer.parseInt(props.getProperty("totalChunks")); String fileName = props.getProperty("fileName"); // 校验分片数量 long chunkCount; try (Stream<Path> stream = Files.list(chunksDir)) { chunkCount = stream.filter(p -> p.getFileName().toString().endsWith(".part")).count(); } if (chunkCount != totalChunks) { return false; } // 按索引顺序合并,顺序不能乱 Path targetFile = uploadDir.resolve(fileName); try (FileOutputStream fos = new FileOutputStream(targetFile.toFile()); BufferedOutputStream bos = new BufferedOutputStream(fos)) { for (int i = 0; i < totalChunks; i++) { Path chunkPath = chunksDir.resolve(i + ".part"); if (!Files.exists(chunkPath)) { return false; } Files.copy(chunkPath, bos); } bos.flush(); } // 合并后做整体MD5校验,防止分片内容损坏 String mergedMd5 = DigestUtils.md5Hex(Files.newInputStream(targetFile)); if (!fileMd5.equalsIgnoreCase(mergedMd5)) { Files.delete(targetFile); throw new RuntimeException("合并文件校验失败,文件已被清理"); } // 清理临时分片和元数据 deleteDir(chunksDir); Files.deleteIfExists(metaFile); return true; } catch (IOException e) { throw new RuntimeException("合并失败", e); } finally { lock.unlock(); } }合并的时候有一个特别容易踩的坑——不能按文件名排序后合并。chunks目录下的part文件名虽然是数字,但Files.list()返回的顺序不保证是有序的。如果转成字符串排序,会得到0.part、1.part、10.part、11.part这种结果。所以这里必须解析成整数再排序,按数字大小决定合并顺序,哪怕中间某个分片缺失,也要按原索引去合并,这样发现的缺失才是准确的。
校验通过后,把临时分片目录和元数据删掉,只留下最终文件。下次同一个文件再上传时,check接口虽然查不到分片了,但init接口里可以增加一个判断:如果最终文件已存在,直接返回“文件已存在”,前端就实现了秒传。这个扩展很简单,在init里加一行Files.exists(uploadDir.resolve(fileName))判断即可,业务价值却很大。
4. 前端配合实现:切片、并发控制与续传逻辑
4.1 计算文件MD5:大文件不能一次读完
前端是整个断点续传的发起方。用户在页面里选完文件,前端要做三件事:算MD5、切片上传、调合并。其中算MD5是最容易写错的地方。
如果直接用FileReader把整个文件一次性读进内存再算MD5,一个1GB的文件,浏览器进程的内存占用直接上去,页面卡死甚至崩溃。正确做法是用增量算法:按分片读取文件,每读完一块就把数据追加到SparkMD5实例里,全部读完后一次性得到最终MD5。
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB function calcFileMd5(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); const spark = new SparkMD5.ArrayBuffer(); let currentChunk = 0; const totalChunks = Math.ceil(file.size / CHUNK_SIZE); function loadNext() { const start = currentChunk * CHUNK_SIZE; const end = Math.min(file.size, start + CHUNK_SIZE); reader.readAsArrayBuffer(file.slice(start, end)); } reader.onload = (e) => { spark.append(e.target.result); currentChunk++; if (currentChunk < totalChunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror = (e) => reject(e); loadNext(); }); }4.2 切片上传主流程:从check到merge
拿到了MD5,后面的流程就简单了。先调init注册文件信息,再调check获取已上传分片,接着遍历所有分片,跳过已传的,最后调merge。
async function uploadFile(file) { const fileMd5 = await calcFileMd5(file); const totalChunks = Math.ceil(file.size / CHUNK_SIZE); // 1. 初始化 await fetch('/upload/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileMd5, fileName: file.name, fileSize: file.size, chunkSize: CHUNK_SIZE }) }); // 2. 查询已上传分片 const checkResp = await fetch('/upload/check?fileMd5=' + fileMd5); const uploadedChunks = await checkResp.json(); const uploadedSet = new Set(uploadedChunks.data); // 3. 逐片上传(可替换为并发实现) for (let i = 0; i < totalChunks; i++) { if (uploadedSet.has(i)) continue; const start = i * CHUNK_SIZE; const blob = file.slice(start, Math.min(file.size, start + CHUNK_SIZE)); const fd = new FormData(); fd.append('file', blob); fd.append('fileMd5', fileMd5); fd.append('chunkIndex', i); await fetch('/upload/chunk', { method: 'POST', body: fd }); } // 4. 合并 await fetch('/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileMd5 }) }); }这段逻辑有个比较实用的点:断点续传不是靠前端记住什么状态,而是每次上传前都去服务端问一遍。所以哪怕用户清空了浏览器缓存,或者换了一台电脑,只要后端还有这个MD5对应的分片目录,就能继续传。当然换电脑的情况还要涉及登录态、跨设备续传,那是另一个话题了,但核心思路是一样的。
4.3 并发控制与失败重试
逐片上传虽然能用,但1GB的文件分成512片,一片一片排队传,在好网速下会明显浪费带宽。加一个固定并发数的上传队列,整体速度会改善很多。
并发控制的实现不需要引入复杂的队列框架,用Promise配合一个游标就能搞定。核心逻辑是:维护一个nextIndex变量,每个worker线程从里面取任务,取一个加一,直到所有分片处理完。
async function uploadChunks(file, fileMd5, totalChunks, uploadedSet, concurrency = 3) { let nextIndex = 0; const failedChunks = []; async function worker() { while (nextIndex < totalChunks) { const i = nextIndex++; if (uploadedSet.has(i)) continue; const start = i * CHUNK_SIZE; const blob = file.slice(start, Math.min(file.size, start + CHUNK_SIZE)); const fd = new FormData(); fd.append('file', blob); fd.append('fileMd5', fileMd5); fd.append('chunkIndex', i); try { const resp = await fetch('/upload/chunk', { method: 'POST', body: fd }); if (!resp.ok) throw new Error('chunk upload failed'); } catch (err) { failedChunks.push(i); } } } const workers = Array.from({ length: concurrency }, () => worker()); await Promise.all(workers); // 失败重传一次,仍然失败则提示用户 if (failedChunks.length > 0) { for (const i of failedChunks) { const start = i * CHUNK_SIZE; const blob = file.slice(start, Math.min(file.size, start + CHUNK_SIZE)); const fd = new FormData(); fd.append('file', blob); fd.append('fileMd5', fileMd5); fd.append('chunkIndex', i); const resp = await fetch('/upload/chunk', { method: 'POST', body: fd }); if (!resp.ok) throw new Error('重试失败,请检查网络'); } } }为什么用并发的形式而不是把所有分片一次性全发出去?因为浏览器对同一域名的并发连接数有限制,而且一次性几十个请求同时发出去,服务端的线程池也扛不住。控制并发数是在“充分利用带宽”和“不给服务端找麻烦”之间取的平衡。并发数通常取3到5,大宽带环境可以调到6,但别再多。
5. 高频问题与排查技巧:避坑实录与生产建议
5.1 高频问题速查表
实际操作中遇到的问题,整理成一张速查表,比长篇大论更实用:
| 现象 | 原因分析 | 处理方式 |
|---|---|---|
| 上传分片报413 | 服务端对请求体大小有限制 | 调大max-file-size和max-request-size,同时检查反向代理层 |
| 合并后文件打不开、花屏 | 分片合并顺序错了 | 合并时按分片索引数值排序,不能按文件名排序 |
| 大文件算MD5时页面卡死 | 一次性读整个文件进内存 | 使用增量算法分片读取计算 |
| 断点续传失效,总是从头传 | check接口返回的已传列表不准确 | 检查part文件命名是否统一,目录是否被清理 |
| 磁盘被临时分片占满 | 上传中断后没有清理机制 | 增加定时任务,清理超过N天的临时目录 |
| 服务端OOM | 用byte[]接收了整个文件 | 坚持用MultipartFile的transferTo流式落盘 |
| 同一个文件重复上传占两份空间 | 没有做文件去重 | 用MD5作为唯一标识,已存在则秒传 |
5.2 合并顺序错乱的经典翻车现场
我自己第一次做合并的时候,直接在stream上按文件名排序合并,以为文件名的0.part、1.part、2.part就是天然的顺序。结果文件传到磁盘上,用一个播放器打开,画面是跳的,进度条拖到一半直接卡死。查了好半天,最后发现10.part被排到了2.part前面。这个坑的本质是字符串排序和数值排序不一致,所以现在做所有分片处理时,只认Integer.parseInt()之后的索引值。
5.3 临时文件清理与磁盘占用
断点续传天然会带来一个问题:用户传了一半不传了,服务端的chunks目录就永远留在磁盘上。如果不清理,时间长了磁盘会被这些半成品挤爆。我常用的方案是写一个Spring定时任务,扫描上传根目录下所有chunks目录,如果修改时间超过24小时就直接删除。为什么要24小时?因为正常的大文件上传不太可能超过这个时间,但也不能设得太短,万一用户暂停后隔天再续传,清理太勤快会把还没传完的任务也删了。
@Scheduled(cron = "0 0 3 * * ?") public void cleanupExpiredChunks() { // 遍历 uploadRoot 下每个子目录的 chunks 目录 // 判断最后修改时间是否超过24小时 // 超时则删除整个 uploadRoot/{fileMd5} 目录 }顺带提醒一下,@Scheduled需要开启@EnableScheduling。不是所有项目都会默认开启,别漏了。
5.4 生产环境升级建议
这套本地磁盘方案非常适合学习、内部系统、中小项目,但到了线上高并发、多实例部署的环境,有两个明显的瓶颈。
第一个瓶颈是:多实例部署时,用户的两次请求可能落在不同的机器上,A机器的分片B机器看不到,check接口就跟不上了。解决办法是把分片和元数据放到共享存储,比如对象存储服务或者分布式文件系统。核心的分片逻辑、幂等校验、合并校验完全不用变,只是把Paths.get(uploadRoot, fileMd5, "chunks")换成操作远程存储的API调用。这也是为什么我建议先把本地磁盘版跑通,逻辑清楚了再迁移,切换成本很低。
第二个瓶颈是:一个超大文件有几千个分片,合并流程放在HTTP请求线程里做,耗时太长,客户端可能一直等到超时。生产上一般会把merge接口改成异步任务:收到合并请求后,把任务丢到队列里,立即返回“合并中”,前端轮询合并状态,完成后再提示用户。这个改造不复杂,但能明显提升体验。
写在最后的几点体会
在我自己维护的文件上传服务里,这个方案稳定跑了一两年,中间补过不少细节。印象最深的一次是某次升级时把check接口漏掉了,前端直接按“从0开始传”的逻辑走,结果用户反馈还不如以前,因为每次断网重传虽然分片更快了,但已传的分片没有跳过,白白浪费带宽。那一次让我彻底想明白了一件事:切片上传只是手段,断点续传的核心在于“查询进度并跳过已完成的部分”,这两个功能缺了任何一个,都不叫断点续传。
另外再分享一个小技巧:合并后的MD5校验虽然好,但大文件全量算MD5也要几十秒。如果不想在merge阶段等太久,可以在分片上传时给每个分片算一个CRC32或SHA1,存到元数据里,合并时只校验分片级别的完整性,最终文件就用抽样对比或者延后异步校验。这样接口响应更快,但逻辑会复杂一档。要简单还是要快,根据你的实际场景去权衡。这套方案从接口设计到前后端代码都是可以直接抄的,跑通之后再看怎么根据你的业务做裁剪,会比从零开始自己研究省很多时间。