SpringBoot大文件上传页面卡死这个问题我在生产环境里真实遇到过。一个2GB的压缩包浏览器转圈转到无响应后端内存直接飙到危险线最后连接被网关掐断用户对着屏幕干瞪眼。后来把上传逻辑改成前端切片、后端接收分块、最后合并的方案GB级文件从“不能传”变成“稳稳传完”上传耗时缩短了一半以上。这篇文章就把这套分块上传方案的完整思路、核心代码、参数选择和踩坑记录原原本本讲清楚适合正在被大文件上传折腾的朋友直接参考。1. 大文件上传卡死的根源在哪不把病因查清楚后面做分块都是白费功夫。我之前接手过一个内部系统文件上传模块还是最原始的MultipartFile一把梭用户一传大文件就出问题。盘点下来卡死基本逃不出下面三个原因。1.1 传统单请求上传的三个瓶颈第一个瓶颈是内存消耗。SpringBoot默认把上传的文件先写到内存里文件稍微大一点内存直接翻车。上传500MB以上的文件时我亲眼见过一个上传请求把堆内存吃掉几百MB服务实例还开着好几个系统不卡才怪。第二个瓶颈是网络连接时长。单个大文件要在一个HTTP连接里完整传完一旦网络抖动、用户切换Wi-Fi、代理超时连接一断整个文件就得重来。我遇到过同事在会议室传1.8GB的录屏文件断断续续传了三次都失败最后只能压缩成几个小包手动拷。第三个瓶颈是网关和容器限制。Nginx默认的client_max_body_size是1MBTomcat对POST请求大小也有上限实际项目里为了大文件把配置调大又带来了新的问题一个上传请求长期占着连接后端的Servlet工作线程被拖死其他用户的小请求也排不上队了。用生活里的例子理解单请求上传就像用一根很细的吸管吸一整桶水吸管还被系统限过速。分块上传则是把整桶水分成一杯一杯地倒每一杯都能独立验证、独立传输倒洒了一杯也不会影响已经倒过去的那些。1.2 分块上传为什么能破解僵局分块上传的做法是前端用File.slice()把文件切成固定大小的分块比如每块5MB然后逐个分块上传到后端后端把收到的每个分块存成临时文件等所有分块都到齐了再按顺序合并成完整文件。这个方案直接绕开了前面说的三个瓶颈。内存方面每次请求只处理一个5MB的分块再大的文件后端也只扛一个分块的内存开销。连接方面单个分块体积小、上传时间短网络波动导致失败的概率大幅降低而且失败后只需要重传这个分块不用整个文件从头再来。并发方面多个分块可以由多个请求并行上传充分利用带宽整体速度自然快。还有一个容易被忽略的好处分块上传天然支持上传进度展示。前端统计已成功上传的分块数就能算出真实的上传百分比。而单请求上传模式下“进度条卡在99%”这种体验有多糟心做过文件系统的朋友都懂。2. 方案整体设计前后端如何配合分块上传不是简单地把文件切碎就完事真正复杂的是全流程的状态协调。我在设计时把整个链路拆成了五个环节切片、上传、记录、合并、校验。下面把这五个环节逐个说清楚。2.1 五个核心环节的职责划分切片环节在前端完成。JavaScript读取用户选择的文件按固定大小切割成多个分块每个分块带上文件唯一标识identifier、分块序号chunkIndex和总分块数totalChunks。上传环节由前端发起HTTP请求把单个分块的二进制数据发给后端。这里值得注意的是上传接口必须支持跨域配置否则浏览器会直接拦截我最初就被这里坑过一次。记录环节在后端完成。每次收到分块后端先落盘保存再把分块的元数据写进数据库或缓存标记“第几块已经上传成功”。有了这个记录断点续传才有依据。合并环节在所有分块上传完成后触发。后端按分块序号把临时文件依次拼接生成目标文件。校验环节是最后一道保险。合并完成后计算整个文件的MD5和前端上传前计算的MD5对比一致才能标记上传完成。这个环节能堵住分块缺失、乱序、文件损坏等问题。2.2 关键设计决策分块大小与目录结构分块大小的选择直接决定上传体验。我用不同分块大小做了对比测试结论如下分块大小1GB文件分块数内存占用失败重传代价适用场景1MB1024很低很小网络差的移动端5MB205低小大部分Web场景10MB103中等中内网或稳定宽带50MB21较高大不推荐我最终选了5MB。理由很简单5MB在弱网环境下重传成本可以接受在正常宽带下又不会因为分块太多导致请求数爆炸。205个请求对一个网关来说毫无压力。目录结构建议按文件标识隔离。我在后端为每个上传任务单独建一个临时目录路径规则是/data/upload-tmp/{identifier}/这个目录下存放该文件的所有分块。临时目录和正式存储目录分开方便后面对临时文件做统一清理。数据库表结构方面我用两张表管理上传状态。第一张是上传任务表记录文件唯一标识、文件名、总大小、总分块数、已上传分块数、状态上传中/合并中/完成/失败、MD5值。第二张是分块明细表记录每个分块的序号、存储路径、上传时间。有这两张表断点续传和进度查询都能直接走索引查出来。提示上传任务的创建放在前端正式上传第一个分块之前先调一个初始化接口让后端分配一个唯一标识并建好临时目录。不要用前端传来的随机字符串当标识那样无法防止冲突。3. 核心实现细节与代码拆解方案说清楚了代码才是硬货。这里给出我实际使用的后端接口设计、合并逻辑和前端切片逻辑每一步我都会解释为什么这么写。3.1 后端接收分块接口实现接收分块是整套方案里最核心的接口。我直接贴出关键代码RestController RequestMapping(/api/upload) public class ChunkUploadController { private static final int CHUNK_SIZE 5 * 1024 * 1024; private final String tmpRoot /data/upload-tmp/; Autowired private UploadTaskService uploadTaskService; PostMapping(/chunk) public Result uploadChunk(RequestParam(file) MultipartFile chunk, RequestParam(identifier) String identifier, RequestParam(chunkIndex) int chunkIndex, RequestParam(totalChunks) int totalChunks) throws IOException { // 1. 校验分块是否存在 if (chunk.isEmpty()) { return Result.error(分块内容为空); } // 2. 创建该上传任务的临时目录 Path taskDir Paths.get(tmpRoot, identifier); Files.createDirectories(taskDir); // 3. 按分块序号落盘文件名用序号并补齐位数方便后续排序 String chunkFileName String.format(%05d, chunkIndex); Path chunkPath taskDir.resolve(chunkFileName); chunk.transferTo(chunkPath.toFile()); // 4. 记录分块元数据 uploadTaskService.recordChunk(identifier, chunkIndex, chunkPath.toString(), totalChunks); return Result.success(分块上传成功); } }落盘这一步我踩过坑MultipartFile.transferTo()在文件已经存在时会抛异常所以重传同一个分块时要先删掉旧文件或者直接覆盖。更好的做法是先判断文件是否存在存在就直接返回成功这样断点续传时跳过已传分块会非常轻量。校验逻辑上如果分块的顺序错乱比如先收到第5块再收到第2块这个问题在合并时处理而不是在接收阶段强制排序。因为并发上传场景下分块到达顺序就是乱的你没法控制先到谁。3.2 合并文件的边界处理与性能优化所有分块到齐后后端触发合并。合并时最大的坑是不能简单地把分块文件按名字排序后一个字节一个字节复制那样太慢。我用FileChannel.transferTo()做零拷贝传输实测1GB文件的合并耗时从十几秒降到了3秒左右。public void mergeFile(String identifier, int totalChunks, String targetPath) throws IOException { Path taskDir Paths.get(tmpRoot, identifier); Path targetFile Paths.get(targetPath); try (FileChannel out FileChannel.open(targetFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { for (int i 0; i totalChunks; i) { String chunkFileName String.format(%05d, i); Path chunkPath taskDir.resolve(chunkFileName); // 关键校验任何一个分块缺失都不能合并 if (!Files.exists(chunkPath)) { throw new ChunkMissingException(缺失分块: i); } try (FileChannel in FileChannel.open(chunkPath, StandardOpenOption.READ)) { in.transferTo(0, in.size(), out); } } } // 合并完成后再删除临时分块目录 deleteDirectory(taskDir); }分块文件名用%05d格式化是个细节但非常重要。文件系统按名称排序时2会排到10后面导致合并顺序错乱。补齐到5位后00002必然排在00010前面顺序就对了。合并操作有个并发隐患如果用户重复点击上传或者前端重试逻辑写得不严谨同一个文件标识可能同时触发两次合并导致文件内容错乱。解决办法是加一个分布式锁或者数据库状态机。我在任务表里加了状态字段合并前先执行UPDATE upload_task SET statusMERGING WHERE identifier? AND statusUPLOADING影响行数为0说明已经有合并任务在进行直接丢弃当前请求。这个方案简单可靠不需要引入额外的锁中间件。3.3 前端切片逻辑与进度反馈前端用原生JavaScript的FileAPI就能完成切片不需要引入任何第三方依赖。关键代码如下const CHUNK_SIZE 5 * 1024 * 1024; // 5MB const fileInput document.getElementById(fileInput); const file fileInput.files[0]; // 计算文件MD5用于去重秒传 const fileMd5 await calculateFileMd5(file); // 初始化上传任务后端返回唯一标识 const initRes await fetch(/api/upload/init, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, totalChunks: Math.ceil(file.size / CHUNK_SIZE), fileMd5: fileMd5 }) }); const { identifier } await initRes.json(); // 查询已上传的分块 const checkRes await fetch(/api/upload/check?identifier${identifier}); const uploadedChunks new Set((await checkRes.json()).uploadedChunks); let uploadedCount uploadedChunks.size; for (let chunkIndex 0; chunkIndex totalChunks; chunkIndex) { if (uploadedChunks.has(chunkIndex)) { continue; // 已经传过直接跳过 } const start chunkIndex * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); const chunkBlob file.slice(start, end); const formData new FormData(); formData.append(file, chunkBlob); formData.append(identifier, identifier); formData.append(chunkIndex, chunkIndex); formData.append(totalChunks, totalChunks); const res await fetch(/api/upload/chunk, { method: POST, body: formData }); if (res.ok) { uploadedCount; updateProgress((uploadedCount / totalChunks) * 100); } else { // 失败重试最多重试3次 // 重试逻辑这里省略但一定要做 } } // 所有分块上传完成后调用合并接口 await fetch(/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ identifier: identifier }) });前端这里要注意的是不能把所有分块一窝蜂全部同时发出去。我最初的实现是并发10个分块一起上传结果在弱网环境里经常出现某几个分块超时后续重试逻辑又写得毛躁导致任务卡死。后来改成并发队列的方式使用一个pLimit库或者手动控制同时最多并发3个分块稳定性和速度都好了很多。进度反馈是分块上传的天然优势。单请求上传没法知道真实进度分块上传按已成功分块数就能精确算出百分比。我做了一个简单的展示进度条显示百分比旁边标注“已上传37/205块”用户看到块数增长才有安全感。3.4 断点续传与秒传的完整实现路径断点续传的实现其实就是依赖前面提到的check接口。前端在开始上传前先询问后端“这个文件标识已经有哪些分块了”后端查询数据库返回已上传分块的序号集合。前端拿到这个集合后对这些分块直接跳过只上传缺失的部分。秒传功能是在初始化接口里做的。前端计算完文件的MD5后传给后端后端在正式存储目录里查一下有没有相同MD5的文件。如果存在直接返回“秒传成功”前端连分块都不用传了。这个功能对重复上传场景非常友好比如测试团队反复上传同一个安装包秒传能把几分钟的等待直接变成眨眼功夫。注意MD5计算在前端进行1GB的文件需要几秒钟。计算过程中页面不能卡死需要把计算逻辑放到Web Worker里异步执行。虽然MD5对超大文件来说并非绝对安全但在日常业务场景中作为去重依据已经足够可靠。4. 性能对比与参数调优实战方案上线前我做了一轮真实的数据对比这里把关键数据整理出来。同样的1.2GB文件、同一台测试服务器、同样的上下行带宽单请求上传和分块上传的结果差异非常明显。4.1 GB级文件实测数据对比指标单请求上传分块上传5MB块并发3上传耗时失败连接中断86秒后端峰值内存850MB45MB失败后重传范围整个文件仅失败分块进度展示基本不准确精确到块网络波动容忍度极差高从上表能看到分块上传最核心的收益其实不是“快”而是“稳”。速度提升主要来自并发分块上传对带宽的充分利用以及避免了整文件重传的浪费。4.2 并发参数与后端线程池调优我把并发数从1调到5分别测了上传1.2GB文件的总耗时并发1约130秒并发2约105秒并发3约86秒并发5约83秒结论一目了然并发3到并发5之间提升已经非常小但并发数越高后端临时文件和数据库请求的处理压力就越大。如果你是单机部署建议把并发压到3。如果是集群部署可以让不同的服务器处理不同分块的请求并发可以放宽到5甚至8。后端接收分块的接口是IO密集型的不涉及复杂计算。我单独配置了一个上传专用的线程池核心线程数设为CPU核数的2倍队列容量设为一个合理的值避免上传任务把业务线程池挤爆。同时一定要给线程池设置拒绝策略当任务堆积超过阈值时直接返回“服务繁忙请稍后重试”比让请求排队等到超时体验更好。这里要提醒一个很多人忽略的问题接收分块接口的请求体大小必须设置得比单分块略大留出一些余量给表单字段和边界数据。我在SpringBoot配置文件里把spring.servlet.multipart.max-file-size设为6MB、max-request-size设为7MB和5MB分块正好匹配。如果你把分块切成10MB那这两个参数也要相应调大边界留20%左右余量。对接Nginx时client_max_body_size也要同步调整。4.3 合并操作的资源占用与速度优化合并1GB文件时磁盘IO会成为新瓶颈。如果服务器用的是普通机械硬盘合并时间会明显变长。我用固态硬盘测试时FileChannel.transferTo()合并1GB文件只要3秒多但机械硬盘可能要10秒以上。合并操作最好做成异步任务。前端调用合并接口后后端直接把任务丢进一个专门的重活线程池接口立即返回“合并中”。前端通过轮询查询合并状态合并完成后更新前端UI。我最初在同步接口里做合并导致用户点击“完成上传”按钮后页面等待好几秒体验很糟糕。磁盘空间检查也要做。合并开始前检查临时目录和正式目录所在磁盘的剩余空间如果剩余空间小于文件总大小的1.5倍直接拒绝合并并提示用户清理磁盘。否则合并到一半磁盘满了临时分块又被清理那才是真正的灾难现场。5. 常见问题与排查方案实录最后这部分是实战排雷。下面列出的问题都是我在开发和上线过程中真实撞上的每个都对应明确的解决方案。5.1 上传中断导致临时文件堆积现象上传任务没有完成但临时目录里的分块文件永远留在磁盘上。时间长了磁盘被上传残留撑爆。排查先看一下临时目录的大小和文件数量几千个残留文件堆积是常态。解决方案是定时清理。我用Spring的Scheduled注解写了一个清理任务每10分钟扫描一次临时根目录找出所有创建时间超过2小时的分块目录直接整目录删除。这里有个细节正在上传的任务不能误删所以判定条件不能只看目录创建时间还要比对任务表里的最后上传分块时间。如果任务表里该标识的最近分块上传时间距当前时间超过2小时说明上传已经中断或放弃这个目录就是可回收的。Scheduled(fixedDelay 600000) public void cleanExpiredChunks() { // 1. 扫描临时目录下所有子目录 // 2. 对每个子目录查询任务表最近分块上传时间 // 3. 最近分块时间超过2小时的删除整个目录 }5.2 分块上传成功了但合并失败现象205个分块全部显示上传成功但调用合并接口时提示“缺失分块”或者合并出来的文件打不开。排查思路先看是不是分块写入时的文件名对不上。前面提到用%05d格式化文件名如果你写成i 那么分块10和分块2在排序后会出现错位合并出的文件就是坏的。第二个常见原因是分块上传成功后的记录写失败了也就是磁盘上有文件但数据库没记录。为了排查方便我在分块落盘和记录写库之间加了日志落盘成功但记录失败时能直观看到数据不一致。解决方案是写一个健康检查接口合并前把所有分块的物理文件和数据库记录做一次交叉比对。不一致的情况自动重传或补记录。我甚至在分布式部署的场景里遇到过网络抖动导致某个分块落到别的节点上文件通过共享存储还能找到但数据库记录查不到这种交叉校验机制能自动修复。5.3 合并出来的文件MD5对不上现象合并完成文件也能正常打开但MD5校验失败说明中间有分块损坏。排查我遇到过一种特殊场景——某个分块文件传了两次第二次传的分块因为网络问题数据不完整但后端的落盘逻辑没有对这个分块做大小校验直接把半个分块当成完整分块保存了。解决方法是前端在发送分块时同步传递该分块的MD5值后端收到分块后校验MD5不一致就丢弃并返回错误。这让复杂度增加了一些但换来的是一道重要的保护。对大文件传输场景来说这个校验值得因为重传一个5MB分块的成本很低但合并一个损坏文件再重新排查的成本高得多。5.4 并发上传时文件内容错乱现象多个用户同时上传不同的大文件合并之后发现部分文件内容出现其他文件的数据。排查这个问题坑了我一天。最后发现是临时文件名冲突导致的——两个用户用了同一个文件标识identifier。原本应该由后端初始化接口生成的唯一标识我偷懒直接用了前端传来的字符串前端某次改造后这个字符串变得不唯一分块文件就互相覆盖了。解决方案很简单identifier必须由后端生成建议用UUID.randomUUID()去除横杠后的字符串或者“用户ID时间戳随机数”的组合。临时目录用这个唯一标识隔离后分块覆盖的问题从根本上消失。5.5 上传带宽占用导致其他业务受影响现象大文件上传期间用户反映页面里的图片加载变得很慢。排查这不是Bug而是带宽被上传任务占满了。解决方案是限流。我在网关层对上传接口做了带宽限制按用户维度限速单个用户上传带宽不超过5MB/s。对多数业务系统来说限制单用户上传带宽是合理的单个文件传快点还是慢点对用户影响不大但保障其他业务顺畅稳定对整体体验影响更大。如果你有CDN或者对象存储可以把分块直接传到OSS或者S3不经过应用服务器这才是大文件上传最理想的架构后端只负责记录分块状态和触发合并。写在最后这套分块上传方案我已经在不同项目里用了两轮从最初的反复踩坑到现在基本稳定最大的体会是大文件上传的核心不是“快”而是“稳”和“可恢复”。分块上传之所以有效不是因为某个环节多厉害而是把大问题拆成了小问题每个小问题都能单独解决、单独重试这比在任何单点配置上想办法都管用。最后再分享一个小技巧如果业务允许优先考虑把文件直接上传到对象存储应用服务器只管理上传任务的状态流转。这样带宽和磁盘压力都交给了专业的存储服务自己的服务再怎么被并发打也不会拖垮业务接口。回到这篇文章的主题即使不做对象存储掌握分块上传的这套思路和代码以后再遇到GB级文件传输心里就完全不慌了。