资讯动态

SpringCloud架构下大文件分片上传实战:从原理到代码实现

发布时间:2026/10/13 10:40:30 来源:尧图企业网站定制
大文件上传这件事在SpringCloud架构下做和单体应用里做完全不是一个思路。单机环境你还能靠加大内存、调长超时硬扛一旦拆了微服务前端请求会经过网关、负载均衡、多个服务实例随便一个大文件直接POST上来要么网关超时断连要么某个实例内存被打满要么传到一半网络抖动全部重来。分片上传不是炫技是微服务架构下处理大文件的必经之路。这篇就聊清楚一件事在SpringCloud页面上用最稳妥、可落地的方式实现大文件分片上传。不仅讲代码怎么写得能跑更讲明白为什么这么设计以及我在真实业务里踩过哪些坑。适合正在做文件模块的后端开发、全栈工程师以及被产品经理逼着“必须支持上传几个G文件”又不知道从哪下手的同学。1. 为什么大文件上传必须分片直接POST整个文件行不行先别急着写代码把为什么要分片想透后面设计接口时你才知道哪些环节是必须的。1.1 从一次真实翻车说起我之前做过一个项目某跨平台系统的后台管理页面需要批量导入数据文件单个文件不到100MB。一开始图省事前端拿到File对象直接FormData扔给后端后端一个MultipartFile接住写本地磁盘完事。测试环境一切正常因为测试用的都是小文件、局域网、单实例部署。结果一上生产问题全冒出来了。前端直连的是SpringCloud Gateway网关默认的请求体缓冲和超时时间都很保守。传一个60MB的文件稍微慢点网关直接给你返回504。更严重的是后端服务在Kubernetes里跑了好几个实例每次请求被负载均衡随机分发到不同Pod。文件倒是传上去了但传到哪个Pod了都不知道后续处理直接找不到文件。那次之后我彻底明白了一件事在SpringCloud这种分布式场景下文件上传如果还按单体时代那种“一把梭”的方式做大概率会被现实教育。1.2 大文件直传的四大致命痛点把这些痛点摆出来你就能对应着看分片方案是如何逐个击破的。痛点表现根本原因请求超时文件传一半网关或服务端断开网关、Tomcat、负载均衡器都有请求超时限制内存暴涨多个大文件并发上传服务直接OOM流式接收并不代表框架不会缓冲处理不当会占满堆内存失败全量重传传到99%断网一切归零没有断点续传机制异常后服务端无法识别已接收的数据进度缺失页面卡住用户不知道传没传完单个请求无法细粒度上报进度连接断开也无感知1.3 分片上传破局的核心思想分片的核心思想特别朴素把一个大文件切成多个小块逐个上传全部传完后服务端按顺序合并。这块切多小可以自己定传完一块就确认一块。这套机制天然解决了上面四个问题。每一片单独请求单片体积小网关不会超时单片在内存里处理的压力远小于整个文件传失败的片可以单独重传不用整个重来前端甚至可以拿“已成功上传的分片数/总分片数”实时渲染进度条。而且SpringCloud场景下还有个隐形优势分片文件可以先落到某个共享存储或者对象存储的临时目录合并时再从临时目录拉取天然绕开了“文件传到哪个实例”的困惑。2. 整体架构设计与接口约定想清楚了为什么接下来就是怎么设计。分片上传的前后端交互本质上就是一个三步走的有状态流程。2.1 三步走的交互流程整个交互流程围绕文件唯一标识展开核心就三步。第一步前端在上传前先调后端的一个初始化接口告诉后端“我要传一个文件名xxx、大小yyy的文件”后端根据文件的唯一标识生成上传ID并计算出总分片数返回给前端。这里的文件唯一标识一般用MD5或者文件的业务ID来生成。第二步前端拿到上传ID后把文件切成指定大小的分片逐片调用上传接口。每一片都带上“这是第几片”和“总分片数”的信息。后端每一片都单独校验、单独存储、单独确认。第三步所有分片都传完后前端调合并接口。后端确认分片齐全按顺序把分片拼成一个完整文件然后做后续处理比如转存对象存储、触发异步解析等。2.2 关键接口定义与参数说明接口设计我拆成三个RESTful接口清晰而且好维护。初始化上传预申请POST /api/file/upload/init请求参数fileName文件名、fileSize文件总字节数、chunkSize希望的分片字节数。 响应参数uploadId本次上传的唯一标识、totalChunks总分片数、existChunks已存在的分片编号列表断点续传用。上传分片POST /api/file/upload/chunk请求参数uploadId、chunkIndex从0开始的分片序号、chunkData二进制分片内容。 响应参数success该分片是否成功、message。合并文件POST /api/file/upload/merge请求参数uploadId、fileName。 响应参数fileUrl、size、status。2.3 为什么分片大小选1MB到5MB最合理分片大小不是拍脑袋定的它直接影响上传成功率和服务端压力。片太大比如还是10MB一片虽然分片数少了但单片超时风险依然存在而且某一小段网络抖动会导致大片重传浪费带宽。片太小比如100KB一片分片数量爆炸100MB的文件就要切1000片每片都要走一次完整HTTP请求请求开销会拖垮整体速度。我实际项目里的经验值是普通办公网络环境分片大小设在2MB到5MB比较平衡。100MB文件切成20到50片单片上传耗时短偶发失败重传的成本也可控。如果是内网高速环境可以用到10MB。如果是移动端弱网建议降到1MB。因为单片大小和并发数是相乘关系后面讲前端并发控制时你还会看到它们如何联动。3. 服务端核心代码实现解析前端切好片传上来后端接收并管理这些分片是核心这部分代码的质量直接决定功能稳不稳。3.1 工程依赖与配置我用的是常见的SpringBoot服务微服务通过OpenFeign或RestTemplate互相调用但文件上传服务本身不需要调用别的服务只要具备文件系统读写能力和Redis缓存能力即可。Redis在这里用来保存上传过程的元数据文件系统或对象存储用来保存分片文件。pom.xml中关键依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcommons-io/groupId artifactIdcommons-io/artifactId version2.11.0/version /dependency配置文件里需要关注的点是上传临时目录和单次请求大小限制。SpringBoot默认的spring.servlet.multipart.max-file-size是1MBmax-request-size是10MB这必须改大。以单片5MB为例设置成10MB和100MB就够用不要设置成无限制防止有人绕过前端直接传大包。spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB redis: host: your-redis-host port: 63793.2 初始化接口把元数据存进Redis这个接口做得是否优雅直接影响断点续传功能是否好用。RestController RequestMapping(/api/file/upload) public class UploadController { Autowired private StringRedisTemplate redisTemplate; private static final String UPLOAD_INFO_PREFIX upload:info:; private static final String UPLOAD_CHUNKS_PREFIX upload:chunks:; PostMapping(/init) public MapString, Object initUpload(RequestBody UploadInitRequest request) { String uploadId generateUploadId(request.getFileName()); int totalChunks (int) Math.ceil((double) request.getFileSize() / request.getChunkSize()); // 保存上传元数据到RedisTTL设置为2小时防止僵尸数据堆积 MapString, String infoMap new HashMap(); infoMap.put(fileName, request.getFileName()); infoMap.put(fileSize, String.valueOf(request.getFileSize())); infoMap.put(chunkSize, String.valueOf(request.getChunkSize())); infoMap.put(totalChunks, String.valueOf(totalChunks)); redisTemplate.opsForHash().putAll(UPLOAD_INFO_PREFIX uploadId, infoMap); redisTemplate.expire(UPLOAD_INFO_PREFIX uploadId, 2, TimeUnit.HOURS); MapString, Object result new HashMap(); result.put(uploadId, uploadId); result.put(totalChunks, totalChunks); result.put(existChunks, getExistChunks(uploadId, totalChunks)); return result; } private ListInteger getExistChunks(String uploadId, int totalChunks) { // 检查哪些分片已上传用于断点续传后面会补全实现 ListInteger existChunks new ArrayList(); for (int i 0; i totalChunks; i) { String key UPLOAD_CHUNKS_PREFIX uploadId : i; if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { existChunks.add(i); } } return existChunks; } }这里有个关键点Math.ceil((double) fileSize / chunkSize)。假设文件100MB、分片5MB结果就是20片。如果文件大小是5MB的整数倍这个公式也能正确处理不会多出第21片。之前有同事图省事用fileSize / chunkSize 1结果文件正好整除时分片总数多了一个前端跟后端对不上排错排了很久。uploadId的生成方式建议用UUID去掉横杠再加时间戳前缀。这样既保证全局唯一也能通过前缀大致看出上传时间。要注意的是不要把uploadId和fileName混为一谈因为同名文件可能被不同用户同时上传uploadId必须是每次上传独立生成的。3.3 上传分片接口确保幂等和完整性分片上传接口是整个流程中被调用次数最多的最需要关注的是幂等性——同样一片传两次不能产生两份数据也不能报错。PostMapping(/chunk) public MapString, Object uploadChunk(RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) Integer chunkIndex, RequestParam(chunkData) MultipartFile chunkData) { // 1. 校验uploadId是否存在 String infoKey UPLOAD_INFO_PREFIX uploadId; if (!redisTemplate.hasKey(infoKey)) { throw new RuntimeException(上传会话不存在或已过期请重新初始化); } // 2. 校验分片序号是否在合法范围内 MapObject, Object infoMap redisTemplate.opsForHash().entries(infoKey); int totalChunks Integer.parseInt(String.valueOf(infoMap.get(totalChunks))); if (chunkIndex 0 || chunkIndex totalChunks) { throw new RuntimeException(非法的分片序号); } // 3. 判断该分片是否已上传过幂等校验的核心 String chunkKey UPLOAD_CHUNKS_PREFIX uploadId : chunkIndex; if (Boolean.TRUE.equals(redisTemplate.hasKey(chunkKey))) { // 已存在说明之前传过直接返回成功避免重复写磁盘 return Map.of(success, true, message, 分片已存在); } // 4. 保存分片到磁盘的临时目录目录以uploadId隔离 String tempDir buildTempDir(uploadId); File targetFile new File(tempDir, chunkIndex .part); try { chunkData.transferTo(targetFile); } catch (IOException e) { throw new RuntimeException(分片保存失败, e); } // 5. 记录分片已完成用Redis的SetBit或者普通String都可 redisTemplate.opsForValue().set(chunkKey, String.valueOf(chunkIndex)); redisTemplate.expire(chunkKey, 2, TimeUnit.HOURS); return Map.of(success, true, message, 分片上传成功); } private String buildTempDir(String uploadId) { // 按日期uploadId两级目录隔离便于排查和清理 String datePath LocalDate.now().toString().replace(-, ); File dir new File(/data/upload_temp/ datePath / uploadId); if (!dir.exists()) { dir.mkdirs(); } return dir.getAbsolutePath(); }分片命名直接用chunkIndex .part合并时只要按数字排序读出来就行。这里踩过一个坑直接用String.valueOf(chunkIndex)做文件名没问题但如果一开始用chunkIndex _ System.currentTimeMillis()这种命名后面合并时排序就会乱必须专门写排序逻辑得不偿失。MultipartFile.transferTo()这个方法是Spring封装的底层会自动处理临时文件的移动性能比手动FileOutputStream写入好推荐直接用。要注意的是目标目录必须存在否则会抛异常所以mkdirs()不能省。还有个容易被忽略的细节transferTo之后如果后续Redis写入失败会出现“磁盘有分片、Redis没记录”的不一致状态。要保证一致严谨的做法是先写磁盘再写Redis然后清理磁盘上可能残留的旧文件。如果你用的是MySQL而不是Redis也是一样的思路先落数据再更状态。性能方面单片5MB左右的写入普通机械硬盘完全扛得住。如果并发量特别大建议把临时目录放到SSD上并用FileChannel写入代替transferTo但这个优化通常在业务量达到一定规模才需要前期不用折腾。3.4 合并接口进行完整性校验与文件合并合并操作是所有分片上传的核心处理环节也是问题最容易爆发的地方。合并前必须校验分片是否齐全合并后必须校验文件大小是否吻合。PostMapping(/merge) public MapString, Object mergeUpload(RequestBody MergeRequest request) { String uploadId request.getUploadId(); String infoKey UPLOAD_INFO_PREFIX uploadId; MapObject, Object infoMap redisTemplate.opsForHash().entries(infoKey); if (infoMap.isEmpty()) { throw new RuntimeException(上传会话不存在或已过期); } String fileName String.valueOf(infoMap.get(fileName)); long fileSize Long.parseLong(String.valueOf(infoMap.get(fileSize))); int chunkSize Integer.parseInt(String.valueOf(infoMap.get(chunkSize))); int totalChunks Integer.parseInt(String.valueOf(infoMap.get(totalChunks))); // 1. 校验分片是否全部就位 String tempDir buildTempDir(uploadId); for (int i 0; i totalChunks; i) { File partFile new File(tempDir, i .part); if (!partFile.exists() || partFile.length() 0) { throw new RuntimeException(缺少分片 i); } } // 2. 创建合并输出流按顺序写入 String datePath LocalDate.now().toString().replace(-, ); File destDir new File(/data/upload_final/ datePath); if (!destDir.exists()) { destDir.mkdirs(); } String destPath destDir / System.currentTimeMillis() _ fileName; File destFile new File(destPath); try (FileOutputStream fos new FileOutputStream(destFile); BufferedOutputStream bos new BufferedOutputStream(fos)) { for (int i 0; i totalChunks; i) { File partFile new File(tempDir, i .part); try (FileInputStream fis new FileInputStream(partFile)) { byte[] buffer new byte[1024 * 1024]; int len; while ((len fis.read(buffer)) ! -1) { bos.write(buffer, 0, len); } } } bos.flush(); } catch (IOException e) { throw new RuntimeException(文件合并失败, e); } // 3. 校验合并后文件大小 if (destFile.length() ! fileSize) { destFile.delete(); throw new RuntimeException(合并文件大小异常); } // 4. 清理临时分片和Redis元数据 deleteTempDir(tempDir); redisTemplate.delete(UPLOAD_INFO_PREFIX uploadId); SetString keys redisTemplate.keys(UPLOAD_CHUNKS_PREFIX uploadId :*); if (keys ! null !keys.isEmpty()) { redisTemplate.delete(keys); } return Map.of(success, true, fileUrl, destFile.getAbsolutePath(), size, destFile.length()); }合并时用了BufferedOutputStream缓冲大小设置成1MB比逐字节写快非常多。这里有一个非常关键的对比——很多同学会用Files.copy(partFile.toPath(), bos)直接拷贝但实测下来分批读写的可控性和性能都不差还能避免在异常时出现半截文件无法定位的情况。还有一个重要的点合并后文件大小校验不能省。它虽然是弱校验不能证明文件内容完全正确但能过滤掉绝大部分问题但实现成本低、效果明显。要更严谨可以加分片MD5校验。也就是说前端上传每一片时额外算一个该分片的MD5后端存储时记录MD5合并前逐个校验。这样能捕获到网络传输中单片数据损坏的情况。我们内部的文件系统存储通常有严格的路由策略所以实际使用中用小文件往往很难触发致命的网络损坏但如果文件数据十分重要加上不是坏事。临时文件的清理也要考虑清楚。如果合并成功直接删除整个临时目录。如果合并失败保留临时目录方便现场排查服务器上可以写一个定时任务定期清理超过一定时间的upload_temp目录。这里还涉及一个微服务下绕不开的问题合并接口发起的时机。如果多个前端实例同时对一个uploadId发起合并请求会出现两个线程同时合并同一个文件产生重复写入。解决办法合并操作加一个分布式锁比如Redis的SETNX锁保证同一uploadId只有一个合并任务在跑。如果是单实例部署用synchronized (uploadId.intern())也能顶一下但不推荐毕竟SpringCloud场景分布式锁才是正解。3.5 分片存储的三种方式对比临时分片存哪里有好几种选项我在不同项目里都试过做个对比方便你做技术选型。方案优点缺点适用场景本地磁盘目录实现简单、读写快多实例下分片分散在各自节点合并时可能找不到单实例部署、只需要短期过渡共享文件目录NAS/挂载盘多实例共享同一路径合并不受实例影响需要运维配合挂载IO可能成为瓶颈内部微服务集群、并发量不大对象存储OSS/S3/MinIO分布式天然共享、扩容方便、可直接转存最终文件依赖外部服务、需要操作SDK云原生部署、大容量长周期存储我个人的建议是如果你们公司已经上了对象存储分片临时目录和最终文件都放对象存储省心又可靠。如果还在裸机或者内部署用共享文件目录配合Redis元数据是最实用的方案。本地磁盘方案虽然代码最简单但在SpringCloud多实例场景下后患无穷除非你确定上传服务只会部署一个实例否则别碰。4. 前端页面核心逻辑实现后端处理逻辑再完备前端配合不好一样会出问题。前端在整个分片上传里管三件事切分文件、控制并发、上报进度。4.1 前端技术选型与页面骨架前端用原生浏览器能力加axios就行不需要引入任何笨重的上传组件。浏览器的File对象天然支持slice()方法切分出指定字节长度的Blob片段。页面基本结构用Vue语法示例如果你用React或者其他框架思路是一样的input typefile idfileInput changehandleFileChange button clickstartUpload开始上传/button div进度{{ progress }}%/div div成功分片{{ successCount }} / {{ totalChunks }}/div4.2 切分与上传的核心JavaScript实现先看核心的上传控制代码然后逐个讲解设计意图。const CHUNK_SIZE 5 * 1024 * 1024; // 5MB const MAX_CONCURRENT 3; async function handleFileChange(event) { const file event.target.files[0]; if (!file) return; // 计算总分片数 const totalChunks Math.ceil(file.size / CHUNK_SIZE); // 1. 调用后端初始化接口拿到uploadId const initResult await axios.post(/api/file/upload/init, { fileName: file.name, fileSize: file.size, chunkSize: CHUNK_SIZE }); const uploadId initResult.data.uploadId; // 2. 从后端拿到已存在的分片编号实现断点续传 const existChunks new Set(initResult.data.existChunks); // 3. 构建所有需要上传的分片任务 const tasks []; for (let i 0; i totalChunks; i) { if (existChunks.has(i)) continue; const start i * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); const blob file.slice(start, end); tasks.push({ index: i, blob }); } // 4. 并发控制同时只跑3个请求 await runWithConcurrency(tasks, MAX_CONCURRENT, async (task) { const formData new FormData(); formData.append(uploadId, uploadId); formData.append(chunkIndex, task.index); formData.append(chunkData, task.blob); await axios.post(/api/file/upload/chunk, formData); }); // 5. 全部成功后触发合并 await axios.post(/api/file/upload/merge, { uploadId: uploadId, fileName: file.name }); } async function runWithConcurrency(tasks, limit, handler) { const results []; const executing new Set(); for (const task of tasks) { const promise handler(task).then(result { results.push(result); executing.delete(promise); }); executing.add(promise); if (executing.size limit) { await Promise.race(executing); } } await Promise.all(executing); return results; }这段代码里有几个细节值得细品。existChunks来自初始化接口返回的已上传分片列表。当页面刷新或网络中断后用户重新选择同一个文件前端重新初始化时后端会把之前已经成功保存的分片编号告诉前端前端就不需要重复上传这些内容了。这个机制在弱网环境下体验提升极其明显。并发数MAX_CONCURRENT设置成3而不是越大越好。并发过大会占满用户的上行带宽导致每片的速度都变慢反而整体变慢。更重要的是服务端和网关的并发连接数有限过高的并发容易触发限流。我实测过家用宽带看3到5个并发最合适。如果有条件根据网络质量动态调整并发数会更好但大部分场景固定3个就够了。进度上报逻辑由两部分组成成功分片数 / 总分片数是基础进度如果想更精确可以用已完成分片的字节数 / 文件总字节数加权计算。let uploadedBytes 0; // 跟踪已上传的字节数 // 每成功一片加上这一片的实际字节数 const progress ((uploadedBytes / file.size) * 100).toFixed(2);这个精细进度在用户视觉上更平滑因为每片大小相同所以两种方式的数值差异不大但考虑到某些浏览器对file.slice末尾多割少割的情况加权计算更稳妥。4.3 暂停、续传与取消的扩展实现如果只是做上传上面代码已经能用了。但真实业务几乎都要求能暂停、能续传。这块实现也不难核心是控制任务的启停。暂停的思路用一个全局标志位在分发任务时检查标志位为false就停止派发新任务。当前正在传输的任务通过axios的AbortController终止。let isPaused false; let abortController null; function pauseUpload() { isPaused true; if (abortController) { abortController.abort(); } } async function startUpload() { isPaused false; // ... 分片任务构建逻辑 for (const task of tasks) { if (isPaused) break; abortController new AbortController(); try { await uploadChunkWithSignal(task, abortController.signal); } catch (e) { if (e.name CanceledError) { // 用户主动取消忽略 break; } throw e; } // 继续下一个任务 } }暂停后如果用户点击继续重新走初始化接口拿到existChunks从断点继续一秒都不用等。这个体验做出来之后产品经理基本挑不出毛病。4.4 前端还有一件重要的事切片时的文件类型处理浏览器里的file.slice()方法切出来的Blob有的浏览器不保留原始文件类型导致后端收到的每一片Content-Type都变成application/octet-stream。这通常不影响分片存储但会影响如果你的后端设置了“只允许特定文件扩展名”的校验逻辑。解决办法在初始化时把file.type一起提交给后端记录合并时用这个原始类型作为最终文件的Content-Type。或者前端在上传第一片时带上完整的文件名和类型信息后端单独存。这个细节在Chrome上没问题但在某些国产浏览器或旧版Safari上会踩到建议统一按“不依赖分片Content-Type”来设计后端逻辑。5. SpringCloud场景下的性能与工程化要点代码能跑通只是第一步放到真实微服务环境里还有几项工程化的事需要提前做。5.1 网关层配置直接影响上传成败SpringCloud Gateway或者Spring Cloud Gateway的默认配置对上传请求不友好尤其是请求体大小和超时时间。如果你不做调整前端分片传着传着就报504。网关层的核心配置有这几个最大请求头大小、最大请求体大小、响应超时、连接空闲超时。用Spring Cloud Gateway Spring Boot 2.x举例spring: cloud: gateway: httpclient: connect-timeout: 5000 response-timeout: 60000 codec: max-in-memory-size: 10MBmax-in-memory-size的默认值是256KB也就是网关默认最多在内存里缓冲256KB的请求体超过就会报错。分片5MB时这个值必须改大但也没必要设成无限制防止恶意请求打穿内存。设成50MB对单片5MB来说绰绰有余。如果是Nginx前置client_max_body_size也要一并调。很多问题排查到最后发现不是代码的事而是Nginx的默认1MB限制把请求拦了。5.2 负载均衡与实例无状态化分片上传本质上是把服务端做成了“无状态”所有状态都放在Redis和共享存储里每个上传分片的请求打到哪个实例都无所谓。这是SpringCloud场景下最优雅的地方。但如果没有引入共享存储前文说了分片会分散在各实例本地磁盘合并时会出大问题。解决思路两种要么保证上传服务单实例部署要么把分片写共享存储。我强烈推荐第二种因为微服务的好处就是弹性扩容你不能因为一个上传功能把扩容能力给卡死。实际操作时把/data/upload_temp和/data/upload_final挂在共享文件系统下所有实例看到的是同一个目录。合并接口在任意一个实例上执行都能读到全部分片。5.3 合并大文件的性能优化合并操作本质上是大量的磁盘读加磁盘写。假设你上传一个5GB的文件分片5MB一共1000片。合并时如果逐片读逐片写IOPS会非常低。实测下来优化手段主要有两种。第一种加大缓冲区。我代码里用1MB的buffer传大文件时可以按JVM堆大小适当调大。缓冲太小会导致系统调用频繁缓冲太大又占内存1MB到4MB之间比较合理。第二种如果能用FileChannel.transferTo()做零拷贝合并性能会更好。用Java NIO的transferTo或者transferFrom把分片内容直接搬运到目标文件部分场景下能显著减少用户态和内核态之间的拷贝次数。try (FileChannel outChannel FileChannel.open(destPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { for (int i 0; i totalChunks; i) { Path partPath Paths.get(tempDir, i .part); try (FileChannel inChannel FileChannel.open(partPath, StandardOpenOption.READ)) { long remaining inChannel.size(); long position 0; while (remaining 0) { long transferred inChannel.transferTo(position, remaining, outChannel); position transferred; remaining - transferred; } } } }这段代码需要注意transferTo不一定一次传输完所有数据所以要用循环检查剩余量。这一点很多教程都没讲清楚导致有人用transferTo一次调用以为完事了结果合并出来的文件缺一段。5.4 定时清理临时文件是必修课分片上传很常见的故障是用户上传了一部分分片但没有继续完成合并Redis元数据过期了但磁盘上的临时分片永远地留在了那里。时间一长磁盘满了整个服务挂掉。运维上必须加一个定时清理任务。Redis元数据的TTL是2小时那磁盘上的临时目录也应该在超过这个时间后被扫掉。一般做法是写一个定时任务凌晨扫描upload_temp目录删除所有超过24小时的目录和文件。我在项目里就是每天凌晨2点执行一次清理这个任务放在上传服务里用Spring Schedule实现代码非常简单。Scheduled(cron 0 0 2 * * ?) public void cleanExpiredTempFiles() { File tempRoot new File(/data/upload_temp); if (!tempRoot.exists()) return; File[] dateDirs tempRoot.listFiles(); if (dateDirs null) return; for (File dateDir : dateDirs) { // 判断日期目录是否超过24小时 if (System.currentTimeMillis() - dateDir.lastModified() 24 * 60 * 60 * 1000L) { // 递归删除 deleteDirRecursively(dateDir); } } }注意这里判断的是“目录的上次修改时间”因为只要有新分片写入目录的mtime就会更新所以不会被误删。6. 常见问题与排查技巧实录这几个问题是我在实际项目中真实遇到过的每个都花了不小精力才定位整理出来省得大家重复踩坑。6.1 合并后文件损坏或MD5对不上现象上传成功文件大小也对但文件打不开或者内容和后端校验不一致。排查过程先排除分片顺序问题。确认前端传给后端的chunkIndex是数字而不是字符串并且在合并时严格按数字升序读文件。如果命名是1.part,2.part,10.part按字符串排序会变成1, 10, 2这种低级错误非常隐蔽。还有一种情况某些分片上传时的file.slice(start, end)边界处理有误。slice的终点是开区间也就是说slice(0, 5MB)包含第0字节到第5MB-1字节不含第5MB。最后一个分片如果写成slice(start, file.size)没问题写成slice(start, start CHUNK_SIZE)就会超出文件末尾产生多余内容。解决方案合并时把最后一个分片的长度和预期值做比对不一致就报错。代码里我最推荐的还是在merge接口中增加一个lastChunkSize参数精确校验最后一片的大小。6.2 上传大文件时后端内存暴涨现象上传过程中监控显示Heap飙升甚至OOM。排查过程大概率是代码中用了byte[] bytes chunkData.getBytes()这类操作把整个分片一次性读入内存。分片5MB不会造成大问题但如果某个接口没有限制前端直接POST整个文件后端getBytes()就会按文件大小分配堆内存。解决方案保持单片大小限制同时后端接口只接收流式数据用InputStream来消费。Spring的MultipartFile提供getInputStream()尽量用流式处理不要在内存中做完整文件的拷贝。try (InputStream in chunkData.getInputStream(); FileOutputStream out new FileOutputStream(targetFile)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } }虽然transferTo内部已经做了优化但理解流式处理的思路更重要遇到特殊场景时能自己调整。6.3 网关返回413或504现象分片上传时偶尔报413 Payload Too Large或者504 Gateway Timeout。排查过程413是请求体大小超限检查网关侧的max-in-memory-size、Nginx的client_max_body_size、后端服务的max-request-size逐项排查。504是请求处理超时在网关层加大response-timeout同时确认后端单片处理的耗时如果单片写入花了十几秒说明磁盘IO或者网络存储性能有问题。解决方案统一梳理链路。客户端 → Nginx → Spring Cloud Gateway → 上传服务这一条链路上任何一环的超时和大小限制都要匹配。分片设计成5MB那从Nginx到网关到服务端全都要允许至少10MB的请求体。超时时间建议网关层设60秒上传服务内部的读超时和写超时也相应放开。6.4 断点续传失效现象刷新页面后重新上传进度没有恢复到原来的位置又开始从头传。排查过程断点续传的前置条件是前端能拿到existChunks列表。如果初始化接口每次都重新生成uploadId那后端肯定查不到历史分片。如果uploadId与文件内容不关联也不行。解决方案uploadId的生成必须与文件内容绑定。最简单的方式是在init请求中增加一个fileHash参数前端用spark-md5库计算文件的MD5后端以fileHash查Redis看看是否已有未完成的同内容上传任务。如果是复用同一个uploadId和已存在的分片列表。更严谨一点的做法是每次上传初始化时先按fileHash查询如果存在未完成的上传会话返回现有uploadId否则创建新的uploadId。6.5 并发上传相同文件导致分片目录冲突现象两个用户同时上传同一个文件结果两个上传任务的分片混在一起合并出来的文件乱七八糟。排查过程问题出在uploadId做了内容绑定。这两个用户上传的是同一个文件所以拿到了同一个uploadId分片目录也共用了一个分片序号完全重叠互相覆盖。解决方案uploadId必须加上用户维度。格式改成userId _ md5(fileName fileSize timestamp)。这样即使文件内容相同不同用户的uploadId也不同分片目录天然隔离。如果业务上允许也可以在Redis的key里带上用户ID作为存储前缀。6.6 合并时文件被占用或权限不足现象合并时报FileNotFoundException或者AccessDeniedException。排查过程多数是临时目录的写权限问题特别是用共享存储时NFS的权限设置和挂载参数可能限制写入。还有可能是某些杀毒软件或文件预览服务锁住了文件。解决方案给上传服务配置独立系统账号授予目标目录的读写权限。合并时先用File.createTempFile做兼容测试确保目录可写。文件合并完成后不要立即删除源分片等确认文件落盘成功后再清理减少中间的竞态。7. 分片上传的进阶扩展与改造思路基础功能够用之后如果业务继续发展还有几个方向可以做。7.1 集成对象存储的分片上传接口现在主流对象存储普遍提供原生分片上传能力比如初始化分片上传、逐个上传分片、合并分片步骤和你自己实现的流程几乎一一对应。如果你已经用对象存储完全可以把中间那层自己写的磁盘分片替换成对象存储的Multipart Upload接口服务端只负责转发前端的分片数据最终合并动作交给对象存储完成省去临时文件的存储和管理成本。这个改造的好处特别明显不用再维护临时目录、不用定时清理、磁盘永远不会被没合并的分片撑爆。缺点是每个存储厂商的SDK接口细节不同封装一层适配器时要注意实现差异。7.2 秒传与文件去重大文件分片上传最搭的进阶功能就是秒传。思路是前端计算文件的MD5先调一个校验接口后台如果发现这个MD5已经存在直接返回“文件已存在”前端连上传都不用往上走直接显示100%。MD5对大文件来说计算时间可能有点长但相比上传一个几百MB的文件多做几秒本地计算完全划算。前端库用spark-md5计算时分块读取文件避免一次把整个文件读进内存。7.3 上传中实时转码或异步处理文件传完后往往不代表事情就完了。视频要转码、图片要压缩、报表要解析。如果这些操作直接放在合并接口里同步执行前端会长时间等待。更好的方案是合并完成后往消息队列里发一个事件异步处理后续逻辑。接口里只返回“文件已上传处理中”的状态由另一个服务消费消息、执行处理、更新状态前端轮询状态接口展示进度。这个模式在SpringCloud里特别好实现上传服务只负责落文件业务处理完全解耦。8. 结语与实践心得做分片上传这个功能代码层面并不算复杂真正的复杂度全在细节上网关的超时怎么调、分片一致性怎么保证、断点续传的标识怎么设计、临时文件怎么清理、多实例下状态放哪里。这些细节不处理好哪怕代码跑通了生产上也会以各种诡异的方式还给你惊喜。我个人在实际操作中的体会是做这类功能一定要先画清楚数据流前端分片长什么样、传输过程中状态存哪里、失败后的最小重试单元是什么、最终文件在哪里合并。把这几个问题答清楚了写代码就是顺水推舟的事。最后再分享一个小技巧开发阶段可以在后端加一个模拟接口不真正存文件只随机抛异常。前端通过这个开关来强制测试各种失败分支。否则真实环境中很难碰上分片丢失、重复上传、合并中断这些情况功能上线后心里没底。这个模拟开关留在生产环境的配置文件里方便后续排查问题不是安全问题纯粹是工程上的便利。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑