首页 / 资讯中心 / 文章详情

Java实现大文件断点续传上传:分片策略与服务端代码详解

Java实现大文件断点续传上传:分片策略与服务端代码详解 ★ FEATURED ARTICLE
1. 为什么大文件上传需要断点续传前段时间给内部的素材管理模块做升级用户往里传的视频单个轻轻松松超过1.5GB还有不少压缩包能到3GB。最开始我没多想直接写了一个老式的POST接口接收MultipartFile就上线了。上线一周反馈群里全是骂声传到一半页面没响应、服务端内存报警、网关直接返回504。后来我把整个上传模块重构换成分片上传叠加断点续传的架构才把这堆问题压下去。这篇文章就围绕“JAVA如何编写支持断点续传的大文件上传示例”这条主线把方案设计、数据模型、服务端代码、前端切分和排坑经验完整讲一遍给准备做类似功能的同学一个可以直接参考的落地样板。1.1 传统上传方案撑不住大文件老式的单请求上传为什么在大文件面前就是不行不是代码写得不好而是方案本身有硬伤。第一个问题是传输链路太长。文件体积越大单个HTTP请求持续的时间就越久。浏览器、网关、负载均衡各层都有自己的超时设置几GB的文件在弱网下要传几十分钟中间任何一个环节超时、断网、进程重启整条连接就没了。用户只能从头开始这种体验放在生产环境基本等于劝退。第二个问题是服务端资源消耗。Spring的MultipartFile默认会把文件先落盘到Tomcat临时目录再以流方式处理看起来不占内存。但不少赶工期的项目会在中间把文件读成byte[]或者转了一次Base64大文件瞬间把堆内存打爆。即便是标准的落地模式Tomcat临时目录空间、磁盘IO、连接数也会被大文件上传拖到报警阈值附近。第三个问题是不确定性。用户传到92%断了再重传100%的成本。要是每次都从0开始网络稍差一点大文件上传就成了碰运气的事。这个痛点不是偶发而是高频所以断点续传并不是“进阶玩法”而是大文件上传场景里必须考虑的基础能力。1.2 断点续传的核心原理理解了痛点断点续传的思路就非常清晰说白了就四件事切分、记录、续传、合并。切分是把一个完整文件按固定大小切割成若干小分片。比如2GB的文件按5MB切分大约得到410个分片。每个分片都是独立的一次HTTP请求单次传输的数据量小超时风险和失败成本都跟着降下来了。记录是服务端必须维护一份“某个文件已经收到哪些分片”的状态清单。这份清单必须持久化不能只存在内存里否则服务端一重启用户前面传的几十个分片全部白费。具体怎么设计文章后面会展开。续传是用户网络恢复后重新上传时前端先向服务端查询这个文件还缺哪些分片只补传缺失的部分已经传过的分片直接跳过。这一步是整个方案体验的核心。合并是等所有分片全部到齐后按分片序号把文件重新拼成一个完整的文件并且做一致性校验。合并顺序如果出错上传流程看着成功了文件却是损坏的这是最坑人的状态。这套思路不是某个特定框架的专属设计主流云存储的分片上传接口底层也是同一套逻辑只是协议封装得更完整。自己用JAVA实现的好处是可控性最强从分片大小到合并策略都能按业务定制出了问题可以直接看源码排查。下面的内容就按这个思路逐步深入。2. 关键参数与数据模型设计很多人拿到需求就急着写Controller写到一半发现分片大小不合理、状态记录设计得不好又回头改表结构白白浪费两三天。先把几个关键参数和数据模型定下来后面的代码只是填空。2.1 分片大小如何取舍分片大小不是一个拍脑袋定的常数它取决于目标网络环境和业务场景。分片太小比如1MB好处是断点粒度细任意一个分片损坏重传成本很低。但一个2GB文件会被切成2048个分片每个分片都是一次完整的HTTP请求。请求数量暴增之后服务端的连接创建、日志写入、数据库记录操作都会成为瓶颈而且每多一次请求就多一次网络往返固定开销占比上升传输效率反而下降。分片太大比如50MB请求数量是少了单请求的传输时间又变长。一旦断网重传50MB依然很疼。弱网环境下传一个分片要好几分钟用户看着进度条几乎没动体验跟整包上传差不多。我在实际项目里的经验值是默认5MB。日常办公网络、普通家用宽带、移动4G/5G都能流畅处理这个粒度。如果是面向弱网用户分片降到2MB更合适如果只在内网或者高速骨干网里用直接提到10MB甚至20MB也没问题。分片大小要配合网络环境动态调整别一个配置走天下。这里给一个有助于定分片大小的粗略参考公式单分片耗时约等于分片大小除以期望最低带宽。假设目标用户最低带宽是500KB/s5MB的分片上传耗时约10秒如果最低带宽只有100KB/s5MB分片要传50秒前端就算三路并发也快不到哪去这时候就该把分片降到2MB。反过来内网带宽动辄几十MB/s用5MB分片反而制造了大量无谓的请求开销直接设20MB更合理。这个公式不是精确模型但它能帮你理解分片大小和网络条件之间的耦合关系。2.2 文件唯一标识的选择断点续传的前提是服务端能认出一份文件。业界常用做法是用文件的MD5值作为唯一标识也就是identifier服务端用这个标识区分不同文件记录分片上传的进度。前端计算MD5的思路是用FileReader按分片读取文件内容逐块喂给SparkMD5库最后算出整个文件的MD5字符串。这个计算完全在客户端进行不需要把文件本体发给服务端。1GB左右的文件现代浏览器通常十几秒内能算完配合进度提示不会让人误以为页面卡死。这里有一个取舍要讲清楚如果文件特别大比如20GB的素材文件前端全量计算MD5可能耗时几十秒甚至更久用户会很不耐烦。对这类场景可以采用抽样Hash方案取文件的首部2MB、中间2MB、尾部2MB连同文件大小拼接成一个字符串再对这个字符串计算MD5。速度快得几乎无感碰撞概率对业务上传场景完全可以接受。代价是identifier不再具备强校验能力合并完成后无法通过hash一致性比对文件完整性。我的建议是普通业务用完整MD5追求极致体验的媒体素材场景用抽样Hash。identifier本质上只是一个状态记录的关联键不是安全哈希够业务用就行别把它神化了。2.3 数据表设计状态记录我用了两张表一张记录文件整体上传进度一张记录单个分片的上传状态。先把两张表的字段定下来。文件表upload_file字段类型说明idBIGINT主键自增identifierVARCHAR(64)文件MD5标识唯一键filenameVARCHAR(255)原始文件名total_chunksINT总分片数uploaded_chunksINT已上传分片数file_pathVARCHAR(255)合并后存储路径statusTINYINT0上传中1已完成create_timeDATETIME创建时间update_timeDATETIME更新时间分片表upload_chunk字段类型说明idBIGINT主键自增identifierVARCHAR(64)文件MD5标识chunk_numberINT分片序号从1开始chunk_pathVARCHAR(255)分片落盘路径uploaded_timeDATETIME上传时间identifier和chunk_number组合起来建唯一索引从数据库层面防止同一个分片重复入库。为什么分片状态要单独建一张表而不是在文件表里开一个JSON数组字段之前接手过一个项目就是把分片状态塞在文件表里的每次查询进度都要反序列化整个数组每更新一个分片就要更新整条记录并发环境下特别容易出现覆盖。单独一张表以后统计进度就是一条select count查询已上传分片就是按identifier扫一下逻辑清晰并发也更安全。两张表之间的关联就是identifier这个字段简单直接。3. 服务端核心代码实现服务端我按Spring Boot 3 MyBatis-Plus MySQL来讲这套组合在中小团队里足够通用。下面把依赖、分片上传、断点查询、合并与校验几个核心点拆开说。3.1 项目依赖与环境准备依赖只需要三个核心组件没有额外引入复杂的文件处理框架。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId /dependency上传分片本质上就是MultipartFile不需要额外引库。在application.properties里要把Spring的multipart限制调大spring.servlet.multipart.max-file-size10MB spring.servlet.multipart.max-request-size10MB之所以设10MB而不是5MB是因为单个分片请求里除了分片文件本身还带了identifier、chunkNumber、totalChunks这些表单字段请求整体体积略大于5MB。这里多留一倍余量避免边缘情况下直接触发异常。如果你的分片定为2MB那max-file-size设4MB就够。另外要关注Tomcat临时目录的空间。Spring处理MultipartFile时会先在临时目录里写入文件再通过transferTo移动到目标位置。默认临时目录通常在系统/tmp下如果机器/tmp空间很小分片转存前就可能磁盘写满。建议显式指定java.io.tmpdir到一个大磁盘目录或者定期检查临时目录占用。3.2 分片上传接口实现先看实体类UploadFileEntity对应文件表使用MyBatis-Plus注解Data TableName(upload_file) public class UploadFileEntity { TableId(type IdType.AUTO) private Long id; private String identifier; private String filename; private Integer totalChunks; private Integer uploadedChunks; private String filePath; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; }分片实体UploadChunkEntity的表结构跟文件表思路一致字段包括id、identifier、chunkNumber、chunkPath、uploadedTime。Controller层不需要写业务逻辑只负责接收参数并透传给Service原因很简单上传接口涉及落盘、计数、合并触发三个动作如果全部堆在Controller里后面再加清理、加校验、加并发锁时代码会迅速膨胀到难以维护。拆到Service层接口变更只需要改一处。Slf4j RestController RequestMapping(/api/upload) public class UploadController { Resource private UploadService uploadService; PostMapping(/chunk) public ResultVoid uploadChunk( RequestParam(file) MultipartFile file, RequestParam(identifier) String identifier, RequestParam(chunkNumber) Integer chunkNumber, RequestParam(totalChunks) Integer totalChunks, RequestParam(filename) String filename) { boolean ok uploadService.saveChunk(file, identifier, chunkNumber, totalChunks, filename); return ok ? Result.success() : Result.error(分片保存失败); } }Service层的saveChunk是断点续传的核心我拆成五步实现Override public boolean saveChunk(MultipartFile file, String identifier, Integer chunkNumber, Integer totalChunks, String filename) { File chunkDir new File(CHUNK_DIR identifier); if (!chunkDir.exists()) { chunkDir.mkdirs(); } File chunkFile new File(chunkDir, chunkNumber .chunk); try { file.transferTo(chunkFile); } catch (IOException e) { log.error(分片保存失败 identifier{}, chunk{}, identifier, chunkNumber, e); return false; } // 分片记录去重重复上传同一分片时只保留一条记录 UploadChunkEntity existing chunkMapper.selectOne( new LambdaQueryWrapperUploadChunkEntity() .eq(UploadChunkEntity::getIdentifier, identifier) .eq(UploadChunkEntity::getChunkNumber, chunkNumber)); if (existing null) { UploadChunkEntity entity new UploadChunkEntity(); entity.setIdentifier(identifier); entity.setChunkNumber(chunkNumber); entity.setChunkPath(chunkFile.getAbsolutePath()); chunkMapper.insert(entity); } // 统计该文件已上传分片总数 Long uploaded chunkMapper.selectCount( new LambdaQueryWrapperUploadChunkEntity() .eq(UploadChunkEntity::getIdentifier, identifier)); // 更新文件整体进度 UploadFileEntity fileEntity fileMapper.selectOne( new LambdaQueryWrapperUploadFileEntity() .eq(UploadFileEntity::getIdentifier, identifier)); if (fileEntity null) { fileEntity new UploadFileEntity(); fileEntity.setIdentifier(identifier); fileEntity.setFilename(filename); fileEntity.setTotalChunks(totalChunks); fileEntity.setUploadedChunks(uploaded.intValue()); fileEntity.setStatus(0); fileMapper.insert(fileEntity); } else { fileEntity.setUploadedChunks(uploaded.intValue()); fileMapper.updateById(fileEntity); } // 全部分片到齐则触发合并 if (uploaded.intValue() totalChunks) { mergeChunks(identifier, filename); } return true; }这块有几个容易踩坑的点我逐一说明。transferTo方法在Spring Boot内嵌Tomcat下会把临时目录中的文件移动或复制到目标位置。如果源目录和目标目录不在同一个分区底层实际是复制再删除所以目标目录所在磁盘空间必须留足不然后半段复制时会报No space left on device。分片记录必须做去重。前端并发上传时网络重试很容易造成同一个chunkNumber被重复提交。如果不判断直接insert会触发唯一索引冲突。我的写法是先查再插高频接口这点损耗可以接受数据库唯一索引作为兜底就算真有并发穿透捕获DuplicateKeyException之后按幂等成功处理即可。合并时机我放在保存分片之后每次判断已上传分片数是否达到totalChunks。分片数量在几千个以内时这种判断方式足够简单可靠。如果你的文件被切成上万分片每次insert都count一次数据库会有压力可以改成用文件表自身的uploadedChunks字段递增配合乐观锁更新思路见后面的并发控制章节。3.3 断点查询接口实现用户断线重传或者重复上传时前端第一步调用check接口拿到已上传分片的列表和整体完成状态GetMapping(/check) public ResultCheckResponse check(RequestParam(identifier) String identifier) { ListInteger uploadedList chunkMapper.selectObjs( new LambdaQueryWrapperUploadChunkEntity() .select(UploadChunkEntity::getChunkNumber) .eq(UploadChunkEntity::getIdentifier, identifier) .orderByAsc(UploadChunkEntity::getChunkNumber)); UploadFileEntity entity fileMapper.selectOne( new LambdaQueryWrapperUploadFileEntity() .eq(UploadFileEntity::getIdentifier, identifier)); CheckResponse resp new CheckResponse(); resp.setUploadedChunks(uploadedList); resp.setFinished(entity ! null entity.getStatus() 1); return Result.success(resp); }这个接口的巧妙之处在于顺带实现了“秒传”逻辑。如果文件之前已经完整上传过check接口返回finishedtrue前端直接提示“秒传完成”用户不花一分流量。这在大文件重复上传的场景里非常受用比如设计团队反复提交同一个宣传视频素材秒传的体验差一天一地。接口的细节是uploadedList要按chunkNumber升序返回前端拿这个列表直接构造Set判断哪些分片缺失。排序放在SQL里做不要取回来再排一边减少数据传输量一边也让前端逻辑更简单。3.4 分片合并与文件校验合并是断点续传里最容易出错的环节。合并第一步是排序第二步是顺序写入第三步是清理临时分片public void mergeChunks(String identifier, String filename) { File chunkDir new File(CHUNK_DIR identifier); File[] chunkFiles chunkDir.listFiles((dir, name) - name.endsWith(.chunk)); if (chunkFiles null || chunkFiles.length 0) { return; } // 按分片序号排序这一步不能省 Arrays.sort(chunkFiles, Comparator.comparingInt( f - Integer.parseInt(f.getName().replace(.chunk, )))); File targetFile new File(FILE_DIR filename); try (BufferedOutputStream bos new BufferedOutputStream(new FileOutputStream(targetFile))) { byte[] buffer new byte[1024 * 1024]; for (File chunkFile : chunkFiles) { try (FileInputStream fis new FileInputStream(chunkFile)) { int len; while ((len fis.read(buffer)) ! -1) { bos.write(buffer, 0, len); } } } bos.flush(); } catch (IOException e) { log.error(分片合并失败 identifier{}, identifier, e); return; } // 合并成功后删除临时分片目录 deleteDir(chunkDir); UploadFileEntity entity fileMapper.selectOne( new LambdaQueryWrapperUploadFileEntity() .eq(UploadFileEntity::getIdentifier, identifier)); if (entity ! null) { entity.setFilePath(targetFile.getAbsolutePath()); entity.setStatus(1); fileMapper.updateById(entity); } }为什么我不推荐RandomAccessFile按偏移量写入很多教程里推荐先创建目标文件再用RandomAccessFile.seek(chunkNumber * chunkSize)定位偏移把分片直接写进对应位置。听起来优雅理论上支持乱序写入不用最后的排序步骤。但前提是除最后一个分片外所有分片大小必须严格一致并且前端要保证每个分片都不丢失、不重复。一旦有一个分片的大小因为切片边界算错而偏差后面所有分片的偏移量全部错位合并出来的文件妥妥损坏而且极难排查。分片独立存储、最后排序合并的方式虽然多了一次文件顺序读写但逻辑链路清晰任何一个分片坏了都能精确定位到序号生产环境调试成本低得多。合并前后的完整性校验也要做。如果前端上传的是完整MD5合并完成后可以在服务端重新计算目标文件的MD5与identifier比对一致才真正标记完成String mergedMd5 DigestUtils.md5DigestAsHex(new FileInputStream(targetFile)); if (!mergedMd5.equalsIgnoreCase(identifier)) { log.error(MD5校验失败 identifier{}, identifier); }校验不通过说明有分片重复、缺失或者合并顺序错误必须把上传状态置为失败保留残缺分片用于排查而不是假装成功。文件校验上了线后台的素材才不会出现“显示上传成功但打开报错”的灵异问题。另外注意合并后的文件名不要直接使用用户的原始filename不同用户上传同名文件时后者会把前者的合并结果覆盖掉。我在落地时会在文件名尾部追加identifier的前8位或者时间戳比如video_20240915_a1b2c3d4.mp4既保留可读性又避免同名冲突文件名本身不参与业务主键展示时再单独维护一份映射关系。4. 前端切分与并发控制服务端准备好以后前端是另一半重点。断点续传不只是后端的事没有前端切分所有后端逻辑都无从谈起。4.1 文件切片与MD5计算浏览器端先用Blob.slice()把文件切成指定大小注意slice是文件对象的切片方法按字节切出的每一段都是独立Blob可以直接塞进FormDataconst CHUNK_SIZE 5 * 1024 * 1024; function createChunks(file) { const totalChunks Math.ceil(file.size / CHUNK_SIZE); const chunks []; for (let i 0; i totalChunks; i) { const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); chunks.push({ chunkNumber: i 1, blob: file.slice(start, end) }); } return chunks; }MD5计算用SparkMD5的ArrayBuffer模式读取文件时要边读边算不能把整个文件一次性读进内存async function calcFileMD5(file) { const spark new SparkMD5.ArrayBuffer(); const totalChunks Math.ceil(file.size / CHUNK_SIZE); let currentChunk 0; return new Promise((resolve, reject) { const reader new FileReader(); const loadNext () { const start currentChunk * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); reader.readAsArrayBuffer(file.slice(start, end)); }; reader.onerror reject; reader.onload (e) { spark.append(e.target.result); currentChunk; if (currentChunk totalChunks) { loadNext(); } else { resolve(spark.end()); } }; loadNext(); }); }FileReader的onload是异步回调用递归方式逐块append最后通过spark.end()拿到完整MD5。这里有个体验细节算MD5阶段要有一个独立的进度提示否则用户选完一个2GB文件发现页面卡了几十秒会以为浏览器崩溃了。实际表现是算MD5的进度和上传的进度分开展示能大幅降低用户的焦虑感。4.2 断点续传完整流程完整流程用下面这个函数概括async function handleUpload(file) { const chunks createChunks(file); const identifier await calcFileMD5(file); // 查询服务端已上传分片 const checkResult await axios.get(/api/upload/check, { params: { identifier } }); if (checkResult.data.finished) { alert(文件已存在秒传完成); return; } const uploadedSet new Set(checkResult.data.uploadedChunks); const pendingChunks chunks.filter(c !uploadedSet.has(c.chunkNumber)); if (pendingChunks.length 0) { alert(全部分片已上传等待服务端合并); return; } // 并发上传剩余分片 await concurrentUpload(file, identifier, chunks.length, pendingChunks, 3); alert(上传完成); }这套流程的容错点是无论上次上传中断了多少次只要同一个文件被再次选中identifier始终不变服务端check之后自然只补缺失分片。用户不需要手动“续传”重新拖一次文件就恢复了。真正意义上把“从头再来”的体验消灭在流程设计里。4.3 并发数与进度管理并发上传的核心是控制同时在途的请求数。几百个分片一次性全部发出服务端连接数、数据库连接池瞬间被打满跟分片上传的初衷背道而驰。我习惯用一个简单的worker池子限制并发为3async function concurrentUpload(file, identifier, totalChunks, pendingChunks, limit, onProgress) { const queue [...pendingChunks]; let completed totalChunks - queue.length; const worker async () { while (queue.length 0) { const chunk queue.shift(); try { await uploadChunk(file, chunk, identifier, totalChunks); completed; onProgress onProgress(Math.floor(completed / totalChunks * 100)); } catch (e) { // 单个分片失败重试3次仍失败则抛出终止 for (let retry 0; retry 3; retry) { try { await uploadChunk(file, chunk, identifier, totalChunks); completed; break; } catch (retryErr) { if (retry 2) throw retryErr; } } onProgress onProgress(Math.floor(completed / totalChunks * 100)); } } }; const workers []; for (let i 0; i limit; i) { workers.push(worker()); } await Promise.all(workers); } async function uploadChunk(file, chunk, identifier, totalChunks) { const formData new FormData(); formData.append(file, chunk.blob); formData.append(identifier, identifier); formData.append(chunkNumber, chunk.chunkNumber); formData.append(totalChunks, totalChunks); formData.append(filename, file.name); await axios.post(/api/upload/chunk, formData, { timeout: 0 }); }并发数为什么是3而不是10因为大文件上传的瓶颈通常不在服务端处理能力而在用户带宽和网关连接数。3路并发可以在传输效率和连接占用之间取得平衡上传进度条也能保持稳定跳动。当然如果目标是千兆内网并发调到8也没问题关键是参数可配而不是写死。进度计算我推荐以分片数为单位总进度等于已上传分片数除以总分片数。因为每个分片大小基本一致这个误差很小。单个分片内部还可以用axios的onUploadProgress去展示当前分片的传输进度把两个维度叠加起来UI上能呈现出很细腻的进度变化但核心的断点恢复数据仍然来自服务端的uploadedChunks列表本地进度只是一个展示投影不是可靠性依据。5. 避坑实录与常见问题这章把我在实际项目里踩过的坑和上线后遇到的问题整理成速查表按出现频率排序每一条都值得你在自测时重点关照。5.1 合并顺序与丢片问题合并乱序是断点续传最常见的问题。表现是服务端明明收到了全部分片合并后的文件却是坏的预览打不开、压缩包解压失败、图片花屏。原因基本就两个合并时没有按chunkNumber排序或者某些分片文件本身是不完整的重复文件。排查思路是先看分片文件大小是否正常再看合并日志中的排序结果。分片文件大小异常通常和前端slice的时候start、end算错有关排序异常则检查Comparator是否写对。我在mergeChunks里特意用纯数字解析排序而不是字符串排序因为字符串比较时10会排在2前面这种错乱极其隐蔽不仔细观察根本发现不了。丢片问题更多出现在并发场景。前端以为某个分片上传成功实际上请求在传输途中网络断开服务端并没有收到。我的经验是前端判断分片成功的唯一标准是服务端响应200不要在本地自己伪造成功状态。check接口天然能纠正这个问题下次续传会自动补上缺失分片但前提是前端必须真的调用check而不是自以为全部传完就跳过。5.2 网关与容器限制很多项目本地测试一切正常一上测试环境就上传失败十有八九是网关挡在中间。Nginx默认的client_max_body_size是1MB这个限制直接堵在应用前面5MB的分片请求会返回413。需要在配置里显式放开client_max_body_size 0; proxy_request_buffering off;client_max_body_size设为0表示不限制请求体大小最省心。proxy_request_buffering off关闭了Nginx对请求体的缓冲避免请求被Nginx整段写入磁盘再转给后端降低了大文件场景下的延迟和磁盘占用。如果用了Spring Cloud Gateway或者其他网关还要检查网关层是否对body大小和超时时间有限制。分片上传是多个请求的接力单个分片耗时可控但整体链路很长。网关的超时时间要按“最大分片数乘以单分片耗时”来评估而不是按“单请求最大耗时”来设否则上传还在进行网关先断开连接前端收到一个莫名其妙的超时错误查起来特别费劲。5.3 状态持久化与重启恢复断点续传方案里最怕服务重启后状态清零。如果把状态只放在内存缓存里重启之后用户上传了一半的分片虽然还躺在磁盘上但服务端不认了前端check时返回空列表用户只能全部重传整个断点续传名存实亡。解决方式很直接状态必须落到数据库。我们的upload_file和upload_chunk两张表每次分片写入都会同时更新记录。服务重启之后这些数据还在断点续传天然恢复。这里强调一句不要为了省事用本地内存Map做去重判断分布式环境下每个实例的内存是独立的不同实例看到的状态不一样最后合并时凑不齐分片。状态存储至少要选中间件但从可靠性和实现成本看直接用数据库表最稳妥。如果服务端是多实例部署还得考虑分片文件存哪里的问题。分片写到某一个实例的本地磁盘意味着同一个文件的不同分片可能落在不同机器上合并时从哪台机器取文件都不全。最简单的方案是单实例部署把这块逻辑跑通多实例则必须把分片目录放到共享文件存储比如NFS挂载点或者S3对象存储只有让所有实例看到同一个分片目录断点续传在集群里才能成立。5.4 脏分片清理与幂等设计上线跑一段时间后服务器磁盘被一堆永远不合并且的分片目录占满这是必然遇到的运维问题。用户传了一半主动放弃、网络反复失败、浏览器直接被关掉都会产生脏分片。我加了一个定时清理任务每天凌晨执行Scheduled(cron 0 0 3 * * ?) public void cleanExpiredChunks() { ListUploadFileEntity pendingList fileMapper.selectList( new LambdaQueryWrapperUploadFileEntity() .eq(UploadFileEntity::getStatus, 0) .lt(UploadFileEntity::getUpdateTime, LocalDateTime.now().minusHours(24))); for (UploadFileEntity entity : pendingList) { deleteDir(new File(CHUNK_DIR entity.getIdentifier())); fileMapper.deleteById(entity.getId()); } }清理阈值设为24小时超过24小时仍未完成的记录连同分片目录直接删除。如果业务存在超大文件在弱网下老老实实传了24小时以上还没结束的情况阈值就要放宽或者提供一个主动上传接口每次有新的分片上传成功就刷新update_time让系统知道这个上传任务还活着。幂等设计是另一个容易被忽略的点。用户重试上传同一个分片服务端不能重复插入分片记录否则统计出来的已上传分片数会超过totalChunks。saveChunk里的查重逻辑和数据库唯一索引就是为了解决这个问题。另外合并操作本身也要幂等否则多个分片的并发请求同时触发合并判断mergeChunks可能会被并发执行两次两个线程同时写目标文件文件被互相覆盖结果彻底错乱。我在工程里就是给合并入口加了一个数据库字段状态机判断先把status从0更新为2再执行合并执行完置为1其他线程看到2就直接跳过这样才彻底规避了并发合并问题。最后再分享一个实际教训上面说的并发合并问题我的项目里真实发生过。两个分片请求同时到达服务端触发了两次合并日志里出现文件被占用的异常最终文件损坏。后来加了状态机判断才解决。断点续传看起来技术不复杂但把边界情况都处理干净还是要花不少心思。希望这篇文章提到的这些坑能帮你少走一段弯路。
阅读完成 · 觉得有帮助?
咨询建站