本文目录导读:

这是一个非常经典的面试题和实际开发需求,分片上传的核心目的是解决大文件上传超时、失败重传成本高、内存溢出等问题。
下面我提供一个完整且具有生产参考价值的 Java 分片上传案例,包括前端(HTML+JS) 和后端(Spring Boot) 的核心代码。
核心设计思路
- 前端: 将文件切成固定大小(5MB)的块,为每个块生成唯一标识(如
fileMd5 + chunkIndex),并发或串行发送到后端。 - 后端: 接收每一个分片,暂存到服务器临时目录,当所有分片上传完成后,提供合并接口,按分片序号顺序合并成完整文件。
- 断点续传: 上传前先请求后端接口查询已上传的分片列表,前端跳过已上传的分片。
前端代码(Vue/原生JS示例)
这里以纯 HTML + JS 为例,使用 spark-md5 计算文件 MD5(用于标识文件)。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">分片上传示例</title>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
</head>
<body>
<input type="file" id="fileInput" />
<button onclick="upload()">开始上传</button>
<div id="progress"></div>
<script>
const chunkSize = 5 * 1024 * 1024; // 每个分片 5MB
function upload() {
const file = document.getElementById('fileInput').files[0];
if (!file) return;
// 1. 计算文件 MD5
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = (e) => {
spark.append(e.target.result);
const fileMd5 = spark.end();
console.log('文件MD5:', fileMd5);
// 2. 开始分片上传
uploadChunks(file, fileMd5);
};
}
async function uploadChunks(file, fileMd5) {
const totalChunks = Math.ceil(file.size / chunkSize);
// 先查询已上传的分片(断点续传)
const uploadedChunks = await checkUploadedChunks(fileMd5);
console.log('已上传分片:', uploadedChunks);
for (let i = 0; i < totalChunks; i++) {
// 如果该分片已上传,跳过
if (uploadedChunks.includes(i)) {
continue;
}
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', i);
formData.append('totalChunks', totalChunks);
formData.append('fileMd5', fileMd5);
formData.append('originalFilename', file.name);
// 上传单个分片
await fetch('/upload/chunk', {
method: 'POST',
body: formData
});
// 更新进度
document.getElementById('progress').innerText =
`上传进度: ${((i + 1) / totalChunks * 100).toFixed(2)}%`;
}
// 3. 所有分片上传完成后,发起合并请求
await fetch('/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileMd5, originalFilename: file.name, totalChunks })
});
alert('上传完成!');
}
// 查询已有分片(用于断点续传)
async function checkUploadedChunks(fileMd5) {
const resp = await fetch(`/upload/chunks?fileMd5=${fileMd5}`);
return await resp.json(); // 返回已上传的分片索引数组,[0,1,2]
}
</script>
</body>
</html>
后端代码(Spring Boot)
项目依赖(pom.xml)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
控制器(UploadController.java)
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.nio.file.*;
import java.util.*;
@RestController
@RequestMapping("/upload")
public class UploadController {
// 分片临时存储目录
@Value("${upload.chunk-dir:/tmp/upload/chunks/}")
private String chunkDir;
// 合并后的文件存储目录
@Value("${upload.final-dir:/tmp/upload/final/}")
private String finalDir;
/**
* 接收单个分片
*/
@PostMapping("/chunk")
public String uploadChunk(@RequestParam("chunk") MultipartFile chunk,
@RequestParam("index") int index,
@RequestParam("fileMd5") String fileMd5) throws IOException {
// 创建该文件的临时分片目录
Path chunkPath = Paths.get(chunkDir, fileMd5);
if (!Files.exists(chunkPath)) {
Files.createDirectories(chunkPath);
}
// 保存分片文件,命名规则:0, 1, 2, ...
File targetFile = new File(chunkPath.toFile(), String.valueOf(index));
chunk.transferTo(targetFile);
return "chunk " + index + " uploaded";
}
/**
* 查询已上传的分片索引列表(用于断点续传)
*/
@GetMapping("/chunks")
public List<Integer> getUploadedChunks(@RequestParam("fileMd5") String fileMd5) {
List<Integer> list = new ArrayList<>();
Path chunkPath = Paths.get(chunkDir, fileMd5);
if (Files.exists(chunkPath)) {
File[] files = chunkPath.toFile().listFiles();
if (files != null) {
for (File f : files) {
list.add(Integer.parseInt(f.getName()));
}
}
}
Collections.sort(list);
return list;
}
/**
* 合并所有分片
*/
@PostMapping("/merge")
public String mergeChunks(@RequestBody MergeRequest request) throws IOException {
String fileMd5 = request.getFileMd5();
String originalFilename = request.getOriginalFilename();
int totalChunks = request.getTotalChunks();
// 准备最终文件路径
Path finalPath = Paths.get(finalDir);
if (!Files.exists(finalPath)) {
Files.createDirectories(finalPath);
}
// 生成最终文件名:使用MD5前缀防止冲突,保留原始后缀
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
Path targetFile = finalPath.resolve(fileMd5 + suffix);
// 如果最终文件已存在,直接返回
if (Files.exists(targetFile)) {
return "file already exists";
}
// 按顺序合并分片
Path chunkPath = Paths.get(chunkDir, fileMd5);
try (FileOutputStream fos = new FileOutputStream(targetFile.toFile())) {
for (int i = 0; i < totalChunks; i++) {
Path chunk = chunkPath.resolve(String.valueOf(i));
if (Files.exists(chunk)) {
Files.copy(chunk, fos);
} else {
throw new RuntimeException("Missing chunk: " + i);
}
}
}
// 合并成功后,删除临时分片目录
deleteDirectory(chunkPath.toFile());
return "merge success: " + targetFile.toString();
}
/**
* 删除目录及内部文件
*/
private void deleteDirectory(File file) {
if (file.isDirectory()) {
File[] files = file.listFiles();
if (files != null) {
for (File f : files) {
deleteDirectory(f);
}
}
}
file.delete();
}
/**
* 合并请求体
*/
static class MergeRequest {
private String fileMd5;
private String originalFilename;
private int totalChunks;
// getter setter(省略)
}
}
配置文件(application.yml)
upload: chunk-dir: /data/tmp/upload/chunks/ # 分片临时目录 final-dir: /data/tmp/upload/final/ # 最终文件目录
关键注意事项(面试加分项)
| 问题 | 解决方案 |
|---|---|
| 并发上传顺序错乱 | 后端不依赖顺序接收,合并时按序号读取 |
| 文件重复上传 | 前端传入 fileMd5,后端判断最终文件是否存在 |
| 服务端重启导致分片丢失 | 可用 Redis 记录分片状态,或直接检查磁盘文件 |
| 大文件 MD5 计算卡顿 | 前端使用 File.slice() 分片计算,或使用 Web Worker |
| 传输安全性 | 分片文件名使用 UUID 避免越界,校验分片大小 |
| 异常处理 | 合并时缺少某分片,应抛出异常并提示前端重传该分片 |
生产环境增强建议
- 异步合并:使用消息队列,分片上传完成后发消息异步合并。
- 校验机制:每个分片上传时携带该分片的 MD5,后端校验完整性。
- 并发限流:对同一个
fileMd5的分片上传请求使用令牌桶限制并发数。 - 存储分布式:分片临时文件可使用分布式文件系统(MinIO/OSS),最终合并可在对象存储中直接调用
Combine接口(OSS 原生支持)。
运行验证
- 启动 Spring Boot 应用。
- 打开前端 HTML 页面,选择一个几百 MB 的大文件。
- 观察控制台和磁盘目录:
/tmp/upload/chunks/{fileMd5}/下出现多个数字命名的分片。- 上传完毕后,
/tmp/upload/final/下生成完整文件。
这个案例覆盖了分片上传、断点续传、合并的核心逻辑,稍加改造即可直接用于真实项目。