拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java后端大文件分块上传:断点续传与MD5校验实战

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默认只有2MB;Spring 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 整体流程:初始化、上块、合并三阶段

分块上传的标准流程可以拆成三个阶段:

  1. 前端计算整个文件的MD5,调用初始化接口,把文件名、总大小、分块大小、文件MD5发给后端。
  2. 后端生成全局唯一的uploadId,记录上传会话,返回总分块数。
  3. 前端用File.slice()按指定大小切分文件,逐个上传分块,每个分块带上uploadId和chunkIndex。
  4. 后端每收到一个分块,先校验大小和MD5,落盘到临时目录,写一条分块记录。
  5. 前端所有分块传完后,调用合并接口。
  6. 后端检查分块是否齐全,按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 Result<UploadInitVO> 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 Result<Void> 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 Result<Void> 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)) { List<UploadChunk> 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 Result<ProgressVO> progress(@RequestParam("uploadId") String uploadId) { List<Integer> uploadedIndexes = uploadChunkMapper.listIndexesByUploadId(uploadId); return Result.ok(new ProgressVO(uploadedIndexes, totalChunks)); }

前端拿到已上传索引列表后,跳过已传分块,只上传缺失部分。如果要做多设备并发上传同一个uploadId,需要额外加分布式锁或乐观锁;军工内网最常见的是单用户单会话,把重试幂等做好就足够。

4. 实战中的坑与排查链路

4.1 合并乱码的根因:字符串排序而不是数字排序

第一次联调时,合并后的文件始终打不开,用十六进制编辑器查看,发现文件内容有大量重叠和顺序错乱。排查过程是这样的:

  1. 先怀疑前端切分逻辑。打印每个File.slice()的大小,发现都符合预期,排除。
  2. 再检查后端每块落盘大小。发现每个临时文件的大小都正确,排除。
  3. 最后看合并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_index,Java侧再用Comparator.comparingInt(UploadChunk::getChunkIndex)兜底。教训就是:所有带序号含义的字段,排序前必须确认是整型比较,而不是字符串比较。

4.2 高并发OOM:getBytes()是隐形杀手

一开始写分块上传时图省事,直接用file.getBytes()拿整个分块的内容。单块4MB,同时10个并发也就40MB内存,看起来没啥问题。但实际军工内网有20多个用户同时上传,网络抖动导致大量重试,Tomcat线程池里的请求堆积,每个请求的MultipartFile底层解析还会持有临时文件引用,最终堆内存被撑爆,应用直接OOM。

排查时先看GC日志,发现大量byte[]分配,然后跟踪到上传接口。修复方式就是改成getInputStream()流式转存,分块文件不再进堆内存,内存占用立刻降了一个量级。这个坑非常典型,建议大家从第一版就写流式,别走弯路。

4.3 分块MD5校验失败的定位过程

有个分块反复报校验失败,但小文件上传完全正常。排查链路如下:

  1. 查看后端日志,发现失败的总是同一个index,不是随机故障。
  2. 让前端在浏览器Console打印该分块的slice()大小和chunkMd5,发现这个块的文件大小正确,但MD5是整文件的值。
  3. 再往前查,原来是前端计算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掩盖。希望这篇经验对你有帮助。

返回列表