那天产品经理又来了句经典台词:“用户要传 10G 的设计图,你让数据库帮忙存一下?” 答案显然是否定的——数据库不是为大文件设计的。对象存储(OSS/S3)才是文件的“云端豪宅”: 能抗(高可用)、能装(海量)、自带 CDN 加速,按需计费更省钱。
那天产品经理又来了句经典台词:“用户要传 10G 的设计图,你让数据库帮忙存一下?” 答案显然是否定的——数据库不是为大文件设计的。对象存储(OSS/S3)才是文件的“云端豪宅”: 能抗(高可用)、能装(海量)、自带 CDN 加速,按需计费更省钱。
本篇从入门到进阶、从概念到实战,带你掌握对象存储在后端文件上传中的完整流程:
- 快速起手:简单上传
- 大文件方案:分片上传 + 断点续传
- 秒传去重:MD5 指纹检测
- 文件安全:文件头 + 内容检测 + 隔离策略
- 前端实战:Thymeleaf + JS + Bootstrap(分片、MD5、进度条)
- 后端实战:Spring Boot 控制器
/check-file、/upload-chunk、/merge-chunks
对象存储(OSS)不是“远程文件夹”
对象存储的文件是以对象形式保存(数据 + 元数据),通常分布式存储在集群里,与传统文件系统概念不同。
特点与适用场景
- 多副本容灾、PB 级容量、按使用量付费
- 适合:图片/视频托管、日志归档、用户文件上传、静态资源分发(结合 CDN)
项目依赖(pom.xml 摘要)
在 Spring Boot 项目中添加阿里云 OSS 依赖(示例):
<!-- pom.xml 中添加 -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.15.1</version>
</dependency>
安全提示: AK/SK 不要硬编码在代码中。优先使用
application.yml、环境变量或凭证服务(RAM、STS)。
快速体验:OSS 简单上传(Java)
文件较小、无需断点续传时可直接用简单上传逻辑:
文件路径(示例):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();
}
}
}
大文件痛点与分片上传思路
直接上传大文件的问题:网络中断、重传代价高、超时等。分片(multipart)上传将大文件切成小块,支持断点续传,只补传丢失的分片。
分片上传核心三步:
- 前端切片(
File.slice()) - 单片上传(携带
fileMd5、chunkIndex、totalChunks) - 后端合并(或 OSS 提供的
completeMultipartUpload)
后端合并示例(合并临时分片并可扩展为上传至 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 并发起合并(示例:从 /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。
避免重复上传(秒传):MD5 指纹机制
实现思路:
- 前端计算文件 MD5(分片计算以避免卡 UI)。
- 调用后端接口
/file/check-file(发送fileMd5)。 - 后端查询数据库或文件存储映射:若存在则返回文件 URL(秒传),否则允许上传。
注意:OSS 的
ETag在分片上传时不一定等同于完整文件 MD5(取决于实现),最好自行维护 MD5 映射表。
防毒机制(文件安全三道防线)
- 文件头校验(Magic Number):不信任扩展名,读取文件前若干字节判断实际类型。
- 内容扫描:接入 ClamAV 或云安全 API(阿里云内容安全等)进行深度检测。
- 权限隔离(双桶策略):上传到临时桶,检测通过后再转移到正式桶;正式桶限制执行与强制下载头
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 后端接口(完整控制层实现)
路径(示例):oss/src/main/java/com/icoderoad/controller/FileUploadController.java
包名:com.icoderoad.controller`
说明:示例实现将分片保存到本地临时目录(可配置),并支持 /check-file、/upload-chunk、/merge-chunks。合并后可扩展为将文件推到 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;
/**
* 文件上传控制器:支持分片上传、断点续传、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 调用)
* 返回示例:
* { "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)检查最终目录是否已存在(实现秒传)
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;
}
/**
* 接收并保存单个分片
* 参数:
* - file (MultipartFile) : 分片内容
* - fileMd5 (String) : 整个文件 MD5
* - chunkIndex (int) : 当前分片编号(从 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)
* 参数:
* - 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: 可在此处调用 OSS Client 上传 mergedFile 到对象存储,并保存 MD5 -> URL 的映射到数据库。
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
该页面功能:
- 计算文件 MD5(分片计算,避免卡 UI)
- 调用
/file/check-file判断是否秒传或哪些分片已存在 - 逐片上传
/file/upload-chunk - 上传进度条显示
- 上传完成后触发
/file/merge-chunks
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>OSS 分片上传演示</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" 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 分片上传 + 秒传 + 进度条演示</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. 计算 MD5(分片方式)
document.getElementById("uploadStatus").innerText = "计算文件 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计算失败");
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 风格)
final-dir: /var/data/uploads/merged # 合并后文件存储目录
请确保运行的服务账号对上述目录有读写权限。
实战建议与优化点
- 密钥管理:用 RAM/STS 或环境变量,不要写死 AK/SK。生产建议使用临时凭证或密钥轮换策略。
- 分片大小:根据网络与客户端能力调整(常用 5MB / 10MB / 20MB)。分片过小增加请求开销,过大增加单次传输失败代价。
- 并发上传:前端可并发上传 N 个分片,后端限制并发流量与速率,避免突发流量打垮服务器。
- 断点续传:
/check-file返回已上传分片索引,前端跳过已上传分片实现续传。 - 重试策略:前端对失败的分片做指数退避重试(例如 3 次),避免短暂网络抖动导致整体失败。
- 分布式合并:在高并发场景可采用 OSS 的 multipart 合并接口,或将合并任务异步化(消息队列 + 后台 worker)。
- 安全检测:合并完成或上传到临时桶后,触发内容扫描(ClamAV / 云 API),通过后再移到正式桶并记录元数据。
- 数据库映射:在合并成功后,把
fileMd5 -> 文件 URL写入数据库,支持秒传和查看历史记录。
后端 ER 的“文件上传生存指南”
- 小文件:直接简单上传,轻量快速。
- 大文件:分片上传(切片→传片→合并),支持断点续传与并发上传。
- 去重(秒传):前端 MD5 + 后端校验(存在则跳过上传)。
- 防毒:文件头检测 + 内容扫描 + 桶隔离(临时桶→正式桶)。
掌握以上套路后,再被产品经理逼着“让用户上传 20G 设计图”时,你只需淡定回应:“放心,OSS 已经帮我们扛住了。”
AI大模型学习福利
作为一名热心肠的互联网老兵,我决定把宝贵的AI知识分享给大家。 至于能学习到多少就看你的学习毅力和能力了 。我已将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。
一、全套AGI大模型学习路线
AI大模型时代的学习之旅:从基础到前沿,掌握人工智能的核心技能!

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获取
二、640套AI大模型报告合集
这套包含640份报告的合集,涵盖了AI大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师,还是对AI大模型感兴趣的爱好者,这套报告合集都将为您提供宝贵的信息和启示。

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
三、AI大模型经典PDF籍
随着人工智能技术的飞速发展,AI大模型已经成为了当今科技领域的一大热点。这些大型预训练模型,如GPT-3、BERT、XLNet等,以其强大的语言理解和生成能力,正在改变我们对人工智能的认识。 那以下这些PDF籍就是非常不错的学习资源。

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
四、AI大模型商业化落地方案

因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获
作为普通人,入局大模型时代需要持续学习和实践,不断提高自己的技能和认知水平,同时也需要有责任感和伦理意识,为人工智能的健康发展贡献力量

被折叠的 条评论
为什么被折叠?



