程序員狂喜!最強(qiáng) OSS 分片上傳實(shí)戰(zhàn):斷點(diǎn)續(xù)傳、去重防毒一站搞定
那天產(chǎn)品經(jīng)理又來了句經(jīng)典臺詞:“用戶要傳 10G 的設(shè)計(jì)圖,你讓數(shù)據(jù)庫幫忙存一下?” 答案顯然是否定的——數(shù)據(jù)庫不是為大文件設(shè)計(jì)的。對象存儲(OSS/S3)才是文件的“云端豪宅”: 能抗(高可用)、能裝(海量)、自帶 CDN 加速,按需計(jì)費(fèi)更省錢。
本篇從入門到進(jìn)階、從概念到實(shí)戰(zhàn),帶你掌握對象存儲在后端文件上傳中的完整流程:
- 快速起手:簡單上傳
- 大文件方案:分片上傳 + 斷點(diǎn)續(xù)傳
- 秒傳去重:MD5 指紋檢測
- 文件安全:文件頭 + 內(nèi)容檢測 + 隔離策略
- 前端實(shí)戰(zhàn):Thymeleaf + JS + Bootstrap(分片、MD5、進(jìn)度條)
- 后端實(shí)戰(zhàn):Spring Boot 控制器
/check-file、/upload-chunk、/merge-chunks
對象存儲(OSS)不是“遠(yuǎn)程文件夾”
對象存儲的文件是以對象形式保存(數(shù)據(jù) + 元數(shù)據(jù)),通常分布式存儲在集群里,與傳統(tǒng)文件系統(tǒng)概念不同。
特點(diǎn)與適用場景
- 多副本容災(zāi)、PB 級容量、按使用量付費(fèi)
- 適合:圖片/視頻托管、日志歸檔、用戶文件上傳、靜態(tài)資源分發(fā)(結(jié)合 CDN)
項(xiàng)目依賴(pom.xml 摘要)
在 Spring Boot 項(xiàng)目中添加阿里云 OSS 依賴(示例):
<!-- pom.xml 中添加 -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.15.1</version>
</dependency>安全提示: AK/SK 不要硬編碼在代碼中。優(yōu)先使用
application.yml、環(huán)境變量或憑證服務(wù)(RAM、STS)。
快速體驗(yàn):OSS 簡單上傳(Java)
文件較小、無需斷點(diǎn)續(xù)傳時可直接用簡單上傳邏輯:
文件路徑(示例):oss/src/main/java/com/icoderoad/oss/OssSimpleUpload.java`
package com.icoderoad.oss;
import com.aliyun.oss.*;
import java.io.*;
import java.util.Date;
public class OssSimpleUpload {
private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
private static final String ACCESS_KEY = System.getenv("ALIYUN_ACCESS_KEY");
private static final String SECRET_KEY = System.getenv("ALIYUN_SECRET_KEY");
private static final String BUCKET_NAME = "your-bucket-name";
public static void uploadFile(File file) {
OSS ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY, SECRET_KEY);
try (FileInputStream fis = new FileInputStream(file)) {
String key = "user-uploads/" + file.getName();
ossClient.putObject(BUCKET_NAME, key, fis);
Date expiration = new Date(System.currentTimeMillis() + 3600 * 1000); // 1 hour
String url = ossClient.generatePresignedUrl(BUCKET_NAME, key, expiration).toString();
System.out.println("上傳成功,訪問鏈接:" + url);
} catch (Exception e) {
e.printStackTrace();
} finally {
ossClient.shutdown();
}
}
}大文件痛點(diǎn)與分片上傳思路
直接上傳大文件的問題:網(wǎng)絡(luò)中斷、重傳代價高、超時等。分片(multipart)上傳將大文件切成小塊,支持?jǐn)帱c(diǎn)續(xù)傳,只補(bǔ)傳丟失的分片。
分片上傳核心三步:
- 前端切片(
File.slice()) - 單片上傳(攜帶
fileMd5、chunkIndex、totalChunks) - 后端合并(或 OSS 提供的
completeMultipartUpload)
后端合并示例(合并臨時分片并可擴(kuò)展為上傳至 OSS)
文件路徑(示例):oss/src/main/java/com/icoderoad/oss/OssMultipartMerge.java
package com.icoderoad.oss;
import com.aliyun.oss.*;
import com.aliyun.oss.model.*;
import org.apache.commons.io.FileUtils;
import java.io.*;
import java.util.*;
public class OssMultipartMerge {
private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
private static final String ACCESS_KEY = System.getenv("ALIYUN_ACCESS_KEY");
private static final String SECRET_KEY = System.getenv("ALIYUN_SECRET_KEY");
private static final String BUCKET_NAME = "your-bucket-name";
/**
* 將本地臨時分片上傳到 OSS 并發(fā)起合并(示例:從 /tmp/oss-chunks/{fileMd5}/{index} 讀?。? */
public void mergeChunksToOss(String fileMd5, String fileName, int totalChunks) throws Exception {
OSS ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY, SECRET_KEY);
try {
InitiateMultipartUploadResult init = ossClient.initiateMultipartUpload(
new InitiateMultipartUploadRequest(BUCKET_NAME, "user-uploads/" + fileName)
);
String uploadId = init.getUploadId();
List<PartETag> partETags = new ArrayList<>();
for (int i = 0; i < totalChunks; i++) {
File chunkFile = new File("/tmp/oss-chunks/" + fileMd5 + "/" + i + ".part");
if (!chunkFile.exists()) {
throw new RuntimeException("缺少分片: " + i);
}
UploadPartRequest uploadPartRequest = new UploadPartRequest()
.withBucketName(BUCKET_NAME)
.withKey("user-uploads/" + fileName)
.withUploadId(uploadId)
.withPartNumber(i + 1)
.withInputStream(new FileInputStream(chunkFile))
.withPartSize(chunkFile.length());
UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
partETags.add(uploadPartResult.getPartETag());
}
ossClient.completeMultipartUpload(new CompleteMultipartUploadRequest(
BUCKET_NAME, "user-uploads/" + fileName, uploadId, partETags
));
} finally {
ossClient.shutdown();
// 可選:刪除本地臨時分片
FileUtils.deleteDirectory(new File("/tmp/oss-chunks/" + fileMd5));
}
}
}也可以選擇在后端僅合并到本地磁盤(示例控制器中展示),再上傳整文件到 OSS。
避免重復(fù)上傳(秒傳):MD5 指紋機(jī)制
實(shí)現(xiàn)思路:
- 前端計(jì)算文件 MD5(分片計(jì)算以避免卡 UI)。
- 調(diào)用后端接口
/file/check-file(發(fā)送fileMd5)。 - 后端查詢數(shù)據(jù)庫或文件存儲映射:若存在則返回文件 URL(秒傳),否則允許上傳。
注意:OSS 的
ETag在分片上傳時不一定等同于完整文件 MD5(取決于實(shí)現(xiàn)),最好自行維護(hù) MD5 映射表。
防毒機(jī)制(文件安全三道防線)
- 文件頭校驗(yàn)(Magic Number):不信任擴(kuò)展名,讀取文件前若干字節(jié)判斷實(shí)際類型。
- 內(nèi)容掃描:接入 ClamAV 或云安全 API(阿里云內(nèi)容安全等)進(jìn)行深度檢測。
- 權(quán)限隔離(雙桶策略):上傳到臨時桶,檢測通過后再轉(zhuǎn)移到正式桶;正式桶限制執(zhí)行與強(qiáng)制下載頭
Content-Disposition: attachment。
示例:文件頭檢測(Java)
public boolean checkFileHeader(File file) {
byte[] header = new byte[8];
try (FileInputStream fis = new FileInputStream(file)) {
fis.read(header);
String headerHex = bytesToHex(header);
return headerHex.startsWith("FFD8FF") // JPG
|| headerHex.startsWith("89504E47") // PNG
|| headerHex.startsWith("47494638"); // GIF
} catch (Exception e) {
return false;
}
}Spring Boot 后端接口(完整控制層實(shí)現(xiàn))
路徑(示例):oss/src/main/java/com/icoderoad/controller/FileUploadController.java
包名:com.icoderoad.controller`
說明:示例實(shí)現(xiàn)將分片保存到本地臨時目錄(可配置),并支持 /check-file、/upload-chunk、/merge-chunks。合并后可擴(kuò)展為將文件推到 OSS 或入庫記錄。
package com.icoderoad.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
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;
/**
* 文件上傳控制器:支持分片上傳、斷點(diǎn)續(xù)傳、MD5 秒傳檢查、合并分片
*/
@RestController
@RequestMapping("/file")
public class FileUploadController {
@Value("${upload.temp-dir:uploads/temp}")
private String tempDir;
@Value("${upload.final-dir:uploads/merged}")
private String finalDir;
/**
* 檢查文件是否已存在或已上傳的分片(前端通過 fileMd5 調(diào)用)
* 返回示例:
* { "skipUpload": true, "uploadedChunks": [] } 或
* { "skipUpload": false, "uploadedChunks": [0,1,2] }
*/
@PostMapping(value = "/check-file", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public Map<String, Object> checkFile(@RequestParam("fileMd5") String fileMd5) {
Map<String, Object> result = new HashMap<>();
if (!StringUtils.hasText(fileMd5)) {
result.put("skipUpload", false);
result.put("uploadedChunks", Collections.emptyList());
return result;
}
// 1)檢查最終目錄是否已存在(實(shí)現(xiàn)秒傳)
File mergedFile = new File(finalDir, fileMd5 + ".dat");
if (mergedFile.exists()) {
result.put("skipUpload", true);
result.put("uploadedChunks", Collections.emptyList());
return result;
}
// 2)檢查臨時目錄已上傳的分片
File chunkFolder = new File(tempDir, fileMd5);
if (chunkFolder.exists() && chunkFolder.isDirectory()) {
File[] files = chunkFolder.listFiles((dir, name) -> name.endsWith(".part"));
List<Integer> uploaded = new ArrayList<>();
if (files != null) {
for (File f : files) {
String name = f.getName(); // e.g., "0.part"
try {
String idxStr = name.split("\\.")[0];
uploaded.add(Integer.parseInt(idxStr));
} catch (Exception ignored) {}
}
Collections.sort(uploaded);
}
result.put("uploadedChunks", uploaded);
} else {
result.put("uploadedChunks", Collections.emptyList());
}
result.put("skipUpload", false);
return result;
}
/**
* 接收并保存單個分片
* 參數(shù):
* - file (MultipartFile) : 分片內(nèi)容
* - fileMd5 (String) : 整個文件 MD5
* - chunkIndex (int) : 當(dāng)前分片編號(從 0 開始)
*/
@PostMapping("/upload-chunk")
public Map<String, Object> uploadChunk(@RequestParam("file") MultipartFile chunk,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunkIndex") int chunkIndex) throws IOException {
if (chunk == null || chunk.isEmpty()) {
throw new RuntimeException("上傳分片為空");
}
File chunkFolder = new File(tempDir, fileMd5);
if (!chunkFolder.exists()) {
chunkFolder.mkdirs();
}
File chunkFile = new File(chunkFolder, chunkIndex + ".part");
try (InputStream in = chunk.getInputStream();
OutputStream out = new FileOutputStream(chunkFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
Map<String, Object> result = new HashMap<>();
result.put("uploaded", true);
result.put("chunkIndex", chunkIndex);
return result;
}
/**
* 合并分片到最終文件(可選:再上傳到 OSS)
* 參數(shù):
* - fileMd5
* - fileName
* - totalChunks
*/
@PostMapping("/merge-chunks")
public Map<String, Object> mergeChunks(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("totalChunks") int totalChunks) throws IOException {
File chunkFolder = new File(tempDir, fileMd5);
if (!chunkFolder.exists() || !chunkFolder.isDirectory()) {
throw new RuntimeException("分片目錄不存在");
}
File mergedFolder = new File(finalDir);
if (!mergedFolder.exists()) mergedFolder.mkdirs();
File mergedFile = new File(mergedFolder, fileName);
try (OutputStream os = new BufferedOutputStream(new FileOutputStream(mergedFile, true))) {
for (int i = 0; i < totalChunks; i++) {
File part = new File(chunkFolder, i + ".part");
if (!part.exists()) {
throw new RuntimeException("缺少分片: " + i);
}
Files.copy(part.toPath(), os);
}
}
// 合并完成后刪除分片(清理)
for (File f : Objects.requireNonNull(chunkFolder.listFiles())) {
f.delete();
}
chunkFolder.delete();
// TODO: 可在此處調(diào)用 OSS Client 上傳 mergedFile 到對象存儲,并保存 MD5 -> URL 的映射到數(shù)據(jù)庫。
Map<String, Object> result = new HashMap<>();
result.put("merged", true);
result.put("filePath", mergedFile.getAbsolutePath());
return result;
}
}前端(Thymeleaf + Bootstrap + JS)完整示例
路徑:/src/main/resources/templates/upload.html
該頁面功能:
- 計(jì)算文件 MD5(分片計(jì)算,避免卡 UI)
- 調(diào)用
/file/check-file判斷是否秒傳或哪些分片已存在 - 逐片上傳
/file/upload-chunk - 上傳進(jìn)度條顯示
- 上傳完成后觸發(fā)
/file/merge-chunks
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>OSS 分片上傳演示</title>
<link rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
</head>
<body class="bg-light">
<div class="container mt-5">
<h2 class="mb-4 text-center">OSS 分片上傳 + 秒傳 + 進(jìn)度條演示</h2>
<div class="card p-4 shadow-sm">
<input type="file" id="fileInput" class="form-control mb-3"/>
<button id="uploadBtn" class="btn btn-primary w-100">開始上傳</button>
<div class="progress mt-3" style="height: 25px;">
<div id="uploadProgress" class="progress-bar progress-bar-striped progress-bar-animated"
style="width: 0%;">0%</div>
</div>
<div id="uploadStatus" class="mt-3 text-center text-secondary"></div>
</div>
</div>
<script>
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB per chunk
document.getElementById("uploadBtn").addEventListener("click", async () => {
const file = document.getElementById("fileInput").files[0];
if (!file) return alert("請選擇文件");
// 1. 計(jì)算 MD5(分片方式)
document.getElementById("uploadStatus").innerText = "計(jì)算文件 MD5 中...";
const fileMd5 = await calcFileMd5(file);
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
document.getElementById("uploadStatus").innerText = `文件 MD5: ${fileMd5}`;
// 2. 檢查文件是否存在或已上傳分片
const checkResp = await fetch('/file/check-file', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ fileMd5 })
});
const checkData = await checkResp.json();
if (checkData.skipUpload) {
document.getElementById("uploadStatus").innerText = "文件已存在,跳過上傳 ";
return;
}
const uploaded = new Set(checkData.uploadedChunks || []);
// 3. 分片上傳
for (let i = 0; i < totalChunks; i++) {
if (uploaded.has(i)) {
updateProgress(i + 1, totalChunks);
continue;
}
const start = i * CHUNK_SIZE;
const end = Math.min(file.size, start + CHUNK_SIZE);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append("file", chunk);
formData.append("fileMd5", fileMd5);
formData.append("chunkIndex", i);
try {
const res = await fetch('/file/upload-chunk', { method: 'POST', body: formData });
if (!res.ok) throw new Error("上傳分片失敗");
} catch (e) {
alert("上傳失敗: " + e.message);
return;
}
updateProgress(i + 1, totalChunks);
}
// 4. 合并分片
const mergeResp = await fetch('/file/merge-chunks', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
fileMd5,
fileName: file.name,
totalChunks
})
});
const mergeData = await mergeResp.json();
if (mergeData.merged) {
document.getElementById("uploadStatus").innerText = "上傳并合并完成 ,路徑:" + mergeData.filePath;
} else {
document.getElementById("uploadStatus").innerText = "合并失敗";
}
});
function updateProgress(done, total) {
const pct = Math.floor((done / total) * 100);
const bar = document.getElementById("uploadProgress");
bar.style.width = pct + "%";
bar.innerText = pct + "%";
}
async function calcFileMd5(file) {
return new Promise((resolve, reject) => {
const chunkSize = 10 * 1024 * 1024;
const chunks = Math.ceil(file.size / chunkSize);
const spark = new SparkMD5.ArrayBuffer();
let currentChunk = 0;
const reader = new FileReader();
reader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) loadNext();
else resolve(spark.end());
};
reader.onerror = () => reject("MD5計(jì)算失敗");
function loadNext() {
const start = currentChunk * chunkSize;
const end = Math.min(file.size, start + chunkSize);
reader.readAsArrayBuffer(file.slice(start, end));
}
loadNext();
});
}
</script>
</body>
</html>配置(application.yml 示例)
server:
port: 8080
upload:
temp-dir: /var/data/uploads/temp # 臨時分片存儲目錄(Linux 風(fēng)格)
final-dir: /var/data/uploads/merged # 合并后文件存儲目錄請確保運(yùn)行的服務(wù)賬號對上述目錄有讀寫權(quán)限。
實(shí)戰(zhàn)建議與優(yōu)化點(diǎn)
- 密鑰管理:用 RAM/STS 或環(huán)境變量,不要寫死 AK/SK。生產(chǎn)建議使用臨時憑證或密鑰輪換策略。
- 分片大小:根據(jù)網(wǎng)絡(luò)與客戶端能力調(diào)整(常用 5MB / 10MB / 20MB)。分片過小增加請求開銷,過大增加單次傳輸失敗代價。
- 并發(fā)上傳:前端可并發(fā)上傳 N 個分片,后端限制并發(fā)流量與速率,避免突發(fā)流量打垮服務(wù)器。
- 斷點(diǎn)續(xù)傳:
/check-file返回已上傳分片索引,前端跳過已上傳分片實(shí)現(xiàn)續(xù)傳。 - 重試策略:前端對失敗的分片做指數(shù)退避重試(例如 3 次),避免短暫網(wǎng)絡(luò)抖動導(dǎo)致整體失敗。
- 分布式合并:在高并發(fā)場景可采用 OSS 的 multipart 合并接口,或?qū)⒑喜⑷蝿?wù)異步化(消息隊(duì)列 + 后臺 worker)。
- 安全檢測:合并完成或上傳到臨時桶后,觸發(fā)內(nèi)容掃描(ClamAV / 云 API),通過后再移到正式桶并記錄元數(shù)據(jù)。
- 數(shù)據(jù)庫映射:在合并成功后,把
fileMd5 -> 文件 URL寫入數(shù)據(jù)庫,支持秒傳和查看歷史記錄。
后端 ER 的“文件上傳生存指南”
- 小文件:直接簡單上傳,輕量快速。
- 大文件:分片上傳(切片→傳片→合并),支持?jǐn)帱c(diǎn)續(xù)傳與并發(fā)上傳。
- 去重(秒傳):前端 MD5 + 后端校驗(yàn)(存在則跳過上傳)。
- 防毒:文件頭檢測 + 內(nèi)容掃描 + 桶隔離(臨時桶→正式桶)。
掌握以上套路后,再被產(chǎn)品經(jīng)理逼著“讓用戶上傳 20G 設(shè)計(jì)圖”時,你只需淡定回應(yīng):“放心,OSS 已經(jīng)幫我們扛住了?!?/span>























