ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot 3 + Vue 3 + MinIO 分片上传实战:断点续传与并发控制

Spring Boot 3 + Vue 3 + MinIO 分片上传实战:断点续传与并发控制

大文件上传,几乎所有做管理系统或对B端产品的人都躲不过去。视频、压缩包、设计稿、数据集,动不动几百MB甚至几个GB,用普通表单提交,要么等半天没反应,要么加载到一半网络断了,整个文件作废重来。更别提服务器直接爆内存或者连接超时的经典翻车现场。

我做这款项目的时候,正好是把旧版本从单体结构往组件化、服务化方向重构的档口。底层服务要能查询、校验前端提交的必要信息,上传模块也需要重新设计,当时技术栈刚好定了 Spring Boot 3 + Vue 3 + Element Plus,存储端一开始用的就是 MinIO 8.2 这个版本。做分片上传,不是因为追求潮流,而是这个场景只有分片才能把体验做扎实。

这篇文章我会把一次完整的落地过程拆开讲清楚——为什么必须分片、后端怎么接收和合并、前端怎么控制并发和续传、MinIO 8.2 在 Spring Boot 3 下面有哪些依赖坑,以及直传方案怎么设计。不说虚的,全是实际写过的代码和踩过的问题。

1. 为什么分片才是大文件上传的正解

1.1 普通上传的“原罪”:你缺的不是网速,而是容错能力

先明确一个前提:把一个大文件一次性 POST 到后端,在网络条件好的内网环境里不是不行,但风险全部集中在“中途失败”这个环节。

一个 2GB 的文件,通过普通表单上传,服务器端要先把整个 multipart 请求读取完,再交给你处理。假设用户网速一般,上传耗时 20 分钟。这 20 分钟里只要断网、浏览器崩溃、后端抛出一次超时异常,一切归零。用户必须从头再传一遍,这个体验放在真实业务里基本是劝退级的。

还有更隐蔽的问题:Spring Boot 的 multipart 默认限制是 1MB,你要支持大文件,就得把max-file-size和max-request-size调大。调大以后,Tomcat 会把这个大请求连同临时文件一起处理,整个应用的内存占用和 IO 压力都会明显上涨。一个两个用户还好,并发上来,后端服务必出问题。

分片的核心价值说白了就三个:

  • 可重试:每个分片独立上传失败,重传这一片就行,不用拖垮整个文件。
  • 可并发:多个分片同时上传,充分利用带宽,大幅缩短总耗时。
  • 可续传:中断后再次上传,已经成功上传的分片直接跳过,形成真实的断点续传体验。

这和搬家是一个道理。一整车东西一次性从城市 A 拉到城市 B,路上只要翻一次车,全部家当就没了;分几个集装箱走,中间丢了一箱,补运一箱就行,其他箱子照常到货。

1.2 分片方案的整体链路设计

我把整个上传流程拆成了四个阶段:

  1. 前端计算整个文件的 MD5,生成唯一标识。
  2. 请求后端查询该文件是否已存在(秒传)以及已有哪些分片(续传)。
  3. 前端结合已上传分片信息,并发上传缺失的分片。
  4. 全部完成后通知后端合并分片,生成完整文件并存入 MinIO,返回访问地址。

这里面的关键不光是“把文件切开”,更是前后端如何约定任务标识、如何保证合并顺序、如何在不同步的状态下恢复现场。任何一个环节设计不到位,整个方案都会流于表面。

1.3 技术选型:为什么是 Spring Boot 3 + Vue 3 + Element Plus + MinIO 8.2

这四个组件的组合,市面上已经有大量项目在跑。

  • Spring Boot 3 基于 Java 17,是当前 Java 后端新项目的常态选择。
  • Vue 3 的组合式 API 配合 Element Plus,做后台管理系统效率极高,文件上传组件ElUpload也提供了http-request钩子,可以完全接管上传行为,适合嵌入分片逻辑。
  • MinIO 作为对象存储,S3 兼容协议,自带分片上传(Multipart Upload)API,后续如果觉得后端中转带宽不够,甚至可以直接让浏览器把分片传到 MinIO,后端只负责签名和合并。

这套组合唯一要特别注意的是依赖版本。网上关于 MinIO 8.2 在 Spring Boot 3 上跑不起来的帖子非常多,后面我单独讲。

2. 环境准备与依赖陷阱:MinIO 8.2 的版本兼容性问题

2.1 引入 MinIO 8.2 依赖的关键点

项目用 Maven 管理依赖,我最初直接在pom.xml里写了:

<dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.2.2</version> </dependency>

然后启动 Spring Boot 项目,界面上能正常起来,但一旦调用 MinIO 相关接口,直接抛出各种异常,最常见的两个:

一个是java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter。

原因不复杂。MinIO 8.2.x 内部某些逻辑依赖 JAXB 的DatatypeConverter,这个类在 JDK 8 里自带,但从 JDK 9 开始就被移出了默认模块。Java 17 下自然找不到。

另一个是 okhttp 版本冲突。Spring Boot 3 自带的 okhttp 版本和 MinIO 8.2 内部使用的 okhttp 4.8.1 不一致,有时会引入NoSuchMethodError或者回调异常。

解决办法是在依赖里补一个 JAXB API,同时把 MinIO 的传递依赖做排查。实际项目中我最终是这样配置的:

<dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.2.2</version> <exclusions> <exclusion> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency>

同时显式声明 okhttp 版本,保证与项目整体一致:

<dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.9.3</version> </dependency>

如果你的环境是 JDK 11 以下,可能不需要 JAXB 补丁,但 Java 17 下这步不能省。另外还有一个思路是直接升级 MinIO 到更新的版本(比如 8.5.x),新版本已经处理了这些依赖问题,但项目初始化时定了 8.2,我保留了这个版本并解决了兼容问题。

2.2 后端基础配置:临时目录、上传大小与存储桶策略

在application.yml里,需要设置几个关键参数:

spring: servlet: multipart: max-file-size: 10MB max-request-size: 200MB application: name: upload-service minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin bucket: my-bucket

max-file-size一定要大于等于前端单个分片的大小。如果分片是 5MB,这个值至少给到 10MB,留出冗余,避免边缘情况导致分片上传失败。

另外,我建议在业务代码里定义一个临时目录常量。所有分片先落磁盘,而不是直接进 MinIO,这样即使 MinIO 临时不可用,重试也容易恢复。

关于存储桶和对象路径,我的设计是:

  • 存储桶固定一个,不使用用户输入,避免越权操作。
  • 对象路径为files/{md5}/{yyyyMMdd-HHmmss}-{原始文件名}。
  • 同 MD5 的文件优先走秒传,不重复存储。

这样做的原因是 MinIO 的对象名天然适合作为业务文件的逻辑路径,后端在合并完成后返回这个对象名,前端直接拼接 MinIO 的访问地址即可。

2.3 初始化 MinIO Client 配置类

用一个配置类生成 MinioClient Bean,方便后续注入使用:

@Configuration public class MinioConfig { @Bean public MinioClient minioClient(MinioProperties properties) { return MinioClient.builder() .endpoint(properties.getEndpoint()) .credentials(properties.getAccessKey(), properties.getSecretKey()) .build(); } }

同时在项目里绑定配置属性:

@ConfigurationProperties(prefix = "minio") public class MinioProperties { private String endpoint; private String accessKey; private String secretKey; private String bucket; // getter/setter 省略 }

到这里,环境准备基本就算完成了。接下来是核心的后端代码设计。

3. 后端实现:任务注册、分片落盘与文件合并

3.1 上传任务的初始化与秒传判断

前端在拿到文件的 MD5 后,会先请求一个检查接口,后端逻辑如下:

@PostMapping("/upload/check") public R<CheckResult> check(@RequestBody CheckRequest request) { String md5 = request.getMd5(); // 1. 查询文件记录表,如果存在说明之前上传过相同内容的文件 FileRecord record = fileRecordMapper.selectByMd5(md5); if (record != null) { return R.ok(new CheckResult(true, record.getObjectPath())); } // 2. 查询临时目录里已存在哪些分片 File dir = new File(CHUNK_DIR); File[] chunks = dir.listFiles((d, name) -> name.startsWith(md5 + "_")); List<Integer> uploadedIndexes = new ArrayList<>(); if (chunks != null) { for (File chunk : chunks) { String name = chunk.getName(); // 格式:{md5}_{index}.part String indexStr = name.substring(name.lastIndexOf('_') + 1, name.lastIndexOf('.')); uploadedIndexes.add(Integer.parseInt(indexStr)); } } // 3. 返回已上传索引,同时返回总需要分片数、分片大小等信息 int total = (int) Math.ceil((double) request.getFileSize() / CHUNK_SIZE); return R.ok(new CheckResult(false, uploadedIndexes, total)); }

这一步有两个重要价值:

一是“秒传”。当后端的文件记录表里已经存在相同 MD5 的记录,前端直接跳过整个上传过程,返回已有文件的访问路径。这在用户重复上传同一个文件、或者多人上传同内容文件时,效果非常明显。

二是“断点续传的基础信息”。接口返回已上传的分片索引,前端不需要自己去猜哪些成功、哪些失败,直接跳过这些分片即可。这个目录扫描方案如果分片数量真的很大(几千片),性能会有点问题,但常规项目中千片级是完全够用的。

3.2 分片接收与合并核心流程

分片接收接口是核心入口:

@PostMapping("/upload/chunk") public R<Void> uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("md5") String md5, @RequestParam("chunkIndex") Integer chunkIndex) { if (file.isEmpty()) { return R.fail("分片内容为空"); } // 分片命名规则:{md5}_{index}.part String chunkFileName = String.format("%s_%04d.part", md5, chunkIndex); File chunkFile = new File(CHUNK_DIR, chunkFileName); try { // 确保目录存在 if (!chunkFile.getParentFile().exists()) { chunkFile.getParentFile().mkdirs(); } file.transferTo(chunkFile.getAbsoluteFile()); } catch (IOException e) { log.error("保存分片失败: {}", chunkFileName, e); return R.fail("分片保存失败"); } return R.ok(); }

这里有几个容易踩的细节:

  • 分片序号建议固定位数,比如%04d格式,合并时按字符串排序不会混乱。
  • 使用transferTo时,目标文件如果已经存在,不会自动覆盖,所以要考虑重复上传分片的情况。可以在写入前删除旧文件,或者直接覆盖。
  • 每个分片请求是独立的,防护方面要注意 MD5 合法性校验,防止恶意路径构造,例如 MD5 里夹带../。虽然 MD5 通常是固定长度字符串,但做系统还是要有校验意识。

合并接口负责把分片拼成完整文件:

@PostMapping("/upload/merge") public R<String> merge(@RequestBody MergeRequest request) { String md5 = request.getMd5(); int total = request.getTotal(); // 1. 检查分片是否齐全 List<File> chunkFiles = new ArrayList<>(); for (int i = 0; i < total; i++) { File chunk = new File(CHUNK_DIR, String.format("%s_%04d.part", md5, i)); if (!chunk.exists()) { return R.fail("分片缺失,无法合并"); } chunkFiles.add(chunk); } // 2. 合并到临时完整文件 File mergedFile = new File(MERGE_DIR, md5 + ".tmp"); try (FileOutputStream fos = new FileOutputStream(mergedFile)) { for (int i = 0; i < total; i++) { Files.copy(chunkFiles.get(i).toPath(), fos); } } catch (IOException e) { log.error("合并文件失败", e); return R.fail("合并失败"); } // 3. 校验合并后的文件 MD5,防止某个分片损坏 String mergedMd5 = DigestUtils.md5DigestAsHex(new FileInputStream(mergedFile)); if (!md5.equalsIgnoreCase(mergedMd5)) { mergedFile.delete(); return R.fail("文件校验失败,请重新上传"); } // 4. 上传到 MinIO String objectPath = String.format("files/%s/%s", md5, request.getFileName()); try (InputStream is = new FileInputStream(mergedFile)) { minioClient.putObject(PutObjectArgs.builder() .bucket(bucket) .object(objectPath) .stream(is, mergedFile.length(), -1) .contentType(request.getContentType()) .build()); } catch (Exception e) { log.error("上传MinIO失败", e); return R.fail("存储失败"); } // 5. 删除临时分片和合并文件,记录文件信息 for (File chunk : chunkFiles) { chunk.delete(); } mergedFile.delete(); FileRecord record = new FileRecord(); record.setMd5(md5); record.setObjectPath(objectPath); record.setFileName(request.getFileName()); fileRecordMapper.insert(record); return R.ok(objectPath); }

合并顺序的问题必须强调:一定要按分片序号从小到大依次写入,而不是按上传完成顺序。假如前端用并发把第 5 片先传完、第 1 片后传完,合并时如果按完成顺序拼文件,整个文件就是乱的,用户下载后会发现文件损坏。

我用的是chunkFiles按i遍历,顺序天然正确。如果你想用File.listFiles()方式,记得在合并前做排序。

3.3 落盘细节与扩展点:临时文件、并发清理策略

后端临时文件管理有几点经验:

第一,临时目录要和工作目录隔离开。我建议用/data/upload/tmp这样的固定目录,设置独立磁盘分区,避免系统盘被大量文件写满。

第二,合并完成后立即删除所有分片,这是基本常识,否则目录会持续膨胀。

第三,要加一个定时清理任务,处理那些上传到一半、分片不全的文件。你可以用@Scheduled每小時执行一次,找出超过 2 小时没有被修改的.part文件并删除。如果不做这一步,用户每次上传失败留下几十个分片,磁盘迟早会爆。

第四,文件记录表要加唯一约束:md5字段设为唯一索引,用来支撑秒传判断。

如果后续想做得更健壮,可以把任务状态从“目录扫描”升级为数据库或 Redis 记录,保存每个任务的上传状态、已上传分片列表、最后更新时间。这样断点续传不再依赖扫描目录,查询速度也更快。但在当前这套实现里,目录扫描已经能满足大部分场景。

4. 前端实现:Vue 3 + Element Plus 的分片控制

4.1 使用 ElUpload 接管上传行为

Element Plus 的ElUpload组件默认自带上传逻辑,但它不知道分片的存在。我们通过http-request属性完全接管上传:

<el-upload drag multiple :http-request="handleUpload" :show-file-list="false" :before-upload="beforeUpload" > <div>拖拽文件到此处,或点击上传</div> </el-upload>

handleUpload接收的参数是 Element Plus 构造的上传选项对象,里面包含file属性和onSuccess、onError回调。我们在这个函数里实现分片逻辑。

这种做法的最大好处是,UI 组件仍然负责文件选择和样式表现,实际传输完全由自己的代码掌控,不会出现 Element Plus 内部状态和分片逻辑互相打架的情况。

4.2 计算文件 MD5:大文件哈希的卡顿问题

分片方案的前提是有一个稳定的文件标识。MD5 是常见选择。问题是:一个几百 MB 的文件计算全量 MD5,在主线程里跑,页面会卡死。

我的处理方式是把 MD5 计算放到 Web Worker 里。如果项目不是特别复杂,也可以用分片读取配合SparkMD5库实现,但要注意读取的块不能太大。下面是一个 Worker 内的示例:

import SparkMD5 from 'spark-md5' self.onmessage = function (e) { const file = e.data.file const chunkSize = 2 * 1024 * 1024 const spark = new SparkMD5.ArrayBuffer() let current = 0 const loadNext = () => { const slice = file.slice(current, current + chunkSize) const reader = new FileReader() reader.onload = (event) => { spark.append(event.target.result) current += chunkSize if (current < file.size) { self.postMessage({ progress: current / file.size }) loadNext() } else { const md5 = spark.end() self.postMessage({ md5 }) } } reader.onerror = (err) => { self.postMessage({ error: err }) } reader.readAsArrayBuffer(slice) } loadNext() }

主线程里这样调用:

function computeMd5(file) { return new Promise((resolve, reject) => { const worker = new Worker('/md5-worker.js') worker.onmessage = (e) => { if (e.data.md5) { worker.terminate() resolve(e.data.md5) } else if (e.data.error) { worker.terminate() reject(e.data.error) } } worker.postMessage({ file }) }) }

计算完 MD5 后 UI 正常操作,进度也流畅。

这里有个容易忽略的优化点:如果文件小于某个阈值(比如 5MB),其实没必要走分片逻辑,直接一次性上传反而更简单。分片对大文件是刚需,小文件在保证效率的前提下不需要引入复杂度。

4.3 分片切割与并发控制

拿到 MD5 并调用检查接口后,前端开始分片。分片大小我选择了 5MB,这是一个平衡值:

  • 太小(比如 1MB):分片数量太多,请求开销大,合并时文件句柄和循环次数也多。
  • 太大(比如 50MB):并发上传时单片失败的重试成本过高,进度恢复粒度太粗。

5MB 在多数网络环境下都是稳妥的选择,尤其是在带宽不稳定的公网环境中。

切割用浏览器原生的Blob.prototype.slice,代码非常直接:

const CHUNK_SIZE = 5 * 1024 * 1024 const total = Math.ceil(file.size / CHUNK_SIZE) for (let i = 0; i < total; i++) { const start = i * CHUNK_SIZE const end = Math.min(start + CHUNK_SIZE, file.size) const blob = file.slice(start, end) chunks.push({ index: i, blob }) }

并发上传不能“全放出去”,否则浏览器同域名连接数会被占满,其他页面的请求也会被拖累。我像这样控制并发数:

const LIMIT = 3 const queue = [] for (const chunk of chunks) { if (chunk.indexs.includes(chunk.index)) continue // 已上传分片跳过 const task = uploadChunk(file.md5, chunk.index, chunk.blob).catch(() => { // 失败分片特殊标记,后续统一重试 }) queue.push(task) if (queue.length >= LIMIT) { await Promise.race(queue) // 清理已完成的 Promise for (let i = queue.length - 1; i >= 0; i--) { if (queue[i].settled) queue.splice(i, 1) } } } await Promise.all(queue)

并发数设成 3 的原因:同时上传 3 个分片,每个分片 5MB,相当于同时有 15MB 的数据在传输,已经能把带宽用足。再往上加,边际收益会非常低,反而可能把后端 Tomcat 的连接池打满。

4.4 合并请求与进度展示

所有分片成功上传后,前端调用合并接口:

const mergeResult = await axios.post('/upload/merge', { md5: file.md5, total, fileName: file.name, contentType: file.type }) if (mergeResult.data.code === 200) { // 拿到文件访问路径,可以刷新列表或跳转 }

进度条的计算同样有自己的讲究。不要用“当前分片的上传进度”,而要用“已上传的分片数量 / 总分片数量”。因为并发上传过程中,单个分片的进度并不直观,总进度才能真实反映文件整体的完成程度。

const percent = Math.round((uploadedCount / total) * 100)

想让进度条更细一点,可以加上“当前正在上传的分片内部进度”的折算,但对一般场景来说,分片数量维度已经足够平滑。

5. 更进一步:直接让浏览器上传分片到 MinIO

5.1 后端中转 vs 前端直传

目前这一套完整可跑,几十 MB 到几个 GB 的文件都能支撑。但有个隐含瓶颈:所有分片都经过后端服务器转发,服务器带宽成为上限。如果部署在一台带宽有限的机器上,分片并发再高,总吞吐也被卡死。

解决思路是前端直传 MinIO。方案是利用 MinIO 的分片上传 API 和预签名 URL:

  1. 后端创建一个 multipart upload,拿到uploadId。
  2. 前端对每个分片请求后端生成带签名的 PUT URL。
  3. 前端直接把分片 PUT 到预签名 URL,完全绕过后端服务器转发。
  4. 全部完成后,后端调用completeMultipartUpload合并分片。

这个方案的核心收益是:数据流不再流经后端,服务器带宽压力大幅下降,存储型项目的典型架构。

5.2 直传方案的后端 API 设计

初始化分片:

@PostMapping("/minio/init") public R<InitResult> init(@RequestBody InitRequest request) { CreateMultipartUploadResponse response = minioClient.createMultipartUpload( BucketArgs.builder().bucket(bucket).object(request.getObjectPath()).build() ); return R.ok(new InitResult(response.uploadId())); }

生成预签名 URL:

@PostMapping("/minio/presign") public R<String> presign(@RequestBody PresignRequest request) { Map<String, String> queryParams = new HashMap<>(); queryParams.put("partNumber", request.getPartNumber().toString()); queryParams.put("uploadId", request.getUploadId()); String url = minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.PUT) .bucket(bucket) .object(request.getObjectPath()) .expiry(60 * 60) .extraQueryParams(queryParams) .build() ); return R.ok(url); }

合并分片:

@PostMapping("/minio/complete") public R<String> complete(@RequestBody CompleteRequest request) { // 需要收集所有 part 的 ETag Iterable<Part> parts = minioClient.listParts( ListPartsArgs.builder() .bucket(bucket) .object(request.getObjectPath()) .uploadId(request.getUploadId()) .build() ); List<Part> partList = new ArrayList<>(); for (Part part : parts) { partList.add(part); } // 按 partNumber 排序 partList.sort(Comparator.comparingInt(Part::partNumber)); minioClient.completeMultipartUpload( CompleteMultipartUploadArgs.builder() .bucket(bucket) .object(request.getObjectPath()) .uploadId(request.getUploadId()) .parts(partList) .build() ); return R.ok(request.getObjectPath()); }

前端在拿到带签名的 PUT URL 后,直接用fetch上传分片:

const response = await fetch(presignedUrl, { method: 'PUT', body: chunk.blob, headers: { 'Content-Type': 'application/octet-stream' } }) if (response.ok) { // 当前分片上传成功 }

这里要特别注意几个坑:

  • 预签名 URL 的过期时间要覆盖整个上传流程。分片越多,耗时越长,建议至少给 1 小时。
  • listParts返回的Part对象里包含partNumber和etag()。completeMultipartUpload要求 part 必须按partNumber升序排列,否则 MinIO 会拒绝请求。
  • 如果某个分片失败,前端重新请求后端生成新的预签名 URL 即可,不需要重新初始化整个 multipart upload。
  • MinIO 的分片最小限制是 5MB,除了最后一片。所以前端分片大小不能低于 5MB,否则服务端会在合并时直接报错。这一点和后端中转方案不一样,必须注意。

这个直传方案在代码复杂度和边界情况上比后端中转要高,但能彻底解决带宽瓶颈。如果你的场景是很多用户同时上传大文件,建议优先考虑。

6. 常见问题与排查实录

6.1 MinIO 8.2 与 Spring Boot 3 的依赖冲突

这个问题我排在第一位,因为太常见了。

现象:项目能启动,Controller 能访问,但一调用 MinIO 接口,报ClassNotFoundException或NoSuchMethodError,尤其集中在javax.xml.bind和 okhttp 两个包上。

排查步骤:

  1. 先确认 JDK 版本。Java 17 下缺少 JAXB 是大概率原因。
  2. 查看 Maven 依赖树:mvn dependency:tree找到 minio 的传递依赖。
  3. 排除 okhttp 后显式声明一个固定版本。
  4. 如果仍然有异常,检查 okhttp 版本兼容性,回退到 4.8.1 或升级到 4.10。

我更推荐的做法是直接升级 MinIO 到新版本,8.2 对我来说主要是历史版本包袱。但如果你因为某些原因必须锁版本,上面的依赖处理方式可以保证项目跑起来。

6.2 合并后的文件损坏,打开报错

文件损坏的最常见原因是合并顺序错了。

排查思路:

  1. 确认后端是不是按照分片序号升序合并的。用listFiles()或者list()拿到的文件列表,如果不显式排序,顺序是不可预测的。
  2. 确认分片序号是否有位数不一致的问题。比如有的文件名是md5_1.part,有的是md5_10.part,按字符串排序会把md5_10排在md5_2前面。所以我用%04d固定四位,sort以后顺序正确。
  3. 确认是否做了 MD5 校验。合并后一定要重新计算完整文件的 MD5 并和前端传入的 MD5 比对,这一步能挡住 90% 的损坏场景。

6.3 断点续传失效

我见过很多人在这个问题上载跟头。前端确实跳过了已上传的分片,但后端在检查时发现某些分片并不存在,导致合并失败。

原因通常是后端检查逻辑只判断目录里有多少个md5_前缀的文件,没校验这些文件的序号是否和合并时需要的序号一一对应。假设第 3 片上传失败后重试的逻辑有问题,那么下次续传时第 3 片会一直缺失,而检查接口因为文件数对得上,可能会误判为“全部上传完成”,直接走合并逻辑,结果报分片缺失。

修复方式:检查接口不仅要返回已上传分片的数量,还应该返回具体的索引列表。前端严格遍历索引数组,只标记已存在的分片,缺失的分片始终在上传队列里。

6.4 上传并发高时后端报错或响应变慢

分片并发数默认 3,如果直接调到 5 以上,Tomcat 默认线程池很容易被打满。

这里可以做两件事:

  1. 给分片上传接口单独设置线程池隔离,避免上传流量影响其他业务接口。
  2. 前端并发数控制在 3 以内,已经足够充分利用一般企业的带宽,不必贪多。

另外,如果后端使用默认的file.transferTo(),分片写入是同步 IO 操作,磁盘性能会成为瓶颈。对高速率写入要求高的场景,可以把临时目录放到单独的 SSD,或者把接收分片改成异步落盘。

6.5 MinIO 上传时报 InvalidKeyException

经常有人在配置 MinIO 时把accessKey和secretKey填反,或者 endpoint 多了个路径前缀。排查时先确认本地的mc命令能够正常连接,如果mc那一步就不通,问题基本在配置项,而不是代码。

代码层面还有一种可能:MinioClient初始化时没有设置region,而 bucket 是在非 us-east-1 的 region 下创建的。S3 协议对 region 有严格要求,如果 error message 里出现 location 相关字样,要在MinioClient.builder()里补上region。

总结一下实测经验

分片上传不是把文件切开再拼起来这么简单,核心是任务状态的追踪和异常恢复。只要你把“文件唯一标识”“分片清单”“已上传索引”这几件事设计好,前后端各司其职,这套方案就能稳定跑很长时间。

我个人在实际操作中比较大的体会是:前端做的时候一定要把并发上限焊死,不要贪快;后端做的时候一定要把临时文件的清理当作一等公民来对待,不然上线一个月后磁盘爆掉只是时间问题。

另外再分享一个小技巧:上传成功之后,别急着删所有状态。在 localStorage 里缓存一份“文件 MD5 → 已上传分片索引”的映射,用户这次传了一半取消,下次拖同一个文件进来,就算不请求后端也能直接跳过很多重复工作。第一次启动时可能有点麻烦,但长期对体验提升非常明显。

分片上传做到这个程度,已经能覆盖绝大多数业务场景了。需要更高的上传吞吐或更强的容灾能力,可以再往前走一步 —— 比如加一个上传任务的持久化表,把分片状态从文件系统搬到数据库;或者把直传 MinIO 方案落地,让服务器从数据传输链路里退出去。但核心的东西就是这些,掌握好后端合并顺序、前端并发控制和 MD5 校验这三个环节,就已经超过大多数项目了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进