
1. 大文件上传的痛点和分片思路为什么传统的multipart方案撑不住先聊个实际场景。我最早接手这类需求时是在做一个内部项目管理平台里面需要支持用户上传几百MB的工程图纸和视频素材。最开始图省事直接前端拿个input typefile后端Spring MVC接一个MultipartFile参数一套下来好像没啥问题。直到有人传一个1.2GB的压缩包浏览器卡死、服务器内存飙高、偶尔传一半断掉又得从头再来——这时候你才会意识到传统的单文件直传方案在处理大文件时从架构上就是错的方向。问题出在几个地方浏览器一次请求的body过大时HTTP连接很容易在网络抖动或代理层超时的情况下中断而文件传输一旦中断整个文件就废了。Servlet容器Tomcat默认对单个请求的内存缓冲有限大文件直接入内存或临时文件交换性能会急剧下降。用户上传过程中遭遇网络波动、电脑休眠、浏览器误关没有恢复手段只能重头再来这种体验在网盘、工程协作、视频制作这类场景里是完全不能接受的。分片上传的思路其实很朴素把大文件切成固定大小的块前端逐个上传后端逐个暂存所有分片传完后后端按顺序合并成完整文件。如果中间有分片失败前端只需要重传失败的那一块而不是整个文件。断点续传在此基础上再加一层——服务端记录每个分片的接收状态页面刷新或断网恢复后前端主动查询哪些分片已经上传成功跳过它们接着传。这个方案看起来不难但落地时会碰到一系列细节分片怎么切才合理后端如何标识一个上传任务多个分片并发上传时怎么保证顺序和完整性合并时如何避免文件损坏进度条怎么算才准确本文就把这些步骤从头到尾拆开讲直接给出一套能跑的Java Spring Boot 前端原生JavaScript的完整方案。2. 分片与断点续传的整体架构设计不只是“切了传”还要考虑任务状态在写代码之前我建议先把整体流程画清楚。一套可用的分片上传方案至少包含以下几个角色和环节文件元信息文件的唯一标识通常用MD5、文件名、总大小、总分片数、分片大小。上传任务记录服务端为一次上传创建一条任务记录保存文件标识、分片索引与状态。分片存储每个分片独立落盘路径按任务ID和分片序号组织。合并逻辑所有分片就绪后触发合并合成最终文件并清理临时分片。校验逻辑合并后的文件大小、MD5与前端提交的元信息一致才认为上传成功。再来看架构时序。以我常用的Spring Boot后端为例流程大致如下前端选择文件后先计算文件MD5分片上传里常用SparkMD5库或Web Worker异步算得到唯一标识。调用后端接口preupload带上文件MD5和大小。后端判断这个文件是否已上传完成秒传逻辑、是否有未完成的碎片任务。若已有未完成任务返回已上传的分片序号列表前端跳过这些分片。前端将文件切成大小为chunkSize的分片每个分片带序号、任务标识和所属文件MD5并发上传。后端接收分片写入临时目录并更新任务状态。全部完成后前端调merge接口后端执行合并校验文件完整性。校验通过后返回最终文件URL或文件ID前端展示结果。我把任务状态用一张数据库表来维护实际项目里也可以直接用文件系统标记但数据库更清晰可靠字段类型说明task_idvarchar(64)任务唯一ID可用UUIDfile_md5varchar(32)文件MD5判断秒传和断点file_namevarchar(255)原始文件名file_sizebigint完整文件大小chunk_sizeint单个分片大小total_chunksint总分片数uploaded_chunkstext已上传分片序号用逗号分隔或JSON数组statusint0:上传中 1:合并中 2:完成 3:失败create_timedatetime创建时间这张表是整个上传体系的中枢前端查询断点信息、秒传校验、合并触发都围绕它来做。提示如果不想引入数据库可以用Redis的bitmap或set来记录分片状态并发量不大的内部系统里也是可行的。但生产环境建议用数据库持久化避免Redis故障导致任务状态丢失。3. 前端分片实现File.slice切片、并发控制与进度上报前端这部分我用原生JavaScript实现不依赖框架方便你移植到Vue、React或者任何项目里。3.1 文件切片与唯一标识HTML里放一个文件选择框拿到File对象后先确认分片大小。分片大小没有绝对标准我常用的规则是100MB以下分片4MB~8MB100MB~1GB分片8MB~16MB1GB以上分片16MB~32MB分片太小会导致请求数量过多网络握手成本偏高分片太大又失去了分片的意义单个分片失败重传代价高。我一般默认用10MB作为基准值同时根据网络环境和文件大小动态调整。const CHUNK_SIZE 10 * 1024 * 1024; // 10MB function createChunks(file) { const chunks []; let start 0; let index 0; while (start file.size) { const end Math.min(start CHUNK_SIZE, file.size); chunks.push({ chunk: file.slice(start, end), index: index, start: start, end: end }); start end; index; } return chunks; }file.slice(start, end)返回的是Blob对象它只是原文件的一个引用切片不会真正复制内存数据所以切片操作本身非常快几万个分片也没问题。文件唯一标识我用SparkMD5计算整个文件的MD5。这里有个细节大文件直接一次性读入内存算MD5会卡死浏览器必须用分片读取的方式增量计算。用SparkMD5库配合FileReader分片读取或者放到Web Worker中异步计算让主线程不阻塞。// 示例每块2MB读取并增量计算MD5 async function calculateMD5(file) { return new Promise((resolve, reject) { const spark new SparkMD5.ArrayBuffer(); const fileReader new FileReader(); let currentChunk 0; const chunkSize 2 * 1024 * 1024; const totalChunks Math.ceil(file.size / chunkSize); fileReader.onerror reject; fileReader.onload (e) { spark.append(e.target.result); currentChunk; if (currentChunk totalChunks) { loadNext(); } else { resolve(spark.end()); } }; function loadNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); fileReader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); }); }提示MD5计算在超大文件上可能需要几十秒这期间页面上要给出明确的等待提示。实际项目中我会把MD5计算放到Web Worker里避免用户拖动页面时卡顿。3.2 并发上传与失败重试分片直接一个个顺序上传太慢全部并行又可能打满带宽或撞上服务端连接限制。通常的做法是控制并发数在3~5个左右既保证速度又避免请求排队过久。async function uploadChunks(chunks, fileMD5, taskId, uploadedSet) { const poolLimit 3; // 并发数 const queue chunks.filter(c !uploadedSet.has(c.index)); async function worker() { while (queue.length 0) { const chunkItem queue.shift(); await uploadSingleChunk(chunkItem, fileMD5, taskId); } } const workers Array.from({ length: poolLimit }, () worker()); await Promise.all(workers); } async function uploadSingleChunk(chunkItem, fileMD5, taskId) { const formData new FormData(); formData.append(chunk, chunkItem.chunk); formData.append(fileMd5, fileMD5); formData.append(taskId, taskId); formData.append(index, chunkItem.index); // 这里可以做重试比如连续3次失败才放弃 for (let retry 0; retry 3; retry) { try { const res await fetch(/upload/chunk, { method: POST, body: formData }); if (res.ok) { updateProgress(chunkItem.index); return; } } catch (e) { // 网络异常时等待一下再重试 await sleep(1000 * (retry 1)); } } throw new Error(第${chunkItem.index}个分片上传失败); }注意这里进度条的计算不能简单用“已传完的分片数/总分片数”因为有些分片可能正在传输中网络速度不一致。更准确的方案是统计已经成功上传的字节数const uploadedBytes uploadedSet.size * CHUNK_SIZE; const currentBytes uploadedBytes (finishedChunks * CHUNK_SIZE); const percent Math.min(100, Math.round(currentBytes / file.size * 100));3.3 断点续传的触发逻辑当页面刷新或者上传中断后用户重新选择同一个文件或者直接拖入同一个文件前端的处理过程是重新计算MD5。调后端check接口携带fileMd5查询任务信息。后端返回任务是否存在、哪些分片已上传、是否已合并完成。如果合并完成直接提示“该文件已上传成功”不再走上传流程。如果任务存在且未完成只上传缺少的分片。这部分的实质是让前端知道“哪些活已经干完了”而不是重新来一遍。这也是断点续传这个功能最核心的价值所在。4. 后端校验与初始化md5秒传、任务创建、已有分片查询后端的第一个接口是preupload也叫初始化或校验接口。它的作用有两个一是秒传判断二是断点续传的恢复入口。先看代码RestController RequestMapping(/upload) public class UploadController { Autowired private UploadTaskService uploadTaskService; /** * 上传前检查秒传 断点记录查询 */ PostMapping(/preupload) public Result preupload(RequestBody PreUploadRequest req) { // 1. 检查是否存在完全相同的文件秒传 UploadTask existTask uploadTaskService.findByMd5AndStatus(req.getFileMd5(), 2); if (existTask ! null) { return Result.ok().data(uploaded, true).data(fileId, existTask.getFileId()); } // 2. 查询是否已有未完成的任务 UploadTask pendingTask uploadTaskService.findByMd5AndStatusNotFinish(req.getFileMd5()); if (pendingTask ! null) { ListInteger uploadedChunks uploadTaskService.getUploadedChunks(pendingTask.getId()); return Result.ok() .data(uploaded, false) .data(taskId, pendingTask.getId()) .data(uploadedChunks, uploadedChunks); } // 3. 没有历史任务创建新任务 UploadTask newTask uploadTaskService.createTask(req); return Result.ok() .data(uploaded, false) .data(taskId, newTask.getId()) .data(uploadedChunks, new ArrayList()); } }这个接口看似简单但有几个容易被忽略的细节第一个细节MD5碰撞问题。理论上MD5不是绝对安全但在文件上传场景中碰撞概率实际上极其低而且我们还可以在合并后用文件大小做二次校验。如果不放心后端可以额外比对文件大小或者用SHA-256取代MD5代价是前端计算更慢一些。绝大多数项目用MD5就够了我自己的项目至今没碰到过碰撞问题。第二个细节秒传要处理好“文件归属”。如果平台是多用户的保存文件时不能只存MD5对应的文件ID还要记录谁上传的、什么时间上传的。同一个MD5文件在系统里只保留一份磁盘存储但用户文件表里可能对应多条记录——这是实现“秒传”却又不想把文件重复存储的关键。第三个细节任务表与文件表的分离。任务表记录的是“上传进行中的状态”文件表记录的是“最终产物”。任务表在合并成功后可以将状态改为完成但保留一条记录方便前端后续查看文件表才是真正对外提供的文件信息。下面是创建任务的Service层实现public UploadTask createTask(PreUploadRequest req) { UploadTask task new UploadTask(); task.setId(UUID.randomUUID().toString().replace(-, )); task.setFileMd5(req.getFileMd5()); task.setFileName(req.getFileName()); task.setFileSize(req.getFileSize()); task.setChunkSize(req.getChunkSize()); task.setTotalChunks((int) Math.ceil((double) req.getFileSize() / req.getChunkSize())); task.setUploadedChunks(); task.setStatus(0); task.setCreateTime(new Date()); uploadTaskMapper.insert(task); // 创建分片临时目录 File tempDir new File(uploadConfig.getTempDir() File.separator task.getId()); if (!tempDir.exists()) { tempDir.mkdirs(); } return task; }存储目录的组织方式也需要注意。我习惯按{tempDir}/{taskId}/{chunkIndex}.part来存放分片taskId能避免不同任务间的文件冲突。合并完成后这个目录会整个删掉避免磁盘被无用的分片文件占满。关于“临时文件清理”我单独强调一下。很多项目上线后时间一长会发现磁盘占用飙升——原因就是上传中断后临时目录里残留了大量半截分片。最稳妥的办法是加一个定时任务每小时扫描一次临时目录删除超过24小时未更新的分片文件。这块看似不起眼但生产环境中几乎一定会遇到。5. 分片上传与断点记录的并发安全这地方最容易踩坑上传分片的接口是整个方案里最有技术含量的部分。它的主要工作是接收一个分片写入临时目录然后更新任务状态中的已上传分片列表。PostMapping(/chunk) public Result uploadChunk(ChunkUploadRequest req, RequestParam(chunk) MultipartFile chunkFile) throws IOException { // 参数校验 if (chunkFile.isEmpty()) { return Result.error(分片为空); } // 1. 写入分片文件 String taskId req.getTaskId(); int index req.getIndex(); File tempDir new File(uploadConfig.getTempDir() File.separator taskId); if (!tempDir.exists()) { return Result.error(任务不存在或已过期请重新上传); } File chunkFileOnDisk new File(tempDir, index .part); if (chunkFileOnDisk.exists()) { // 如果该分片已存在直接返回成功幂等 return Result.ok(); } // 使用transferTo进行落盘 chunkFile.transferTo(chunkFileOnDisk.getAbsoluteFile()); // 2. 更新任务状态 uploadTaskService.markChunkUploaded(taskId, index); return Result.ok(); }这里有三个核心关注点我逐个说清楚。5.1 幂等性重复提交分片不能出问题前端并发上传时如果某个分片因网络超时触发重试或者用户在多个标签页同时发起上传同一个分片可能被提交多次。后端的应对策略很简单先检查分片文件是否已存在存在则直接返回成功不重复写入。注意transferTo之后要立即标记状态标记操作本身也要做到幂等。不能在数据库里用类似UPDATE ... SET uploaded_chunks CONCAT(uploaded_chunks, ?)这样无脑拼接的方式要判断序号是否已存在。5.2 并发更新问题多个线程同时修改上传记录这是最容易踩坑的地方。多个分片并发上传时它们都会去更新同一个任务记录的uploaded_chunks字段导致“丢失更新”问题。比如上传分片5和分片6同时到达两个线程都读取到当前的已上传列表[1,2,3,4]一个往列表里加5另一个往列表里加6最后提交时后提交的那个把先提交的覆盖掉分片5的状态就丢了。解决方案有几种我按推荐度排序方案一用一条独立的明细表记录每个分片的上传状态。每上传一个分片往upload_chunk_detail表插一条记录task_id, chunk_index, upload_time。查询时用SELECT chunk_index FROM upload_chunk_detail WHERE task_id ?。这种方式天然并发安全不会互相覆盖只是多一张表、多一次查询。方案二使用分布式锁。在更新uploaded_chunks字段前对taskId加锁Redis锁或数据库悲观锁更新完再释放。缺点是锁的粒度略大并发上传时会有锁竞争。方案三数据库行锁。更新任务记录时使用SELECT ... FOR UPDATE锁定行更新完提交后释放。简单有效但引入数据库行锁需要注意事务边界。我强烈推荐方案一原因很简单它不仅能解决并发覆盖问题还能让“查询已上传分片”变得非常高效——按taskId索引查明细表即可不需要解析逗号分隔的字符串。数据库的一张明细表比你在内存或字符串上做各种花活要稳妥得多。Transactional public void markChunkUploaded(String taskId, int index) { // 插入明细记录主键用taskId index防止重复插入 uploadChunkMapper.insertIgnore(taskId, index); }5.3 文件完整性写入没有原子性怎么办分片文件写入的原子性其实是一个容易被忽视的点。如果transferTo写到一半进程崩溃磁盘上会残留一个半截文件。下次重传同一个分片时判断“文件已存在”就会误判直接跳过导致合并出的文件损坏。我的处理方式是分片写入时先写临时后缀写完后重命名成正式分片名。比如先写index.part.tmp完成后renameTo为index.part。这样index.part文件的存在就代表整个分片完整落盘而不会出现半截文件被误认为完整分片的情况。File tmpFile new File(tempDir, index .part.tmp); File finalFile new File(tempDir, index .part); // 先写入临时文件 chunkFile.transferTo(tmpFile); // 确认写入完成后再重命名 if (!tmpFile.renameTo(finalFile)) { // 重命名失败需要清理临时文件 Files.move(tmpFile.toPath(), finalFile.toPath(), StandardCopyOption.REPLACE_EXISTING); }这里的renameTo在同一个目录下是原子操作不会出现最终分片文件“只写了一部分”的状态。如果分片本身写坏了下次重传时index.part不存在前端会重新上传不会误判。6. 合并分片与完整性校验零拷贝合并你不是只有“流读写”一条路所有分片上传完成后前端调用merge接口告诉后端“可以合并了”。后端要做的事是把临时目录下所有分片按顺序拼成一个完整的文件。6.1 合并前的校验合并之前必须先校验“分片数量是否齐全”。如果缺了某个分片就去合并文件必然是坏的。我习惯在合并前做两步校验根据total_chunks检查磁盘上所有分片文件是否存在且大小与预定义分片大小匹配最后一片除外因为最后一片可能不足分片大小。若分片不完整返回错误给前端并附上缺失的序号列表前端据此补传。public MergeResult verifyChunks(String taskId, int totalChunks, long fileSize, int chunkSize) { File tempDir new File(uploadConfig.getTempDir() File.separator taskId); ListInteger missingIndexes new ArrayList(); for (int i 0; i totalChunks; i) { File partFile new File(tempDir, i .part); if (!partFile.exists() || partFile.length() 0) { missingIndexes.add(i); } } if (!missingIndexes.isEmpty()) { return MergeResult.missing(missingIndexes); } return MergeResult.ok(); }这里还有一个细节不能只看文件存在还要看文件大小是否正确。正常情况下一到倒数第二个分片的大小都等于chunkSize最后一片大小等于fileSize - chunkSize * (totalChunks - 1)。如果某个分片大小异常大概率是写入时出了数据截断问题这种分片宁可丢弃重传也不要留着混进合并流程。6.2 合并的几种方案对比分片合并可以简单用字节流循环写入try (FileOutputStream fos new FileOutputStream(finalFile)) { for (int i 0; i totalChunks; i) { File partFile new File(tempDir, i .part); try (FileInputStream fis new FileInputStream(partFile)) { byte[] buffer new byte[8192]; int len; while ((len fis.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } } }这种写法本身没错但经过一次次的文件读写拷贝性能比较差。如果你处理的是几个GB的大文件建议用FileChannel配合transferTo做零拷贝合并让数据在内核态直接搬运不经过Java堆内存public void mergeChunks(String taskId, String finalFilePath, int totalChunks) throws IOException { File destFile new File(finalFilePath); try (FileChannel outChannel FileChannel.open(destFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { File tempDir new File(uploadConfig.getTempDir() File.separator taskId); for (int i 0; i totalChunks; i) { File partFile new File(tempDir, i .part); try (FileChannel inChannel FileChannel.open(partFile.toPath(), StandardOpenOption.READ)) { long position 0; long size inChannel.size(); while (position size) { position inChannel.transferTo(position, size - position, outChannel); } } } } }transferTo在Linux和Windows上表现不一样Windows下对单次传输大小有限制所以需要用循环不断调用直到整个分片都写完。这段代码里的while (position size)循环就是在处理这种情况。6.3 合并后的MD5校验合并完成后最关键的一步是校验重新计算合并后文件的MD5与前端提交的MD5比对。不一致就说明合并过程或分片数据有问题需要标记失败让用户重新上传。这一步不能省。我做过的项目里至少有两次是因为网络代理把某个分片内容截断了但分片文件大小恰好没变最终只有MD5校验能抓出这种问题。public boolean verifyMergeFile(String finalFilePath, String expectedMd5) { try { String actualMd5 DigestUtils.md5DigestAsHex(new FileInputStream(finalFilePath)); return expectedMd5.equalsIgnoreCase(actualMd5); } catch (IOException e) { return false; } }校验通过后可以把文件从临时目录移动到正式存储目录做一次Files.move注意跨存储介质时可能不是原子操作必要时先复制再删除然后在文件表插入一条正式记录最后清理临时分片目录。需要补充的一个点合并是一个比较重的操作大文件合并可能要持续几十秒甚至几分钟。把合并逻辑放在HTTP请求里同步处理用户端容易等得怀疑人生。我通常的做法是合并接口只负责触发合并并返回“合并中”状态真正的合并逻辑丢进异步线程池执行。前端通过轮询文件状态直到变成“完成”再展示结果。这样接口响应快用户体验也自然还能避免Web容器线程被长时间占用。7. 断点续传的完整状态流转从“传了一半”到“再次进来接着传”断点续传在代码层面其实不复杂复杂的是让状态流转清晰可靠。我建议把整个上传过程中的状态机理清楚前端和后端都遵循同一套规则状态含义触发条件下一步动作0上传中任务创建后接收分片直到所有分片就绪1合并中前端调用merge异步线程池执行合并2完成合并校验通过返回文件ID可正常下载访问3失败合并校验失败/超时前端可重新触发合并或重传前端在页面初始化时如果检测到本地有上一次未完成的文件记录可以将文件MD5、名称、分片大小记录在localStorage就自动调用preupload接口检查服务端情况。服务端返回已上传的分片序号后前端只补充上传剩余的部分。这样一个完整的断点续传流程就串起来了。说一个我实际踩过的坑前端在传入相同文件时不要依赖文件名判断是否续传一定要用MD5。用户可能改了文件名再拖进来比如从“设计稿-final.zip”改成“设计稿-final2.zip”文件内容一个字没变但文件名变了如果你用文件名作为标识就会误判成新文件重新上传。用MD5作为任务标识就完全不受文件名干扰。还有一个容易被忽略的点是服务端超时清理策略与断点续传的矛盾。如果你把5分钟未更新的临时任务直接删掉用户上传到90%时去楼下取了个快递回来发现任务被清理了整个上传就废了。所以临时任务的超时时间要放宽一般24小时比较合理同时要允许用户在续传时“刷新”任务的最后更新时间防止被误清理。8. 实测中的经典问题与排查思路说说我测试时踩过的那些坑到一个阶段我必须把实际测试过程中遇到的几个经典问题列一列。这些问题不是理论推导出来的是我跑demo、压测、真机验证时真实碰到的。列出来给各位排雷。8.1 第一个坑Nginx默认的请求体大小限制前端传10MB分片本来没什么问题但如果你的服务前面挂了一层Nginx过不去就麻烦了。Nginx默认client_max_body_size是1MB超过就会返回413。有些人调试时发现大文件上传一直被拒但自己本地直连后端接口测又正常问题就出在这。排查方法很简单看浏览器Network面板里那几个失败请求的状态码如果是413直接去Nginx配置里调大client_max_body_size 50m;这里的50m要大于你的分片大小建议留出一定余量比如分片10MB就设50MB防止表单额外字段把请求体顶到临界值。如果你并发传4个分片Nginx默认的并发连接数也可能成为瓶颈需要在nginx.conf里根据实际并发数适当调整worker_connections。8.2 第二个坑Tomcat对multipart请求的大小限制Spring Boot内嵌Tomcat时spring.servlet.multipart.max-file-size默认是1MBmax-request-size默认是10MB。如果你没改配置哪怕Nginx放行了Tomcat层也会拒绝超过1MB的分片。在application.yml里做如下配置spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MB注意max-request-size是单个请求的总大小包括文件和其他表单字段。这个值要留有余量比如分片10MB加上一些元数据设成100MB基本上稳了。要是分片改成30MB这个值也要跟着往上调。8.3 第三个坑数据库连接池不够导致上传分片全部卡住在一次压测中我发现10个文件同时并发上传每个文件3个分片并发前端请求大量堆积后来排查发现不是Tomcat线程不够而是数据库连接池被打满了。每个分片请求都要更新数据库几十个请求同时进来默认HikariCP连接池大小是10很快就被占满其他请求全部阻塞。我调大了连接池同时优化了更新逻辑——分片状态更新走批量入库或异步队列避免每个分片都占一个数据库连接。这个坑背后的经验是设计接口时一定要估算好单次上传会带来的数据库QPS。一个1GB文件切100个分片100个分片并发上传产生100次数据库写入如果是100个人同时上传瞬间就是一万次写入。这时候要么提高连接池要么用异步批量写入或Redis暂存后再落库总之不能天真地认为分片上传只是文件读写那么简单。8.4 第四个坑Last-Modified和ETag对文件下载的干扰文件合并完成后如果后续要通过HTTP下载给用户加上断点续传的响应头但注意排查浏览器代理或CDN缓存对文件的影响。如果CDN缓存了旧版本的Part文件同名文件重新上传时用户下载到的可能是之前的内容。我遇到过一次重传同名工程文件后某些用户下载的仍然是旧版本排查了半天发现是CDN缓存了文件URL。解决方案是文件URL里加版本号或MD5前缀比如/file/{md5}/{fileName}MD5变了URL就变了CDN缓存自然失效。这一步操作很便宜但能省掉很多说不清道不明的线上问题。9. 百万级上传场景的进阶优化分片大小动态调整、合并策略、存储层权衡如果你不满足于“能跑”想把方案推向更高的并发和更大的文件规模下面这几个方向是值得去做的。9.1 分片大小动态调整固定10MB分片在弱网环境中依然可能失败率高。一些网盘产品的做法是基于网络探测动态调整分片大小网络好时用大分片减少请求数量网络差时用小分片降低单个请求失败造成的重传成本。前端可以通过navigator.connection的downlink估算网络带宽也可以根据最近几个分片的上传耗时动态调整后续分片大小。比如检测到最近3个分片平均耗时超过10秒就把分片大小减半反之增大。这个动态调整逻辑不难实现但带来的稳定性提升很可观。9.2 合并的异步化与线程池隔离合并操作在真正的大文件场景里需要几分钟甚至更久。如果合并放在业务线程池里一个超大文件的合并可能拖垮整个应用的并发能力。我建议单独建一个合并线程池核心线程数适当调低合并操作本身就是IO密集不需要太多线程队列容量根据业务量估算。Bean(mergeExecutor) public ThreadPoolTaskExecutor mergeExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix(merge-worker-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; }前端在合并接口返回后可以轮询任务状态也可以让后端在合并完成后通过WebSocket或SSE通知前端。轮询实现简单几秒一次状态查询足够WebSocket更实时但需要额外维护连接状态看团队技术栈取舍。9.3 存储层的取舍我默认的方案是分片先写本地磁盘合并后再移到正式存储目录。但到了一定的规模本地磁盘会成为隐患磁盘空间有限所有临时分片都占同一块磁盘容易写满。单机磁盘IO有上限并发上传大会出现写瓶颈。生产环境中可以考虑把临时目录放到SSD或内存文件系统tmpfs上提高写入速度分片文件也可以直接用对象存储如MinIO、S3来存——每个分片作为一个独立Object放到临时桶里合并时用对象存储服务端的Copy操作逐个拼接或者直接下载完整文件。用对象存储做分片存储的好处是天然分布式、容量近乎无限坏处是合并时网络流量较大需要结合具体场景评估。我个人给的建议是如果你的系统只是内网使用、日均上传量不多本地磁盘方案最省心如果面向公网高并发上传尽早切换到对象存储方案本地磁盘在扩容和灾备上都会让你后期付出额外成本。10. 前端进度展示与异常恢复的体验设计上传体验不只是技术上的分片逻辑用户看到什么、操作起来顺不顺手比后端更影响这个功能的上线成败。10.1 进度条与速度计算进度条应该显示“整体文件上传进度”而不是“当前分片上传进度”。实际操作时可以维护一个已成功上传的字节数变量每成功一个分片就把这个分片的大小累加进去。瞬时速度可以用滑动窗口计算——取最近5秒内成功上传的字节数除以5显示出来的速度比较平滑不会狂跳。10.2 中断后的恢复提示如果页面在断网时仍然挂着要给出明确的“网络已断开正在尝试恢复”提示并在后端可访问时自动触发续传。如果用户主动取消了上传要询问是否保留已上传分片——保留的话下次拖入同一文件还能秒续。10.3 多文件队列文件量大的场景最好用队列管理。一个文件上传中其他文件处于等待状态选一个用户最急需的优先传。可以给队列加“置顶”“暂停”“重试”这些基础操作后端接口设计时就要为“暂停恢复”留好入口也就是preupload接口返回分片清单这个动作前端暂停后恢复就是对缺得分片重新执行上传本身不需要额外的复杂状态。11. 我能分享的最后一个细节日志与监控这些细节看着不起眼但对排查线上问题帮助极大。分片上传的请求多单次失败快的很必须做到可以从日志里完整还原某一次上传的整个过程。我习惯在以下三个关键节点加结构化日志任务创建时taskId、fileMd5、fileSize、totalChunks每个分片上传完成时taskId、chunkIndex、耗时合并完成时taskId、finalPath、actualMd5、文件大小用logback的MDC机制把taskId贯穿整个调用链。如果分片上传失败率突然升高从日志里快速定位到是网络问题、超时问题还是磁盘写入问题效率会高很多。另外定时任务清理临时分片后如果文件最终没有合并成功前端轮询会一直拿到“上传中”状态这种情况要在监听清理时主动把任务状态置为失败避免用户永远等不到结果。分片上传与断点续传这套方案的代码量不算大核心概念即使没见过也能很快理解但把每处细节做好做扎实需要踩的坑确实不少。希望这篇文章里的实现步骤和我在真实项目中积累的这些教训能帮你少走一些弯路。