
做过视频上传功能的同学应该都懂那种感觉——本地文件明明只有1.2G网速看着也不慢结果传到90%的时候啪一下断掉又得从头再来一遍。我当初接手公司视频管理模块的时候在这个坑里爬了将近两周。今天这篇不是讲空泛的理论是我在JSPServlet这套传统技术栈下把视频文件跨平台分块续传方案完整落地的一次实战记录。前端怎么切块、Servlet怎么接分块、断网之后怎么续上每一步都有可复制的代码和血泪踩坑说明。正在给视频上传功能做改造的同学这份笔记能帮你少走至少一周弯路。1. 为什么视频上传必须走分块续传1.1 传统一次性上传的三个死穴早期做上传功能很多人第一反应就是一个multipart请求把整个文件塞给后端。文件小的时候没感觉一旦换成视频问题全冒出来了。第一个死穴是体积。视频文件动辄几百MB甚至几个GB一次性上传意味着整个文件要在单个请求里走完任何一个环节抖动整次请求就废了。HTTP请求本身有超时上限浏览器页面也有生命周期请求体越大撞上超时的概率越高。第二个死穴是断点没法续。传统上传方式只要中断服务端只留下一个残缺文件客户端也没有任何标记告诉服务器我已经传了百分之多少唯一的选择就是从零开始再来一遍。第三个死穴是服务端压力。一个2GB的请求进来Tomcat要分配对应缓冲区内存直接飙升网络一抖动大量半死的连接还会把线程池堵得死死的。这三个死穴不是我推演出来的是我被用户连续反馈又传失败了之后在日志里一条条翻出来的真实情况。当时看到满屏的连接重置和请求超时我就明白整体上传在大视频场景下根本走不通必须换思路。1.2 分块续传到底做了什么分块上传的思路其实特别朴素把一个大文件切成很多小块逐块上传服务端先把每块存到临时位置等全部到齐之后再按顺序组装成一个完整文件。续传则是分块上传的自然延伸。因为每个块都有独立编号上传中断后下次再传时不需要整个文件重来一遍只需要问一下服务端哪些块已经到了然后只补传缺失的部分就行。这个逻辑一说就明白但落地时真正的难点集中在两个地方一是怎么记录和查询块的到达状态二是合并时怎么保证块的顺序和完整性。这两个问题的完整解法我会在第三章和第四章里展开。这套方案解决的不是某一个特定问题而是把大文件上传从一次性赌博变成模块化施工。每一块都小到足以扛住单次网络波动丢了哪块补哪块心里有数。1.3 这套方案适合谁参考如果你正在做视频点播平台、网盘应用、企业内部培训系统的课程上传或者任何涉及大文件素材管理的项目这套JSPServlet的分块续传方案都值得参考。技术栈是传统了点但胜在简单可靠不需要额外引入Spring、Hutool之类的重量级依赖。整个方案的核心就是Servlet 3.0的MultipartConfig注解加上浏览器自带的File API部署环境要求很低一台普通的Tomcat就能跑。当然如果你的项目已经全面拥抱Spring Boot也可以把这套思路原封不动迁移过去把Servlet换成RestController即可分块与合并的核心逻辑是通用的。2. 方案架构与关键参数设计2.1 前端切块、后端合片各自管好自己那摊事整个方案的分工可以总结成一句话前端负责把文件切成块、按序发送、记录进度后端负责接收分块、记录哪些块已到达、全部收齐后按顺序合并。前端切块用的是浏览器自带的File.slice()方法它能把一个File对象按字节偏移量切出独立的Blob然后塞进FormData里用AJAX发送。这里不需要任何插件也不需要WebAssembly现代浏览器全部原生支持。后端接收分块用的是Servlet 3.0标准的MultipartConfig通过request.getPart(chunk)拿到前端传来的二进制块再以fileId_块编号.part的形式落到临时目录。为什么不让后端来切块因为HTTP无状态服务器拿到请求的时候整个文件已经原封不动到内存里了切块也就没有意义。让前端切优势在于文件始终保存在客户端本地每一块都是独立请求服务器只处理小块数据内存和带宽压力都被摊平了。2.2 10MB分块大小是怎么算出来的分块大小不是一个拍脑袋的数字它直接决定上传的效率和稳定性。我建议视频文件用10MB一块理由如下分块大小优势劣势适用场景1MB单块极小重传成本低请求数量大网络开销高弱网、移动端5MB均衡需要调优普通文件10MB请求数量适中重传可控弱网下偏大视频等大文件50MB请求数量少吞吐高一次断线损失大内网、专线实际计算也很直接假设网络带宽是10Mbps也就是每秒约1.2MB那么传完一个10MB分块需要8秒左右。这个时长在网络抖动频发的场景下还在常规TCP连接可容忍的范围内。如果块太大比如50MB遇到弱网可能半分钟都传不完一块中途断线损失就大了。如果块太小比如1MB一个2GB的视频要拆成2000多个请求每个请求都要走一遍HTTP握手和FormData封装光网络开销就能把速度拖垮。我在生产环境实测过10MB是视频场景下稳定性和效率的平衡点。你可以根据自己用户的实际网络环境微调但建议控制在5MB到20MB之间。2.3 跨平台的底层保障来自哪里标题里特意强调了跨平台这一点值得单独说说。跨平台在这里包含两层含义一是服务端部署平台二是客户端浏览器平台。服务端层面Java本身跨平台能力就很强。但要注意一个问题文件路径分隔符。Windows用的是反斜杠\Linux和macOS用的是正斜杠/。代码里如果写死D:\\upload\\temp这种路径部署到Linux上直接就炸了。正确做法是使用File.separator或者干脆用System.getProperty(file.separator)动态获取。我的代码里统一用File.separator拼接路径这样同一份代码在Windows上开发、在Linux服务器上部署完全不用改。客户端层面File.slice()、XMLHttpRequest、FormData这些API在所有主流浏览器里都是标准实现包括Chrome、Firefox、Edge、Safari。需要注意的只是个别浏览器的兼容性细节比如Safari旧版本对File.lastModified的支持差异这些在第五章会讲到。3. 完整代码实现从前端到后端3.1 JSP页面文件选择与进度展示先看JSP页面它的职责很纯粹提供文件选择入口、展示上传进度把JavaScript入口挂载好。% page contentTypetext/html;charsetUTF-8 languagejava % !DOCTYPE html html head meta charsetUTF-8 title视频分块续传/title style .container { max-width: 640px; margin: 40px auto; padding: 0 16px; } .progress-bar { width: 100%; height: 22px; background: #eee; border-radius: 4px; overflow: hidden; margin-top: 20px; } .progress-fill { height: 100%; width: 0%; background: #4caf50; transition: width 0.2s; } #status { margin-top: 8px; color: #666; font-size: 14px; } /style /head body div classcontainer h2上传视频文件/h2 input typefile idvideoFile acceptvideo/* multiple button iduploadBtn disabled开始上传/button div classprogress-bar div classprogress-fill idprogressFill/div /div div idstatus请先选择视频文件/div /div script srcjs/uploader.js/script /body /html这里有两个细节要注意。第一acceptvideo/*只是在选择器里做了过滤前端并不真正校验文件内容后端合并前一定要做大小校验防止传上来的是伪装成视频的假文件。第二multiple属性允许一次选择多个视频文件JS端会逐个进入上传队列。如果你需要选择整个文件夹可以加上webkitdirectory属性这是Chrome和Edge支持的文件夹选择方式每个文件会单独进入上传流程核心逻辑和单文件完全一致。3.2 JavaScript切块上传与续传控制接下来是前端最核心的部分文件切块、状态查询、断点续传全在这里。const CHUNK_SIZE 10 * 1024 * 1024; // 10MB function generateFileId(file) { // 用文件名、大小、修改时间组合出一个相对稳定的标识 const raw file.name _ file.size _ file.lastModified; let hash 0; for (let i 0; i raw.length; i) { const chr raw.charCodeAt(i); hash ((hash 5) - hash) chr; hash | 0; } return Math.abs(hash).toString(36) _ Date.now().toString(36); } async function uploadFile(file, onProgress) { const fileId generateFileId(file); const totalChunks Math.ceil(file.size / CHUNK_SIZE); // 先向服务端查询哪些分块已经存在实现续传 let uploaded []; try { const resp await fetch(/upload/status?fileId encodeURIComponent(fileId)); uploaded await resp.json(); } catch (e) { console.warn(查询上传状态失败按全新上传处理, e); } for (let index 0; index totalChunks; index) { if (uploaded.includes(index)) { continue; // 已传过的块直接跳过 } const start index * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const blob file.slice(start, end); const formData new FormData(); formData.append(chunk, blob); formData.append(fileId, fileId); formData.append(chunkIndex, index); formData.append(totalChunks, totalChunks); formData.append(fileName, file.name); await new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, /upload/chunk); xhr.onload () resolve(); xhr.onerror () reject(new Error(第 index 块上传失败)); xhr.upload.onprogress (e) { if (e.lengthComputable) { onProgress(index * CHUNK_SIZE e.loaded, file.size); } }; xhr.send(formData); }); } // 所有分块传完通知服务端合并 await fetch(/upload/merge, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded;charsetUTF-8 }, body: fileId encodeURIComponent(fileId) fileName encodeURIComponent(file.name) totalChunks totalChunks fileSize file.size }); }这段代码里有个关键设计每次上传前先调用/upload/status查询已到达的分块列表返回的数组里包含哪些编号已经存上了前端直接跳过这些块。这就把续传变成了一个非常自然的流程中断后重新点击上传根本不需要特殊处理状态查询天然返回已传列表。我用的await new Promise包装XMLHttpRequest是故意不用fetch来上传分块的。原因是fetch对上传进度的支持一直不算好upload.onprogress在XHR里是成熟稳定的方案。另一个好处是如果某一块失败我可以在catch里精确重试这一块而不影响其他块。3.3 Servlet接收分块核心处理逻辑后端我用一个UploadServlet统一接收所有与上传相关的请求通过请求路径区分动作。看代码MultipartConfig( maxFileSize 1024 * 1024 * 50, // 单个分块最大50MB maxRequestSize 1024 * 1024 * 200, // 整个请求最大200MB fileSizeThreshold 1024 * 1024 // 超过1MB自动落盘 ) WebServlet(/upload/*) public class UploadServlet extends HttpServlet { private static final String BASE_DIR /data/video_upload; private static final String TEMP_DIR BASE_DIR File.separator temp; Override public void init() throws ServletException { File dir new File(TEMP_DIR); if (!dir.exists() !dir.mkdirs()) { throw new ServletException(临时目录创建失败: dir.getAbsolutePath()); } } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); String path req.getPathInfo(); if (/chunk.equals(path)) { handleChunk(req, resp); } else if (/merge.equals(path)) { handleMerge(req, resp); } else { resp.setStatus(HttpServletResponse.SC_NOT_FOUND); } } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { if (/status.equals(req.getPathInfo())) { handleStatus(req, resp); } else { resp.setStatus(HttpServletResponse.SC_NOT_FOUND); } } }MultipartConfig三个参数分别对应单文件大小上限、整个请求体大小上限、超过多少字节后数据不再驻留内存而是写入磁盘临时文件。设置的时候要注意协调好maxRequestSize必须大于等于maxFileSize否则前端传的块虽然没超过单文件上限但请求整体被拦了就会出现明明设置了50MB却传不了10MB文件的怪问题。分块接收的核心逻辑在handleChunk方法里private void handleChunk(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Part chunk req.getPart(chunk); String fileId req.getParameter(fileId); int chunkIndex Integer.parseInt(req.getParameter(chunkIndex)); // 分块文件命名规则: fileId_chunkIndex.part String chunkFileName fileId _ chunkIndex .part; // 防止路径穿越和非法文件名 if (!chunkFileName.matches([A-Za-z0-9_\\-]\\.part)) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); return; } File chunkFile new File(TEMP_DIR, chunkFileName); // 幂等处理分块已存在则直接返回成功 if (chunkFile.exists()) { resp.setStatus(HttpServletResponse.SC_OK); return; } try (InputStream in chunk.getInputStream()) { Files.copy(in, chunkFile.toPath(), StandardCopyOption.REPLACE_EXISTING); } resp.setStatus(HttpServletResponse.SC_OK); }这里两个点很关键。第一是幂等同一块重复到达时直接返回成功不做覆盖写入。这个设计在续传场景里非常重要网络超时后前端重试同一个分块如果服务端不判断已存在会导致重复写入或者部分写入的脏数据。第二是文件名白名单正则校验fileId是前端传过来的字符串如果直接拼到路径里恶意请求可以构造../../xxx之类的路径穿越攻击。用正则把名字限制在字母、数字、下划线和横线范围内路径穿越就堵死了。3.4 分块合并与文件完整性校验所有分块传完之后前端发一个/merge请求服务端开始合片。这个方法的重点在完整性的双层校验private void handleMerge(HttpServletRequest req, HttpServletResponse resp) throws IOException { String fileId req.getParameter(fileId); String fileName req.getParameter(fileName); int totalChunks Integer.parseInt(req.getParameter(totalChunks)); long clientSize Long.parseLong(req.getParameter(fileSize)); File tempDir new File(TEMP_DIR); File finalDir new File(BASE_DIR); if (!finalDir.exists() !finalDir.mkdirs()) { throw new IOException(存储目录创建失败); } // 第一层校验统计临时分块的总大小 long expectSize 0; for (int i 0; i totalChunks; i) { File chunkFile new File(tempDir, fileId _ i .part); if (!chunkFile.exists()) { throw new IOException(缺少分块: chunkFile.getName()); } expectSize chunkFile.length(); } // 与前端上报的文件大小比对不一致直接拒绝 if (expectSize ! clientSize) { throw new IOException(分块总大小与客户端文件大小不一致); } File finalFile new File(finalDir, fileId _ fileName); try (BufferedOutputStream bos new BufferedOutputStream( new FileOutputStream(finalFile))) { byte[] buffer new byte[1024 * 1024]; for (int i 0; i totalChunks; i) { File chunkFile new File(tempDir, fileId _ i .part); try (BufferedInputStream bis new BufferedInputStream( new FileInputStream(chunkFile))) { int len; while ((len bis.read(buffer)) ! -1) { bos.write(buffer, 0, len); } } } } // 第二层校验合并后的实际文件大小 if (finalFile.length() ! expectSize) { finalFile.delete(); throw new IOException(合并后文件大小校验失败); } // 清理临时分块 for (int i 0; i totalChunks; i) { new File(tempDir, fileId _ i .part).delete(); } resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write({\status\:\success\,\path\:\ finalFile.getAbsolutePath().replace(\\, /) \}); }合并时有两个细节容易忽略。一是缓冲区大小1MB的buffer每次从分块读出、写入最终文件循环处理时IO次数已经优化过1GB文件大概也就1000多次系统调用性能没问题。二是文件名的处理我在最终文件名前面加了fileId前缀防止不同用户上传同名文件互相覆盖。如果你有业务上保留原始文件名的需求可以在数据库里单独存一列原始文件名磁盘上还是用fileId_原始文件名的格式更安全。4. 断点续传到底是怎么续起来的4.1 服务端状态查询哪些块已经到了续传机制的核心就是前面提到的/upload/status接口。它做的事情很简单扫描临时目录下所有以fileId开头的.part文件把分块编号抽出来以JSON数组返回。private void handleStatus(HttpServletRequest req, HttpServletResponse resp) throws IOException { String fileId req.getParameter(fileId); StringBuilder json new StringBuilder([); File tempDir new File(TEMP_DIR); File[] files tempDir.listFiles((dir, name) - name.startsWith(fileId _) name.endsWith(.part)); if (files ! null) { ListInteger list new ArrayList(); for (File f : files) { String name f.getName(); int start fileId.length() 1; int end name.length() - .part.length(); list.add(Integer.parseInt(name.substring(start, end))); } Collections.sort(list); for (int i 0; i list.size(); i) { if (i 0) json.append(,); json.append(list.get(i)); } } json.append(]); resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write(json.toString()); }为什么不用内存里的Map来记录哪些块已传因为服务端一旦重启内存状态就全丢了用户之前传了一半的文件又得从头来。而通过扫描临时目录来重建状态天然具备持久化能力只要分块文件还在磁盘上状态就在。这种方案代价极小就是每次查询时多一次目录列举操作一个目录下几百个文件根本构不成性能问题。前端拿到这个数组之后在循环里判断uploaded.includes(index)为true的块直接跳过。这就是续传的完整闭环。4.2 页面刷新后怎么恢复上传进度页面一刷新JavaScript里的变量全部归零之前生成的fileId也丢了。要让用户刷新后还能续传必须把fileId在本地持久化下来。我用的是localStoragefunction saveTask(file) { const tasks JSON.parse(localStorage.getItem(uploadTasks) || []); tasks.push({ fileId, name: file.name, size: file.size }); localStorage.setItem(uploadTasks, JSON.stringify(tasks)); }页面加载的时候先读localStorage里的任务列表把未完成的文件展示出来用户点击续传时直接用保存的fileId去查询状态、继续补传。这套机制让我在浏览器崩溃、电脑重启之后依然能恢复之前的进度。注意generateFileId里包含了Date.now()的时间戳这产生了一个问题如果整个任务全部传完了重新选择同一个文件再上传fileId还是会变服务端会把旧的分块当成垃圾存着。这是故意设计的折中方案避免其实想传一个新文件结果因为文件名和大小相同被续传了这种歧义。旧任务的分块靠定期清理兜底下面第五章会讲。4.3 极端情况的兜底策略一个是网络断开。分块请求失败后我的代码会reject这个Promise外层可以捕获并重试当前块。我实际用的策略是重试三次每次间隔1秒、2秒、4秒递增退避。如果再失败就提示用户网络异常让用户手动点击续传。这里不必做太复杂的自动恢复因为分块已经足够小用户重新点一次上传的成本很低。另一个是服务端磁盘写满。这种情况我有一次在测试环境真的遇到了分块写入时报No space left on device前端却只看HTTP状态码认为成功了结果后面合并时发现块缺失。后来我在前端对xhr.status做了判断只有200才算成功其他状态码都归入失败重试分支。后端也加了异常处理写分块失败时返回500让前端能感知到。5. 实际踩过的坑与排错技巧5.1 路径分隔符与中文文件名的坑跨平台开发最容易踩的就是路径分隔符。我第一版代码在Windows上开发时用的upload/temp部署到Linux服务器后目录确实能创建但在拼接绝对路径给前端展示时出了问题。后来统一改成File.separator问题消失。中文文件名也是一个重灾区。前端传fileName参数时我特意加了encodeURIComponent后端也设置了req.setCharacterEncoding(UTF-8)但如果你漏了resp.setCharacterEncoding(UTF-8)返回给前端的JSON里中文路径就会变成乱码。另外Windows文件系统默认编码是GBKJava在读取文件名时如果没指定编码也会出现诡异乱码。我的建议是所有入参出参统一UTF-8最终存储在磁盘的文件名尽量用fileId_原名格式避免纯中文名在部分老系统上的兼容问题。5.2 Tomcat默认限制导致的上传失败这个问题排查了整整半天症状很诡异小文件传得上去大文件一律失败控制台还看不到任何异常。后来查文档才发现Tomcat对POST请求体有一个默认的maxPostSize限制默认值只有2MB虽然单个请求体里的每个分块是10MB但如果某个分块恰好超过2MB请求会被直接拒绝。解决办法有两种。一是在server.xml里把maxPostSize设为0表示不限制。二是保持分块小于2MB但这会大幅增加请求数量不推荐。我最终选择修改Tomcat配置Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 maxPostSize0 maxSwallowSize-1 /顺带说一句maxSwallowSize如果不设置为-1当请求解析失败时Tomcat默认想吞掉剩余请求体遇到大请求会拖慢连接释放所以也一并放开。5.3 并发上传与磁盘空间管理多用户同时上传时临时目录里会堆大量分块。我在做压测的时候10个用户同时上传2GB视频每个文件20个分块临时目录瞬间多出200个文件磁盘占用涨了将近2GB。解决思路分两层。第一层给单一用户的上传并发加限制前端同一时间只发3个分块请求避免瞬时请求风暴。这个用简单的信号量就能实现async function uploadWithConcurrency(blob, fileId, index, limit 3) { // 用一个数组模拟并发池满3个就等待 }第二层服务端做定时清理。我会写一个Scheduled风格的定时任务在纯Servlet环境里就是ScheduledExecutorService每30分钟扫描一次临时目录删除最后修改时间超过24小时且未合并的.part文件。产物上传后用户长时间没完成合并的垃圾分块会被自动清掉不会拖垮磁盘。5.4 常见问题速查表问题现象可能原因解决办法小文件能传大文件失败Tomcat maxPostSize默认2MBserver.xml设置maxPostSize0Safari上传文件名乱码前端未encodeURIComponent统一UTF-8编码前端encode后端setCharacterEncoding上传后文件损坏打不开合并时缺少分块未校验合并前检查所有分块存在校验总大小同一分块重复写入网络重试导致服务端做幂等判断已存在直接返回成功Linux下路径不对硬编码了Windows分隔符使用File.separator服务端重启后无法续传状态只存在内存里用扫描临时目录方式重建状态上传进度条卡住不动用了fetch监听上传进度改用XHR的upload.onprogress磁盘很快被占满未清理过期分块定时任务删除超24小时的.part文件除了表格里的这些还有一个容易忽略的点前端点击开始上传之后一定要禁用按钮并显示明确的进行中状态否则用户手滑又点了一次会触发两套上传循环服务端看到一个fileId下面同时在传两遍分块虽然幂等设计不会写坏文件但白白消耗带宽和磁盘IO。最后说点题外的经验。这套方案上线之后我们平台的视频上传失败率从每周十几起降到了基本为零用户群里的抱怨声也消失了。后来我试着把分块从10MB调到20MB发现在弱网用户那边重传成本明显变高最终又改回了10MB。分块续传这套东西看起来代码量不大但参数选型、状态记录、异常兜底这些细节真的是要在生产环境里摔过几跤才能体会每一步为什么必须这么做。希望这篇实战记录能帮你少摔几跤。