ARTICLE DETAIL

资讯详情

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

Java大文件分块上传:秒传判定与进度回溯全链路实践

Java大文件分块上传:秒传判定与进度回溯全链路实践 最近在给单位OA系统做附件中心改造遇到一个特别现实的需求用户上传2GB左右的培训视频、项目压缩包经常传到90%断线又要从头再来。查了一圈网上的方案发现很多文章一上来就贴分块上传代码却把秒传机制和进度回溯这两个配套环节轻轻带过实际落地时远远不够用。这篇就按我实际改造的完整链路把Java如何实现大文件分块上传、秒传判定和进度回溯讲清楚包括接口设计、核心代码、部署注意事项以及联调时踩过的坑。这套东西不是新技术但要在OA系统这种网络环境不统一、用户操作习惯五花八门、历史附件量大到惊人的场景里稳定跑起来细节远比想象中多。如果你正在做类似的功能改造或者准备面试题里关于大文件上传的部分这篇应该能给你一个可以直接抄作业的框架。1. 先捋清楚分块、秒传、进度回溯究竟在解决什么问题1.1 一个真实场景传了58分钟断了重传单位OA系统的附件中心用户上传一份接近2GB的培训视频网速看着还行但快传完时无线网络闪断了一下。浏览器直接显示上传失败用户找不到任何进度记录只能重头再传。后台日志显示这次请求在网关上超时文件虽然传了一部分但临时数据没有被保留等于白传。这个场景说服力很强。现在OA系统里承载的附件早就不是几十KB的Word文档了几十MB的图纸、上百MB的表格、几个GB的视频压缩包都很常见。如果还用老办法让浏览器一次性把完整文件POST到后端网络稍微抖动一下整个请求断掉之前传输的数据全部作废。所以我们当时把三个机制一起写进了需求里分块上传把大文件切成固定大小的块一块一块传。秒传机制通过文件指纹判断服务端是不是已经存在这个文件有的话直接“假装”传完。进度回溯传到一半断了下次能接着传不用重头再来。这三个词经常被放在一起但各自解决的问题完全不同设计时如果混为一谈代码很容易写出四不像。1.2 一句俗话解释三个机制可以把上传大文件类比搬家公司搬一套实木家具原来的方案是把整套柜子整体搬电梯进不去楼道拐不了弯路上有点闪失整件报废。分块上传就是先把柜子拆成一块一块板分批运到目的地再组装。秒传是什么你以为要搬结果发现邻居家已经买过一模一样的柜子仓库里就有同款那还搬什么直接把这套柜子登记到你名下就行。进度回溯就是搬了一半车坏了修好以后你已经知道哪些板子搬过去了把剩下的补上就行不用把已经搬过去的板子又拉回来重搬一遍。所以设计上其实是两层第一层判断“这个文件到底要不要传”对应秒传第二层判断“如果真要传那要传哪些块”对应分块加进度回溯。很多项目只做了第二层秒传完全没做结果同一个大文件被用户反复上传带宽和存储白白浪费。也有的只做了秒传文件一变MD5对不上又退回到全量上传的老路体验照样糟糕。1.3 在OA系统场景里怎么取舍有人会问是不是所有文件都要做秒传和分块我的建议是分场景处理几个MB的普通附件传统方式直接上传就行最多加长请求超时时间没必要做分块和秒传成本和收益不成正比。几十MB到1GB的文档、视频、图纸适合分块上传分块大小控制在2到8MB秒传可做可不做但建议做实现成本并不高。超过1GB的频繁流转文件分块、秒传、进度回溯三个必须全做。这种场景下用户重复上传同一个文件的概率极高企业里的培训录像、活动视频、项目归档材料会被反复使用不做秒传等于把单位带宽和存储空间当流水。秒传影响的不只是用户等待时间更是系统资源。一个1GB文件如果内部100个人都各传一遍就是100GB流量和临时空间。秒传把这个量砍掉虽然无法精确归零但覆盖大部分重复上传场景后收益非常惊人。2. 秒传机制设计先算文件指纹再决定要不要传2.1 秒传的原理不是“秒传”是“不用传”秒传的本质不是把上传过程加速到秒级而是上传前先做一次“这个文件我是不是已经有了”的判断判断命中就直接返回上传成功。判断依据通常有三个文件内容的MD5值这是主指纹。文件大小作为辅助校验。文件名仅作为展示信息不能作为唯一依据。服务端流程是前端在分块之前对完整文件计算MD5连同文件名、总大小一起发给后端。后端查文件信息表如果MD5和大小都匹配说明内容已经在服务端存在。后端建立一条引用记录关联到已存在的物理文件然后返回“秒传成功”。看着很简单但实现里有几个坑后面会详细说。2.2 Java里算大文件MD5别一次性读进内存很多人写MD5工具类图省事直接Files.readAllBytes(file.toPath())。文件小没事文件一上GB内存直接告急服务多来几个并发就挂。正确做法是分段读取、边读边更新摘要public static String calcMd5(File file) { try (BufferedInputStream in new BufferedInputStream(new FileInputStream(file))) { MessageDigest md MessageDigest.getInstance(MD5); byte[] buf new byte[8192]; int len; while ((len in.read(buf)) ! -1) { md.update(buf, 0, len); } return toHex(md.digest()); } catch (Exception e) { throw new RuntimeException(计算MD5失败, e); } } private static String toHex(byte[] bytes) { StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); }实测下来1GB文件计算MD5大概需要2到5秒具体看磁盘和CPU。这里有个工程取舍如果每个文件都先算完整MD5再上传会多付出一次文件读取成本但相比传输1GB文件的时间这个成本可以接受。从性能角度看前端算MD5也有方案比如SparkMD5流式计算。好处是不占服务器CPU坏处是用户等待时间会比较长而且老浏览器兼容性问题不少。我个人的习惯是文件超过200MB就以后端计算为主上传过程中同步算文件小于200MB前端算也无所谓体验更一体化。这个没有标准答案要看部署环境CPU和带宽哪个更紧张。2.3 秒传接口的请求与响应设计秒传检查接口我通常这样设计POST /api/file/upload/check Content-Type: application/json { fileName: 2025年度培训视频.mp4, fileSize: 2147483648, md5: 9d7a5c9e0fa0b1e1a6f4d4c4d6e2f1a0 }响应分两种情况。如果服务端已经存在完整文件{ code: 200, data: { uploaded: true, fileId: 10086, url: /file/download?id10086 } }如果服务端没有完整文件但我希望顺便告诉前端当前进度可以多返回一个字段{ code: 200, data: { uploaded: false, existsChunks: [0, 1, 2, 3, 4, 5, 6, 8, 9] } }这个existsChunks就是进度回溯的核心信息。前端拿到它只上传缺失的索引断点续传的接口基础就有了。特别注意一个细节秒传检查查的是文件信息表不是临时分块目录。同一个MD5可能在“已合并完成”表里也可能在“上传中”表里。如果文件正在被别人上传、还没合并此时第二个人做秒传检查不能返回秒传成功只能告诉对方“这个文件正在上传中你也加入一起传”。这种并发场景在OA系统里不常见但接口文档里要提前约定好免得上线后出现“文件还没传完就秒传成功”的诡异问题。2.4 文件名冲突怎么处理秒传命中后同一个MD5对应多个用户、多个文件名是正常操作。物理文件只有一份引用记录可以有很多条。所以在数据库设计上不能把“文件名”作为唯一键也不能让“MD5”作为文件表的唯一键就完事。至少要两张表文件物理表记录MD5、大小、物理存储路径、状态一个MD5对应一条物理记录加唯一约束。上传引用表记录文件归属、原文件名、文件大小、物理表ID一个物理文件可以被多条引用。引用表和物理表分开以后同名覆盖问题就很好处理了。用户上传了一个新版本文件即使文件名相同MD5不同物理表会多一条记录引用表也加一条两个版本都保留。OA系统里最常见的“版本覆盖”惨案多数就是只按文件名做逻辑判断导致的。3. 分块上传与进度回溯的Java落地代码3.1 前端怎么分块后端怎么收块前端用File.slice()把文件切成块每个块有一个索引chunkIndex然后按顺序上传。分块大小的选择需要权衡分块大小适用场景优劣势1MB~2MB网络差、链路不稳定单块失败重试成本低但请求数量多5MB~10MBOA系统内网环境请求数量适中重试粒度可控20MB~50MB专线、带宽充足请求数少但一旦失败重传成本高我一般默认用5MB总大小除以5MB得出总块数最后一块可能不满5MB按实际大小处理。前端伪代码const CHUNK_SIZE 5 * 1024 * 1024; const file fileInput.files[0]; const totalChunks Math.ceil(file.size / CHUNK_SIZE); async function uploadChunk(index, retryCount 3) { const start index * CHUNK_SIZE; const blob file.slice(start, Math.min(start CHUNK_SIZE, file.size)); const form new FormData(); form.append(file, blob); form.append(md5, fileMd5); form.append(chunkIndex, index); form.append(totalChunks, totalChunks); form.append(fileName, file.name); try { await axios.post(/api/file/upload/chunk, form); progressList[index] true; } catch (e) { if (retryCount 0) return uploadChunk(index, retryCount - 1); throw e; } } // 建议同时并发3个分块不要一次性全部并行 const workers 3; let next 0; async function runWorker() { while (next totalChunks) { const index next; await uploadChunk(index); } } await Promise.all(Array.from({ length: workers }, runWorker));后端收块时需要先把块写入临时目录。目录结构建议/upload/temp/{md5}/chunk_0 /upload/temp/{md5}/chunk_1 ... /upload/temp/{md5}/chunk_9代码类似PostMapping(/api/file/upload/chunk) public Result receiveChunk(RequestParam(file) MultipartFile file, RequestParam(md5) String md5, RequestParam(chunkIndex) Integer chunkIndex) { File chunkDir new File(uploadTempRoot, md5); if (!chunkDir.exists() !chunkDir.mkdirs()) { throw new BusinessException(创建临时目录失败); } File chunkFile new File(chunkDir, chunk_ chunkIndex); try { file.transferTo(chunkFile.getAbsoluteFile()); } catch (IOException e) { throw new BusinessException(保存分块失败); } // 更新进度 uploadProgressService.markChunkUploaded(md5, chunkIndex); return Result.ok(); }这里有个必须注意的点transferTo对同一个chunkFile调用多次如果之前已经存在同名文件最后一次会覆盖它。这本身保证了分块上传的幂等性重复传同一块最终结果都是这块文件被最新内容覆盖不会出现半个块。3.2 进度记录表为什么不能只用HashMap第一次做这个功能时最容易图省事在后端用一个ConcurrentHashMap记进度key是md5value是已上传的分块索引集合。单机测试确实能跑一上线就出问题服务重启内存里进度全部丢失已传完的分块还在临时目录但系统已经不认识它们了。前面挂了负载均衡多个实例时A实例记录的进度B实例看不到进度回溯时一会有一会没有。无法判断哪些临时文件是垃圾长期占据磁盘。所以进度必须持久化。我用一张进度表CREATE TABLE upload_progress ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_md5 VARCHAR(32) NOT NULL, file_name VARCHAR(255) NOT NULL, total_size BIGINT NOT NULL, chunk_size INT NOT NULL, total_chunks INT NOT NULL, uploaded_chunks VARCHAR(2048), status TINYINT NOT NULL DEFAULT 0 COMMENT 0:上传中 1:合并中 2:已完成 3:失败, create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_md5 (file_md5) );uploaded_chunks这一列我建议直接存逗号分隔的索引字符串比如0,1,2,3,5。虽然不满足第三范式但在分块场景下查询性能好、实现够简单。分块数量不会太大一个2GB文件按5MB切也不过400多块字符串长度完全可控。如果分块数量可能上万比如按1MB切2GB文件字符串会很长可以改成子表或Redis BitMap。但OA系统里的场景10MB以上分块是常态字符串方案足够。更新进度时每次接收一个分块就执行一次update400个分块大约400次更新。如果上传人数一多数据库压力确实不小。经验做法是前端连续上传分块时进度不实时刷库每上传10到20块才批量刷新一次断点回溯的精度由文件系统兜底临时目录里实际存在的块才是最终依据。3.3 分块合并与完整性校验所有分块都传完后前端调用合并接口或者后端定期检查自动触发合并。合并接口要做成幂等操作重复调用不能产生重复文件。PostMapping(/api/file/upload/merge) public Result merge(RequestParam(md5) String md5, RequestParam(fileName) String fileName) { boolean locked tryLock(md5); if (!locked) { return Result.error(文件正在合并中); } try { File chunkDir new File(uploadTempRoot, md5); File dest new File(uploadRealRoot, md5 _ fileName); try (RandomAccessFile raf new RandomAccessFile(dest, rw)) { int totalChunks getTotalChunks(md5); for (int i 0; i totalChunks; i) { File chunk new File(chunkDir, chunk_ i); if (!chunk.exists() || chunk.length() 0) { throw new BusinessException(分块缺失: i); } raf.seek((long) i * chunkSize); raf.write(Files.readAllBytes(chunk.toPath())); } raf.getFD().sync(); } String calc calcMd5(dest); if (!md5.equalsIgnoreCase(calc)) { throw new BusinessException(合并文件校验失败); } fileRecordService.saveOrUpdate(md5, fileName, dest); uploadProgressService.markDone(md5); FileUtils.deleteDirectory(chunkDir); return Result.ok(); } finally { unlock(md5); } }RandomAccessFile按偏移量写好处是合并不依赖分块顺序只要索引和偏移量对得上任何顺序写入结果都一样。这里有个细节最后一块的文件大小一般小于chunkSizewrite方法实际写入的是Files.readAllBytes(chunk)返回的实际字节长度不是补满整个分块。如果错写了固定byte[chunkSize]文件末尾会被补进一堆0x00MD5校验会立刻暴露问题。合并后校验MD5这一步很多人会省略我强烈建议不要省。分块上传链路长任何一环出了问题都可能导致文件损坏合并后校验是做完整性的最后一关。代价是再读一遍文件但值得。4. 断点续传进度回溯的关键链路与并发控制4.1 恢复上传的完整时序断点续传看起来是个“点击继续”的按钮实际是一串完整的接口调用循环。拿用户传到一半掉线、第二天重新上传举例用户重新选择文件前端立即开始计算文件MD5或者从localStorage读之前算好的值。前端调用/upload/check带上md5和文件大小。后端查进度表和临时目录返回existsChunks数组。前端把existsChunks标记为已完成只对剩余分块发起上传。所有分块上传完成调用/merge。这个流程要求接口设计必须围绕md5展开而不是围绕一次HTTP请求展开。不管用户上次在哪台机器上、哪个浏览器里传了一半只要文件内容是同一个md5、后端临时目录里还有块进度就能恢复。这里有个前端实现上的坑浏览器安全模型不允许你把一个File对象永久存到localStorage里页面刷新后File对象就没了。所以“点击继续上传”并不是直接恢复原来的File对象而是让用户重新选择文件前端再按md5去后端取进度。很多产品经理不理解这一点开发时要提前讲清楚。4.2 服务端如何准确报告“还缺哪些分块”秒传接口返回的existsChunks不能只查数据库里的uploaded_chunks必须以临时目录中的实际文件为准。原因是分块可能已经上传成功写在磁盘上但数据库update还没完成或者反过来数据库显示成功但文件被清理任务删了。所以我在实现existsChunks时是这样做的public ListInteger existsChunks(String md5, int totalChunks) { File chunkDir new File(uploadTempRoot, md5); if (!chunkDir.exists()) return Collections.emptyList(); ListInteger exists new ArrayList(); for (int i 0; i totalChunks; i) { File chunk new File(chunkDir, chunk_ i); if (chunk.exists() chunk.length() 0) { exists.add(i); } } return exists; }单纯遍历目录有几个问题目录里可能混入不完整的块上传过程中文件正在写入长度可能短于预期。所以最好校验块长度不是所有块都等于chunkSize最后一块大小允许小于chunkSize但不能等于0。遍历大量小文件有IO开销但分块数量几百个时完全可控。如果上传过程中分块写到一半进程崩溃留下的半块文件长度大于0但小于预期会被误判为完整块合并时就会缺内容。解决方法是再维护一个上传中的临时子目录比如chunk_10.tmp先写.tmp再rename到正式文件名程序检查只认正式文件。4.3 多实例部署时避免合并冲突OA系统后端部署形态不固定但很多单位为了发布升级不中断会挂两到三个实例。分块上传本身不冲突每个请求落到哪个实例都能写临时目录前提是临时目录是共享存储比如NFS或CIFS挂载盘。如果各实例写各自的本地磁盘进度回溯就会失效这个在部署设计时必须有明确结论。冲突集中在合并环节。两个用户可能上传同一个md5的文件或者同一个用户用两个浏览器标签页同时传合并时如果没有互斥会同时写目标文件导致文件互相覆盖或损坏。我用的锁方案是数据库乐观锁合并前执行一条update语句把状态从0改为1如果影响行数为0说明已经有其他请求把状态改成1了直接返回“正在合并中”。UPDATE upload_progress SET status 1, update_time NOW() WHERE file_md5 #{md5} AND status 0;这个方案简单有效不需要引入Redis和Zookeeper在绝大多数OA系统场景下够用。要注意的是update执行完成后临时目录里的分块不要立刻删。假如合并校验失败需要保留分块供重新合并即使合并成功删除操作也最好做一个延迟比如合并成功后状态改为2清理任务扫描status2且update_time超过24小时的记录再删临时文件。这样可以避免“刚合并完还没来得及删用户立刻发起秒传检查时文件临时目录已经没了”的边界问题。4.4 事务与文件操作分离分块上传过程中最忌讳的就是把数据库事务和写文件操作包在同一个事务里。文件IO的耗时不可控如果事务长时间不提交数据库连接很快就会被占满。我的做法是写文件、删临时文件不做数据库事务这些本来就是可重入的幂等操作。数据库只记录状态和元数据每次更新尽量短小。合并成功后的“保存文件记录”和“更新进度状态”也没有必要放在一个事务里。先保存文件记录再更新进度即使中途挂了最多留下一条物理文件存在但进度状态还没刷新的记录清理任务可以兜底处理。这里说一个我踩过的坑一开始把“分块写入成功”和“更新progress表”放在同一个Transactional里上传稍微一多数据库连接池就告警。查监控发现每个分块请求平均要等文件落盘100ms以上事务却一直没提交连接全部被卡住。改成先写文件后更新进度甚至是异步更新进度问题立刻消失。5. 实战中踩过的坑和排查方案5.1 分块传完了合并后文件打不开这是最常见的故障。用户上传一个压缩包所有分块都提示成功合并完成后下载下来却提示“文件已损坏”。排查步骤对比源文件大小和目标文件大小。如果不一致基本可以断定分块有缺失或合并时写了多余数据。检查合并代码是否按最后一个分块的实际长度写入。很多人写死byte[] buf new byte[chunkSize]最后一块就会多写一堆0文件大小多出几KB到几十KB。检查分块是否串了。前端同时并发3个分块时如果每个分块请求的chunkIndex都错了比如都写成0最后一个覆盖前面的也会出现大小对但内容错。用MD5对比源文件和合并文件。前端算一个后端合并后再算一个不一致就说明链路里有损。最稳的预防措施是合并后立刻计算MD5和呼入的md5比对这一步不要省。文件越大越想省这一步越容易翻车。前端并发分块还有一个隐蔽坑axios对FormData里的chunkIndex默认会做数字转字符串但有些老浏览器会把数字append成空串导致后端拿到的chunkIndex全是0。上传时不报错合并时全乱。联调阶段一定要用后端日志核对每个分块的index。5.2 秒传误判的前置条件有次测试反馈A上传了一个项目预算表.xlsx秒传成功下载打开却是另一个人的岗位说明书.docx内容。排查发现原因是早期字段设计里把“文件名”当成了唯一键所有叫项目预算表.xlsx的文件都秒传命中。这个错误很典型。秒传判断的唯一可信依据是文件内容指纹也就是MD5必要时叠加文件大小、CRC32绝不能把文件名当依据。文件名只是展示给用户的元数据。正确设计里同一个MD5可以对应多个文件名。例如A上传了项目预算表.xlsxB上传了内容完全相同的预算表新版本.xlsx物理存储只有一份引用记录是两条。下载时按照各自的引用记录映射到同一物理文件展示各自文件名互不影响。还有一个MD5误判的边界理论上有极小概率两个不同文件MD5相同。虽然概率极低但在安全要求高的系统里可以加一层SHA-256或CRC校验。我不建议一上来就做双重哈希先按MD5加文件大小就够了真出了问题再加维度。5.3 磁盘被临时分块占满分块上传最容易忽视的运维问题就是临时目录清理。用户上传到一半放弃或者分块保存成功但用户没点合并临时目录里的分块就会一直躺着。一个100人上传的OA系统一天产生的垃圾临时文件可能有几十GB。我的清理策略是进度表里记录每条上传记录的update_time。定时任务每小时扫一次找出状态为“上传中”且update_time超过24小时未更新的记录先判断对应临时目录是否还在被写入判断方法很简单看临时目录里是否有.tmp文件或文件最近修改时间是否在5分钟以内。确认没有活跃写入后删除临时目录并更新记录状态为“失败或已清理”。状态为“合并中”但长时间没完成的记录也要扫描这种大概率是代码Bug或进程被杀清理后释放锁否则同一md5的文件永远无法合并。删除临时目录前一定要二次确认正在上传的分块进程可能还在写文件直接删除会让上传线程报IOException。我的习惯是先重命名目录为md5_deleting_时间戳再延迟删除给正在写入的请求一个兜底时间。5.4 网关和容器对大请求的限制分块上传能生效的前提是网关和Java容器没有在一个HTTP请求层面上把文件大小或请求体大小限制死。Nginx默认client_max_body_size是1MB不调整的话超过1MB的块直接413。Tomcat默认对POST表单有大小限制spring.servlet.multipart.max-file-size和max-request-size需要设置spring: servlet: multipart: max-file-size: 20MB max-request-size: 30MB如果前面还有网关网关层也检查一下请求体上限有的硬网关默认限制10MB5MB的分块没问题但如果你把分块调到20MB就会很尴尬。我建议分块大小不超过10MB这样即使Nginx、Tomcat、网关三层都设置了10MB左右的上限也能少踩点雷。分块太大带来的收益有限反而增加各层配置风险。5.5 联调阶段的检查清单最后分享一个联调阶段的检查清单照着过一遍能少踩很多坑检查项检查方法分块index是否串号后端日志统计每个chunkIndex收到的请求数和文件名最后一个分块内容是否正确合并前后都计算MD5比较是否一致秒传判断是否依赖文件名查秒传接口的SQL看where条件是否有file_name断点续传是否存在半块误判停掉上传进程模拟kill后重新check existsChunks合并并发冲突是否拦截两个请求同时调merge观察是否只有一个成功临时目录清理是否兜底手工制造一批上传中记录跑清理任务验证这张表看起来简单每一条背后都对应着一个真实事故。分块上传、秒传、进度回溯三个功能加在一起链路复杂度比普通文件上传高一个等级测试不能只测成功路径一定要把断网、刷新页面、杀进程、双标签页并发这些异常场景都过一遍。我个人的经验是这类功能上线前最好内部先组织一次“上百兆文件加断网恢复”的演练在测试网络里上传过程中手动断开再恢复反复验证进度回溯。能把这一步跑通基本就可以进测试环境联调了。另外在代码里多打几条带md5、chunkIndex的日志排查问题时能少看很多代码。这套设计里还有个可以继续扩展的点如果以后要接入对象存储比如OSS或S3前端分块上传逻辑基本不用动后端只需要把分块落盘改成调用对象存储接口再做一次分片上传聚合就行。我在实际改造中体会最深的一点是把秒传、分块、进度回溯三个接口的边界定义清楚后面接什么存储都是顺手的事。
返回列表