百M以上文件上传这种需求,几乎每个做Web系统的团队迟早都会撞上。早期我跟很多人一样,天真地以为把spring.servlet.multipart.max-file-size调大就万事大吉,结果生产环境第一个500MB的文件就直接让服务端内存飙满,连带着应用一起卡死。后来踩过几次坑、重构过两版之后,才算真正把大文件上传这条路走通。这篇就把我实际用下来的方案、配置、代码和排坑过程完整梳理一遍,给正准备做这块的开发者一个能直接落地的参考。
先说清楚一个基本事实:浏览器和服务器默认配置都只适合小文件。Spring Boot内置的Multipart解析器,默认单文件上限是1MB,请求上限是10MB,超过直接抛MaxUploadSizeExceededException。就算你把这两个参数调到足够大,把几百MB文件一次性怼到后端,MultipartFile会先把整个文件加载进内存,这个内存占用对应用来说是灾难性的。所以大文件上传不只是"调大配置"这么简单,核心要解决的是三个问题:文件怎么传、内存怎么省、中断怎么办。
1. 整体思路拆解:为什么大文件必须走分片
先看一个典型的处理方案对比,这是我实际操作过的几种路径:
| 方案 | 实现难度 | 内存压力 | 断点续传 | 适用场景 |
|---|---|---|---|---|
调大max-file-size直接传 | 最低 | 极高 | 不支持 | 几十MB以内 |
| 前端读文件后Blob分片上传 | 中 | 低 | 需自行维护进度 | 100MB~2GB |
后端流式接收(Servlet输出流) | 中 | 低 | 不支持 | 一般不用 |
| 分片上传+断点续传+秒传校验 | 较高 | 低 | 支持 | 真正的大文件场景 |
我最终选的路线是"前端分片+后端合并+断点续传"。这个方案的核心逻辑其实很简单:前端把大文件按固定大小切成若干片,一片一片传到服务端;服务端每收到一片就写盘保存,等所有分片都到了,再按顺序合并成完整文件。整个过程内存里只保留当前这一片的数据,整体内存占用被压到了很小。
1.1 分片方案选型背后的考量
你可能想问,为什么不是简单调大配置?因为百M以上的文件,如果一次性走常规MultipartFile流程,框架会把整个请求体解析完,再实例化成一个大对象交给Controller。这个解析过程,尤其是文件落进CommonsMultipartResolver或StandardServletMultipartResolver这类实现里的行为,整体对内存不太友好。文件越大,GC压力越大,垃圾回收一卡顿,接口响应时间就飙上去了。
而分片上传的逻辑是:每次只发一个2MB~10MB的分片,后端每次只处理一个大小的数据块,单次内存消耗极小且可控。好处不只是省内存,还天然获得了三个额外能力:
一是进度可追踪。每个分片上传成功与否是独立的,前端可以随时知道整个任务走到了百分之多少。二是失败重试成本极低。某一片上传失败,只需要重传那一片,而不是整个文件从头再来。三是可以做到断点续传。浏览器崩溃、网络断开,刷新后检查一下哪些分片已经传过了,把剩下的传完就行。
1.2 完整交互流程先过一遍
整个上传流程,抽象出来是这几步:
第一步,前端选定文件后,先调后端接口询问该文件是否已经上传过、以及已有哪些分片。第二步,根据后端返回的结果,把没传过的分片按顺序逐个上传。第三步,每传完一片,前端把这个分片的编号、文件标识告诉后端,后端做记录。第四步,所有分片传完后,前端调"合并分片"接口,后端把所有分片按顺序拼接成完整文件,并做MD5校验。第五步,校验通过,文件落库记录;不通过,则提示重传缺失分片。
这套流程拆开看每一步都很简单,但合起来就是一个健壮的大文件上传解决方案。后面的内容我把每一步的细节、代码、参数选择都补全。
2. 服务端核心细节解析与实操要点
先放一个完整的pom.xml依赖和基础配置,这是整个方案的地基。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>spring-boot-starter-web自带的Servlet容器(一般是Tomcat)已经包含了MultipartResolver,所以不需要额外引入文件上传库,除非你要用commons-fileupload做更底层的流式处理。我建议先只用Spring Boot内置的,因为配置简单、足够用。
2.1 关键配置逐项说明
配置文件里这几个参数,每一个都有它存在的理由:
# 单个分片大小,我这里设10MB spring.servlet.multipart.max-file-size=10MB # 整个请求体大小,比单分片稍大,防止多余表单字段撑爆 spring.servlet.multipart.max-request-size=12MB # 文件大小达到阈值后写入磁盘,默认是0,也就是直接进内存 spring.servlet.multipart.file-size-threshold=2MB # 上传文件的临时目录 spring.servlet.multipart.location=/data/upload_tmp这里最容易被忽略的是file-size-threshold。默认情况下,上传的文件是先往内存里写的,到了一定大小才转磁盘。如果这个值设得太小,分片数据频繁在内存和磁盘之间倒腾,会有额外的IO开销;设得太大,内存压力又上来了。2MB对于10MB的分片来说是个合理的折中:小分片直接走内存,大分片提前落盘,内存占用始终可控。
还有一个新手很容易踩的坑:这些配置只管Multipart格式的请求。如果你的前端是用axios、fetch直接塞二进制body,而不是用FormData,那这些配置统统不生效。这种情况要走流式读取,代码又是另一套写法了。我实际用的方案是FormData方式,和Spring Boot的MultipartFile配合最顺手。
2.2 存储路径规划
分片文件、临时文件、最终文件,这三个路径最好分开管理,不要都堆在同一个目录。我的习惯是这样规划:
/data/ upload_tmp/ # 上传过程中的临时目录(分片文件) upload_files/ # 合并后的最终存储目录为什么坚持分目录?两个原因。第一,临时目录里的分片文件随时可能被清理,比如定期任务删掉超过48小时未合并的残留分片;最终目录是正式数据,不能误删。第二,磁盘满了的时候排查起来也清楚,到底哪块占用暴涨一目了然。
另外,Before合并大文件时,你会需要确认目标磁盘的剩余空间充足,不然合并到一半磁盘写满了,那真是欲哭无泪。可以做一个简单的容量校验逻辑,后面代码里会带上。
2.3 文件元信息表设计
分片上传最关键的点是:服务端要知道"这个文件有哪些分片已经传过了"。这个状态必须持久化,不能只存在内存里。最简单的方式是建一张表存文件上传记录:
CREATE TABLE upload_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_md5 VARCHAR(64) NOT NULL COMMENT '文件MD5标识', filename VARCHAR(255) NOT NULL COMMENT '原始文件名', chunk_total INT NOT NULL COMMENT '总分片数', chunk_size BIGINT NOT NULL COMMENT '分片大小', uploaded_chunks TEXT COMMENT '已上传分片编号,逗号分隔', file_path VARCHAR(500) COMMENT '合并后的存储路径', file_size BIGINT COMMENT '文件总大小', status TINYINT COMMENT '0上传中 1已合并 2合并失败', create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_file_md5 (file_md5) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个表同时承担了断点续传和秒传两个职责。file_md5是文件的唯一标识,前端计算后传过来。uploaded_chunks字段记录已经成功上传的分片号,前端每次问"哪些还没传"时,后端把这份清单返回去就行。status标记整个文件当前处于什么状态。
这里有一点要说清楚:MD5的计算建议放在前端做。后端算MD5也能算,但在大文件场景下,后端在接收完整文件后再去逐块读文件计算MD5,过程会非常耗时,而且你需要再读一遍完整文件,多一轮IO。前端在选完文件后,通过FileReader分块读取计算,几百MB的文件也就几秒的事,用户体验上完全可接受。而且合并完成后,后端还会再做一次MD5校验来兜底,双保险。
3. 完整实现过程与核心代码
下面进入正题,把整个代码写出来,从Controller到Service再到工具类,每一步配合说明。
3.1 上传接口Controller层
@RestController @RequestMapping("/api/upload") public class FileUploadController { @Resource private FileUploadService uploadService; /** * 检查文件和分片状态,用于断点续传和秒传判断 */ @PostMapping("/check") public Result<CheckFileVO> checkFile(@RequestBody CheckFileDTO dto) { return Result.success(uploadService.checkFile(dto.getFileMd5(), dto.getFilename())); } /** * 上传单个分片 */ @PostMapping("/chunk") public Result<Void> uploadChunk(UploadChunkDTO dto, @RequestParam("file") MultipartFile file) { uploadService.uploadChunk(dto, file); return Result.success(); } /** * 合并分片 */ @PostMapping("/merge") public Result<MergeVO> mergeChunks(@RequestBody MergeChunkDTO dto) { return Result.success(uploadService.mergeChunks(dto)); } }Controller层保持得很薄,所有业务逻辑都下沉到FileUploadService。注意uploadChunk接口接收的是MultipartFile,所以前端必须用FormData方式提交,字段名要对应file,其他分片元数据放在普通表单字段里。
3.2 DTO类设计
分片上传相关的数据传输对象,我这里把三个接口的入参都列出来,方便直接抄:
public class CheckFileDTO { private String fileMd5; // 文件MD5 private String filename; // 原始文件名 private Long fileSize; // 总大小 } public class UploadChunkDTO { private String fileMd5; // 文件唯一标识 private String filename; // 原始文件名 private Integer chunkIndex; // 当前分片序号,从0开始 private Integer chunkTotal; // 总分片数 private Long fileSize; // 文件总大小 } public class MergeChunkDTO { private String fileMd5; // 文件MD5 private String filename; // 原始文件名 private Integer chunkTotal; // 总分片数 private Long fileSize; // 文件总大小 }分片序号从0开始还是从1开始,这个习惯要统一。我这里从0开始,前端从0切片,后端按0到chunkTotal-1合并,代码里注意统一就行,别混用。
3.3 Service层核心实现逻辑
Service层是这个方案的心脏,重点看uploadChunk和mergeChunks两个方法。
@Service public class FileUploadServiceImpl implements FileUploadService { // 分片临时目录 private static final String CHUNK_DIR = "/data/upload_tmp/"; // 最终文件目录 private static final String FILE_DIR = "/data/upload_files/"; // 分片目录允许保留时间(毫秒) private static final long CHUNK_TIMEOUT = 48 * 60 * 60 * 1000L; @Resource private UploadRecordMapper recordMapper; @Override public CheckFileVO checkFile(String fileMd5, String filename) { CheckFileVO vo = new CheckFileVO(); UploadRecord record = recordMapper.selectByFileMd5(fileMd5); if (record == null) { vo.setUploaded(false); vo.setUploadedChunks(new ArrayList<>()); return vo; } // 已合并成功,秒传场景 if (record.getStatus() == 1) { vo.setUploaded(true); vo.setFileExists(true); vo.setFilePath(record.getFilePath()); return vo; } // 已上传部分或全部,断点续传场景 vo.setUploaded(false); List<Integer> chunks = parseChunks(record.getUploadedChunks()); vo.setUploadedChunks(chunks); return vo; } @Override public void uploadChunk(UploadChunkDTO dto, MultipartFile file) { // 1. 校验总分片数是否非法 if (dto.getChunkTotal() <= 0 || dto.getChunkIndex() < 0) { throw new BizException("分片参数非法"); } // 2. 目录创建,多线程环境下保证目录存在 File chunkDir = new File(CHUNK_DIR + dto.getFileMd5()); if (!chunkDir.exists()) { synchronized (this) { if (!chunkDir.exists()) { chunkDir.mkdirs(); } } } // 3. 保存分片:分片文件命名规范:{md5}_{index}.part File chunkFile = new File(chunkDir, dto.getFileMd5() + "_" + dto.getChunkIndex() + ".part"); try { file.transferTo(chunkFile); // 注意:transferTo在部分场景下有坑,见下文说明 } catch (IOException e) { throw new BizException("分片保存失败: " + e.getMessage()); } // 4. 更新数据库记录里的已上传分片清单 UploadRecord record = recordMapper.selectByFileMd5(dto.getFileMd5()); if (record == null) { record = new UploadRecord(); record.setFileMd5(dto.getFileMd5()); record.setFilename(dto.getFilename()); record.setChunkTotal(dto.getChunkTotal()); record.setChunkSize(file.getSize()); record.setFileSize(dto.getFileSize()); record.setStatus(0); record.setUploadedChunks(String.valueOf(dto.getChunkIndex())); recordMapper.insert(record); } else { // 添加当前分片号到已有清单,注意去重 List<Integer> chunks = parseChunks(record.getUploadedChunks()); if (!chunks.contains(dto.getChunkIndex())) { chunks.add(dto.getChunkIndex()); Collections.sort(chunks); record.setUploadedChunks(joinChunks(chunks)); recordMapper.updateById(record); } } } }这里要专门说一下transferTo的坑。MultipartFile.transferTo()在部分Servlet容器实现里,如果目标文件已存在会拒绝写入并抛异常;而且transferTo走的是"重命名临时文件"的逻辑,如果跨文件系统(比如临时目录和应用目录不在同一个挂载点),会降级成copy,过程不可控。所以分片文件保存这个环节,我更推荐自己用InputStream加Files.copy来写:
try (InputStream in = file.getInputStream()) { Files.copy(in, chunkFile.toPath(), StandardCopyOption.REPLACE_EXISTING); }这样至少能保证目标位置明确、可覆盖,不受容器实现影响。实测下来稳定性比transferTo好不少。
3.4 合并分片的核心逻辑
合并这块是整个方案里最容易出问题的地方,主要难点在于:分片顺序必须正确、半路失败要能回滚、合并完成后要处理残留分片。
@Override public MergeVO mergeChunks(MergeChunkDTO dto) { // 1. 先查数据库,看状态 UploadRecord record = recordMapper.selectByFileMd5(dto.getFileMd5()); if (record == null) { throw new BizException("上传记录不存在"); } if (record.getStatus() == 1) { // 已经合并过,直接返回 MergeVO vo = new MergeVO(); vo.setFilePath(record.getFilePath()); vo.setFileMd5(dto.getFileMd5()); vo.setMergeSuccess(true); return vo; } // 2. 校验是否所有分片都已上传 List<Integer> uploadedChunks = parseChunks(record.getUploadedChunks()); if (uploadedChunks.size() != dto.getChunkTotal()) { // 找出缺失的分片号返回给前端 List<Integer> missingChunks = new ArrayList<>(); for (int i = 0; i < dto.getChunkTotal(); i++) { if (!uploadedChunks.contains(i)) { missingChunks.add(i); } } MergeVO vo = new MergeVO(); vo.setMergeSuccess(false); vo.setMissingChunks(missingChunks); return vo; } // 3. 磁盘剩余空间校验 File dir = new File(FILE_DIR); long freeSpace = dir.getFreeSpace(); if (freeSpace < dto.getFileSize()) { throw new BizException("磁盘剩余空间不足,请清理后重试"); } // 4. 按顺序合并文件 String finalFilePath = FILE_DIR + dto.getFileMd5() + "_" + dto.getFilename(); File finalFile = new File(finalFilePath); // 为了安全,使用临时文件合并,完全写完后rename File tmpFinalFile = new File(finalFilePath + ".merging"); try (FileChannel out = new FileChannel.open(tmpFinalFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { for (int i = 0; i < dto.getChunkTotal(); i++) { File chunkFile = new File(CHUNK_DIR + dto.getFileMd5() + "/" + dto.getFileMd5() + "_" + i + ".part"); if (!chunkFile.exists()) { throw new BizException("分片文件缺失: " + i); } try (FileChannel in = new FileChannel.open(chunkFile.toPath(), StandardOpenOption.READ)) { // 流式传输,避免一次性读入内存 long size = in.size(); long position = 0; while (position < size) { position += in.transferTo(position, size - position, out); } } } out.force(true); // 确保数据落盘 } catch (IOException e) { // 合并失败,删除临时文件 tmpFinalFile.delete(); throw new BizException("合并失败: " + e.getMessage()); } // 5. 文件名冲突处理:如果目标文件已存在,加时间戳后缀 if (finalFile.exists()) { String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); finalFilePath = FILE_DIR + dto.getFileMd5() + "_" + timestamp + "_" + dto.getFilename(); tmpFinalFile.renameTo(new File(finalFilePath)); } else { tmpFinalFile.renameTo(finalFile); } // 6. 记录合并状态 record.setStatus(1); record.setFilePath(finalFilePath); recordMapper.updateById(record); // 7. 清理分片目录 deleteChunkDir(dto.getFileMd5()); // 8. 返回结果 MergeVO vo = new MergeVO(); vo.setMergeSuccess(true); vo.setFilePath(finalFilePath); vo.setFileMd5(dto.getFileMd5()); return vo; }这段代码里几个细节值得单独说。
用FileChannel.transferTo做合并,关键好处是数据不经过用户态内存,由操作系统内核完成拷贝,几百MB的文件合并耗时极短,实测10GB文件合并也就是十几秒的事。另一个细节是合并的时候先写.merging临时文件,成功后再改名成最终名,这个操作保证了中途失败的时候不会留下一个残缺的"正式文件",避免前端以为上传成功了。
关于renameTo跨文件系统的问题:如果临时目录和最终目录不在同一个挂载点,renameTo可能直接失败。所以这里的合并方式,我实际是把分片目录、最终目录都放在同一个磁盘分区下,避免跨文件系统的麻烦。如果你确实需要跨存储,那就要改成"边读边写"的方式,不能依赖renameTo。
3.5 定时清理残留分片
分片上传有个天然的问题:用户传了一半关掉浏览器,或者某个分片一直失败,这些分片文件会永远留在磁盘上。必须有一个兜底的清理机制。我用Spring的@Scheduled写了个定时任务:
@Component public class ChunkCleanTask { @Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点 public void cleanExpiredChunks() { File chunkRoot = new File(CHUNK_DIR); if (!chunkRoot.exists()) return; File[] dirs = chunkRoot.listFiles(); if (dirs == null) return; long now = System.currentTimeMillis(); for (File dir : dirs) { // 超过48小时未更新的目录直接删掉 if (now - dir.lastModified() > CHUNK_TIMEOUT) { deleteDir(dir); } } } }这个定时任务成本很低,凌晨执行一次就行。执行时只清理超过48小时未更新的目录,在传的文件不会被误删,因为只要有任何新分片写入,目录的lastModified就会刷新。
4. 需要特别注意的安全与校验
大文件上传除了功能性,还牵扯到几个安全点,这部分反而容易被忽视。
4.1 文件类型校验不能只看后缀
大文件上传场景,文件类型校验往往比小文件上传更痛苦。小文件直接读字节判断魔数就行,大文件总不能为了校验类型先把整个文件读完。我的做法是"双重校验":
第一重,前端选择文件时拦截后缀和白名单。第二重,后端校验时,先只看文件名后缀做快速过滤;合并完成后,再用工具读取文件头部字节判断真实类型。
Java里可以用Files.probeContentType判断MIME,但这个方法依赖系统配置,不总是准确。更可靠的方式是读文件头几个字节和各类文件的魔数比对。比如PDF文件头是%PDF-,PNG是\x89PNG,JPEG是\xFF\xD8\xFF。合并结束后,抽出前8个字节做一次魔数匹配,能拦截大部分伪装的恶意文件。
4.2 并发上传与一致性
多个用户上传同一个MD5的文件时,会出现并发写同一个文件记录、分片目录互相覆盖的问题。分片保存逻辑里已经用synchronized锁了目录创建,但更严谨一点,可以引入分布式锁,或者在数据库表上做唯一约束、用INSERT ... ON DUPLICATE KEY UPDATE的方式更新分片清单。
对于绝大多数场景,单机synchronized保护分片目录创建已经足够。因为这些操作对象是{md5}_{index}.part这样的文件,不同分片的文件本身不冲突,同一个人重复上传同一分片也只是被REPLACE_EXISTING覆盖而已。真正需要认真对待的是"同一个文件,多个用户同时上传且都去合并"的极端情况,这个用数据库记录的status做一个乐观锁就能解决:合并前UPDATE ... SET status=1 WHERE file_md5=? AND status=0,影响行数为0说明有人抢先合并了,直接返回已有结果即可。
4.3 额外的表单参数校验
uploadChunk接口接收的参数,包括chunkIndex、chunkTotal、fileMd5,每一项都需要做基础校验,尤其是fileMd5的格式,直接校验是否是32位十六进制字符串。不然有人拿了个超长字符串拼接路径,可能引起不必要的路径问题。这里的常规防线,就是统一校验分片大小是否超过了配置单文件上限,以及分片序号是否在合法区间。多说一句,非法参数在Controller层的@Validated注解里就能挡一大部分,DTO上加@NotNull、@Pattern之类的约束,比在Service里手写if判断要干净得多。
5. 真实场景中常见的坑与排查实录
这段是我反复踩、反复填的经验积累,列成表格方便速查。
| 问题现象 | 根因分析 | 解决办法 |
|---|---|---|
上传分片报MaxUploadSizeExceededException | max-file-size或max-request-size配置没调大 | 改成10MB/12MB,重启应用 |
| 分片传完了,合并报"分片文件缺失" | 分片保存时用的transferTo偶发失败或目标跨盘 | 改用InputStream + Files.copy |
| 合并过程内存暴涨 | 合并时用了FileUtils.copyInputStreamToFile之类一次性读入的封装 | 改用FileChannel.transferTo |
| 刷新页面后进度从0开始 | 前端每次重新计算了MD5,或fileMd5值不稳定 | 前端将MD5值存在本地,刷新后沿用 |
| 合并后文件打不开 | 分片顺序错乱或某片段写错位置 | 后端按序号严格排序,合并完做MD5比对 |
| 多个用户传同一个文件互相干扰 | 分片目录或数据库记录冲突 | 数据库唯一约束+乐观锁 |
| 磁盘被未完成的分片塞满 | 没有清理机制 | 定时任务清理48小时以上的残留分片 |
5.1 最典型的合并失败问题排查
有一次我遇到的现象是:1000个分片全部显示传完了,但合并时程序报某个分片文件不存在。查了半天发现是上传分片时数据库记录抢先更新了,但磁盘文件因为IO异常没写成功。我用了Files.copy加REPLACE_EXISTING之后,这个问题就基本解决了,因为无论分片文件是否存在,都会强制覆盖写入,且写失败会主动抛异常。另一个原因是前端在某个分片失败后自动重试,但重试时传的chunkIndex不对,导致覆盖了别的分片。这个问题的防线是在前端:每个分片请求发出前,打印日志确认序号,重试时严格复用同一序号。
5.2 内存溢出的排查实录
升级后第一版方案,一个2GB文件上传时,JVM直接GC Overhead了。排查过程是先看jstat的GC曲线,发现老年代持续增长,线程dump一看,大量byte数组堆积。定位到问题出在合并环节,之前用的是IOUtils.copyLarge配合FileInputStream读一个分片再写,本身频率不高,但每个分片10MB、1000个分片,中间环节的缓冲对象生命周期没控制好,导致GC压力剧增。换成FileChannel后,GC问题彻底消失。核心思路就一条:大文件场景下,数据流能走底层走的绝不在应用层多做一次拷贝。
5.3 前端进度显示与网络波动处理
后端全部就绪后,前端的体验优化也是个不能跳过的环节。实操下来最重要的一点是:每个分片上传要做好重试和超时控制。
分片上传时,前端的axios默认超时时间要调大,建议60秒以上。如果一片超时,不能直接把整个任务判死,要做重试,比如单片最多重试3次,重试间隔每次递增1~3秒。连续重试失败再中断任务并提示用户检查网络。进度条的计算公式很简单:
let loaded = uploadedChunks.length * chunkSize; let total = file.size; let percent = Math.floor((loaded / total) * 100);这里有一个体验细节:如果启用了断点续传,刷新页面后进度的初始值不能是0,而应该用后端返回的uploadedChunks先算出已有进度,再加上本次新传分片的进度。用户看到"进度条从60%继续走"和"进度条又从头开始",对产品的信任感是完全不同的。
前端还有一个效率提升点:分片上传时控制并发数。一次把所有分片全部发出去,连接数会爆炸,尤其浏览器对同一域名的并发连接数有限制,超出的请求会排队等待,性能反而下降。我实测6个并发是比较合适的,既不会把带宽打满,也不会因为连接排队影响速度。Tomcat默认的max-threads是200,6个并发上传对服务端压力也不大。
6. 参数选型背后的计算逻辑
很多人在参数设置上是拍脑袋,不知道怎么算合理。这里我把我实战中算参数的方式完整讲一下,方便你根据自己场景套用。
6.1 分片大小怎么定
分片大小受三个因素约束:内存占用、网络环境的丢包率、服务器吞吐能力。分片越大,片数越少,请求次数越少,但单片失败的重试代价越大,且MultipartResolver解析单片的瞬时内存占用越高。分片太小,则请求数过多,HTTP开销会拖慢整体速度。
我常用的计算公式是:目标文件最大预估200MB~2GB,取分片10MB。算下来,2GB文件分成约200片,单次内存占用峰值大约10MB。对于一台4GB内存的服务器,再多并发同时上传,内存也扛得住。如果是最大文件100MB~200MB的场景,分片可以调整到5MB,让单片上传更稳。
6.2 并发数怎么定
前端的并发上传数,要同时考虑本机带宽、服务器带宽、服务器线程池容量。一个简单经验:6并发是比较安全的默认值。如果服务器是低配虚拟机、带宽很小,可以降到3;如果内网环境、带宽大、机器强,可以升到10。并发数不是越高越好,分片数量有限,并发太高反而导致大量请求排队。
6.3 超时时间怎么定
前端单个分片的超时时间,要按最慢网络环境估算。以10MB分片为例,最慢的网络可能只有100KB/s的实际上传速度,传完一个分片就要100秒。如果按默认的10秒超时,这种场景下分片会全部超时。所以单片超时必须放宽:
const CHUNK_TIMEOUT = Math.max(60000, (chunkSize / 1024 / minUploadSpeedKBps) * 1000 * 1.5);minUploadSpeedKBps按你预期的最慢网速设定,1.5是余量系数。这个公式算出来的超时时间,能覆盖绝大多数网络波动场景。
7. 前端关键实现参考
前端代码不展开全量,只把最核心的分片上传逻辑贴上,后端通了之后前端照着这个思路实现即可。
async function uploadFile(file, md5, chunkSize = 10 * 1024 * 1024) { const chunkTotal = Math.ceil(file.size / chunkSize); // 1. 先查询断点信息 const checkRes = await axios.post('/api/upload/check', { fileMd5: md5, filename: file.name, fileSize: file.size }); const uploadedSet = new Set(checkRes.data.uploadedChunks || []); if (checkRes.data.fileExists) { // 秒传 return checkRes.data.filePath; } // 2. 计算未上传的分片 const pendingChunks = []; for (let i = 0; i < chunkTotal; i++) { if (!uploadedSet.has(i)) { pendingChunks.push(i); } } // 3. 并发上传,自己控制并发数 const CONCURRENCY = 6; let cur = 0; const uploadOne = async (index) => { const start = index * chunkSize; const end = Math.min(start + chunkSize, file.size); const blob = file.slice(start, end); const formData = new FormData(); formData.append('file', blob, file.name); formData.append('fileMd5', md5); formData.append('filename', file.name); formData.append('chunkIndex', index); formData.append('chunkTotal', chunkTotal); formData.append('fileSize', file.size); await axios.post('/api/upload/chunk', formData, { timeout: CHUNK_TIMEOUT, headers: { 'Content-Type': 'multipart/form-data' } }); }; const worker = async () => { while (cur < pendingChunks.length) { const idx = cur++; try { await uploadOne(pendingChunks[idx]); } catch (e) { // 重试三次 let retry = 0; while (retry < 3) { await sleep(retry * 1000 + 1000); try { await uploadOne(pendingChunks[idx]); break; } catch (e2) { retry++; } } if (retry >= 3) { throw new Error(`分片${pendingChunks[idx]}上传失败`); } } } }; await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())); // 4. 所有分片传完,调合并接口 const mergeRes = await axios.post('/api/upload/merge', { fileMd5: md5, filename: file.name, chunkTotal: chunkTotal, fileSize: file.size }); // 5. 如果合并失败,按提示重传缺失分片 if (!mergeRes.data.mergeSuccess) { const missing = mergeRes.data.missingChunks || []; // 重新上传缺失分片,然后再次合并 } return mergeRes.data.filePath; }这里有几个细节值得强调。第一,file.slice是前端做分片的法宝,它不会把整个文件复制一份,而是按内存映射的方式切出指定范围的Blob,大文件切片开销极低。第二,并发上传用Promise.all配合简单的worker模式,不用引入成熟的队列库,代码反而更可控。第三,Content-Type不是每个分片都要手动指定的,但明确指定multipart/form-data能避免一些浏览器自动加boundary出错的偶发问题,实测加了更稳。
关于MD5的计算,我用的是SparkMD5这个库,配合FileReader按chunkSize分块读取:
function calcFileMd5(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); const spark = new SparkMD5.ArrayBuffer(); const chunkSize = 2 * 1024 * 1024; let cur = 0; const processNext = () => { const end = Math.min(cur + chunkSize, file.size); reader.readAsArrayBuffer(file.slice(cur, end)); }; reader.onload = (e) => { spark.append(e.target.result); cur += chunkSize; if (cur < file.size) { processNext(); } else { resolve(spark.end()); } }; reader.onerror = reject; processNext(); }); }这里用2MB的小块来算MD5而不是直接用10MB的分片大小,是因为SparkMD5在追加大数据块时需要的内存更大,计算本身也需要更长的时间去哈希。实测2MB块平衡了计算速度和内存占用,一个1GB文件计算MD5大约需要3~5秒,可接受。
8. 断点续传的实现细节
断点续传是整个方案里最体现"好用"的地方。它在代码层面其实不复杂,核心就是两个信息要正确传递:文件标识和已上传的分片集合。
前端实现断点续传,要解决"刷新后怎么知道之前传过什么"这个问题。我的做法是把每批上传任务的fileMd5存在localStorage里,刷新后根据文件名和大小找到对应的MD5值,再拿着MD5去调check接口拿已传分片清单。但是要注意一点:localStorage的容量限制是5MB左右,大文件算完的MD5本身只是一串32位字符串,占用空间极小,所以不用担心存不下任务记录本身。
有一种边界情况需要处理:同一个文件名、同样大小,但内容被用户改过了,MD5会变。所以前端判断断点续传的依据,必须以MD5为准,不能以文件名+大小组合做判断。MD5不同的两个文件,即便文件名一样,也应该走全新的上传流程。
后端check接口返回的uploadedChunks不能只做"哪些传过"的判断题,还要连同分片总数、文件大小一起返回。前端拿到这些信息后,才能准确算出剩余要传的片数和初始进度位置。如果后端返回的分片索引集合是乱序的,前端展示进度时会计算错乱,所以后端在查出来的时候要做Collections.sort,这个细节我踩过坑。
万一用户更换了浏览器或者把文件移动到了另一台电脑,刷新后本地localStorage没有对应记录,那也无法知道之前传过哪些分片了。这个属于跨终端断点续传的范畴,需要把上传任务信息存储到服务端并和用户账号关联才行。常规业务里"同一台电脑、同一个浏览器刷新或断网"的场景,上面的方案已经完全够用。
9. 实际部署后的性能观察
最后说说这套方案真实跑起来的表现,给你一个直观参考。
我部署的是一台2核4GB的云服务器,带宽大概50Mbps。分片大小设定为10MB,前端并发6。实测上传一个约1.2GB的文件,整体流程(含MD5计算、分片上传、合并校验)耗时大约在3分钟上下。因为带宽限制,实际瓶颈在网络传输,服务端CPU和内存占用都很平稳。分片上传期间,JVM堆内存一直稳定在500MB左右,因为单片10MB传完就释放,完全没有堆积。合并阶段CPU会有一个短时小高峰,但也就持续几秒,整体无感。
同样规格的机器,如果之前用单文件一次性上传,1.2GB文件会在解析阶段直接把JVM堆撑到1.5GB以上,Tomcat工作线程会被阻塞很久,其他接口响应明显变慢。两种方案的差距就是在这种压力场景下拉开的。
测试时我还专门验证了弱网环境:模拟每分钟掉线几次的场景,前端自动重试机制生效,断点续传也能在刷新后继续推进。唯一要提醒的是,如果网络断到连TCP连接都无法建立,那分片重试会一直失败,这时候前端要做熔断,不要无限重试,提示用户网络异常后由人工恢复,重新刷新页面继续。
做完这套功能后,我们的文件服务已经从"勉强能传5MB"进化到"稳定支撑2GB单文件",前后端同学再也没为上传问题找我。值得一提的是,这套分片上传方案不只适用于Spring Boot,其他后端框架也完全可以套用同一套设计思想,核心点都在于:控制单次请求体量、服务端落盘而非入内存、状态持久化支持断点续传、合并用流式通道。记住这四个点,你在任何技术栈里都能做出健壮的大文件上传方案。