资讯详情

JSP+Servlet实现大文件分块上传:断点续传与并发控制实战

📅 2026/10/14 4:25:40 | 华诺云谱 👁 阅读
JSP+Servlet实现大文件分块上传:断点续传与并发控制实战
做Web开发的朋友几乎都会遇到大文件上传的难题。视频素材、数据库备份、日志包、安装包动辄几百MB甚至几个GB直接丢给表单提交大概率是超时、内存溢出、上传失败三连。我自己最早也踩过这个坑后来在JSP页面里配合Java Servlet用分块上传的思路把问题彻底解决了。简单说就是前端把大文件切成一小块一小块逐块发到后端暂存全部传完后再合并成完整文件。这篇文章我就把这个流程完整拆开从前端JSP页面的JavaScript分块逻辑到后端Servlet的接收、暂存和合并代码再到断点续传和并发控制放在一起讲附上可以直接用的Java代码示例。适合用过Servlet/JSP、正在被大文件上传折磨的Java后端开发者也适合前端同学了解后端如何处理分块数据。1. 整体设计思路与分块上传的底层逻辑1.1 为什么传统单请求上传扛不住大文件很多人最开始上传大文件都是直接写一个form表单enctype设成multipart/form-data后端一个Servlet接住完事。这种写法在小文件时代完全没问题一旦文件超过几十上百MB各种问题就接踵而至。首先是请求大小限制。多数Servlet容器对单次POST请求都有默认上限很多应用服务器默认只有几MB。你传一个200MB的文件请求刚发出去就被服务器直接拒绝。就算把这个限制调大了反向代理或网关那一层通常也有自己的限制还有网络设备、超时时间层层卡你。其次是内存问题。后端接收multipart请求时框架会把请求体先读出来超过阈值就写临时文件没超就堆在内存里。单次大文件上传会让JVM的堆内存瞬间暴涨并发一多直接OOM。我见过不少线上案例不是接口写得不对是上传功能把整个服务拖垮了。第三个问题是网络不稳定。单请求传输中途断一下用户点重试又得从头传。一个大文件重传几次用户心态直接爆炸。分块上传其实就是换了个思路把一个完整文件切成若干固定大小的块比如每块2MB逐块上传后端先收着等全齐了再拼起来。一趟车装不完的货拆成一个个小包裹发出去哪个丢了就补哪个这比整车翻沟里再重装要稳得多。1.2 分块上传的完整数据流向整个流程拆开看其实不复杂核心链路分五步用户在前端JSP页面选择一个文件JavaScript拿到文件对象后用File API的slice方法把文件切成固定大小的分块。每个分块带着文件标识、分块序号、总分块数等参数通过Ajax逐块提交到后端的上传Servlet。后端Servlet接收到分块后将其写入临时目录文件名格式带上分块索引方便后续按序合并。当后端发现某个文件的所有分块都已到达就按分块顺序把内容追加写成一个完整的最终文件。合并成功之后清理临时分块目录返回上传结果给前端。这里有一个关键点不同分块之间怎么认定它们属于同一个文件。如果只靠文件名两个用户上传同名文件就会串台。所以在设计时增加一个文件标识fileKey由前端根据文件名、文件大小、最后修改时间等组合生成后端以fileKey为维度创建独立临时目录。这样即使完全同名的文件只要时间戳或大小不同也能区分开。1.3 技术选型为什么用Servlet内置的Part接口分块上传在Java后端有不少实现方式。早年间大家用commons-fileupload现在Servlet 3.0以上规范已经自带multipart解析能力通过MultipartConfig注解配合Part接口就能搞定不需要再引入额外依赖。这对传统JSP/Servlet项目尤其友好部署环境只要支持标准Servlet容器就行。前端部分我选择原生JavaScript加XMLHttpRequest不引入框架。这样做的原因很简单分块上传的核心逻辑用原生API完全能实现而且不依赖任何构建工具JSP页面直接引用即可。如果用了Vue、React这类框架反而要把精力分散到组件通信和状态管理上。对于技术博客里的示例原生写法最直白也最容易迁移到各种Java Web项目中。分块大小选择也有讲究。太小了请求数暴增2GB文件分成1KB一块要发两百万个请求后端会被打爆太大了又失去分块的意义。我实际测试下来1MB到10MB之间是常见区间网络好的环境用5MB不稳定环境用2MB。本文示例兼容这些大小默认取2MB。2. 前端JSP页面实现JavaScript分块上传代码2.1 页面结构与基础交互JSP页面本质上就是个HTML模板服务端渲染和静态HTML没有太大区别。我们需要一个文件选择框、一个上传按钮、一个进度条还有一个状态展示区域。% page contentTypetext/html;charsetUTF-8 languagejava % !DOCTYPE html html head meta charsetUTF-8 title大文件分块上传示例/title style .container { max-width: 600px; margin: 40px auto; padding: 24px; border: 1px solid #ddd; border-radius: 8px; } .progress-wrap { height: 18px; background: #f0f0f0; border-radius: 4px; overflow: hidden; margin: 16px 0; } .progress-bar { height: 100%; width: 0; background: #4caf50; text-align: center; color: #fff; font-size: 12px; line-height: 18px; } .btn { padding: 8px 20px; cursor: pointer; } .btn:disabled { cursor: not-allowed; opacity: 0.6; } #status { margin-top: 12px; font-size: 14px; color: #666; } /style /head body div classcontainer h2大文件分块上传/h2 input typefile idfileInput / button classbtn iduploadBtn onclickstartUpload()开始上传/button div classprogress-wrap div classprogress-bar idprogressBar0%/div /div div idchunkInfo/div div idstatus/div /div script srcupload.js/script /body /html这段代码比较常规。值得一提的是进度条我用一个简单的div宽度百分比来表示不用任何图表库因为上传进度本身就是一个数值没必要引入额外依赖。2.2 分块切割与顺序上传核心逻辑接下来是重头戏。JavaScript里核心API有三个File对象的slice方法负责切割FormData负责封装分块和参数XMLHttpRequest负责发送请求。由于我们是顺序上传一个块传完再传下一个天然避免了并发导致的分块乱序问题。这里有人会问能不能并发上传多个分块来提速可以但代价是后端需要处理乱序和并发合并复杂度高不少。对于大多数业务场景顺序上传配合进度展示已经足够流畅。真要追求极致速度可以在后续优化里再做并发分块上传。下面这个upload.js文件是完整可用的代码var CHUNK_SIZE 2 * 1024 * 1024; var file null; var currentChunk 0; var totalChunks 0; // 根据文件名、大小、最后修改时间生成稳定的文件标识 function safeFileKey(file) { var raw file.name _ file.size _ file.lastModified; return raw.replace(/[^a-zA-Z0-9_-]/g, _).substring(0, 200); } function startUpload() { var input document.getElementById(fileInput); if (!input.files || input.files.length 0) { alert(请先选择文件); return; } file input.files[0]; totalChunks Math.ceil(file.size / CHUNK_SIZE); currentChunk 0; document.getElementById(uploadBtn).disabled true; document.getElementById(chunkInfo).textContent 文件大小 formatSize(file.size) 总分块数 totalChunks; document.getElementById(status).textContent 准备上传...; // 先查询已上传的分块实现断点续传 queryUploadedChunks(safeFileKey(file), function(chunkSet) { uploadNextChunk(chunkSet); }); } function uploadNextChunk(uploadedSet) { if (currentChunk totalChunks) { document.getElementById(status).textContent 所有分块已上传等待后端合并; document.getElementById(progressBar).style.width 100%; document.getElementById(progressBar).textContent 100%; setTimeout(function() { document.getElementById(uploadBtn).disabled false; }, 500); return; } // 跳过已经上传过的分块 if (uploadedSet uploadedSet.indexOf(currentChunk) 0) { currentChunk; uploadNextChunk(uploadedSet); updateProgress(currentChunk, totalChunks); return; } var start currentChunk * CHUNK_SIZE; var end Math.min(start CHUNK_SIZE, file.size); var chunk file.slice(start, end); var formData new FormData(); formData.append(file, chunk, file.name); formData.append(fileName, file.name); formData.append(fileKey, safeFileKey(file)); formData.append(chunkIndex, currentChunk); formData.append(totalChunks, totalChunks); formData.append(fileSize, file.size); var xhr new XMLHttpRequest(); xhr.open(POST, uploadServlet, true); xhr.upload.onprogress function(e) { if (e.lengthComputable) { var percent ((currentChunk e.loaded / e.total) / totalChunks) * 100; updateProgress(0, totalChunks, percent); } }; xhr.onload function() { if (xhr.status 200) { currentChunk; updateProgress(currentChunk, totalChunks); uploadNextChunk(uploadedSet); } else { // 上传失败1.5秒后重试 setTimeout(function() { uploadNextChunk(uploadedSet); }, 1500); } }; xhr.onerror function() { // 网络错误也要重试 setTimeout(function() { uploadNextChunk(uploadedSet); }, 1500); }; xhr.send(formData); } function queryUploadedChunks(fileKey, callback) { var xhr new XMLHttpRequest(); xhr.open(GET, uploadServlet?actionstatusfileKey encodeURIComponent(fileKey), true); xhr.onload function() { var uploadedSet []; if (xhr.status 200) { try { var arr JSON.parse(xhr.responseText); uploadedSet arr.map(function(name) { return parseInt(name.replace(chunk_, ), 10); }); } catch (e) { uploadedSet []; } } callback(uploadedSet); }; xhr.onerror function() { callback([]); }; xhr.send(); } function updateProgress(current, total, optionalPercent) { var percent optionalPercent ! undefined ? optionalPercent : (current / total * 100); document.getElementById(progressBar).style.width percent.toFixed(1) %; document.getElementById(progressBar).textContent percent.toFixed(1) %; } function formatSize(bytes) { if (bytes 1024) return bytes B; if (bytes 1024 * 1024) return (bytes / 1024).toFixed(1) KB; if (bytes 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) MB; return (bytes / 1024 / 1024 / 1024).toFixed(2) GB; }这里的逻辑不复杂但有几个细节需要注意。file.slice(start, end)里end不能超过文件大小所以要用Math.min兜底。FormData的append顺序不影响后端读取但参数名必须一致。重试时机用了1.5秒延迟既能应对瞬时网络抖动又不会短时间轰炸服务器。2.3 进度条展示背后的原理进度条我用了两层计算。第一层是分块级别也就是xhr.upload.onprogress事件它只能告诉我当前这一个分块传了百分之多少。第二层是全局级别一个分块占总文件的比重是1/totalChunks所以全局进度可以这样算当前正在传的分块占总进度的权重为 1/totalChunks已经完成的分块占 currentChunk / totalChunks再加上当前分块内部已经传输的比例乘上权重就是准确的整体进度。这段逻辑直接体现在代码里var percent ((currentChunk e.loaded / e.total) / totalChunks) * 100;这样用户看到的是一个平滑上升的进度条而不是分块之间猛跳一下。这个细节很多人在初版实现时会忽略只显示分块序号体验差不少。3. 后端Java Servlet实现接收、存储、合并3.1 MultipartConfig参数配置后端核心是一个Servlet我用WebServlet注解注册并用MultipartConfig配置multipart参数。先看参数含义fileSizeThreshold数据超过多少字节后开始写入磁盘。默认阈值很小超出部分会落盘。设成1MB可以兼顾小文件内存处理和大文件磁盘缓冲。maxFileSize单个文件分块的大小上限。这里单个分块我们控制在2MB所以设成10MB留足余量。maxRequestSize一次POST请求的最大字节数。除了文件分块还要算上文件名、参数等字段所以比maxFileSize再大一些。WebServlet(/uploadServlet) MultipartConfig( fileSizeThreshold 1024 * 1024, maxFileSize 10 * 1024 * 1024, maxRequestSize 12 * 1024 * 1024 )这里要特别注意maxFileSize不能设成整个大文件的大小。如果客户端没分块直接提交一个1GB文件会被拒绝。这个配置限制的是单次请求体大小而我们每次请求只传一个分块所以阈值只需要略大于分块大小即可。3.2 接收分块与临时目录设计doPost方法里要做的第一件事是解析参数。除了文件分块本身还需要fileName、fileKey、chunkIndex、totalChunks、fileSize这些参数。后端拿到参数后必须做几个校验不能直接信任前端传过来的数据。文件名要用new File(fileName).getName()再取一次名字这是防止路径穿越攻击的经典做法。比如前端传一个../../etc/passwdgetName之后只剩下passwd就不会出现写文件时路径跳出的问题。fileKey作为临时目录名也需要做合法性校验。我习惯用一个白名单正则只允许字母数字下划线和中划线长度不超过64位。如果前端生成的fileKey不合法就直接打回或替换成随机值绝不让它参与路径拼接。临时目录结构设计成两层根目录temp_upload下面每个fileKey对应一个独立的子目录子目录里面的分块文件统一命名为chunk_加固定宽度数字序号。File chunkDir new File(tempRoot, fileKey); File chunkFile new File(chunkDir, String.format(chunk_%08d, chunkIndex));用固定宽度数字命名是为了合并时按文件名排序就是按分块序号排序省去解析序号再排序的麻烦。8位宽度意味着最多支持一亿个分块足够任何场景使用。接收分块时直接用Part接口的write方法写到目标位置Part filePart request.getPart(file); filePart.write(chunkFile.getAbsolutePath());Part.write的底层会把上传的数据流写入指定文件对大文件分块来说效率很高不在内存里堆积大量字节。3.3 分块合并与文件完整性校验每保存一个分块后后端都要检查一次当前目录下的分块数量是否等于totalChunks。如果没齐就正常返回“分块上传成功”。如果齐了触发合并流程。合并逻辑就是把临时目录里的分块按序号依次读出追加写入最终文件。这里我用BufferedInputStream和BufferedOutputStream做流式拷贝缓冲区设成8KB兼顾IO效率和内存占用。每读完一个分块就把这个分块文件删掉减少临时目录的磁盘占用。合并完成后还要做文件大小校验。前端传了fileSize参数合并出的最终文件长度如果不等于这个值说明数据有问题要抛异常。严谨一点的方案还可以做MD5校验但MD5要么客户端逐个分块计算要么拿完整文件计算对大文件来说成本不低。实际项目中如果对安全性要求高可以在分块上传阶段让前端对每个分块计算MD5后端合并时不需要校验整个文件只需校验每个分块的哈希是否匹配成本更可控。private void mergeChunks(File chunkDir, File finalFile, int totalChunks, long expectedSize) throws IOException { try (BufferedOutputStream bos new BufferedOutputStream(new FileOutputStream(finalFile))) { byte[] buffer new byte[8192]; for (int i 0; i totalChunks; i) { File chunk new File(chunkDir, String.format(chunk_%08d, i)); if (!chunk.exists()) { throw new IOException(分块缺失: chunk.getName()); } try (BufferedInputStream bis new BufferedInputStream(new FileInputStream(chunk))) { int len; while ((len bis.read(buffer)) ! -1) { bos.write(buffer, 0, len); } } chunk.delete(); } } if (finalFile.length() ! expectedSize) { throw new IOException(文件大小校验失败期望 expectedSize 实际 finalFile.length()); } deleteRecursively(chunkDir); }3.4 完整的Servlet代码把上面的逻辑串起来完整的servlet核心代码是这样package com.example.upload; import javax.servlet.ServletException; import javax.servlet.annotation.MultipartConfig; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.Part; import java.io.*; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; import java.util.regex.Pattern; WebServlet(/uploadServlet) MultipartConfig( fileSizeThreshold 1024 * 1024, maxFileSize 10 * 1024 * 1024, maxRequestSize 12 * 1024 * 1024 ) public class UploadServlet extends HttpServlet { private static final String TEMP_DIR temp_upload; private static final String FINAL_DIR upload; private static final Pattern FILE_KEY_PATTERN Pattern.compile([A-Za-z0-9_-]{1,64}); private static final ConcurrentHashMapString, ReentrantLock LOCKS new ConcurrentHashMap(); private File tempRoot; private File finalRoot; Override public void init() { String base getServletContext().getRealPath(/); tempRoot new File(base, TEMP_DIR); finalRoot new File(base, FINAL_DIR); if (!tempRoot.exists()) { tempRoot.mkdirs(); } if (!finalRoot.exists()) { finalRoot.mkdirs(); } } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String action req.getParameter(action); if (status.equals(action)) { String fileKey sanitizeFileKey(req.getParameter(fileKey)); resp.setContentType(application/json;charsetUTF-8); File chunkDir new File(tempRoot, fileKey); StringBuilder json new StringBuilder([); if (chunkDir.exists()) { File[] chunks chunkDir.listFiles(); if (chunks ! null) { boolean first true; for (File f : chunks) { if (!first) { json.append(,); } json.append().append(f.getName()).append(); first false; } } } json.append(]); resp.getWriter().write(json.toString()); return; } resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.getWriter().write({\code\:400,\message\:\unknown action\}); } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setContentType(application/json;charsetUTF-8); String fileName req.getParameter(fileName); String fileKey sanitizeFileKey(req.getParameter(fileKey)); String chunkIndexStr req.getParameter(chunkIndex); String totalChunksStr req.getParameter(totalChunks); String fileSizeStr req.getParameter(fileSize); if (fileName null || fileKey null || chunkIndexStr null || totalChunksStr null || fileSizeStr null) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.getWriter().write({\code\:400,\message\:\缺少必要参数\}); return; } int chunkIndex; int totalChunks; long fileSize; try { chunkIndex Integer.parseInt(chunkIndexStr); totalChunks Integer.parseInt(totalChunksStr); fileSize Long.parseLong(fileSizeStr); } catch (NumberFormatException e) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.getWriter().write({\code\:400,\message\:\参数格式不正确\}); return; } if (chunkIndex 0 || chunkIndex totalChunks || totalChunks 0) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.getWriter().write({\code\:400,\message\:\分块序号非法\}); return; } Part filePart req.getPart(file); if (filePart null) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.getWriter().write({\code\:400,\message\:\没有接收到文件分块\}); return; } String safeName new File(fileName).getName(); if (!checkExtension(safeName)) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.getWriter().write({\code\:400,\message\:\不允许的文件类型\}); return; } File chunkDir new File(tempRoot, fileKey); if (!chunkDir.exists() !chunkDir.mkdirs()) { resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); resp.getWriter().write({\code\:500,\message\:\临时目录创建失败\}); return; } File chunkFile new File(chunkDir, String.format(chunk_%08d, chunkIndex)); filePart.write(chunkFile.getAbsolutePath()); int uploaded countChunks(chunkDir); File finalFile new File(finalRoot, fileKey _ safeName); if (uploaded totalChunks !finalFile.exists()) { ReentrantLock lock LOCKS.computeIfAbsent(fileKey, k - new ReentrantLock()); lock.lock(); try { if (countChunks(chunkDir) totalChunks) { mergeChunks(chunkDir, finalFile, totalChunks, fileSize); resp.getWriter().write({\code\:200,\message\:\上传完成并已合并\,\url\:\ finalFile.getName() \}); return; } } finally { lock.unlock(); } } resp.getWriter().write({\code\:200,\message\:\分块上传成功\,\chunkIndex\: chunkIndex ,\received\: uploaded }); } private String sanitizeFileKey(String fileKey) { if (fileKey null || !FILE_KEY_PATTERN.matcher(fileKey).matches()) { return default_ UUID.randomUUID().toString().replaceAll(-, ); } return fileKey; } private boolean checkExtension(String filename) { int dot filename.lastIndexOf(.); if (dot 0) { return false; } String ext filename.substring(dot 1).toLowerCase(); SetString allowed new HashSet(Arrays.asList( zip, rar, 7z, tar, gz, mp4, mkv, log, sql, bak, pdf, png, jpg, jar, war)); return allowed.contains(ext); } private int countChunks(File chunkDir) { File[] files chunkDir.listFiles(); return files null ? 0 : files.length; } private void mergeChunks(File chunkDir, File finalFile, int totalChunks, long expectedSize) throws IOException { try (BufferedOutputStream bos new BufferedOutputStream(new FileOutputStream(finalFile))) { byte[] buffer new byte[8192]; for (int i 0; i totalChunks; i) { File chunk new File(chunkDir, String.format(chunk_%08d, i)); if (!chunk.exists()) { throw new IOException(分块缺失: chunk.getName()); } try (BufferedInputStream bis new BufferedInputStream(new FileInputStream(chunk))) { int len; while ((len bis.read(buffer)) ! -1) { bos.write(buffer, 0, len); } } chunk.delete(); } } if (finalFile.length() ! expectedSize) { throw new IOException(文件大小校验失败期望 expectedSize 实际 finalFile.length()); } deleteRecursively(chunkDir); } private boolean deleteRecursively(File dir) { if (dir null || !dir.exists()) { return true; } File[] children dir.listFiles(); if (children ! null) { for (File child : children) { if (child.isDirectory()) { deleteRecursively(child); } else { child.delete(); } } } return dir.delete(); } }4. 断点续传与并发控制4.1 状态查询接口实现断点续传断点续传是整个大文件上传体验里最能留住用户的功能。实现思路其实不复杂前端在开始上传前先向后端查询这个fileKey已经上传了哪些分块然后把已经存在的分块跳过只传缺失的部分。后端提供的查询接口就是Servlet里的doGet方法逻辑很简单根据fileKey找到临时目录列出里面的所有分块文件返回一个JSON数组。前端的queryUploadedChunks函数拿到数组后转成分块序号集合在uploadNextChunk里用indexOf判断当前序号是否已经上传。这个方案应对浏览器刷新、网络中断、用户手动关闭页面重开都很有效。只要临时目录没有被清理分块数据就还在重新打开页面选同一个文件可以接着上次的进度继续传。要注意一个细节fileKey必须稳定。如果前端每次打开页面都生成一个随机fileKey断点续传就失效了。我们这里用文件名、大小、最后修改时间组合生成同一个文件只要没改过重新选择后生成的fileKey就是同一个临时目录能找到断点续传自然成立。用MD5也行但大文件提前算完MD5耗时很长组合参数方式成本低得多。4.2 并发重复上传如何保证不会合并出脏文件分块上传天然伴随并发问题。用户可能开了两个页面同时传同一个文件或者两个请求刚好同时到达都检测到分块数量齐了然后同时触发合并。如果不加控制两个线程同时写同一个最终文件数据可能交错或者直接抛异常。我在代码里用了ConcurrentHashMap加ReentrantLock的方式每个fileKey对应一把独立的锁ReentrantLock lock LOCKS.computeIfAbsent(fileKey, k - new ReentrantLock()); lock.lock(); try { // 二次确认分块齐全再执行合并 if (countChunks(chunkDir) totalChunks) { mergeChunks(...); } } finally { lock.unlock(); LOCKS.remove(fileKey); }锁内做二次确认很关键。因为在获取锁之前另一个线程可能已经完成了合并并清理了临时目录。如果不检查就直接合并会拿到一个不存在的目录或空目录。虽说这种极端情况概率不高但生产环境就是要处理这些边界。加一行判断整个逻辑就稳了。4.3 大文件上传的内存和磁盘优化很多人第一次写分块上传容易陷入一个误区觉得文件大就要把读取缓冲区设得很大。其实缓冲区效率和内存占用之间需要平衡。8KB到64KB的缓冲区在绝大多数场景下表现都很好4096K这种看起来在“提速”实际上并不会带来质的飞跃反而每个连接都吃1MB内存并发一高就是灾难。另外分块合并时我选择边读边删每合并完一个分块就删除一个临时文件。这样合并过程中临时目录的占用是持续下降的而不是等到全部合并完再统一清理。如果磁盘空间紧张这个细节能避免极大文件合并时出现磁盘容量不足的尴尬。还有一个容易被忽略的点上传过程中服务器要同时接收新的分块和读取旧分块去合并。如果这两个操作都在同一块磁盘上会互相争抢IO。磁盘性能不够时可以考虑把临时目录和最终目录放在不同磁盘或不同目录上。云服务器上如果挂载了数据盘把临时目录配置到系统盘、最终文件写进数据盘实测合并速度有明显改善。5. 常见问题排查与避坑实录5.1 前端上传一直失败或进度条卡住前端最典型的坑是File.slice在老版本浏览器里不支持。比如早期Safari对Blob.prototype.slice的兼容性有问题有的版本返回空Blob有的直接报错。解决方案是加一个polyfill或者做能力检测function sliceFile(file, start, end) { if (file.slice) { return file.slice(start, end); } // 兼容旧版浏览器的webkitSlice和mozSlice return file.webkitSlice(start, end); }第二个坑是重试逻辑写成了死循环。比如网络断开但xhr.onerror一直触发如果没有重试次数上限会无限重试下去把服务器打挂。实际项目里要加一个最大重试次数超过就停止并告知用户。第三个坑和FormData有关。有的浏览器对fromData里Blob的fileName处理不一致后端拿到part提交文件名可能为空或带路径。前端append时显式传第三个参数文件名可以在大多数浏览器下正常传递。后端不要依赖Part提交文件名应该用前端传来的fileName参数。5.2 后端500错误和请求被拒绝如果看到服务器返回413或者请求直接被拒第一反应查三处配置。一是MultipartConfig的maxFileSize和maxRequestSize是否太小二是Servlet容器对POST请求体大小的限制三是反向代理的请求体大小限制。很多时候开发环境正常部署到生产环境走反向代理就失败就是中间这一层参数没调。默认情况下很多应用服务器对POST参数的大小限制并不大。分块上传每次请求带了一个2MB分块加上几个参数一般不会触发限制。但如果有人误把分块调大到几十MB就会卡在容器请求体限制上。排查时可以先起一个简单的测试接口上传一个指定大小的分块看是否能通逐步缩小范围。另一个高频问题是上传的文件全是0字节。最大的可能不是分块逻辑错而是Servlet没有加MultipartConfig注解。没有这个注解request.getPart会抛异常或者Part拿到的流已经读完。新手写Servlet时很容易忘掉这个注解检查代码时第一眼看有没有MultipartConfig。5.3 合并出来的文件损坏合并文件损坏最常见的原因是分块顺序错乱。虽然我前端的顺序代码是单线程逐块发送但如果某些网络框架或浏览器并发请求后端收到的分块顺序可能不是编号顺序。因此合并时不能依赖文件到达顺序一定要按分块文件名排序因为chunk_00000000这种命名天然保证了字典序和编号序一致。第二个原因是分块数量判断错误。如果客户端传入的totalChunks和真实分块不一致比如最后一个分块因为end越界被切成0字节文件合并时会出现文件长度不匹配。前端计算分块数量时必须用Math.ceil(file.size / CHUNK_SIZE)并且slice的end取Math.min(start CHUNK_SIZE, file.size)。第三个原因是临时目录残留。如果某个分块重复保存目录里相同序号只会有一个文件不会出现多个同名文件所以覆盖不会导致顺序错乱但可能造成上一次残留的旧分块和新分块混在一起。清理策略是上传开始前记录分块总数上传过程中每次保存分块后都去校验已保存分块数量如果某一次发现数量大于totalChunks就说明目录不干净需要清空重传。做个排查速查表方便遇到问题直接对照现象可能原因解决办法请求被拒绝MultipartConfig限制过小调大maxFileSize和maxRequestSize分块上传成功但无法合并totalChunks比实际分块数大检查前端Math.ceil计算合并文件打不开分块顺序错乱合并时按chunk文件名排序断点续传不生效fileKey不稳定使用文件名大小时间戳组合最终文件大小为0分块保存的是空流检查Part获取和写入逻辑上传成功但临时目录残留合并未触发或失败检查锁和分块数量判断逻辑5.4 我踩过的一个印象最深的坑曾经上线一个资料库系统用户上传几百MB的压缩包。开发环境一切正常上线后第二天就有用户反馈上传到70%左右页面卡死再刷新又得重传。当时的临时目录用的是应用服务器解压目录下的相对路径。后来排查发现运维配置了定时清理临时目录的脚本把超过两小时未访问的目录当作垃圾清掉了。用户传了半小时的大文件分块存在一半就被清理后续分块再往同一目录写目录在不在都没判断部分分块直接丢失。合并时发现分块数量比totalChunks少抛异常。这个问题的教训有两个第一临时上传目录不放在解压目录里放在独立的外部目录避免被容器清理或发布脚本覆盖第二服务端保存每个分块前必须检查目标目录是否存在不存在就重建。后来我把这两条写进所有文件上传相关项目的基础规范里再没踩过类似坑。6. 生产环境部署建议与后续扩展6.1 上传目录不要放在应用部署目录里我的示例代码中临时目录和最终目录都放在getServletContext().getRealPath(/)下。这在开发环境没问题但生产环境存在风险。应用服务器在重新部署或清理work目录时可能把解压出来的临时文件清掉有些环境甚至因为权限限制不允许应用往部署目录写文件直接报权限异常。更好的做法是把上传根目录配置成外部绝对路径比如Linux服务器挂在/data/uploaddata下面从环境变量或配置文件读取。这样发布新版本不会丢失用户上传的数据磁盘规划也更灵活。对应到Servlet里init方法改成从配置读取basePath而不是getRealPathString base System.getenv().getOrDefault(UPLOAD_BASE, /data/uploaddata);6.2 临时目录的定期清理机制分块上传天然会在服务器上留下大量临时目录。用户传了一半放弃、传完合并失败、或者网络抖动导致残留分块这些孤儿分块会持续消耗磁盘。我所在的项目一开始没做清理一个月之后临时目录占了几十GB空间。简单方案是用定时任务扫描临时目录删除最后修改时间超过一定阈值比如24小时的目录。Java里可以用ServletContextListener配合后台线程或者直接交给运维写cron脚本。清理时间阈值要大于大文件上传可能的最长中断时间避免把正在断点续传的分块删掉。6.3 文件类型和大小安全策略示例代码里做了一个简单的扩展名白名单。生产环境里上传接口是攻击者重点关注的目标白名单必须收敛到业务真实需要的类型。可以进一步做MIME类型校验但MIME类型可以被伪装所以更可靠的做法是限制扩展名加文件大小上限并在合并后做一些内容检查比如压缩包类文件可以用解压工具试探性读取一次。还要注意最终文件的下载或访问权限。如果上传后的目录落在静态资源可访问范围内你不想让别人遍历找到别人的文件。通常我是把最终目录放在静态资源路由之外访问时通过一个独立的下载接口做权限校验。6.4 从分块到并发的升级路径当前的顺序上传已经能满足多数业务。如果文件几百GB、网速又慢用户会觉得等待时间长这时可以考虑并发上传分块。改动点主要在前端用一个固定数量的线程池并发发送分块请求比如同时发3到5个分块后端接收已经是按文件名独立存储天然支持乱序到达合并时还是按文件名排序。并发上传带来的最大挑战是断点续传的进度计算变复杂。前端要维护一个待发送分块队列和一个已确认分块集合后端在锁内合并时也要处理多个分块同时到达的竞态。我在第4节设计的ReentrantLock方案并发场景下依然有效因为锁粒度在fileKey上不会出现多个线程同时写最终文件的问题。6.5 大文件分块上传的定位边界最后说句实在话分块上传解决的是Web场景下的文件传输可用性问题不是性能银弹。真要追求极致上传速度光靠分块还不够要考虑多路复用、服务端网络带宽、CDN节点加速等因素。分块上传最适合的是业务带宽有限、网络不稳定、需要断点续传的场景。做文件传输方案时先想清楚业务的核心诉求是可靠还是速度再决定要不要把复杂度堆上去。我在实际项目中的体会是分块上传这套流程看似代码量不大真正难点全在各种边界场景临时目录的清理、断点续传的稳定性、并发触发的合并竞态。每次上线前我都会把这几个点重新过一遍顺便提醒自己不要只盯着“能不能传上去”还得想清楚传完之后文件和目录的状态是不是干净的。这套代码我后面又迭代过好几轮加过进度主动刷新、加过分块并行、加过云端对象存储但核心的分块接收和按序合并思路一直保留着。你拿去二次开发的时候建议也先把基础流程跑通再逐步叠加新功能这样排查问题会轻松很多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑