Java后端大文件分块上传:断点续传与MD5校验实战
上个月我接了一个军工配套的网页项目业务逻辑并不复杂但有一个功能差点把整个团队拖垮上传大型附件。用户要传的不是几个MB的Excel表而是好几个GB的三维模型、仿真日志和设计图纸。当时大家图省事直接在网页里用一个POST请求把整个文件丢给Java后端结果在真实内网环境里一试全军覆没——要么传到一半连接被断开要么后端报请求超时更常见的是Tomcat直接抛出内存溢出。折腾了整整两天我才真正确认一个结论在JAVA网页开发里做军工行业的大附件上传分块上传不是可选项而是必选项。这篇文章就把我这次改造的完整思路和落地代码整理出来供碰到类似场景的同行参考。不过先泼一盆冷水分块上传不是简单把文件切成几块传上去就完了里面涉及的断点续传、分块校验、并发合并、安全审计每一环都藏着坑。下面按我实际项目的推进顺序从为什么必须分块到方案设计、后端实现再到踩坑记录和军工环境里的加固经验一次讲透。1. 军工内网传大文件直接POST为什么行不通1.1 网络抖动与长连接中断风险军工行业的很多业务系统部署在涉密内网或专用网络上网络结构分层、跨地域通信走专线虽然有专网保障但带宽并不是“想传多少传多少”。一次上传几个GB的文件HTTP连接可能需要持续打开十分钟甚至几十分钟。中间只要出现一次网络抖动TCP重传一旦超过阈值连接就会被断开。前端拿不到服务端响应通常只会显示“上传失败”但服务端实际上已经收到了大量数据——这些半截数据如果不清除会残留成垃圾文件如果用户重新上传浪费的带宽和时间都是实打实的成本。我在内网实测过用普通POST传一个2.8GB的文件最长一次坚持了30多分钟最终还是断在93%的位置。崩溃不在于等的那半小时而在于断掉之后一切都得重来。1.2 应用服务器默认限制和内存模型JAVA后端最常见的部署方式是Tomcat加Spring Boot。Tomcat对POST请求体大小有默认限制maxPostSize默认只有2MBSpring Boot的spring.servlet.multipart.max-file-size默认也只有1MB。就算你把这两个配置都调到10GB问题也没有解决——因为Servlet容器处理上传时会把请求体解析成MultipartFile对象。如果把文件整个读进内存几百MB甚至几个GB的文件会压垮堆内存轻则频繁Full GC重则直接OutOfMemory。很多人以为“上传报错就是参数没调大”其实调大配置只是让Tomcat能接收大请求真正决定你死不死的是内存模型。这也是我见过最多人翻车的地方改完配置文件重启传小文件没问题传大文件依然挂。1.3 安全审计和可追溯性要求军工行业的系统对操作留痕和可追溯性要求远高于普通企业。文件上传属于高风险动作审计时往往需要回答谁在什么时间上传了什么文件这个文件有没有被中间人篡改过传输过程中有没有分块失败重试最终落盘的文件和源文件二进制是否完全一致直接POST整个大文件服务端能看到的只有“什么时间、谁、上传了一个文件”中间过程完全是黑盒。一旦事后发现文件内容有问题你连“哪个分块出了问题”“重传过几次”都说不清。分块上传天然把文件切分成多个离散事件每个分块都可以记录大小、MD5、上传时间审计链路才算完整。1.4 分块上传的本质把大文件拆成小任务分块上传的逻辑其实很朴素。就像搬家时一个大柜子搬不进电梯拆成几块分批搬。每次只传一个几MB的分块单个请求时间短失败重试的代价小。以4GB文件、4MB分块为例总共1024个分块每个分块独立传输、独立校验最后按顺序合并。即使中间断了也只需要重传失败的那几个分块而不是整个文件。维度直接POST整文件分块上传单请求耗时几十分钟几秒连接中断影响全部重来只重传失败的块内存占用高需要完整缓冲低流式处理单块审计粒度只有最终结果每个分块可追踪并发能力大文件容易拖垮线程多个分块可并行上传所以在军工内网这种弱网、高安全、长链路的环境里分块上传不是一个“优化项”而是绕不过去的底层方案。2. 设计一套可落地的分块上传方案2.1 整体流程初始化、上块、合并三阶段分块上传的标准流程可以拆成三个阶段前端计算整个文件的MD5调用初始化接口把文件名、总大小、分块大小、文件MD5发给后端。后端生成全局唯一的uploadId记录上传会话返回总分块数。前端用File.slice()按指定大小切分文件逐个上传分块每个分块带上uploadId和chunkIndex。后端每收到一个分块先校验大小和MD5落盘到临时目录写一条分块记录。前端所有分块传完后调用合并接口。后端检查分块是否齐全按chunkIndex升序合并计算合并后完整文件的MD5与初始化阶段的MD5比对一致则更新状态为完成。这个流程每个环节职责单一前端负责切分和调度后端负责校验和存储。如果系统是集群部署上传会话状态需要放到Redis或数据库里避免A节点接收了初始化请求、B节点却不认识这个uploadId。对中小型项目MySQL加一张表就够用。2.2 分块大小怎么定才不掉链子分块大小直接影响体验和资源开销没有万能答案要根据网络条件来。我在军工内网里的经验是内网带宽稳定比如万兆局域网分块设8MB~16MB减少请求次数减轻数据库压力。专线/跨地域内网分块设2MB~4MB因为跨节点链路不稳定单块越小失败影响半径越小。公网环境军工系统很少用但普通行业会用到分块设1MB~2MB公网抖动更频繁宁可多传几次。一个简单的评估思路单个分块最坏重试耗时 × 总分块失败率预期应该控制在用户体验可接受的范围内。比如单块传输1秒但如果重传一次要额外花2秒1000个分块里10%失败额外时间就是200秒还能接受如果分块设成64MB一次重传的时间成本就陡增。我通常选4MB作为折中值既不会因为块数太多导致数据库膨胀也不会因为单块太大导致重试成本过高。2.3 断点续传与MD5秒传断点续传的关键是服务端能告诉前端“哪些分块已经上传成功”。前端在上传前调用进度查询接口拿到已上传分块的索引列表然后跳过这些分块只上传缺失部分。这个机制在内网尤其好用因为断网后重新连接不需要从头开始。秒传则是另一套逻辑初始化时前端会把整个文件的MD5传给后端后端查询数据库里是否有相同MD5的已完成记录。如果有直接返回“文件已存在无需上传”前端弹个秒传成功的提示就完了。在军工内网里这个场景很常见——同一个版本的设计图纸、同一个批次的仿真结果往往有几十个人反复传同一份文件。用MD5秒传能省掉大量无效带宽。2.4 核心表结构上传会话与分块明细这里给出我在项目里用的两张核心表字段可以根据实际业务调整但主体结构不需要太大变动。CREATE TABLE upload_session ( id BIGINT AUTO_INCREMENT PRIMARY KEY, upload_id VARCHAR(64) NOT NULL COMMENT 全局唯一上传ID, user_id VARCHAR(64) NOT NULL COMMENT 上传人, file_name VARCHAR(255) NOT NULL COMMENT 原始文件名, file_md5 VARCHAR(32) NOT NULL COMMENT 整文件MD5, total_size BIGINT NOT NULL COMMENT 文件总字节数, chunk_size INT NOT NULL COMMENT 单块字节数, total_chunks INT NOT NULL COMMENT 总分块数, status TINYINT NOT NULL DEFAULT 0 COMMENT 0初始化 1上传中 2合并中 3完成 4失败, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_upload_id (upload_id) ); CREATE TABLE upload_chunk ( id BIGINT AUTO_INCREMENT PRIMARY KEY, upload_id VARCHAR(64) NOT NULL COMMENT 所属会话, chunk_index INT NOT NULL COMMENT 分块序号从0开始, chunk_size INT NOT NULL COMMENT 分块字节数, chunk_md5 VARCHAR(32) DEFAULT NULL COMMENT 分块MD5, stored_path VARCHAR(255) NOT NULL COMMENT 分块存储路径, upload_time DATETIME NOT NULL, UNIQUE KEY uk_upload_chunk (upload_id, chunk_index) );注意分块文件本身存在磁盘上数据库只记录元数据。stored_path不推荐直接暴露给前端后端生成会话时可以把路径规则保持为上传目录/uploadId/index.tmp这样查询和清理都方便。3. Java后端实现的核心代码与要点3.1 初始化接口生成上传会话初始化接口的作用是“备案”。军工系统里必须有用户身份所以第一步从安全上下文拿当前用户ID然后计算总分块数、生成uploadId、保存会话。如果文件MD5已经存在相同记录可以直接返回“秒传”标记。PostMapping(/upload/init) public ResultUploadInitVO init(RequestBody UploadInitDTO dto) { // 军工系统必须有身份认证匿名直接拒绝 String userId SecurityUtils.getCurrentUserId(); // 计算总分块数 int totalChunks (int) Math.ceil((double) dto.getTotalSize() / dto.getChunkSize()); // 生成全局唯一ID String uploadId UUID.randomUUID().toString().replace(-, ); // 保存上传会话 UploadSession session new UploadSession(); session.setUploadId(uploadId); session.setUserId(userId); session.setFileName(dto.getFileName()); session.setFileMd5(dto.getFileMd5()); session.setTotalSize(dto.getTotalSize()); session.setChunkSize(dto.getChunkSize()); session.setTotalChunks(totalChunks); uploadSessionMapper.insert(session); // 如果MD5已存在返回秒传标记 boolean fileExists checkFileExists(dto.getFileMd5()); return Result.ok(new UploadInitVO(uploadId, totalChunks, fileExists)); }注意一个细节不要让前端直接传totalChunks后端必须根据totalSize和chunkSize自己算否则前端算出10块、后端实际切出11块合并时会出bug。3.2 分块上传接口流式落盘、校验MD5分块上传是最核心的接口。我强调两条铁律第一永远用InputStream流式写入不要用MultipartFile.getBytes()第二每个分块必须有独立的MD5校验校验失败立刻删除落地文件并返回错误。PostMapping(/upload/chunk) public ResultVoid uploadChunk( RequestParam(uploadId) String uploadId, RequestParam(index) Integer index, RequestParam(chunkMd5) String chunkMd5, RequestPart(file) MultipartFile file) throws IOException { // 获取并校验上传会话 UploadSession session getSessionOrThrow(uploadId); // 存储路径上传临时目录 uploadId 分块序号 String storePath String.format(%s/%s_%d.tmp, uploadTempDir, session.getUploadId(), index); // 流式写入磁盘全程不进入堆内存 try (InputStream in file.getInputStream(); OutputStream out new BufferedOutputStream(new FileOutputStream(storePath))) { IOUtils.copy(in, out); } // 校验分块MD5不一致则删除文件返回错误 String actualMd5 DigestUtils.md5Hex(new File(storePath)); if (!chunkMd5.equalsIgnoreCase(actualMd5)) { Files.delete(Paths.get(storePath)); return Result.error(分块校验失败请重试); } // 记录分块明细 uploadChunkMapper.insert(uploadId, index, file.getSize(), chunkMd5, storePath); return Result.ok(); }这里有个幂等设计同一个index重复上传时数据库的唯一键uk_upload_chunk会冲突。我的做法是捕获唯一键冲突异常在冲突时直接覆盖旧分块或返回成功这样前端重试同一个分块时不会莫名其妙报错。3.3 合并接口按索引合并、整体校验合并接口要做的检查很多分块是否齐全、chunk_index是否从0到totalChunks-1连续、合并后的完整文件MD5是否等于初始化时记录的fileMd5。PostMapping(/upload/merge) public ResultVoid merge(RequestParam(uploadId) String uploadId) { UploadSession session getSessionOrThrow(uploadId); // 1. 检查分块数量是否齐全 int uploadedChunks uploadChunkMapper.countByUploadId(uploadId); if (uploadedChunks ! session.getTotalChunks()) { return Result.error(还有分块未上传无法合并); } // 2. 打开最终文件按顺序流式合并 Path finalPath Paths.get(uploadStoreDir, session.getUploadId() .bin); try (FileOutputStream fos new FileOutputStream(finalPath.toFile()); BufferedOutputStream bos new BufferedOutputStream(fos)) { ListUploadChunk chunks uploadChunkMapper.listByUploadIdOrderByChunkIndex(uploadId); for (UploadChunk chunk : chunks) { Files.copy(Paths.get(chunk.getStoredPath()), bos); } } // 3. 计算合并后MD5与初始化时比对 String finalMd5 DigestUtils.md5Hex(new FileInputStream(finalPath.toFile())); if (!session.getFileMd5().equalsIgnoreCase(finalMd5)) { Files.delete(finalPath); return Result.error(文件合并后MD5不一致请重新上传); } // 4. 更新状态为完成并重命名为正式文件名按需保留 uploadSessionMapper.updateStatus(uploadId, COMPLETED); return Result.ok(); }如果合并的是几十GB的超大文件用BufferedOutputStream和Files.copy的组合已经够用追求极致性能可以用FileChannel.transferTo但要注意transferTo在跨文件系统时会退化为普通拷贝性能反而下降建议在明确同盘的情况下再用。3.4 进度查询与重复分块的幂等处理断点续传的核心是进度查询接口GetMapping(/upload/progress) public ResultProgressVO progress(RequestParam(uploadId) String uploadId) { ListInteger uploadedIndexes uploadChunkMapper.listIndexesByUploadId(uploadId); return Result.ok(new ProgressVO(uploadedIndexes, totalChunks)); }前端拿到已上传索引列表后跳过已传分块只上传缺失部分。如果要做多设备并发上传同一个uploadId需要额外加分布式锁或乐观锁军工内网最常见的是单用户单会话把重试幂等做好就足够。4. 实战中的坑与排查链路4.1 合并乱码的根因字符串排序而不是数字排序第一次联调时合并后的文件始终打不开用十六进制编辑器查看发现文件内容有大量重叠和顺序错乱。排查过程是这样的先怀疑前端切分逻辑。打印每个File.slice()的大小发现都符合预期排除。再检查后端每块落盘大小。发现每个临时文件的大小都正确排除。最后看合并SQL才发现问题我一开始写的查询是ORDER BY stored_name临时文件名是uploadId_1.tmp、uploadId_2.tmp这种字符串排序会变成uploadId_1.tmp、uploadId_10.tmp、uploadId_11.tmp、uploadId_2.tmp……顺序完全乱了。解决办法很简单SQL改成ORDER BY chunk_indexJava侧再用Comparator.comparingInt(UploadChunk::getChunkIndex)兜底。教训就是所有带序号含义的字段排序前必须确认是整型比较而不是字符串比较。4.2 高并发OOMgetBytes()是隐形杀手一开始写分块上传时图省事直接用file.getBytes()拿整个分块的内容。单块4MB同时10个并发也就40MB内存看起来没啥问题。但实际军工内网有20多个用户同时上传网络抖动导致大量重试Tomcat线程池里的请求堆积每个请求的MultipartFile底层解析还会持有临时文件引用最终堆内存被撑爆应用直接OOM。排查时先看GC日志发现大量byte[]分配然后跟踪到上传接口。修复方式就是改成getInputStream()流式转存分块文件不再进堆内存内存占用立刻降了一个量级。这个坑非常典型建议大家从第一版就写流式别走弯路。4.3 分块MD5校验失败的定位过程有个分块反复报校验失败但小文件上传完全正常。排查链路如下查看后端日志发现失败的总是同一个index不是随机故障。让前端在浏览器Console打印该分块的slice()大小和chunkMd5发现这个块的文件大小正确但MD5是整文件的值。再往前查原来是前端计算MD5时传错了对象——用了整个文件的File对象而不是file.slice()出来的分块Blob。所以每个分块发过来的chunkMd5都等于整个文件的MD5后端拿到的分块内容和MD5对不上自然永远校验失败。这个坑在代码review时根本看不出来必须联调时把分块的二进制和MD5打印出来比对。后来我在前后端联调日志里加了一行chunkIndex | md5Prefix一眼就能定位问题。4.4 前端超时重试与后端的幂等配合页面里用axios上传默认超时时间可能很长但不会自动重试。我建议前端同学给每个分块设置30秒超时失败后指数退避重试最多重试5次重试前先调用progress接口获取已上传列表避免重复传已经成功的分块。这里后端必须做好幂等同一个index重复上传时要么覆盖旧分块要么直接返回成功。我用的是“先查分块记录存在则删除旧文件再写新文件”这样可以保证重试时不会产生脏数据。如果不做幂等前端重试同一个分块时数据库唯一键冲突会让用户看到一个莫名其妙的错误。4.5 文件名路径穿越与磁盘写满军工环境对文件路径很敏感不能直接用用户传入的文件名拼接路径。我统一用uploadId _ chunkIndex生成存储路径用户原始文件名只存数据库合并后再做一次文件名清理和映射。这样即使有人故意传../../xxx也不会造成路径穿越。另外大附件上传最容易被忽视的是磁盘空间。4GB的文件在临时目录存储分块时会占用2倍于文件总大小的空间——分块临时文件和合并后的最终文件同时存在。我在上传目录挂载了一个磁盘空间监控低于阈值时直接拒绝新上传会话避免写满系统盘导致应用全面崩溃。5. 军工环境下的加固与交付经验5.1 身份认证绑定与权限校验军工系统普遍有严格的权限管控不能允许匿名上传。我在初始化会话时从Spring Security上下文拿到用户ID绑定到upload_session表合并接口再次校验当前用户与会话创建者一致。如果有目标文件夹权限要求还要在初始化阶段校验用户是否有上传权限。权限校验失败统一记录审计日志不能静默放行。5.2 操作审计怎么做才经得起检查我们把上传会话、分块记录、合并事件都写入独立的审计表字段包括用户ID、操作时间、来源IP、上传文件名、文件大小、每个分块的MD5、是否重试过、最终是否通过校验、合并耗时。这些数据不随业务数据删除至少存网180天。实现上最简单的方式是异步写一条JSON日志落到独立审计表或文档库避免影响上传主链路。审计不是上线后才做的而是从设计阶段就纳入接口流程。每个接口的出入参都要有日志尤其是“失败重试”“MD5不一致”这类异常事件最后检查时最能体现问题。5.3 存储目录隔离与流式加密上传目录绝对不能放在Web应用可以静态访问的地方比如src/main/resources/static。我把临时分块目录和最终文件目录都配置在系统盘外的专用数据分区上通过系统服务挂载。如果有加密要求可以引入CipherOutputStream对文件流做AES加密后再落盘读取时再解密。注意流式加密不要一次性把整个文件读入内存。密钥放在运维配置中心代码里不出现硬编码。5.4 下载鉴权与一次性签名URL上传解决了下载同样不能直接暴露静态路径。军工内网通常要求下载也走Java接口校验权限后通过InputStreamResource写回同时记录下载日志。可以把下载地址做成一次性签名URL有效期内只能访问一次这样能减少文件被滥用的风险。5.5 交付前的弱网压测与验证清单交付前一定要在真实内网环境做压测不能只在开发机上“看起来正常”。我的验证清单包括先传一个小文件确认基本链路通。用脚本模拟连续断网5次确认断点续传有效、最终MD5一致。用JMeter模拟30个用户同时上传不同大小的文件观察CPU、内存、磁盘IO。检查超过10GB的超大文件磁盘剩余空间和合并耗时。检查审计日志是否完整记录每一步操作。军工内网还有一个特点客户端环境可能是国产浏览器、或特殊定制的浏览器前端要用标准File API尽量别依赖特定浏览器私有方法否则上线会踩兼容性的雷。最后分享一个我自己的体会分块上传这块技术本身并不复杂真正麻烦的是把它放进军工内网这种“弱网、高安全、多审计”的环境里。Java后端只要把握住三个原则——永远用流式处理、状态尽量落库、每一块都可校验就不太会出大问题。另外不要自己闷头搞一定要拉着前端同学把时序图对齐尤其是失败重试和进度查询的联动前端经验不足的话后端做得再稳也会被bug掩盖。希望这篇经验对你有帮助。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →