资讯详情

Java服务端大文件分片上传与断点续传完整方案

📅 2026/10/3 9:19:05 | 华诺云谱 👁 阅读
Java服务端大文件分片上传与断点续传完整方案
1. 从一根管道说起巡检日志附件为什么会大到让人头疼先还原一个现场场景。能源化工企业的管道巡检可不是拿着手电筒走一圈就完事。巡检人员要记录阀门编号、法兰连接状态、防腐层破损情况、保温层是否脱落遇到疑似泄漏点要用气体检测仪读数还要拍照留存。现在很多企业还配了红外热成像仪一张红外照片就是十几兆。要是发现隐患现场拍一段视频三分钟就奔着200兆去了。这些素材汇总到一起就是巡检日志的附件。一条巡检记录挂上五六张照片加一段视频附件总量动辄几百MB遇到大修期间的专项检查一条日志挂上几GB附件也不是什么稀罕事。问题就出在上传环节。传统化工企业的现场网络环境没那么理想。很多厂区位于偏远地区车间里有Wi-Fi但信号不稳定地下管廊和罐区深处甚至只有4G信号还得碰运气。工人辛辛苦苦拍完素材点击上传进度条走到68%忽然断了再点一次又从头开始传。几百MB的文件传了三四次都失败最后只能回到办公室里连有线网络再传一遍。这种情况在真实生产环境里每天都在发生。断点续传的价值恰恰就体现在这种网络不靠谱但业务不能断的场景里。它解决的核心问题很简单文件已经传了一部分能不能把上传进度记住下次接着传而不是从头来过这个需求在Java服务端落地牵扯到的技术点比很多人想象的多得多。不是一个MultipartFile接收完事而是要处理分片、断点、校验、并发甚至还要考虑临时文件的清理策略。我下面把这套方案的完整实现思路、核心代码和踩坑经历全部拆开讲。2. 断点续传方案选型为什么最终选了HTTP Range加分片落盘先明确需求边界。在能源化工企业这种场景下做断点续传有几个硬性约束单文件体积大集中在200MB到2GB之间偶尔有视频素材超过2GB。上传环境不稳定Wi-Fi、4G、有线网络来回切换随时可能中断。服务端需要知道文件传了多少、剩多少客户端需要能从中断位置恢复。文件必须完整可校验巡检日志涉及安全追溯半截文件不算数。围绕这些约束业界比较成熟的方案就那几种。2.1 方案对比HTTP Range、分片上传、第三方SDK第一种是HTTP Range断点续传。客户端先向服务端询问文件已经传了多大服务端返回已接收的字节数客户端用基于HTTP的Range机制从那个位置继续写。这种方式逻辑简单服务端只需要用一个RandomAccessFile按字节偏移写入就行但是对网络异常特别敏感——只要TCP连接断了客户端不是总能准确知道服务端到底写到哪个位置需要频繁发起探测请求。第二种是前端分片上传。把大文件切成固定大小的块比如每片5MB客户端逐片上传每片都有独立的序号和校验值。服务端先把分片落盘到临时目录全部传完后合并。这种方式对网络抖动容忍度高单片传失败只需要重传这一片而且支持并发上传多片。缺点是逻辑复杂度上来了需要管理分片状态、合并、幂等服务端多一套分片管理表。第三种是直接用云存储SDK。比如接入对象存储的断点续传能力服务端只做转发。但能源化工企业很多数据不出厂区内网部署是刚需公有云SDK直接出局。最终选型是分片上传为主、本地落盘续传为辅的混合方案。前端把文件切成固定分片一片一片传服务端用分片序号加文件标识来记录上传进度。前端本地也缓存一份哪些分片已成功的记录断网恢复后直接从中断处分片重传而不是整文件重传。提示分片方案和服务端断点续传不矛盾。分片上传解决的是从哪一片继续服务端的RandomAccessFile解决的是写文件时从哪个字节偏移继续。两者结合才是完整可靠的断点续传。2.2 分片大小的选择逻辑分片大小直接决定整个方案的上限体验。我做过几轮对比测试在园区内网条件下结论是这样的分片大小网络中断损失并发效率适用场景1MB小单片几乎不会失败需要大量并发请求才能跑满带宽极差网络如地下管廊5MB中等单片成功率较高8-16并发轻松跑满百兆带宽厂区Wi-Fi、4G20MB较大网络抖动时容易单片失败并发需求低压力小内网有线高带宽低延迟我推荐5MB。原因很实在5MB的分片在4G信号下就算丢几个包单片重传成本也很低而在百兆内网下12到16个并发分片同时传上传速率能稳定跑到60MB/s以上一上午就能传完几GB的巡检视频。分片再小到1MB服务端要处理的请求数暴增Tomcat线程池压力大反而拉低整体吞吐。3. Java服务端收到分片之后核心处理链路与关键代码方案定了接下来是Java服务端的核心实现。我按接收分片-落盘索引-合并校验三步拆解。3.1 先定义分片上传的请求模型前端每次请求传一片请求参数里必须带上文件标识、总分片数、当前分片序号。这里我用文件指纹来做文件标识也就是文件的MD5值这样还能顺便实现一个秒传效果——服务端检测到文件已经存在完整版本直接返回上传完成不用再传。public class ChunkUploadRequest { private String fileId; // 文件唯一标识用MD5 private Integer chunkIndex; // 当前分片序号从0开始 private Integer totalChunks; // 总分片数 private Long totalSize; // 文件总字节数 private MultipartFile file; // 当前分片的文件流 }Controller层用RequestPart接收文件和其他字段这里不赘述SpringMVC的基础写法重点讲服务端接收后的核心逻辑。3.2 RandomAccessFile定位写入断点续传的地基服务端收到一个分片后要做的第一件事是把这个分片的内容写到临时文件对应的偏移位置。偏移量是分片序号乘以分片大小这个计算很简单但写文件的方式有讲究。private void writeChunkToFile(String basePath, ChunkUploadRequest request) { Path tempFile Paths.get(basePath, request.getFileId() .tmp); try (RandomAccessFile raf new RandomAccessFile(tempFile.toFile(), rw)) { long offset (long) request.getChunkIndex() * chunkSize; raf.seek(offset); byte[] buffer new byte[4096]; try (InputStream in request.getFile().getInputStream()) { int len; while ((len in.read(buffer)) ! -1) { raf.write(buffer, 0, len); } } } }RandomAccessFile的seek方法决定了写入起点这就是断点续传最底层的机制。前端传第13片服务端就把指针移到13乘以5MB的偏移位置直接写不用管这个文件前面的部分是从哪来的。但这只是第一步如果只做这个一旦上传中断再恢复我怎么知道第13片前面已经写完了所以还需要分片索引。3.3 分片索引用Redis记录还差哪几片我在生产环境里维护了一张分片上传状态表记录每个文件标识的接收情况。这张表的数据结构不复杂但设计上有讲究。用Redis位图来处理这种场景非常合适。文件有多少片位图就有多少位。收到第5片就把第5位置为1。查询进度时直接用bitcount统计有多少位是1和总分片数比较立刻知道传完了没有。// 分片到达后标记位图 stringRedisTemplate.opsForValue().setBit( UPLOAD_CHUNK_KEY fileId, request.getChunkIndex(), true ); // 查询当前已收到的分片数 Long uploaded stringRedisTemplate.execute( (RedisCallbackLong) connection - connection.bitCount((UPLOAD_CHUNK_KEY fileId).getBytes()) ); // 已传分片数和总分片数相等触发合并 if (uploaded ! null uploaded request.getTotalChunks()) { mergeChunks(fileId); }选择Redis而不是数据库主要有两个原因。一是位图操作本身就是Redis的强项一个200MB文件的40个分片bitCount一次O(1)级别就出来了数据库要多查几十行记录二是Redis的key过期机制可以顺带处理临时文件的生命周期设置24小时过期上传中断的后台任务会自动大量缓存不用额外写清理逻辑。3.4 设置过期时间的副作用这里有个细节值得说。Redis位图标记分片状态时key的过期时间要从首次写入时设置而不是每次写入都刷新。原因很直接如果一个用户上传文件传了10分钟分片一直在写入每次写入都刷新过期时间那24小时的有效期就会无限顺延但如果是传了一会儿就断网了服务端需要的是24小时后这个残缺文件的临时状态自动清掉所以过期时间只在第一次写入时设置。// 只在第一次上传分片时设置过期时间 Boolean firstWrite stringRedisTemplate.opsForValue().setIfAbsent( UPLOAD_CHUNK_KEY fileId, 1, Duration.ofHours(24) );3.5 合并分片临时文件转正的最后一跳所有分片到齐之后触发合并。合并不是把多个文件复制到一个文件里而是把分片按顺序写入一个完整文件。我用的是文件转正策略分片写入临时文件合并完成后再把临时文件命名为正式文件名并删除标记状态。private void mergeChunks(String fileId, String uploadDir, String targetDir) { Path tempFile Paths.get(uploadDir, fileId .tmp); Path targetFile Paths.get(targetDir, fileId _ System.currentTimeMillis() .bin); // 临时文件已经通过RandomAccessFile按偏移写入了所有分片 // 合并动作本质上是验证完整性 原子性改名 Files.move(tempFile, targetFile, StandardCopyOption.ATOMIC_MOVE); }很多第一次做分片上传的人会在这一步犯一个错误以为每个分片是独立文件合并时要按顺序把分片内容复制进最终文件。其实完全没必要。所有分片从一开始就是往同一个临时文件的不同偏移位置写写入完成之后临时文件本身就是完整的文件了。唯一要做的只是验证大小和校验值然后改名而已。这样既快又不会有分片复制过程中顺序错乱的风险。4. 可靠性保障校验、幂等、并发一个都不能漏断点续传如果只做能传的部分那在生产环境里撑不过一周。我把这几个月在生产环境里遇到过的可靠性问题集中说一下。4.1 校验不只是MD5还要校验大小和分片序号连续性分片上传过程中最怕的是某个分片因为网络问题没传完整但服务端当成正常分片写入临时文件了。这种情况如果发生在中间某个分片最终的临时文件大小是对的但内容错位合并出来的文件就是坏的。解决办法是双校验。第一层是单分片校验。前端在请求头带两个值分片的SHA-256值和分片大小。服务端把分片流写入临时文件后立刻计算这个分片接收到的字节数。如果字节数不等于声明的大小直接返回错误要求前端重传该分片。第二层是整文件校验。所有分片合并并改名后计算整个文件的大小和请求时声明的totalSize比对。大小不一致说明中间有分片写串了直接删掉临时文件让前端重新传。巡检日志领域对文件的完整性要求高所以我最终加了全文件MD5比对以牺牲一点IO开销换绝对可靠。if (Files.size(targetFile) ! totalSize) { Files.deleteIfExists(targetFile); throw new UploadException(文件大小校验失败所有分片需重传); }4.2 幂等设计同一分片重复上传不能造成数据错乱断点续传场景下客户端断网重连后经常会重传一个其实已经成功上送的分片。这时候服务端如果不清不楚地又写了一次就会把临时文件对应偏移位置覆盖掉。RandomAccessFile的seek加write天然是覆盖写所以同一分片重复写结果是一致的这个问题倒不大。真正的坑在Redis位图状态上。假设第10片实际没传成功但位图被提前置为1了那合并时就会拿一个不完整的文件去转正。所以请求处理顺序必须是分片落盘成功 - 位图置1 - 返回成功。任何一步没完成都不要更新状态。4.3 高并发分片上传时的文件锁问题前面提到过我推荐12到16个并发分片同时上传。这意味着同一时间可能有十几个线程在写同一个临时文件的不同偏移位置。RandomAccessFile的写操作本身到操作系统层面是有并发保护的但Java层面多个线程同时调用write时write并不是全程线程安全的尤其是在写入长度超过单次write缓冲限制时。保险起见我给写文件的代码块加了ReentrantLock按fileId加锁。同一文件的分片写操作串行化不同文件之间不互相阻塞。private final ConcurrentHashMapString, ReentrantLock lockMap new ConcurrentHashMap(); private ReentrantLock getFileLock(String fileId) { return lockMap.computeIfAbsent(fileId, id - new ReentrantLock()); } private void writeChunkWithLock(String fileId, ChunkUploadRequest request, String basePath) { ReentrantLock lock getFileLock(fileId); lock.lock(); try { writeChunkToFile(basePath, request); } finally { lock.unlock(); } }5. 生产环境里的性能调优Tomcat、磁盘IO、大文件缓冲方案跑通之后性能优化才是让系统真的能扛住生产压力的关键。巡检日志的上传窗口期高度集中早上班前一小时、下午班后一小时是上传高峰几十个巡检员同时传大文件服务端压力不小。5.1 Tomcat参数调整与上传限制Spring Boot内嵌Tomcat默认限制单次请求大小为1MB做分片上传前必须改。分片本身只要5MB所以单次请求限制调到10MB就够了不用为2GB的文件调大否则反而容易把线程池拖垮。spring.servlet.multipart.max-file-size10MB spring.servlet.multipart.max-request-size12MB同时把Tomcat的核心线程数和队列长度调整一下。分片上传的请求特点是并发量大、单请求处理时间短我按这个特征把最大线程数从默认的200调到400等待队列从默认的100调到200。server.tomcat.threads.max400 server.tomcat.threads.min-spare40 server.tomcat.accept-count2005.2 磁盘IO临时目录和正式目录不要放在同一块盘这个坑我踩过。一开始临时文件和最终文件都在应用服务器的同一块数据盘上巡检高峰期上传和合并同时进行磁盘IO直接打满上传速率从60MB/s掉到不到10MB/s。后来我把临时文件目录放到一块独立的固态盘上正式文件目录放在另一块盘上。分片写入和合并转正错开IO路径整个上传链路就通了。如果条件有限只有一块盘至少要把临时目录和正式目录分在不同目录层级避免ext4文件系统索引节点竞争。5.3 合并大文件时的内存控制合并分片不要用Files.readAllBytes去读整个文件2GB的文件直接OOM。我上面用Files.move做转正其实已经绕开了这个问题。但文件校验时计算MD5同样要注意要用流式计算每次读64KB缓冲区而不是一次性载入内存。private String calcMd5(Path file) throws IOException { MessageDigest md MessageDigest.getInstance(MD5); try (InputStream is Files.newInputStream(file)) { byte[] buffer new byte[65536]; int len; while ((len is.read(buffer)) ! -1) { md.update(buffer, 0, len); } } return Base64.getEncoder().encodeToString(md.digest()); }强调一句这块代码用Files.readAllBytes写过的人基本都见过OOM报错。6. 移入巡检业务后的实际效果与二次踩坑整套方案迁移到巡检日志系统之后我记录了三个月的运行数据。在同样的弱网条件下大附件上传成功率从改造前的不足六成提升到99.2%。剩余0.8%的失败也主要集中在服务端重启或存储节点故障这类极端情况断点续传机制本身没有成为瓶颈。但有几个业务层面的问题值得单独拿出来说。6.1 断点续传不等于断网续传有同事问过既然有断点续传那我在地下车库断网了回家再打开APP是不是文件还能继续传答案是除非把上传状态持久化到本地方便APP重启后恢复否则做不到。服务端的续传状态默认是24小时过期APP进程一旦被杀掉客户端持有的分片记录就丢了重新打开APP会变成全部重传。解决方案是把前端的分片上传记录用Room数据库持久化重新打开APP时先从本地数据库恢复未完成的上传任务。这块逻辑传统上归前端管但服务端要配合提供一个查询已收分片的接口前端才能知道从哪片续传。GetMapping(/upload/status/{fileId}) public ResponseEntityUploadStatusVO queryStatus(PathVariable String fileId) { Long uploaded stringRedisTemplate.execute( (RedisCallbackLong) connection - connection.bitCount((UPLOAD_CHUNK_KEY fileId).getBytes()) ); return ResponseEntity.ok(new UploadStatusVO(uploaded, totalChunks)); }6.2 同一文件多人重复上传的文件标识冲突巡检场景里经常出现好几台手机拍到同一个隐患点生成的视频内容非常相似MD5可能碰巧一致。如果服务端用MD5作为唯一文件标识就会误判成已经传过了直接返回秒传成功但文件内容是甲拍的隐患点乙的日志里配了一张别人的图片。解决方式是文件标识用用户ID加MD5的复合标识或者引入UUID作为主标识、MD5只做文件内容的完整性校验不承担文件唯一身份的功能。6.3 不要忽略服务端存储空间预检2GB的文件分片全部落盘后临时目录的占用会瞬时飙升。高峰期几十个文件同时上传轻轻松松占掉几十GB空间。我后来在每次新建上传任务时先做一个存储空间预检剩余空间低于阈值就直接拒绝新任务避免中途存储写满导致所有任务全部失败。File storeDir new File(tempDir); long freeBytes storeDir.getUsableSpace(); if (freeBytes RESERVED_SIZE) { throw new UploadException(存储空间不足请稍后再试); }这个逻辑跟断点续传没有直接关系但一个上传任务跑到99%因为磁盘满了挂掉是最让人懊恼的失败方式。先把这种底层隐患堵住断点续传用起来才踏实。6.4 巡检报告自动生成场景里的扩展断点续传的另一种用途文件上传只是第一步。巡检日志生成后系统还要把照片和视频打包成PDF或ZIP发送给安全部门。这里同样遇到了超大附件问题——一份包含几百张现场照片的报告压缩包动辄300MB通过邮件网关发送时频繁超时。后来我把断点续传的思路反过来用在下载侧报告生成后先持久化到文件服务器邮件里不放附件只放下载链接下载接口支持HTTP Range。这样即使下载中断也可以从断点继续拉取和上传侧的断点续传正好形成闭环。工程上省掉了邮件网关的超时改造用户体验还更好了。这个经验可以推广到所有把大文件从A系统搬到B系统的场景。数据搬运的速度永远受制于最差的那段链路与其想方设法提升链路的稳定性不如让搬运过程本身具备从中断处继续的能力。这就是断点续传最本质的价值。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑