SpringBoot分片上传实战:解决大文件上传难题
做过后端的人基本都遇到过这种场景用户反馈视频传了大半天还在转圈或者文件传到百分之九十九突然断网全部作废。文件一旦上了几百MB甚至几个GB普通的上传接口就会频繁踩坑——请求超时、网关限制、传输中断每一关都让体验变得很差。分片上传就是用来解决这件事的把大文件切成小块分别上传哪块失败就重传哪块服务端最终再按顺序拼回完整文件。这篇文章我会用SpringBoot从零实现一套分片上传方案从思路、参数设计、后端代码到实测数据一次性说清楚适合刚接手文件上传功能的开发者也适合想优化大文件上传体验的后端同学。1. 上传一个1GB文件有多痛——分片上传前先看清问题在哪1.1 单次上传三个绕不过去的坎普通的文件上传接口本质上是一次HTTP请求把整个文件塞进请求体。文件体积一上来三个问题就会依次冒出来。第一个是网关和中间件的请求体大小限制。很多代理层、网关层对单个请求体默认限制在1MB到10MB之间超过直接给你断开连接。哪怕你在SpringBoot里把max-post-size调大了前置网关没调上去请求照样被拦截。第二个是网络抖动和响应超时。上传一个几百MB的文件耗时很长TCP连接在中间任何一秒断开整个传输直接失败后端拿到的是半截文件还得额外清理垃圾数据。第三个是服务端内存和IO压力。如果不做流式处理直接把整个MultipartFile读进内存文件越大越容易拖垮应用GC频繁触发接口响应越来越慢。这三个瓶颈叠加起来用户看到的表象就是上传慢传不上去但根因往往不是带宽不够而是传输链路里任何一个环节出问题都会让整个文件作废。1.2 分片上传到底改了什么分片上传的原理并不神秘核心动作就三个切、传、合。客户端把文件按固定大小切成N片每一片携带编号传给后端后端把分片落到临时目录等所有分片全部到位再按编号顺序拼成完整文件。它改变的不是文件本身而是问题的粒度。原来上传一个大文件是一个完整事务中间任何一次失败都要从头再来分片之后每个分片的上传是独立请求只负责自己那几MB数据失败了大不了重传这一片。同时分片可以并行发送在带宽允许的情况下整体吞吐量明显高于单线程串行上传。举个例子一片5MB的分片上传用了3秒100个分片并行发出去理想状态下总耗时可能只有单片的几十倍而不是串行的几百倍。更重要的是服务端可以从已收到的分片精确判断上传进度这为断点续传、失败续传提供了数据基础。用户这次传了一半退出下次打开页面还能接着从缺失分片继续不需要再传一遍。1.3 分片上传、断点续传、秒传的关系很多人分不清这三者的关系我这里按工程依赖顺序捋一遍。分片上传是基础能力解决的是大文件传得动的问题。断点续传是分片状态管理带来的附加能力后端记录了哪些分片已收到前端发起上传前先查一遍把缺失的分片补传即可。秒传则是建立在文件指纹之上的去重能力前端先算出整个文件的MD5发给后端后端发现这个文件已经保存过直接返回上传成功前端根本不用真的传数据。分片模型只要设计对了断点续传和秒传都是顺带能长出来的能力不构成额外的大工程。反之如果一上来只写了个接收单文件的Controller以后想补这些能力改造量大得多。1.4 什么情况下不推荐分片上传分片上传虽好但不是银弹。小于10MB的小文件完全没有分片的必要单片请求、合并、校验的额外开销反而会比普通上传更慢。另外如果服务端并发能力本身很弱还得先评估分片并发上传会不会把应用压垮。我见过一个场景是后端只部署在单台低配机器上前端一次性并发20个分片直接把IO打满普通上传反而没那么糟。分片适合的场景是文件体积大、网络不稳定、用户体验要求高的上传任务比如视频素材、安装包、备份文件。2. 动笔之前先定四件事——分片方案设计的核心取舍2.1 分片大小用你最慢的网络反推分片大小是最容易拍脑袋、也最影响整体体验的参数。我的建议是不要凭空想默认用1MB而是结合目标网络带宽和可接受的重试代价反推。假设用户大概率走移动网络上行带宽在1MB/s到5MB/s之间。单片大小如果设成20MB弱网下传一片可能要二十秒中途断一下就得整片重来失败成本太高。如果设成256KB单片确实传得快但一个4GB文件会被切成16000多片HTTP握手、请求头、服务端逐片落盘的开销会吃掉大量性能。我在项目里按2GB文件粗算过每片5MB大约切410片串行上传加上连接复用请求开销占比很低每片落盘也都是顺序写不会出现大量随机IO。但如果单片改成512KB请求次数变成4000多次合并前的分片校验、多次磁盘写带来的时间损耗反而更突出。综合来看1MB到5MB是大多数场景比较稳的区间。内网带宽高可以放大到10MB外网弱环境建议往1MB靠。这个值应该做成可配置项不要写死在代码里方便不同业务场景单独调。2.2 文件标识与分片索引没有编号就没有顺序分片上传能不能正确合并依赖一套清晰的标识体系。我这里建议约定四个字段uploadId、fileMd5、chunkIndex、totalChunks。uploadId是后端生成的全局唯一上传批次标识用来隔离不同上传任务避免两个用户传同名文件时互相覆盖。fileMd5是整个文件的MD5摘要前端在分片前算好后端一方面用它判断是否已存在相同文件实现秒传另一方面在合并完成后做完整性校验。chunkIndex是分片序号从0开始表示这块数据在原始文件中的位置。totalChunks是总分片数合并阶段判断分片是否收齐要用到。这里提醒一件事在大文件上算MD5会消耗不少CPU浏览器端如果用同步方式算页面会卡顿明显。我一般用增量哈希库做流式计算或者放到Web Worker里跑避免阻塞UI线程。2.3 临时目录与已传分片状态合并前的中间态不能裸奔服务端收到分片后文件不能直接落在最终目录里因为这一刻数据还不完整。我会为每个uploadId建一个独立的临时目录结构类似下面这样upload_tmp/{uploadId}/ ├── chunk_0.part ├── chunk_1.part └── chunk_2.part已传分片的状态管理我会根据部署形态决定。单机、量不大的场景直接用内存里的MapuploadId,SetchunkIndex就能顶住简单高效。但如果部署了多个实例内存状态就没法共享了A实例接收了分片B实例合并时却查不到状态任务直接失败。这时候需要把状态挪到Redis或者数据库里合并前统一从外部存储确认分片完整性。还有一个经常被忽略的坑合并成功后临时目录一定要清理。我在项目里专门加过一个定时任务清理超过24小时还没完成合并的僵尸目录否则磁盘会被不明不白占满等到运维报警才反应过来。2.4 秒传与断点续传是顺带长出来的能力分片上传模型建好后秒传的实现就是一个判断逻辑客户端上传前先把fileMd5发给后端后端查一下文件是否已经存在存在就直接返回成功标识前端不用上传任何分片。断点续传的实现也不复杂后端按uploadId记录已收到的分片索引前端在上传前调用一个查询接口获取已存在分片列表然后只上传缺失的那几片。用户中断后再次打开页面不需要重新扫描整个文件续传体验很自然。这两块在代码量上都是小改动但前提是分片标识、状态管理机制在设计阶段就定好。要是等代码写完了再补改动面会波及前端上传逻辑、后端接口和状态存储折腾成本成倍增加。3. SpringBoot后端实现三个接口把分片上传跑通3.1 接口约定前端调后端就这么三个动作完整的分片上传流程后端只需要提供三个接口POST /upload/init 入参fileName, fileSize, fileMd5, chunkSize POST /upload/chunk 入参uploadId, chunkIndex, totalChunks, file POST /upload/merge 入参uploadId, fileName, fileMd5init接口不是必须的但加上它有几个好处后端可以提前创建临时目录同时把生成的uploadId下发给前端。更关键的是它给了后端一次判断秒传的机会——收到fileMd5后先查文件是否已存在存在就返回秒传标记前端直接跳过上传阶段。chunk接口只需要负责接收单片分片不关心其他分片是否到位。merge接口负责在分片全部到齐后做合并和校验。这里有个容易被忽视的安全细节后端必须再次校验单个分片的大小。不能完全信任前端传过来的参数万一有人绕过页面直接手动调接口塞一个超大分片进来磁盘IO会被瞬间打满。服务端的信任边界永远要握在自己手里。3.2 接收分片落盘前的三个检查接收分片的核心逻辑不复杂但有三件事必须做确认上传任务存在、确认分片索引在合法范围内、确认分片文件有效。代码实现大致这样PostMapping(/upload/chunk) public ResultVoid uploadChunk( RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(totalChunks) Integer totalChunks, RequestParam(file) MultipartFile file) { // 检查1分片索引必须在合法区间 if (chunkIndex 0 || chunkIndex totalChunks) { return Result.fail(非法的分片索引); } // 检查2上传任务必须已经初始化 Path dir Paths.get(UPLOAD_TMP_DIR, uploadId); if (!Files.exists(dir)) { return Result.fail(上传任务不存在请先初始化); } // 检查3单片大小不能超过约定上限 if (file.getSize() MAX_CHUNK_SIZE) { return Result.fail(分片大小超过上限); } Path chunkPath dir.resolve(chunk_ chunkIndex .part); file.transferTo(chunkPath.toFile()); uploadedChunks.computeIfAbsent(uploadId, k - ConcurrentHashMap.newKeySet()).add(chunkIndex); return Result.ok(); }这里用transferTo而不是getBytes()就是避免把分片数据整体读进内存再写出去那会失去分片节省内存的意义。uploadedChunks我用的是ConcurrentHashMap加Set保证并发场景下的线程安全。3.3 合并文件排序、缓冲、校验三步走合并是整个流程里最容易翻车的地方。最容易犯的错是直接用文件名排序——chunk_10.part会排在chunk_2.part前面顺序一乱合并出来的文件损坏排查起来还很隐蔽。一定要提取分片编号按数值大小排序。合并完成后必须计算整个文件的MD5与前端传上来的原始MD5比对。不一致直接删除合并产物并报错这是对文件完整性的兜底。PostMapping(/upload/merge) public ResultString merge(RequestParam(uploadId) String uploadId, RequestParam(fileName) String fileName, RequestParam(fileMd5) String fileMd5) throws IOException { Path dir Paths.get(UPLOAD_TMP_DIR, uploadId); if (!Files.exists(dir)) { return Result.fail(任务不存在); } ListPath chunks Files.list(dir) .filter(p - p.getFileName().toString().startsWith(chunk_)) .sorted(Comparator.comparingInt(p - extractIndex(p.getFileName().toString()))) .collect(Collectors.toList()); if (chunks.size() ! totalChunksMap.get(uploadId)) { return Result.fail(分片不完整请重传缺失分片); } Path target Paths.get(FINAL_DIR, fileName); try (FileChannel out FileChannel.open(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (Path chunk : chunks) { try (FileChannel in FileChannel.open(chunk, StandardOpenOption.READ)) { long size in.size(); long transferred 0; while (transferred size) { transferred in.transferTo(transferred, size - transferred, out); } } } } String mergedMd5 DigestUtils.md5Hex(Files.newInputStream(target)); if (!mergedMd5.equalsIgnoreCase(fileMd5)) { Files.deleteIfExists(target); return Result.fail(合并文件校验失败); } deleteDirectory(dir); return Result.ok(target.toString()); }合并过程用FileChannel.transferTo做零拷贝传输在大文件场景下比手动申请缓冲数组逐块读写的效率高很多。循环调用transferTo是因为部分操作系统上单次传输不一定能把全部字节写完必须循环直到完全传输。3.4 前端配合并发上传别把服务端压垮前端部分不展开写完整代码但有几点后端必须提醒前端注意。并发分片数一定要限制住。我一般建议同时上传的分片数控制在3到5个避免几百个请求同时打到服务端把连接数和IO全部打满。很多页面卡顿和上传失败不是因为后端处理不过来而是前端一次性把所有分片全部发出去网络层先崩了。失败重试要做指数退避。第一次失败等1秒、第二次等2秒、第三次等4秒超过3次就停下来提示用户检查网络。简单粗暴的失败后立刻重试在弱网环境下反而会把带宽全部占满。还有一个常见误解浏览器自带的onUploadProgress进度回调拿到的只是单片的进度。整体上传进度应该用已成功分片数 / 总分片数来计算后端返回的分片状态就是进度判断依据。不然前端进度条会特别矛盾显示100%了后端却说没传完。4. 真实环境下的性能数据与高频问题排查4.1 弱网环境下的分片大小实测我在项目里做过一轮对比测试目标是一个1.2GB的演示视频文件网络环境模拟弱网上行带宽限制在1MB/s左右三种分片大小配置的结果如下分片大小分片数量弱网总耗时失败重传次数体验评价512KB2400片明显偏长较多请求量过大进度刷新频繁2MB600片相对最短少量均衡性最好推荐使用8MB150片反而更慢明显偏多单片重传代价太高结果和理论分析基本吻合分片太小时HTTP握手和请求头的开销被放大分片太大时弱网下单片成本过高传失败一次就要重传很久。2MB左右在这个环境下是甜点区间。不过这个数值是跟网络环境强相关的我建议你用自己的目标网络环境做一轮对比用数据选参数而不是直接照搬。4.2 高频问题排查速查表把我在项目里处理过的高频问题整理成一张表直接对照排查现象原因解决方式合并后文件打开报错分片顺序错乱合并前按chunkIndex数值排序传一半报500临时目录没有提前创建init接口统一建目录接收分片时校验文件合并成功但MD5不一致某个分片在传输中被截断或篡改合并后校验MD5失败则删除重传偶尔报413 Request Entity Too Large网关容器请求体上限未调大调整max-post-size并同步修改网关配置磁盘空间被慢慢吃满合并完成后临时目录未清理合并成功后清理定时清理孤儿目录多实例部署时合并偶发失败已传分片状态存单机内存状态挪到Redis合并前统一确认大文件合并时内存溢出合并阶段用了readAllBytes改用FileChannel或缓冲流边读边写前端并发过高导致上传失败没有限制同时上传的分片数前端限制并发分片数为3到5个这张表是我踩坑经验的浓缩。大部分问题不是逻辑复杂到解不了而是设计阶段少想了一步。比如临时目录不清理最开始我觉得无所谓直到磁盘报警才意识到这个遗漏会在生产环境日积月累地放大。4.3 几个值得展开的调优细节第一个细节是单片重试策略。很多前端代码失败后立刻重传同一切片遇到网络抖动反而把宝贵的带宽消耗在重复请求上。指数退避是我的标准做法1秒、2秒、4秒递增最多重试3次仍然失败就停止并提示用户。实测下来这套策略在弱网环境能明显降低重复传输带来的额外耗时。第二个细节是合并操作的异步化。合并一个大文件可能要读几百个分片耗时动辄二三十秒。如果合并接口同步阻塞前端长连接一直挂着超时风险很高。我通常的做法是合并接口先快速校验分片数量是否齐全不齐立即返回错误齐了就交给独立线程池异步合并前端通过轮询或WebSocket获取合并结果。上传接口本身保持轻量不拖住HTTP线程。第三个细节是日志级别。每个分片打一条info日志几百个分片就是几百条记录日志文件会疯涨。分片上传这种高频小操作用debug级别记录即可在合并完成或失败时再打关键info日志。这样出问题时还能从日志里翻出脉络又不会把磁盘打爆。第四个细节是磁盘规划。临时目录最好放在独立挂载的数据盘上跟系统分区、日志分区分开。多人同时上传时大量分片落盘会产生不小的IO压力如果临时目录和系统盘挤在一起可能影响其他核心服务的稳定性。最后再分享一个我自己的体会分片上传真正难的不是写代码而是把分片状态管理好、把合并顺序排对、把清理策略落实。我在早期版本里图省事跳过MD5校验结果吃过一次文件静默损坏的亏用户下载文件才发现内容不对。后来把完整性和状态设计补齐全整个上传链路就很少再出问题了。如果你准备在SpringBoot里做分片上传第一版就把临时目录、分片标识、完整性校验这三样做扎实后面再谈性能优化路会顺很多。