ARTICLE DETAIL

资讯详情

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

秒传与分块上传:大文件上传的完整实现方案

秒传与分块上传:大文件上传的完整实现方案 1. 秒传与分块上传先分清这两个概念才不会做重复设计做Java网页开发时我见过太多人把“秒传”和“分块上传”混为一谈结果设计出来的方案要么是传了一堆没用的分片要么是秒传逻辑在超大文件面前完全失效。先说清楚这两个东西解决的是完全不同的问题。秒传解决的是“能不能不传”的问题。它的核心逻辑是如果你要上传的文件服务器上已经存在一份一模一样的那就没必要再传一遍直接告诉你“上传成功”。判断“一模一样”靠的是文件内容哈希最常见的是MD5而不是文件名、文件大小这些不可靠的元数据。它本质上是去重是上传链路上的“前置拦截器”。分块上传解决的是“怎么传得动”的问题。当一个文件大到几百MB甚至几个GB时一次性交给HTTP协议传输哪怕内网环境都容易超时中断。分块上传把文件切成若干个独立的小块分别在客户端请求中上传服务端接收完全部块后再合并。它的价值在于失败回退的颗粒度整包传失败要重头再来分块传失败只需要重传那一个块。这两个能力放在同一个上传体系里正确的关系是先秒传判断再分块上传。用一句话概括通过文件指纹判断是否“已经存在”若存在直接短路完成若不存在则进入分块上传流程对分片做断点续传和合并校验。这样既省流量又防超时还天然支持了“传到一半刷新页面不用重新传”的体验。这个组合方案特别适合这类场景企业内部OA系统上传视频/图纸、网盘类应用的批量文件入库、带附件的工单系统。普通网页开发里简单地用input typefile提交大文件接到生产环境早晚被真实用户教做人。下面直接进入整体设计。2. 整体架构与关键选型为什么把指纹计算放在前端而不是后端先给出一张全流程的模块划分然后再逐一解释每个模块“为什么这样做”。前端读取本地文件计算文件指纹按固定大小切片并发或串行上传分片断点续传时主动查询“已上传分片”。服务端提供秒传校验接口、分片上传接口、合并接口用Redis记录分片上传状态用MySQL保存文件元数据。存储层合并后的最终文件落在本地磁盘或对象存储临时分片放在独立目录依靠定时任务清理。这套模块划分里最容易引起争议的选型是文件指纹在前端算还是后端算。我的结论是前端算。原因腰不算复杂。一个1GB的文件后端算MD5意味着客户端先把整个文件通过HTTP上传给服务器服务器算完指纹再做秒传判断——那这个秒传还有什么意义流量一点没省。前端用Web Worker异步计算MD5虽然也会消耗用户的CPU但在现代浏览器里配合File.slice()分片读取2GB级别的文件也就几秒到十几秒这个成本可以接受。后端只负责接收指纹做比对不参与文件内容的重复计算。文件指纹算法方面我在实际项目中默认用MD5。虽然SHA-256更安全但在秒传去重场景下我们并不对抗恶意攻击MD5的碰撞概率对于普通业务完全够用。如果你非要不放心可以改成SHA-256前端用crypto-js或者SparkMD5都能实现差别只在于计算耗时略长。分片大小的选择也直接影响体验下面直接给出我踩坑后的推荐值。文件大小区间推荐分片大小说明100MB以内2MB分片数量少合并开销低100MB-1GB5MB平衡请求次数与失败重传粒度1GB以上10MB减少请求数量配合并发上传更稳分片太大单块上传超时风险上升失败重传的代价也大分片太小HTTP请求数量暴增服务器接口压力陡增合并时的文件排序和IO开销也不划算。经验法则是让小文件的分片数量控制在50-100个以内大文件不超过500个。3. 秒传判定链路从提交一刻到“提示已存在”的完整过程秒传的判定看似简单但真正落地时要处理缓存、持久化、并发穿透三层问题。我在生产环境中实际使用的接口流程大致分为以下几步。3.1 指纹查询接口Redis缓存 数据库记录的二级判定前端计算出MD5之后调用一个类似于GET /api/file/exists?md5xxx的接口。服务端逻辑是先查Redis再查MySQL。GetMapping(/exists) public ResultFileExistsVO exists(RequestParam String md5) { // 第一级Redis 快速判定 Boolean cached stringRedisTemplate.hasKey(FILE_MD5_KEY md5); if (Boolean.TRUE.equals(cached)) { return Result.success(FileExistsVO.alreadyExists()); } // 第二级数据库兜底防止 Redis 数据丢失 FileRecord record fileRecordMapper.selectByMd5(md5); if (record ! null) { stringRedisTemplate.opsForValue() .set(FILE_MD5_KEY md5, record.getFilePath(), 24, TimeUnit.HOURS); return Result.success(FileExistsVO.alreadyExists()); } return Result.success(FileExistsVO.notExists()); }Redis那层是为了扛住高频查询压力数据库那层是为了保底。这里有一个关键细节Redis里存的key是MD5value是文件的存储路径或文件ID。因为一旦并发秒传命中多个用户同时上传同一个文件数据库只允许一条记录建立“物理文件与逻辑文件”的映射其他人直接引用这个文件ID即可。这也是秒传后文件去重的核心不是复制文件内容只是插入一条指向已存在文件的元数据。3.2 秒传命中后的处理返回虚拟路径还是复制文件秒传命中后你要考虑一个业务问题如果用户A上传了一个文件用户B秒传命中“同一份文件”B系统里的“我的附件”应该怎么体现我在设计时采用的做法是物理文件只保留一份逻辑记录各自独立。数据库里“file_record”表存的是物理文件的唯一记录包含md5、存储路径、大小“user_file”表存的是每个用户与文件的关联记录包含user_id、file_record_id、上传时间、业务类型。这样A删除了他自己那份关联底下的物理文件不会被删掉因为B的关联还在。只有当某个物理文件的关联记录数为零时才真正删除物理文件。这个状态下秒传命中的接口返回值就应该是文件ID、文件路径、文件名这些完整信息前端直接把这个值当作“上传成功”的结果去提交表单和正常上传后的返回值保持一致不需要任何特殊分支。3.3 秒传误判的边界同一个MD5不同文件名、权限、过期秒传最容易被忽视的坑之一是文件名不同。同一个文件的MD5是一致的但用户的文件名可能是“毕业设计终版(2).pdf”和“毕业设计最终版.pdf”秒传命中后如果直接把数据库里已有的文件名返回给用户显然不符合业务预期。所以文件名一定要以当前用户提交的文件名为准路径则沿用已存储路径。另一个边界是权限控制。举个例子用户A上传了一份仅自己可见的加密文档用户B如果也能通过秒传接口命中同一份文件那就构成了越权访问。严格的做法是秒传查询时要附带一个业务维度比如“所属部门”“上传批次”“可见范围”命中条件必须同时满足MD5一致和权限范围匹配。如果拿不准宁可让用户重新传一份也不要把别人的文件泄露给不相关的人。4. 分块上传核心接口设计上传分片、断点续传、合并与校验当秒传判断为不存在时前端立即转入分块流程。这块我直接给出三个核心接口的设计思路和Java实现要点分别是初始化上传、上传分片、合并文件。4.1 三个接口与状态机先定义上传任务的状态机整个过程围绕它推进INIT前端调用初始化接口服务端生成uploadId分配临时目录 UPLOADING前端逐块上传Redis记录已上传分片编号 COMPLETED所有分片upload完成服务端开始合并 FAILED合并失败或超时允许重试合并初始化接口负责返回一个全局唯一的uploadId同时服务端在磁盘上创建一个临时目录。这个uploadId需要关联文件md5、原始文件名、总分片数等信息可以存Redis也可以建一张upload_task表。我个人更推荐MySQL落库原因很简单分片上传常常要跨很长时间用户上传到一半去开会了回来继续传Redis里的数据如果恰好过期用户的“续传”就直接失效了。PostMapping(/init) public ResultUploadInitVO init(RequestBody UploadInitReq req) { String uploadId UUID.randomUUID().toString().replace(-, ); // 临时目录按照 uploadId 建目录避免多个任务的文件混杂 File tmpDir new File(uploadRootDir, uploadId); tmpDir.mkdirs(); UploadTask task new UploadTask(); task.setUploadId(uploadId); task.setMd5(req.getMd5()); task.setFileName(req.getFileName()); task.setTotalChunks(req.getChunks()); task.setStatus(INIT); uploadTaskMapper.insert(task); return Result.success(new UploadInitVO(uploadId)); }4.2 上传分片的实现细节临时目录命名、分片序号校验分片上传接口是核心中的核心。前端请求参数至少包含uploadId、当前分片序号chunkIndex、总分片数totalChunks、分片文件本身。服务端要做的事情是校验uploadId对应的任务状态不是已完成或已失效。把上传的分片文件写到临时目录命名格式建议直接用{uploadId}_{chunkIndex}.part方便合并时按索引排序。记录该分片已上传。这里我选择Redis的SET集合key是upload:{uploadId}:chunks添加的member是chunkIndex。记完了顺便设置一个过期时间兜底比如24小时。PostMapping(/chunk) public ResultVoid uploadChunk(UploadChunkReq req, RequestParam(file) MultipartFile file) { String uploadId req.getUploadId(); String taskKey upload: uploadId :chunks; // 写入临时分片 File chunkFile new File(uploadRootDir uploadId, uploadId _ req.getChunkIndex() .part); file.transferTo(chunkFile); // 记录已上传分片编号 stringRedisTemplate.opsForSet().add(taskKey, String.valueOf(req.getChunkIndex())); stringRedisTemplate.expire(taskKey, 24, TimeUnit.HOURS); return Result.success(); }这里有个很实在的教训不要盲目开启并发上传。我在早期的版本里用前端限制了3个并发上传分片结果高并发分片请求到达服务端时Tomcat默认的线程池被占满其他业务接口也跟着瘫痪。后来把前端的并发数降到了2同时在后端针对上传接口单独配置了线程池隔离才解决问题。如果你用的是原生Servlet或Spring Boot默认配置请一定注意上传接口的线程资源隔离。4.3 合并分片的实现顺序、完整性校验、文件锁当所有分片上传完成后前端调用合并接口。这里的合并不是简单地“把文件拼起来”而是一个带有完整校验的IO操作。PostMapping(/merge) public ResultFileRecordVO merge(RequestBody UploadMergeReq req) { String uploadId req.getUploadId(); UploadTask task uploadTaskMapper.selectByUploadId(uploadId); // 校验已上传分片数量是否等于总分片数 Long uploadedCount stringRedisTemplate.opsForSet() .size(upload: uploadId :chunks); if (uploadedCount null || uploadedCount ! task.getTotalChunks()) { return Result.error(分片未上传完整缺失 (task.getTotalChunks() - uploadedCount) 个分片); } // 按序号依次合并 File mergedFile new File(finalDir, task.getFileName()); try (FileOutputStream fos new FileOutputStream(mergedFile)) { for (int i 0; i task.getTotalChunks(); i) { File chunkFile new File(tmpDir, uploadId _ i .part); if (!chunkFile.exists()) { throw new RuntimeException(分片缺失index i); } Files.copy(chunkFile.toPath(), fos); } } // 合并后重新计算一次 MD5与服务端初始化时记录的 md5 做比对 String mergedMd5 DigestUtils.md5DigestAsHex( new FileInputStream(mergedFile)); if (!mergedMd5.equals(task.getMd5())) { mergedFile.delete(); return Result.error(合并后文件校验失败); } // 成功后删除临时目录和分片同时把 Redis 状态标记为完成 FileUtils.deleteDirectory(tmpDir); return Result.success(...); }这个合并过程的完整校验非常重要。分片上传依赖于每个分片在传输过程中的正确性但HTTP传输偶尔会遇到内容截断、编码转换错误等问题分片本身如果出了问题合并出来的整体文件MD5必然不匹配。所以合并后必须重新计算一次完整文件MD5和初始化时前端计算的MD5做比对不一致直接删除合并结果返回失败让用户重试。这一步是整套方案可靠性的底线。合并接口还需要注意一个并发问题用户连续点击“上传完成”按钮可能导致两个合并请求同时执行。我通常会加一把文件锁以uploadId为粒度在内存或Redis中加锁防止二次合并产生文件占用冲突。4.4 断点续传前端如何获取“已上传分片列表”断点续传其实是分块上传一个顺水推舟的结果。当用户上传到一半刷新页面或断网重新进入页面后前端先调用初始化接口或者直接用本地记录的uploadId再调用查询接口获取已经上传成功的分片编号跳过这些分片继续传剩下的。查询接口实现很简单GetMapping(/chunks/{uploadId}) public ResultSetString uploadedChunks(PathVariable String uploadId) { SetString members stringRedisTemplate.opsForSet() .members(upload: uploadId :chunks); return Result.success(members ! null ? members : Collections.emptySet()); }前端拿回这个集合后遍历所有分片若集合中不包含该分片序号则执行上传。这样的断点续传粒度精确到分片用户体验已经是相当顺滑了。5. 常见的坑与排查链路为什么总是“只差最后一片”就失败分块上传秒传方案在原理层面并不复杂但落地到生产环境时各种边界问题会接踵而来。这一节我把踩过的坑和排查思路完整写下来。5.1 并发上传导致的分片覆盖问题现象前端开启多个并发上传时偶尔出现文件合并后MD5不匹配甚至合并时提示某分片文件为空。根因前端的并发请求并不是严格按照chunkIndex的顺序发出去的而服务端写入分片用的是文件名{uploadId}_{chunkIndex}.part。如果前端因为某些原因重发了同一分片而服务端又没做“已存在则跳过”的操作后写入的分片可能覆盖之前已经写入的分片。这里的前端bug是重试逻辑写得不严谨比如在HTTP超时后同一个分片被提交了两次而服务端每次接收都直接执行file.transferTo()覆盖。解决方案分两层。前端确保每个分片只有在上传失败后才重试且重试期间禁用该分片的重复提交。后端不能只依赖前端这个约定还应该在写入分片前检查Redis集合里是否已存在该分片编号存在则直接返回成功不再写文件。5.2 合并时磁盘空间不足与OOM现象合并1GB以上的大文件时服务端报IOException或者内存溢出。根因很多人实现合并时图省事把所有分片都读取到内存再写出去比如先ListByteArrayOutputStream收集再统一写入。文件大了之后JVM堆直接被撑爆。另外临时分片目录和最终文件目录如果放在同一个磁盘分区磁盘空间在“分片全量上传合并”期间会出现双份占用空间不足时合并必然失败。解决方案合并必须使用流式读写严格保持“读一个分片写一个分片”。更稳妥的做法是在初始化时检查磁盘剩余空间确保大于等于文件预估大小的1.5倍否则直接拒绝初始化。5.3 临时文件清理策略上了生产环境才遇到的头疼事分片上传总会产生临时文件用户传到一半放弃、合并成功后没删除临时目录都会让临时文件堆积。最坏的情况是某个上传任务初始化后一直没有任何分片上传临时目录永远空着但目录本身占着inode。我的做法是三层清理机制合并成功后立即物理删除临时目录。定时任务每天扫描临时根目录删除超过48小时的目录。Redis中记录的任务状态若7天未更新为COMPLETED自动标记为过期同时触发数据库任务状态更新。这三层机制配合起来基本不会出现磁盘被临时文件塞满的线上事故。唯一要注意的是定时任务不要和业务高峰期重叠通常放在凌晨执行。5.4 大文件MD5计算导致前端卡死现象计算一个2GB文件的MD5时页面直接卡住滚动不动。根因很多前端实现用纯JavaScript循环读取文件块并累加计算MD5这个过程是同步的阻塞了浏览器主线程。文件越大阻塞时间越长。解决方案使用Web Worker在后台线程计算MD5。主线程只负责把文件切片交给WorkerWorker内部用SparkMD5之类的库按顺序读取分片并累加哈希值完成后再把最终MD5回传主线程。这样页面不会卡死用户还能看到计算进度的动画。我实测过1.5GB文件在普通笔记本上用Web Worker计算MD5大约需要8秒左右体验完全可以接受。6. 从“能跑”到“好用”性能验证与参数调优实战最后聊一聊方案在真实负载下的表现以及我从验证中总结的调优参数这会直接影响你把这套方案搬到线上时的信心。我拿一个实际项目举例某内部系统需要上传视频培训资料文件典型大小在500MB到2GB之间高峰期同时在线上传人数约50人。整个方案上线后我专门做了三轮测试。第一轮是秒传命中率测试。因为内部员工经常重复上传公司统一发布的培训视频秒传命中率大约在30%左右。按照平均文件大小1GB、单位流量成本粗略估算光是这30%的命中每天就省下了几十GB的上传带宽。这个数据充分说明在文件重复率较高的业务场景中秒传的ROI极高。第二轮是分块上传的稳定性测试。500MB文件切成5MB分片即100个分片前端串行上传整体耗时大约4分钟。开启2路并发后时间缩短到2分半左右但服务端CPU占用和带宽占用都有明显上升。如果你对上传时效要求不高我建议内部系统优先选择串行上传因为实现简单且不会产生分片乱序问题只有对大规模上传有硬性体验要求时才引入并发。第三轮是异常恢复测试。我在上传到第47个分片时手动断网恢复网络后重新进入页面查询已上传的分片列表前端跳过了前47片从第48片继续上传整个过程没有出现重复传输或合并失败。这块验证的是断点续传的可靠性和分片记录状态的一致性。基于测试结果我给出一份可以直接抄作业的配置参考配置项推荐值说明分片大小5MB1GB以上取10MB见前文分片选择表前端并发上传数2过多会压垮Tomcat默认线程池Redis分片记录过期时间24小时超出后任务判定为失效合并后MD5校验必须开启防止分片内容损坏临时目录清理周期48小时定时扫描删除单文件大小上限建议不超过10GB超过需考虑对象存储分片方案把分块上传和秒传结合好核心不在于某一个接口写得多漂亮而在于整个链路里“去重判断”“分片状态记录”“合并校验”这三角色是否各自清晰、互相咬合。跳过了哪个环节都会在真实用户上传大文件时还回来。这套方案里没有特别高深的技术但每处细节都值得你在自己的项目里动手验证一遍。
返回列表