SpringMVC大文件上传实战:分片上传、断点续传与秒传方案解析 1. 混乱的项目现场一次大附件上传引发的“血案”大概每个做Java后端的人都会被“文件上传”这件事恶心过几回。小文件还好默认配置一开就能跑但一旦文件体积从几MB跳到几百MB甚至几个GB整个系统的脾气就全变了。我印象特别深的一次是某项目组做内部资料库系统附件动辄三四百MB的CAD图纸和设计文档。上线第一周就炸了运维反馈说服务器频繁内存告警后来直接OOMTomcat进程被系统杀掉。当时负责这块的同事一脸懵他配置的SpringMVC上传逻辑明明“看着没问题”——CommonsMultipartResolver是配了的maxUploadSize也设了200MB前端表单就是普普通通的multipart/form-data点击提交等就完事了。结果呢等来的不是成功提示而是浏览器转圈转到超时紧接着Nginx报413 Request Entity Too Large后端日志里一串IOException: Connection reset by peer。更诡异的是偶尔能传上去几个小文件但大文件每次必挂。这不是个例。SpringMVC大附件上传这件事很多团队是在生产环境被教育过之后才愿意认真思考方案的。今天这篇文章我就把这个问题从头到尾拆开讲为什么传统上传方式在大文件面前全线崩溃、分片上传到底该怎么设计、后端接口怎么落、断点续传和秒传怎么处理以及部署阶段那些容易把人坑到怀疑人生的细节。2. “传统三件套”为什么在大文件场景下全线溃败2.1 CommonsMultipartResolver的“全量加载”逻辑很多老项目用的是SpringMVC经典配置CommonsMultipartResolverMultipartFile 一个RequestParam(file)。这套组合处理5MB以内的文件确实香——但是大文件场景下它有一个致命的默认行为Servlet容器解析multipart请求时会把请求体整个读入内存。具体点说在Spring的配置里有一个隐藏开关bean idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver !-- 上传文件总大小上限 -- property namemaxUploadSize value209715200/ !-- 单个文件大小上限 -- property namemaxUploadSizePerFile value209715200/ !-- 内存阈值默认1KB -- property namemaxInMemorySize value1024/ /beanmaxInMemorySize这个参数是很多人的盲区。它代表的是“文件在内存中缓冲的阈值”默认只有1024字节。也就是说超过1KB的文件会先落盘到临时目录理论上大文件走的是磁盘临时文件路径不至于直接整块塞内存。但问题在于临时文件仍然是一整个文件。SpringMVC的处理流程是先把整个multipart请求体完整解析完把每个文件暂存到FileUpload的临时目录里所有字段也全部解析到位后才会进入你的Controller方法。在这个解析完成之前整个请求的数据都以文件形式躺在磁盘上Tomcat的worker线程则一直阻塞在等待解析上。假设用户上传一个500MB的文件磁盘IO、CPU解析、内存拷贝几乎同时被拉满如果放着不管任何一个环节瓶颈都会导致请求超时或连接被重置。2.2 被忽略的Tomcat与Nginx层限制还有一层问题是请求体大小限制很多人只调了Spring的配置却不知道Tomcat和Nginx还有自己的闸门Tomcat从8.5版本开始maxSwallowSize默认2MB它决定的是连接器在请求体解析出错后“吞掉”剩余数据的最大字节数超出的部分会直接导致连接关闭前端表现为Connection reset by peer。Nginx的client_max_body_size默认是1MB超过就返回413。这几乎是所有人在“为什么我后端调大了还是传不上去”这个问题里第一个遇到的坑。组件默认限制大文件常见异常表现Nginxclient_max_body_size1MB413 Request Entity Too LargeTomcatmaxSwallowSize2MBIOException: Connection reset by peerSpringmaxUploadSize无限制401/500或OOM浏览器超时无统一默认值上传进度长时间不动后超时即便你把所有配置都调大传统方案还有一个不可回避的硬伤没有进度反馈机制。浏览器就是干等用户根本不知道传了一半还是卡了。一旦网络抖动导致中断又要从头再传。几百MB的文件让用户一遍遍重传体验可想而知。2.3 避坑小结传统方案“三宗罪”归纳下来传统方式在大附件场景的失败有三个本质原因整体性依赖——整个文件必须完整传到服务器才进入业务逻辑中间任何环节断了都得重来没有“部分成功”的概念。资源占用峰值高——解析期间内存、磁盘IO、Tomcat线程同时被一个请求长期占用并发一上来直接拖垮节点。链路限制层次多——Nginx、Tomcat、Spring三层配置各自为政一个没调就是一片红排查起来令人发狂。所以方案切换不是“升级配置”的问题而是架构思路的转变把“一次大传输”拆成“多次小传输”把“整体成功”变成“分片可重试”。3. 分片上传的核心设计从文件到分片的映射逻辑3.1 为什么分片是唯一务实的选择市面上的成熟方案无论是某云OSS的断点续传还是各种网盘的上传本质都是分片Multipart Upload。核心思路是前端把大文件按固定大小切成若干分片逐片上传。每一片都是一个独立的HTTP请求失败只重传失败的那一片。所有分片传完后后端按顺序合并成完整文件。这个设计把一个大风险拆成了很多小风险每个小风险都能单独恢复。而且单片体积小单次请求耗时短后端内存占用被压得很低Tomcat线程也不会被长时间占用。3.2 分片大小怎么定一个掰开揉碎的计算分片大小不是随便拍的。我一般建议在5MB到20MB之间具体取决于你的网络带宽和并发策略。给你一个实际算过的例子假设目标文件是1GB分片设为10MB那就是102个分片。如果客户端上行带宽是10Mbps约1.25MB/s实际速率每片理论耗时8秒左右102片串行跑完大约是13.6分钟。如果前端控制并发数为3总耗时能压缩到5分钟左右。但如果分片设成1MB1024个分片会带来两个问题一是HTTP请求数量太多每次请求都有网络RTT和鉴权开销反而拖慢整体速度二是后端合并时打开、关闭文件句柄的次数成倍增加。分片设成50MB甚至100MB也可以但单片传输时间变长断点重试的粒度就变粗了用户体验和容错性都会下降。我个人的经验值是10MB是通用性最好的分片大小。日活不大的内部系统用5MB也稳妥带宽充足且文件普遍超大的场景可以提高到20MB。3.3 前端必须处理的三个细节分片上传虽然是后端主导但前端的准备工作直接决定成败。第一读取文件分片。浏览器端用File.slice()方法即可不需要读取整个文件进内存。有个反直觉的错误做法是用FileReader把整个文件读成base64再切那会直接把浏览器内存撑爆。正确做法是只切分索引const FILE_CHUNK_SIZE 10 * 1024 * 1024; // 10MB function createChunks(file) { const chunks []; let start 0; while (start file.size) { const end Math.min(start FILE_CHUNK_SIZE, file.size); const blob file.slice(start, end); chunks.push({ blob: blob, start: start, end: end, index: Math.floor(start / FILE_CHUNK_SIZE) }); start end; } return chunks; }blob对象在这个阶段只是引用原文件的一部分不会复制数据所以放心用。第二全局唯一标识。给文件生成一个唯一的fileId建议基于内容计算MD5后面详细说也可以在前端生成UUID。这个ID是整个上传流程的“主键”后续的断点续传、合并、进度记录全都要靠它。第三并发控制。多数浏览器对同一域名的并发连接数有限制而且如果一次把几十个分片全发出去后端会同时收到一大堆请求很容易把服务打崩。保险做法是限制并发数在3到5这里给一个简单的并发调度示例async function uploadChunks(chunks, fileId, concurrency 3) { const queue chunks.map(chunk ({ blob: chunk.blob, index: chunk.index, fileId: fileId, fileName: fileNameValue })); const pool []; for (let i 0; i concurrency; i) { const worker (async () { while (queue.length 0) { const item queue.shift(); await uploadOne(item); } })(); pool.push(worker); } await Promise.all(pool); }注意在实际工程里还要加上指数退避重试避免网络抖动时连续失败导致任务整体卡死。4. 后端接口与合并逻辑一份可以直接落地的实现4.1 接口清单与数据模型设计分片上传后端至少要暴露三个接口接口用途关键参数POST /api/file/upload/chunk上传单个分片fileId,chunkIndex,totalChunks,filePOST /api/file/upload/merge合并全部分片fileId,fileName,totalChunksGET /api/file/upload/status查询已上传分片断点续传用fileId设计数据模型时建议用两张表一张记录上传任务一张记录分片完成状态。上传任务表核心字段create table upload_task ( id bigint primary key auto_increment, file_id varchar(64) not null, file_name varchar(255) not null, total_size bigint not null, chunk_size bigint not null, total_chunks int not null, status tinyint default 0, -- 0新建 1上传中 2合并中 3完成 4失败 create_time datetime, update_time datetime, unique key uk_file_id (file_id) );分片状态表核心字段create table upload_chunk ( id bigint primary key auto_increment, file_id varchar(64) not null, chunk_index int not null, chunk_size bigint not null, upload_time datetime, unique key uk_file_chunk (file_id, chunk_index) );这张分片表是断点续传的核心依据每次上传分片时先查它如果该分片已经存在且大小一致就跳过上传从而避免重复传输。4.2 分片上传接口的Controller实现Controller的定义很简单核心逻辑在Service层RestController RequestMapping(/api/file/upload) public class FileUploadController { PostMapping(/chunk) public Result uploadChunk(RequestParam(fileId) String fileId, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(totalChunks) Integer totalChunks, RequestParam(fileName) String fileName, RequestParam(file) MultipartFile file) { uploadService.uploadChunk(fileId, chunkIndex, totalChunks, fileName, file); return Result.success(); } PostMapping(/merge) public Result merge(RequestBody MergeRequest request) { String filePath uploadService.merge(request.getFileId(), request.getFileName(), request.getTotalChunks()); return Result.success(filePath); } }这里要重点说明分片上传接口收到的每一片本质上仍是一个小文件用MultipartFile接收完全没问题。前面全量上传的问题在于“一个请求里塞了整个文件”分片后每个请求最多10MB压力完全可控。4.3 Service层的分片落盘与去重逻辑Service层最关键的是分片落盘逻辑。推荐的做法是每个分片单独存储在独立目录里。目录结构可以是{存储根目录}/{fileId}/{chunkIndex}.part。Service public class UploadService { Autowired private UploadTaskMapper taskMapper; Autowired private UploadChunkMapper chunkMapper; private String rootDir /data/file_storage; public void uploadChunk(String fileId, Integer chunkIndex, Integer totalChunks, String fileName, MultipartFile file) throws IOException { // 1. 首次上传时初始化任务记录 if (chunkIndex 0) { UploadTask task new UploadTask(); task.setFileId(fileId); task.setFileName(fileName); task.setTotalChunks(totalChunks); task.setStatus(1); taskMapper.insert(task); } // 2. 分片去重已存在尺寸一致的分片直接返回 UploadChunk exist chunkMapper.selectByFileIdAndIndex(fileId, chunkIndex); if (exist ! null exist.getChunkSize().equals(file.getSize())) { return; } // 3. 写分片到磁盘 Path chunkPath Paths.get(rootDir, fileId, chunkIndex .part); Files.createDirectories(chunkPath.getParent()); try (InputStream in file.getInputStream()) { Files.copy(in, chunkPath, StandardCopyOption.REPLACE_EXISTING); } // 4. 记录分片元数据 UploadChunk chunk new UploadChunk(); chunk.setFileId(fileId); chunk.setChunkIndex(chunkIndex); chunk.setChunkSize(file.getSize()); chunkMapper.insertOrUpdate(chunk); } }注意上面几个容易被忽略的细节目录隔离每个fileId一个目录避免多个文件的分片混在一起。分片去重这个判断非常关键。如果前端因重试导致同一分片重复提交后端不用重复写磁盘直接忽略。空文件边界最后一个分片可能小于固定分片大小所以不能默认假设每片都是10MB。我在代码里用file.getSize()记录实际大小而不是用固定值就是为了兼容末片。4.4 合并的实现与边界处理合并是整个流程的最后一个关键动作。逻辑上不复杂按chunkIndex从小到大把所有分片按顺序写入一个目标文件。public String merge(String fileId, String fileName, Integer totalChunks) throws IOException { Path dir Paths.get(rootDir, fileId); Path target Paths.get(rootDir, fileName System.currentTimeMillis() .dat); // 1. 校验分片完整性有多少分片已经上传 ListUploadChunk uploadedChunks chunkMapper.selectByFileId(fileId); if (uploadedChunks.size() ! totalChunks) { throw new IllegalStateException(分片不完整已上传 uploadedChunks.size() / totalChunks); } // 2. 按索引排序后逐个写入 SetInteger existsIndex uploadedChunks.stream() .map(UploadChunk::getChunkIndex).collect(Collectors.toSet()); try (FileChannel out FileChannel.open(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i 0; i totalChunks; i) { if (!existsIndex.contains(i)) { throw new IllegalStateException(缺失分片: i); } Path chunkPath dir.resolve(i .part); try (FileChannel in FileChannel.open(chunkPath, StandardOpenOption.READ)) { long size in.size(); long transferred 0; while (transferred size) { transferred in.transferTo(transferred, size - transferred, out); } } } } // 3. 清理临时分片目录 deleteDirectory(dir.toFile()); return target.toString(); }合并逻辑里有一个值得展开的点不要用普通的InputStream读取再写入更不要用Files.readAllBytes()。大文件场景下这种搞法会大量占用内存。正确姿势是用FileChannel.transferTo()它底层走的是零拷贝路径数据不经过用户态缓冲合并1GB文件的内存占用非常稳定。另外校验逻辑里“缺失分片直接抛异常”不是最优解。更友好的做法是返回缺失分片的索引列表让前端知道还差哪几片、继续补传。这里为了简洁用了异常在真实项目中我建议改成返回状态码缺失列表。4.5 一个隐蔽坑同名文件冲突与秒传顺带解决合并目标文件名如果直接用fileName会引发两个后果一是不同用户上传同名文件时互相覆盖二是重复上传同一个文件会白白占用磁盘。正确的设计思路是存储层文件名与业务文件名分离——存储层用fileId或UUID命名业务层在数据库里记录原始文件名和存储路径的映射关系。而且这个设计天然支持“秒传”功能如果前端在上传前先计算出文件MD5后端在数据库里按MD5查一下发现已经存在相同内容的文件直接返回“已有文件地址”前端连分片都不用传了。我后面单独讲这块。5. 断点续传、秒传与并发合并时的状态一致性5.1 断点续传不仅仅是个“前端功能”断点续传经常被误解成“前端重新传一遍服务器跳过已传分片”。但其实后端必须提供两个能力查询已上传分片状态——前端恢复上传时需要知道哪几片不用重传。对重复分片的幂等处理——即使前端判断失误重传了某个分片后端也不能报错。实现幂等的关键就是我上面写的“分片去重”逻辑先查upload_chunk表如果分片记录存在且大小一致直接返回成功。这个设计能让前端无脑重试大大简化了断点续传的复杂度。5.2 秒传的MD5方案先查后传秒传的实现思路很简单在正式上传前前端先计算整个文件的MD5然后调用一个检查接口后端在文件记录表里按MD5查找。GetMapping(/check) public Result checkFile(RequestParam(md5) String md5, RequestParam(fileName) String fileName) { FileRecord record fileRecordMapper.selectByMd5(md5); if (record ! null) { return Result.success(record.getStorePath()); } return Result.fail(文件不存在); }如果返回了存储路径前端直接跳过上传用户体验就像“秒传”。这里有两个实际工程里要特别留意的问题前端MD5计算性能。一个2GB的文件在浏览器里用SparkMD5全量计算可能需要好几十秒甚至几分钟。优化思路有两个一是用File.slice分段增量计算避免一次性读取二是任务开始时就立即在后台线程算MD5同时先把首个分片上传了两者并行。不要为了算MD5把首次上传卡在原地。后端MD5查重可靠性。MD5本身有极小概率碰撞对安全性要求极高的系统比如涉密件、合同扫描件建议再加一层“文件大小MD5”双重校验甚至用SHA-256。正常业务场景MD5已经够用。5.3 合并时的并发问题同一个fileId的并发合并请求合并接口有一个隐蔽的并发隐患如果前端网络抖动导致合并请求被重复提交后端会同时执行两份合并逻辑。两份逻辑都读到了“分片完整”同时写入目标文件会导致目标文件损坏或写入异常。解决方案是在合并接口上加锁。用分布式系统常用的基于Redis的分布式锁或者简化版用文件系统的原子操作。单体应用用synchronized(fileId.intern())也能顶一阵private final MapString, Object lockMap new ConcurrentHashMap(); public String merge(String fileId, String fileName, Integer totalChunks) throws IOException { Object lock lockMap.computeIfAbsent(fileId, k - new Object()); synchronized (lock) { // 合并逻辑 } lockMap.remove(fileId); }注意lockMap要清理否则文件一多内存就会泄漏。更稳妥的做法是设置过期时间或用Caffeine缓存管理。5.4 合并中偶发的“分片不见了”排查我在实际项目里遇到过一种诡异情况合并时校验分片完整但真正读文件的时候某个分片却找不到。定位到最后是分片清理线程和合并线程的竞争。如果后端设计了“定时清理未完成任务”的Job这个Job可能会把尚在传输中但数据库状态还没更新的分片文件删掉。解决方案是在清理策略里留一个“最后活跃时间”的宽限期比如超过2小时没有任何分片上传动作的任务才允许清理并且清理前要锁定任务状态。6. 部署层面的隐藏陷阱上传目录、磁盘、JVM与监控6.1 临时目录别放在/tmp很多SpringMVC项目默认把上传临时文件放在/tmp下Servlet容器的javax.servlet.context.tempdir。Linux的/tmp目录有三个隐患系统重启会清空。如果系统恰好在你上传到一半时重启所有已传分片直接蒸发。分区空间有限。/tmp通常是根分区的一部分跟操作系统共用空间。几百MB的分片文件堆起来很容易把磁盘写满导致服务器崩溃。权限混乱。多用户服务器上如果权限设置不严可能存在其他进程读写风险。建议统一在系统规划里单独挂一个数据盘比如/data/file_storage并且权限收紧到只有应用账号可读写。另外整个分片目录最好是“纯本地磁盘”不要用NFS网络盘分片频繁读写时网络盘IO延迟高、稳定性差一直在生产环境给我惹麻烦。6.2 磁盘空间与文件清理策略大文件上传服务器的磁盘管理是一门必修课。我见过太多服务器因为忘记清理临时文件而被塞爆的案例。设计清理机制有三个原则上传过程中不清理活动文件。通过记录每个任务的最新活跃时间判断任务是否在推进。合并完成后立即清理分片目录。不要等到“定时Job”来收尾合并成功的路径里直接同步删除最快。定期任务兜底清理僵尸任务。比如每小时扫描一次找出超过30分钟没有任何分片更新的任务删除分片目录并把任务状态标记为失败。这里再分享一个磁盘压力分摊的经验上传高峰期多分片同时写盘会让单块磁盘的IO队列非常深。如果系统规模稍大建议把分片存储按fileId哈希分散到多个磁盘目录比如/data/upload_shard_01、/data/upload_shard_02用哈希值取模决定落在哪个盘。加个三行代码就能显著降低单盘压力。6.3 JVM与并发参数的配套调整分片上传看似解决了“大请求一次性加载”的问题但并发数上来了之后JVM和线程池依然需要配套优化Tomcat最大工作线程数。默认maxThreads是200假如每请求平均耗时1秒200并发已经是不少了。但如果单片只有几MB一个请求的CPU和IO开销都极低一般不需要刻意调大。倒是进程句柄上限ulimit -n很重要默认1024在多分片并发时很容易被耗尽。文件句柄泄漏。代码里如果每一个分片都打开InputStream而不关闭一段时间后就会看到Too many open files。建议所有IO都使用try-with-resources。上传接口的限流。为了不让用户疯狂点重传把服务压垮可以用令牌桶或简单的并发信号量限制全局并发上传数比如Semaphore控制在50。6.4 日志与监控出问题时如何快速定位整理分片上传的监控时我最看重的四个指标指标作用分片上传TPS判断当前上传压力分片平均耗时判断网络和磁盘是否健康失败分片数判断链路稳定性磁盘剩余空间防止磁盘写满日志方面强烈建议在分片上传接口里记录fileId、chunkIndex、chunkSize、耗时四个字段。这样问题排查时可以精确到“某一个分片为什么慢”。我在实践中遇到过明明是同一个文件前50片都很快第51片突然耗时飙升的情况排查后是磁盘因为其他业务写入导致瞬时IO拥塞。没有分片级日志这种问题压根查不出来。7. 一次完整排障实录分片上传偶尔失败的那几片最后分享一个真实的排障过程过程比结论有参考价值。某次上线后有用户反馈“大文件上传有一定概率失败报错说某一片上传超时”。同事一开始怀疑是网络问题但重试后还是偶发失败而且失败的分片索引随机。后来查后端日志发现失败的请求全部卡在一个现象上Request timed out但看网络流量并没有异常。继续往下挖发现这批用户走的链路经过了Nginx和一层网关网关有一个proxy_read_timeout 60s的配置。正常情况下10MB分片上传几秒钟就完成了但有个别分片因为客户端所在网络比较差TCP窗口协商加上TLS握手耗时接近60秒于是被网关掐断。解决办法有两个方向一是把网关和Nginx的proxy_read_timeout调大比如120秒二是把分片再切细一点从10MB降到5MB减少单片网络耗时。因为产品端用户网络环境确实较差最终两个方案都做了问题彻底消失。这个案例说明一个容易被忽略的问题分片上传方案不仅要考虑后端处理和合并逻辑前端到后端之间的每一层代理的超时时间、请求头大小限制都要纳入设计参数。大附件上传是个全链路问题不是单纯的后端接口问题。还有一个类似的坑有的负载均衡器对multipart/form-data请求会在转发时解析请求体导致后端收到的分片变成内存对象但没有文件头信息。一旦遇到“配置都对、上传就失败”的情况优先检查链路中是否有额外组件做了请求体解析。8. 回到项目视角方案选型与落地顺序建议聊了这么多细节回到最早的“SpringMVC大附件上传有什么方案”这个问题上。我个人的建议是10MB分片 后端分片目录存储 数据库状态跟踪 合并接口 前端并发控制这套组合是通用性最强、实现成本可控、可维护性也好的方案。如果只想用现成框架可以参考淘淘等开源项目但理解原理依然重要否则出了问题根本无从下手。落地时建议按以下顺序推进先把基础分片上传跑通前后端能分片传输、合并成功。再加断点续传后端提供分片查询接口前端本地记录已传分片。再加秒传前端计算文件MD5后端查重。最后做运维加固监控指标、清理任务、高可用配置。每一步都不大但叠加起来就是一个相当完整的企业级大文件上传服务。关于分片大小、并发数、存储路径规划这些参数不需要一开始就追求完美上线前做一轮真实带宽、真实文件大小的压测数据会告诉你该调哪。内部系统20并发、100MB文件起步压测基本就能发现大部分隐藏问题。我做了这么多年上传功能最大的心得是文件上传不是写完接口就结束的功能它是一个涉及前端、后端、存储、网络、运维的完整闭环。先把原理吃透再动手写代码后面运维的日子会好过很多。