☰
SpringCloud架构下大文件分片上传实战:从原理到代码实现
2026/10/13 10:40:17 网站建设 项目流程

大文件上传这件事,在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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.11.0</version> </dependency>

配置文件里需要关注的点是上传临时目录和单次请求大小限制。SpringBoot默认的spring.servlet.multipart.max-file-size是1MB,max-request-size是10MB,这必须改大。以单片5MB为例,设置成10MB和100MB就够用,不要设置成无限制,防止有人绕过前端直接传大包。

spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB redis: host: your-redis-host port: 6379

3.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 Map<String, Object> initUpload(@RequestBody UploadInitRequest request) { String uploadId = generateUploadId(request.getFileName()); int totalChunks = (int) Math.ceil((double) request.getFileSize() / request.getChunkSize()); // 保存上传元数据到Redis,TTL设置为2小时,防止僵尸数据堆积 Map<String, 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); Map<String, Object> result = new HashMap<>(); result.put("uploadId", uploadId); result.put("totalChunks", totalChunks); result.put("existChunks", getExistChunks(uploadId, totalChunks)); return result; } private List<Integer> getExistChunks(String uploadId, int totalChunks) { // 检查哪些分片已上传,用于断点续传,后面会补全实现 List<Integer> 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 Map<String, 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. 校验分片序号是否在合法范围内 Map<Object, 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 Map<String, Object> mergeUpload(@RequestBody MergeRequest request) { String uploadId = request.getUploadId(); String infoKey = UPLOAD_INFO_PREFIX + uploadId; Map<Object, 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); Set<String> 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 type="file" id="fileInput" @change="handleFileChange"> <button @click="startUpload">开始上传</button> <div>进度:{{ progress }}%</div> <div>成功分片:{{ successCount }} / {{ totalChunks }}</div>

4.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: 10MB

max-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. 结语与实践心得

做分片上传这个功能,代码层面并不算复杂,真正的复杂度全在细节上:网关的超时怎么调、分片一致性怎么保证、断点续传的标识怎么设计、临时文件怎么清理、多实例下状态放哪里。这些细节不处理好,哪怕代码跑通了,生产上也会以各种诡异的方式还给你惊喜。

我个人在实际操作中的体会是,做这类功能一定要先画清楚数据流:前端分片长什么样、传输过程中状态存哪里、失败后的最小重试单元是什么、最终文件在哪里合并。把这几个问题答清楚了,写代码就是顺水推舟的事。

最后再分享一个小技巧:开发阶段可以在后端加一个模拟接口,不真正存文件,只随机抛异常。前端通过这个开关来强制测试各种失败分支。否则真实环境中很难碰上分片丢失、重复上传、合并中断这些情况,功能上线后心里没底。这个模拟开关留在生产环境的配置文件里,方便后续排查问题,不是安全问题,纯粹是工程上的便利。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询