告別卡頓!SpringBoot 大文件上傳最佳實踐:分片+并發+秒傳全搞定
在現代 Web 系統中,尤其是涉及視頻、圖像、大型文檔管理等場景時,大文件上傳常常成為性能瓶頸。傳統上傳方式面對 100MB 以上的大文件時常出現超時、資源溢出、上傳失敗等問題。為了提升上傳效率與穩定性,我們構建了一套基于 Spring Boot 的高性能文件分片上傳系統,結合斷點續傳、并發上傳、安全驗證與 MinIO 云存儲,打造出企業級健壯傳輸方案。
為什么要進行文件分片上傳?
在上傳大于 100MB 的文件時,傳統上傳方式暴露出以下問題:
- 傳輸不穩定:長時間傳輸極易超時或被網絡波動中斷
- 資源壓力大:服務器需要一次性讀取整個文件,造成內存暴漲
- 失敗代價高:上傳中斷后無法恢復,只能重新上傳整個文件
通過將大文件拆分為多個較小的塊,可以顯著優化上述問題:
優勢 | 說明 |
斷點續傳 | 支持中斷后從上次上傳點恢復 |
并發加速 | 多個分塊同時上傳,提升速度 |
降低服務器壓力 | 每個分塊獨立上傳,降低內存占用 |
分片上傳的原理
分片上傳核心思想如下:
- 將大文件在前端分割為若干固定大小的塊
- 后端接受每個分塊,并臨時存儲
- 上傳完成后,后端將所有分塊按順序合并為最終文件
- 合并成功后清理臨時分塊
項目結構設計
/upload-platform
├── src/
│ ├── main/
│ │ ├── java/com/icoderoad/upload/
│ │ │ ├── controller/ChunkUploadController.java # 上傳控制器
│ │ │ ├── service/FileMergeService.java # 文件合并服務
│ │ │ ├── config/MinioConfig.java # MinIO配置
│ │ │ └── UploadPlatformApplication.java # 主程序
│ │ ├── resources/
│ │ │ ├── templates/
│ │ │ │ └── upload.html # 上傳頁面(Thymeleaf)
│ │ │ ├── static/
│ │ │ │ ├── css/bootstrap.min.css
│ │ │ │ └── js/upload.js # 分片上傳邏輯
│ │ │ └── application.yml # 配置文件
├── pom.xml
后端實現
pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.3</version>
</dependency>
</dependencies>
application.yml
server:
port: 8080
spring:
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
MinioConfig.java
package com.icoderoad.upload.config;
import io.minio.MinioClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MinioConfig {
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint("http://localhost:9000")
.credentials("minio-access", "minio-secret")
.build();
}
}
ChunkUploadController.java
package com.icoderoad.upload.controller;
import org.apache.commons.io.FileUtils;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/upload")
public class ChunkUploadController {
private static final String CHUNK_DIR = "/tmp/upload/chunks/";
private static final String FINAL_DIR = "/tmp/upload/final/";
@PostMapping("/init")
public ResponseEntity<String> initUpload(@RequestParam String fileName, @RequestParam String fileMd5) {
String uploadId = UUID.randomUUID().toString();
Path path = Paths.get(CHUNK_DIR, fileMd5 + "_" + uploadId);
try {
Files.createDirectories(path);
} catch (IOException e) {
return ResponseEntity.status(500).body("初始化失敗");
}
return ResponseEntity.ok(uploadId);
}
@PostMapping("/chunk")
public ResponseEntity<String> uploadChunk(@RequestParam MultipartFile chunk,
@RequestParam String uploadId,
@RequestParam String fileMd5,
@RequestParam Integer index) {
String chunkName = "chunk_" + index + ".tmp";
Path target = Paths.get(CHUNK_DIR, fileMd5 + "_" + uploadId, chunkName);
try {
chunk.transferTo(target);
return ResponseEntity.ok("分塊成功");
} catch (IOException e) {
return ResponseEntity.status(500).body("保存失敗");
}
}
@PostMapping("/merge")
public ResponseEntity<String> mergeChunks(@RequestParam String fileName,
@RequestParam String uploadId,
@RequestParam String fileMd5) {
File chunkFolder = new File(CHUNK_DIR + fileMd5 + "_" + uploadId);
File[] chunkFiles = chunkFolder.listFiles();
if (chunkFiles == null || chunkFiles.length == 0) {
return ResponseEntity.badRequest().body("未找到分塊");
}
Arrays.sort(chunkFiles, Comparator.comparingInt(f ->
Integer.parseInt(f.getName().replace("chunk_", "").replace(".tmp", ""))));
Path finalPath = Paths.get(FINAL_DIR, fileName);
try (BufferedOutputStream output = new BufferedOutputStream(Files.newOutputStream(finalPath))) {
for (File chunk : chunkFiles) {
Files.copy(chunk.toPath(), output);
}
FileUtils.deleteDirectory(chunkFolder);
return ResponseEntity.ok("上傳完成:" + finalPath);
} catch (IOException e) {
return ResponseEntity.status(500).body("合并失敗:" + e.getMessage());
}
}
@GetMapping("/check/{fileMd5}/{uploadId}")
public ResponseEntity<List<Integer>> checkChunks(@PathVariable String fileMd5, @PathVariable String uploadId) {
Path chunkPath = Paths.get(CHUNK_DIR, fileMd5 + "_" + uploadId);
if (!Files.exists(chunkPath)) return ResponseEntity.ok(Collections.emptyList());
try {
List<Integer> indices = Files.list(chunkPath)
.map(p -> p.getFileName().toString())
.map(name -> name.replace("chunk_", "").replace(".tmp", ""))
.map(Integer::parseInt)
.collect(Collectors.toList());
return ResponseEntity.ok(indices);
} catch (IOException e) {
return ResponseEntity.status(500).body(Collections.emptyList());
}
}
}
前端頁面(upload.html)
路徑:resources/templates/upload.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>分片上傳演示</title>
<link rel="stylesheet" href="/css/bootstrap.min.css"/>
</head>
<body class="p-4">
<h3>分片上傳</h3>
<input type="file" id="fileInput" class="form-control mb-2"/>
<div class="progress mb-2">
<div class="progress-bar" id="progressBar" role="progressbar" style="width: 0%;">0%</div>
</div>
<button class="btn btn-primary" onclick="startUpload()">開始上傳</button>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/upload.js"></script>
</body>
</html>
JS 邏輯(upload.js)
路徑:resources/static/js/upload.js
const CHUNK_SIZE = 5 * 1024 * 1024;
async function calculateFileMd5(file) {
// 簡化處理:用文件名和大小模擬唯一標識(實際應用使用 SparkMD5)
return `${file.name}-${file.size}`;
}
function splitFile(file) {
const chunks = [];
const count = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < count; i++) {
chunks.push(file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));
}
return chunks;
}
function updateProgress(percent) {
const bar = document.getElementById("progressBar");
bar.style.width = `${percent}%`;
bar.textContent = `${percent}%`;
}
async function startUpload() {
const file = document.getElementById("fileInput").files[0];
if (!file) return alert("請選擇文件");
const fileMd5 = await calculateFileMd5(file);
const { data: uploadId } = await axios.post("/upload/init", {
fileName: file.name,
fileMd5
});
const chunks = splitFile(file);
const uploadedChunks = (await axios.get(`/upload/check/${fileMd5}/${uploadId}`)).data;
let uploaded = uploadedChunks.length;
await Promise.all(chunks.map(async (chunk, index) => {
if (uploadedChunks.includes(index)) return;
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("index", index);
formData.append("uploadId", uploadId);
formData.append("fileMd5", fileMd5);
await axios.post("/upload/chunk", formData, {
onUploadProgress: () => {
uploaded++;
const percent = ((uploaded / chunks.length) * 100).toFixed(1);
updateProgress(percent);
}
});
}));
await axios.post("/upload/merge", {
fileName: file.name,
uploadId,
fileMd5
});
alert("上傳完成!");
}
總結建議
- 適配分片大小:
局域網:10MB ~ 20MB
移動網絡:1MB ~ 5MB
廣域網:500KB ~ 1MB
- 定時清理策略:
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3點清理臨時目錄
public void cleanChunks() {
FileUtils.deleteQuietly(new File("/tmp/upload/chunks/"));
}
- 安全驗證建議:
使用 HMAC + 文件摘要校驗簽名完整性
上傳前預計算 MD5 或 SHA256
- 云存儲集成推薦:
- MinIO / OSS / COS / S3 等對象存儲平臺
- 可直接分片上傳至對象存儲,節省本地合并
結語
通過 Spring Boot 構建的分片上傳系統,不僅解決了大文件傳輸過程中的性能瓶頸,還提供了斷點續傳、上傳加速、失敗重試、安全校驗等完整機制。結合前端優化與后端云服務整合,可無縫部署到生產環境,廣泛適用于音視頻平臺、文檔系統、網盤等多種場景。
如果你正在構建涉及大文件上傳的系統,這套方案將為你帶來更穩定、更高效的體驗!