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

Java Web大文件分片直传实战:从Word教案上传到断点续传

Java Web大文件分片直传实战:从Word教案上传到断点续传 ★ FEATURED ARTICLE
做教育行业项目这几年最让我头疼的从来不是业务逻辑多复杂而是老师传不上来教案。一套教案管理系统里Word附件是最常见的载体但只要文件里塞了几张实验照片、一段课堂实录体积轻松冲到五六十MB甚至上百MB。浏览器用传统表单一提交就是长时间转圈、连接断开、上传失败后台Tomcat和Nginx日志一堆报错业务同事还以为是程序写崩了。后来我把方案切换成浏览器端分片直传前端把大Word教案按固定大小切成多个分片并发上传到Java后端接口再由后端合并落盘。这套方案上线后附件上传成功率从不到六成稳定到99%以上连弱网环境都能续传。这篇文章就把我在Java Web项目里落地Word教案大附件分片直传的思路、接口设计、前端实现和踩坑过程完整写出来。1. 教案大附件的传输瓶颈与分片直传的适用边界1.1 一份普通教案的体积构成与网络现实在很多非教育行业的同事看来教案就是几页Word文档撑死一两MB。但真实教学场景根本不是这样。我在项目里统计过老师们上传的教案附件最小几百KB最大能到300MB中位数通常在20-60MB之间。体积膨胀的源头非常固定老师直接从手机或相机导入原图不经过压缩一张照片就是3-8MB教案里嵌入了课堂实况短视频哪怕只有几分钟H.264编码也动辄几十MB部分学校会把教案打印签字后扫描存档扫描仪的PDF或图片序列同样体积巨大Word里嵌入了字体、录音批注、历史修订记录这些隐藏内容也会让文件明显膨胀。网络端的现实更残酷。学校场景里很多老师用的是公用Wi-Fi上行带宽经常只有2-5Mbps还有一部分老师在家里用宽带上传上行同样受限。一个50MB的教案按3Mbps上行算纯传输时间就要超过两分钟。如果这期间发生一次Wi-Fi切换、一次路由器空闲超时、一次代理层断连整个上传就前功尽弃。1.2 传统表单上传的固有缺陷最初的版本和大多数项目一样前端用form直接POST multipart数据Java后端用CommonsMultipartResolver接文件。这套方式在小文件时代没有问题一旦面对几十MB的Word教案四个缺陷会被无限放大请求体整体传输没有中途归档能力。整份文件必须一次性完整到达服务器中间任何一秒钟断网、超时、网关重置服务端拿到的就是残缺数据只能丢弃重来。服务端线程被长时间占用。大文件传输期间Tomcat的工作线程一直在等待IO完成上传一个100MB文件可能要占用线程一两分钟。教育类系统服务器往往不算豪华几十个老师同时上传线程池很快打满后面所有请求全部排队。失败代价是全量重传。传了一个小时进度显示92%结果断网重新再来一遍。老师们对这类系统的口碑崩塌基本都源于此。默认参数限制严格。Tomcat对POST表单大小有默认上限Spring对multipart请求也有maxFileSize约束。教育现场的服务器经常同时跑着多个Web应用改这些参数还得考虑其他应用是否受影响。1.3 先搞清楚直传到底指什么浏览器端分片直传这个说法在不同场景里含义不完全一样。第一种是前端分片后直接传给云存储服务应用服务器只负责签发凭证文件不经过Java后端第二种是前端分片后通过异步接口直接传给Java后端由后端归并落盘但不再走表单整体提交那条老路。很多人一听到直传就默认是第一种其实第二种才是大多数Java Web项目的常规落地方式。本文讲的方案属于第二种。核心不是绕过后端而是让浏览器把一个大请求拆成多个小请求独立失败、独立重试最后在后端完成合并。这套设计里前端的切分逻辑、并发控制、进度计算、失败重试都是通用的如果未来要把存储层换到对象存储只需要把分片上传到Java接口换成分片上传到预签名URL其余链路几乎不用动。2. 整体流程设计从文件切分到合并校验的四步链路2.1 四阶段流程总览分片直传整个生命周期可以拆成四个阶段顺序上不能乱初始化上传前端拿到File对象后先向Java后端发起一次init请求携带文件名、文件总大小、打算切分成多少片。后端返回一个全局唯一的uploadId并为这个uploadId创建临时归档目录。init请求的意义在于让后端提前知道接下来有一批分片要到达并约定归档标识避免后续分片到达时不知道该往哪里放。分片上传前端用File.slice()把大文件按固定大小切成若干Blob分片每个分片携带uploadId 分片序号 分片内容通过异步请求上传。这一步是真正消耗带宽和时间的阶段也是我们要做并发控制和断点续传的地方。合并落盘前端确认所有分片上传成功后调用complete接口。后端校验临时目录下分片数量是否等于声明值、总大小是否等于文件原始大小校验通过后按序号把分片合并成完整文件写入最终存储目录。结果确认与清理合并成功后返回可访问路径或文件ID前端清理本地断点续传记录后端把临时目录里的分片文件删除释放磁盘空间。很多第一次做分片上传的同学会漏掉第四步。实际上临时分片不清理用不了几天磁盘就会被大量残留分片塞满这是另一个大坑后面我会专门讲。2.2 分片大小与并发数的选择逻辑分片大小直接决定整个上传链路的稳定性与效率没有绝对标准但有几个经验边界可以遵循。我在这个教案项目中最初用的是2MB分片后来调成5MB最终稳定在5-10MB之间。分片太小时比如1MB以下一个100MB附件就要产生100多个小请求每个请求都有HTTP握手、Tomcat解析、文件写入的开销整体吞吐反而下降而且临时目录里碎片文件过多管理混乱分片太大时比如50MB以上单个分片的失败成本和重传代价就明显上升弱网下成功率会衰减又退回了大请求赌运气的老路。并发数的选择同样要权衡。浏览器对同一域名的HTTP/1.1连接数通常限制在6个左右但并不意味着我们可以把并发拉满。前端并发高意味着后端同时打开的临时文件和IO线程也多教育项目的服务器磁盘多是普通SATA盘随机写入能力有限并发太高只会增加磁盘竞争和线程阻塞。我的经验值是并发3个分片请求在服务器配置较好、内网环境下可以放宽到5个。这个并发量既能保证带宽被充分利用又不会瞬间压垮Tomcat默认线程池。2.3 为什么必须用uploadId而不是文件名组织分片第一版设计时我偷过懒直接用timestamp fileName建目录后来被两个问题当场打脸一是同名的教案文件几乎每天都会出现时间戳加文件名也会碰到自然冲突二是文件名里经常带着中文、空格、括号甚至在Windows上复制粘贴还会带出特殊字符拼到路径里要么乱码要么触发异常。所以统一使用UUID生成的uploadId作为临时目录名和分片归集标识。Spring后端不要直接信任前端传上来的文件名必须做一次安全清洗移除路径分隔符、..、空字节等危险字符再用uploadId把原始文件名映射到最终存储位置。uploadId作为唯一标识贯穿init、chunk、complete三个阶段同时也方便后端做日志追踪出问题时按uploadId一查就能知道这个文件从初始化到合并花了多长时间、哪些分片重试过。3. 后端Java接口落地初始化、分片暂存、合并落盘的三个核心接口3.1 初始化接口endpoint设计与核心逻辑init接口不负责接收文件内容只做三件事生成uploadId、创建临时目录、记录上传元数据。下面是这个项目里用的简化版Spring Boot接口PostMapping(/upload/init) public ResultString initUpload(RequestBody InitUploadRequest request) { // 1. 清洗文件名防止路径穿越与特殊字符问题 String fileName StringUtils.cleanPath(request.getFileName()); if (fileName.contains(..) || fileName.length() 100) { return Result.error(非法文件名); } // 2. 生成唯一上传ID String uploadId UUID.randomUUID().toString().replace(-, ); Path dir Paths.get(uploadRoot, uploadId); Files.createDirectories(dir); // 3. 记录元数据实际项目一般会落到数据库这里用内存Map做示范 uploadMeta.put(uploadId, UploadMeta.builder() .originalName(fileName) .totalSize(request.getTotalSize()) .totalChunks(request.getTotalChunks()) .createdAt(Instant.now()) .build()); return Result.ok(uploadId); }一个容易被忽略的细节totalChunks和totalSize这两个字段不能只被当成前端传参。后端的complete阶段要用它们做校验——如果前端声明有20个分片结果服务端临时目录里只找到19个就应该果断判定合并失败并返回分片不完整的错误码让前端重新补齐缺失分片。有了这个校验锚点才能挡住假成功。3.2 分片接收接口按序号落盘与幂等覆盖chunk接口接收每个分片对应伪代码如下PostMapping(/upload/chunk) public ResultString uploadChunk(RequestParam(uploadId) String uploadId, RequestParam(index) Long index, RequestParam(file) MultipartFile chunk) { // 1. 校验uploadId是否在有效上传会话中 if (!uploadMeta.containsKey(uploadId)) { return Result.error(uploadId无效或已过期); } // 2. 分片文件按固定位数命名避免排序错乱 String chunkName String.format(%08d, index); Path chunkPath Paths.get(uploadRoot, uploadId, chunkName); // 3. 覆盖写入天然具备幂等性重复上传同一分片不会产生重复文件 try (InputStream in chunk.getInputStream()) { Files.copy(in, chunkPath, StandardCopyOption.REPLACE_EXISTING); } return Result.ok(); }命名分片文件时用String.format(%08d, index)这种固定宽度格式非常关键。如果直接用1、2这样的文件名到第10个分片时排序就会变成1、10、2合并出来的文件顺序错乱Word文件会直接损坏。用固定宽度补零后字典序和数值序就完全一致了。分片接收接口必须设计成幂等的。弱网环境里前端会对失败分片做重试同一个分片可能被提交两次甚至三次后端用REPLACE_EXISTING覆盖写入后重复提交不会产生脏数据。这个设计牺牲了极小的写入开销换来了前端重试逻辑的大幅简化。3.3 合并接口零拷贝合并与完整性校验complete接口是后端最需要抠细节的地方。合并的本质就是把同一个uploadId目录下的多个分片按序号顺序写入一个新文件。我用Java NIO的FileChannel.transferTo来做PostMapping(/upload/complete) public ResultFileInfo complete(RequestBody CompleteRequest request) { Path dir Paths.get(uploadRoot, request.getUploadId()); Path finalPath Paths.get(finalRoot, request.getUploadId() _ meta.originalName); // 1. 先校验分片数量是否完整 long actualChunks Files.list(dir).count(); if (actualChunks ! meta.totalChunks) { return Result.error(分片不完整已上传 actualChunks / meta.totalChunks); } // 2. 按序号依次把分片写入目标文件 try (FileChannel out FileChannel.open(finalPath, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i 0; i meta.totalChunks; i) { Path chunkPath dir.resolve(String.format(%08d, i)); try (FileChannel in FileChannel.open(chunkPath, StandardOpenOption.READ)) { long pos 0, size in.size(); while (pos size) { pos in.transferTo(pos, size - pos, out); } } } } // 3. 校验文件大小不满足直接报错 if (Files.size(finalPath) ! meta.totalSize) { Files.deleteIfExists(finalPath); return Result.error(合并结果大小不一致); } // 4. 清理临时分片目录 deleteDirectory(dir); return Result.ok(FileInfo.of(finalPath, meta.originalName)); }transferTo虽然被称为零拷贝但在某些文件系统、某些高并发场景下单次调用可能不会传输完全部数据所以必须用while循环确保每个分片都被完整搬运。这一步我踩过native接口的坑之后再也不敢直接写出in.transferTo(0, in.size(), out)这种不校验返回值的写法。合并完成后不要急着对外宣称成功再做一次大小校验是最简单也最有效的完整性防线。Word是复合文档结构分片顺序一旦错乱、某个分片内容不完整文件表面能打开但内容就是缺章节或者直接报损坏。大小校验能挡住大部分这类问题。3.4 服务端必须处理的边界过期清理、线程安全与文件类型校验临时分片清理绝对要写成一等公民逻辑。我在测试环境里就吃过一次亏模拟上传几十个附件后发现服务器磁盘可用空间从60%掉到了30%一查全是残留分片。正常的complete调用会清理临时目录但网络中断、进程崩溃、前端放弃上传等异常场景会留下孤儿目录。最后的解法是加了一个定时任务每两小时扫描一次临时根目录删除创建时间超过24小时的目录。这个逻辑对生产环境而言不是可选项而是必需品。元数据并发问题也需要考虑。如果用的是数据库记录上传元数据要注意并发写入和状态流转如果像上面示例用内存Map那必须在complete接口里保证同一uploadId不会被两个请求同时合并。最简单的方法是在complete里对uploadId加分布式锁或本地锁没有锁的话两个并发complete请求可能同时读分片目录、同时创建输出文件其中一个就会异常。文件类型校验不能省略。教育行业的文件上传接口是重点防护目标不能因为老师传的是Word就只校验扩展名。实践上我会同时做两层第一层在init时校验扩展名白名单比如doc、docx、pdf、zip第二层在合并结束后读取文件头的Magic Number检查与扩展名是否一致。Word的docx和zip文件结构上高度相似不能单看扩展名就放行。4. 前端JS实现分片、并发控制、进度计算与断点续传4.1 分片切割File.slice的兼容性与注意事项前端的基础切分逻辑非常直接用File.slice()把文件切成多个Blobconst SLICE_SIZE 5 * 1024 * 1024; // 5MB 一个分片 const file uploadInput.files[0]; const totalChunks Math.ceil(file.size / SLICE_SIZE); const slices []; for (let i 0; i totalChunks; i) { slices.push(file.slice(i * SLICE_SIZE, Math.min((i 1) * SLICE_SIZE, file.size))); }这里有两个容易被忽略的细节。一个是File.slice在老旧浏览器里的兼容性部分教育场景用的还是旧版浏览器内核可能是file.webkitSlice或file.mozSlice写代码时做一个三分支的兼容判断会稳妥很多。另一个是分片边界问题slice的参数是字节偏移数学表达是半开区间[start, end)数学上不会重复也不会遗漏但代码里一定要用Math.min(end, file.size)收尾否则最后一个分片会多出一个空块。4.2 并发控制用Promise池限制同时上传的分片数前端最容易犯的错误是Promise.all(slices.map(uploadOne))一把梭。一个100MB的文件切成20片这个操作会瞬间发起20个XMLHttpRequest请求浏览器排队、服务器被打爆实际效果比串行还差。正确做法是自己实现一个简单的并发池async function runConcurrentTasks(tasks, limit, handler) { const queue tasks.slice(); const results new Array(tasks.length); const workers Array.from({ length: limit }, async () { while (queue.length 0) { const task queue.shift(); results[task.index] await handler(task); } }); await Promise.all(workers); return results; } const tasks Array.from({ length: totalChunks }, (_, index) ({ index })); await runConcurrentTasks(tasks, 3, async (task) { const blob slices[task.index]; await uploadChunk(uploadId, task.index, blob); uploadedCount; updateProgress(uploadedCount / totalChunks); });并发池的实现要点是每个worker从共享队列里取任务执行完再取下一个。这样能保证任意时刻在途的请求数不超过limit。并发数上限我建议按服务器负载动态配置前端写成常量也可以但要留出修改入口实测中并发从3提升到6如果服务器磁盘是机械盘上传总耗时几乎没有变化甚至反而变慢。4.3 进度计算整片跳动还是细分加权分片上传的进度计算有个天然的粒度问题如果以已上传分片数/总分片数来算进度条是跳动的20片就是每5%跳一格。对老师来说进度条精确到百分比反而会引发是不是卡住了的焦虑这里有两种处理方式第一种是精细计算把当前正在上传分片的xhr.upload.onprogress进度也纳入公式function updateProgress(uploadedChunks, totalChunks, currentChunkProgress) { const base uploadedChunks / totalChunks; const current currentChunkProgress / 100 / totalChunks; return Math.min(99, Math.floor((base current) * 100)); }第二种是阶段式展示不显示具体百分比而是显示已上传 3/20 片这样的文案配合进度条。我在实际项目里最终采用的是第二种因为老师更关心的是到底还剩多少片而不是精确到了百分之几。无论选哪种有一个细节必须注意进度最大只显示到99%直到complete接口返回成功后再跳到100%否则会出现进度条100%但文件还没合并完的错误预期。4.4 断点续传localStorage记录与幂等重试的组合断点续传的核心思路是把已成功上传的分片序号记录下来下次进入页面时通过查询接口拿到服务端已接收的分片列表跳过这些分片只上传缺失的部分。前端记录方式有很多种我用的方案是localStorage按uploadId存一个已完成的序号数组const KEY resume_ uploadId; localStorage.setItem(KEY, JSON.stringify(Array.from(uploadedSet))); // 重新进入页面后 const doneSet new Set(JSON.parse(localStorage.getItem(KEY) || [])); const pending tasks.filter(t !doneSet.has(t.index));这个方案的边界条件要处理好如果服务端临时文件因为超时被清理了前端localStorage里还记着某些分片已完成此时如果盲目跳过合并就会失败。所以前端在断点续传前应该先调用一个查询接口让后端告诉自己这批分片实际收到了哪些两边的交集才是真正可以跳过的分片。服务端简单点直接扫描临时目录返回已落盘的分片序号列表即可。对于单片上传失败重试策略也不能简单写失败就弹出提示让用户重新传。一般单片失败后连续重试3次重试间隔按1秒、2秒、4秒递增重试3次仍然失败才中断整体任务保存现场状态提示用户稍后点击继续。这里还牵涉一个HTTP层面的细节分片上传请求必须保证幂等性后端覆盖写入前端重试才不会产生重复数据。这两端设计是配套的任何一端单独做断点续传都走不通。5. 实测阶段踩过的坑从Nginx限制到假成功的完整定位5.1 现象一分片全部上传成功但合并时直接返回413第一次在客户现场联调时前端日志显示分片都传完了但complete调用直接报413。刚开始怀疑是Tomcat的maxPostSize限制查了一遍发现Tomcat侧正常最后在Nginx配置里找到罪魁祸首client_max_body_size默认值是1MB。虽然前端已经做了5MB分片但合并接口还是以POST方式提交了uploadId和文件名等元数据Nginx按整个请求体大小判断一旦超过1MB就立即拦截。这个坑的排查链路有典型性。遇到413时先确认请求经过了几层反向代理每一层都可能有client_max_body_size限制再把分片接口和合并接口的请求体大小都过一遍因为就算分片做了切割元数据请求也不一定小。教育行业的网络拓扑往往比较复杂前端请求可能要经过多个转发层任何一个节点配置不当分片方案都会莫名失败。5.2 现象二显示100%后Word打不开修复过程全记录分片直传上线后的第三天有老师反馈进度条走完了成功提示也弹了但下载下来的Word打不开。我第一反应是合并环节出了问题但服务端合并接口明明返回了成功。顺着排查我先去服务器看临时目录和合并后的文件发现合并文件大小和原始文件确实不一致——少了大概40KB正好接近一个分片的体积。顺着这条线索继续查问题出在合并代码的假成功上。我的第一版合并逻辑是短时间内连续读取每个分片文件In-Memory Buffer不足时没有做完整的循环拷贝。当分片数量较多时transferTo返回的字节数可能小于分片大小没有循环搬运完所有数据就进入了下一个分片最终合并文件缺少尾部数据。修复方式就是前面3.3节里那个while循环while (pos size) { pos in.transferTo(pos, size - pos, out); }同时加大小校验兜底。这个教训后来固化成了团队的代码规范凡是做文件拷贝类操作一律不允许只调用一次transferTo而忽略返回值。5.3 现象三并发一拉高就大面积超时分片方案刚部署时我把前端并发数调到了8结果测试环境里上传一个80MB的文件前面几片跑得飞快后面开始大量请求超时再后来连普通接口也变得极卡。打开服务器监控Tomcat线程池已经满了磁盘IOutilization接近100%。原因分两层第一前端并发8个请求对公网带宽并不算多但后端磁盘是普通SATA盘并发随机写入让磁盘长时间排队第二Tomcat默认线程池的处理能力有限分片上传请求虽然逻辑简单但每多一个并发就多占一个工作线程线程池被打满后其他业务接口自然跟着遭殃。最后的调整方案是前端并发降到3后端给分片接口单独配置了一个线程池并开启tomcat.threads.max不足时的快速失败策略。经过压力测试验证并发3比并发8的总吞吐反而更高稳定性也更好。5.4 一套可复现的分片上传排错路径吃了这么多亏之后我总结了一套分片上传问题排错路径在此基础上排查新问题基本都能定位。先确认分片是否完整到达服务器。登录到服务器看临时目录下文件数量和大小用ls | wc -l数一下分片个数是否等于声明值。这一步能直接区分问题是出在前端传输还是后端合并。再确认合并文件的大小与源文件一致。如果大小不一致检查合并代码的拷贝逻辑、分片命名排序重点看有没有补零、有没有循环拷贝完整数据。用curl手写模拟分片流程。遇到前端才能复现的问题时直接在服务器上用curl分别发起init、chunk、complete请求逐个排除前端依赖。这个方法比在后端打印日志还快# 模拟一个3分片的文件上传 curl -X POST /upload/init -d {fileName:test.docx,totalSize:3072,totalChunks:3} curl -X POST /upload/chunk -F uploadIdxxx -F index0 -F filepart0 curl -X POST /upload/chunk -F uploadIdxxx -F index1 -F filepart1 curl -X POST /upload/chunk -F uploadIdxxx -F index2 -F filepart2 curl -X POST /upload/complete -d {uploadId:xxx}观察后端日志中的uploadId链路。从init到complete全链路打印uploadId、每个分片的序号、大小和耗时问题基本都能从日志时间线上看出端倪。6. 参数校准与对象存储直传的进阶思考6.1 分片大小与并发参数的最终校准方法参数不能拍脑袋必须结合真实网络环境和服务器负载做实测。我在项目中做过一组对照测试同一份80MB的测试文件在同一网络的三个时间段分别用2MB、5MB、10MB的分片大小和3、5、8个并发组合跑记录成功率、平均耗时、最大耗时。结果和预期基本一致分片大小并发数平均耗时成功率备注2MB348s96%分片数太多请求开销大5MB336s99.5%推荐组合5MB533s98%网络好时速度更快10MB338s92%单片失败后重传成本高10MB841s85%服务器磁盘瓶颈明显看到实测数据后我把生产环境的默认参数定在5MB分片 3并发同时在前端管理后台留一个配置入口允许客户现场根据网络情况微调。这个默认保守、允许调优的思路比直接照搬网上的推荐值要靠谱得多。6.2 要不要上对象存储直传一个判断框架很多刚接触分片直传的同学会问既然分片方案这么成熟为什么还要考虑上对象存储我用一个判断框架来回答如果教案附件的总存储量不大每天新增在几GB以内应用服务器磁盘完全扛得住那自建分片直传就足够了不需要引入额外的存储依赖。如果教案附件动辄数百GB甚至上TB而且需要长期留存、多节点共享、按CDN加速分发那就要考虑对象存储。此时Java后端可以退化为STS凭证签发 元数据管理前端分片直传对象存储流程仍然高度复用我们前面设计的init/chunk/complete三段式结构只有传分片那一步换了目标地址。如果存在跨地域访问需求比如集团学校多个校区同时访问教案对象存储的CDN分发能力会明显优于应用服务器直出文件。教育行业多数项目的技术栈并不复杂没必要一上来就追求重型架构。先把浏览器端分片直传这一套跑通等业务量出现明显增长再迁移对象存储这个演进路径成本最低、风险也可控。6.3 教育场景的额外考量老师们需要的是安全感技术落地之外还有两个容易被纯技术视角忽略的点。第一是教育场景的浏览器环境比想象中复杂教室里的老电脑、旧系统、低版本浏览器并存前端代码的兼容性判断必须留有余地不能默认所有访问者都用最新版Chrome第二是老师真正在意的不是分片并发优化而是传了好几分钟到底成没成、失败后要不要从头再来。我在上线后的使用反馈里看到老师对进度条和断点续传的满意度远超对上传速度的满意度。在那之后我给上传组件加了一个小优化进度条旁边实时显示已连接网络、正在上传第X片/共Y片剩余约Z秒同时上传失败时自动保留现场收件箱里挂一个继续上传按钮。这个改动不算大但对一线老师的体验提升非常明显也让我意识到在大附件传输这个场景里工程的价值不只体现在技术参数上更体现在如何降低用户的焦虑感。
阅读完成 · 觉得有帮助?
咨询建站