📖 开场:上传照片
想象你用百度网盘上传照片 📷:
小文件上传(简单):
markdown
照片大小:2MB
↓
直接上传 → 服务器 → 存储 ✅
实现:HTTP POST,简单!
大文件上传(复杂):
markdown
视频大小:2GB
↓
问题:
1. 上传太慢(1小时)⏰
2. 网络中断 → 重新上传 😱
3. 服务器内存爆了 💥
需求:
1. 断点续传(中断后继续)
2. 分片上传(并发上传)
3. 秒传(相同文件不重复上传)
这就是文件上传和存储服务的挑战!
🤔 核心需求
业务场景
| 场景 | 说明 | 难度 |
|---|---|---|
| 网盘 | 百度网盘、阿里云盘 | ⭐⭐⭐ |
| 社交 | 微信、QQ发文件 | ⭐⭐ |
| 视频网站 | B站、YouTube | ⭐⭐⭐ |
| 图床 | 图片托管服务 | ⭐⭐ |
核心功能
| 功能 | 说明 | 重要性 |
|---|---|---|
| 分片上传 | 大文件拆分上传 | ⭐⭐⭐ |
| 断点续传 | 中断后继续 | ⭐⭐⭐ |
| 秒传 | 相同文件不重复上传 | ⭐⭐⭐ |
| 下载 | 支持断点下载 | ⭐⭐ |
| CDN加速 | 全球加速 | ⭐⭐ |
🎯 技术方案
方案1:简单上传(小文件)📤
前端代码
html
<!DOCTYPE html>
<html>
<head>
<title>文件上传</title>
</head>
<body>
<input type="file" id="fileInput" />
<button onclick="upload()">上传</button>
<div id="progress"></div>
<script>
function upload() {
const file = document.getElementById('fileInput').files[0];
if (!file) {
alert('请选择文件');
return;
}
// ⭐ 创建FormData
const formData = new FormData();
formData.append('file', file);
// ⭐ 创建XMLHttpRequest
const xhr = new XMLHttpRequest();
// ⭐ 监听进度
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total * 100).toFixed(2);
document.getElementById('progress').innerText = '上传进度: ' + percent + '%';
}
});
// ⭐ 监听完成
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
alert('上传成功!文件URL: ' + result.url);
} else {
alert('上传失败');
}
});
// ⭐ 发送请求
xhr.open('POST', '/api/upload');
xhr.send(formData);
}
</script>
</body>
</html>
后端代码(Spring Boot)
java
@RestController
@RequestMapping("/api")
public class FileUploadController {
@Value("${upload.path}")
private String uploadPath; // 上传目录
/**
* ⭐ 简单上传
*/
@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return Result.fail("文件为空");
}
try {
// 1. 生成文件名(UUID)
String originalFilename = file.getOriginalFilename();
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
String filename = UUID.randomUUID().toString() + extension;
// 2. 保存文件
String filePath = uploadPath + "/" + filename;
File dest = new File(filePath);
// 创建目录
if (!dest.getParentFile().exists()) {
dest.getParentFile().mkdirs();
}
// 保存
file.transferTo(dest);
// 3. 返回URL
String url = "/files/" + filename;
log.info("文件上传成功: filename={}, size={}", filename, file.getSize());
return Result.success(url);
} catch (IOException e) {
log.error("文件上传失败", e);
return Result.fail("上传失败");
}
}
}
问题:
- 大文件(>100MB)上传慢 ❌
- 网络中断需要重新上传 ❌
- 服务器内存占用大 ❌
方案2:分片上传 + 断点续传 ⭐⭐⭐
原理
markdown
大文件:1GB
↓
拆分成100个分片(每片10MB)
↓
并发上传多个分片 🚀
↓
全部上传完成 → 合并分片 ✅
优点:
- 并发上传,速度快 ✅
- 网络中断,只需重传失败的分片 ✅
- 服务器内存占用小(每次只处理10MB)✅
前端代码
javascript
class FileUploader {
constructor() {
this.CHUNK_SIZE = 10 * 1024 * 1024; // 分片大小:10MB
this.MAX_CONCURRENT = 3; // 最大并发数
}
/**
* ⭐ 上传文件
*/
async uploadFile(file) {
// 1. 计算文件MD5(用于秒传和断点续传)
const fileMd5 = await this.calculateMD5(file);
console.log('文件MD5:', fileMd5);
// 2. 检查是否已上传(秒传)
const checkResult = await this.checkFileExists(fileMd5);
if (checkResult.exists) {
console.log('文件已存在,秒传成功!');
return checkResult.url;
}
// 3. 计算分片数量
const chunks = Math.ceil(file.size / this.CHUNK_SIZE);
console.log('总分片数:', chunks);
// 4. 查询已上传的分片(断点续传)
const uploadedChunks = await this.getUploadedChunks(fileMd5);
console.log('已上传分片:', uploadedChunks);
// 5. 上传未完成的分片
const tasks = [];
for (let i = 0; i < chunks; i++) {
if (uploadedChunks.includes(i)) {
continue; // 跳过已上传的分片
}
const start = i * this.CHUNK_SIZE;
const end = Math.min(start + this.CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
// ⭐ 创建上传任务
const task = () => this.uploadChunk(fileMd5, i, chunk, chunks);
tasks.push(task);
}
// 6. ⭐ 并发上传(最多3个并发)
await this.concurrentUpload(tasks, this.MAX_CONCURRENT);
// 7. ⭐ 合并分片
const mergeResult = await this.mergeChunks(fileMd5, file.name, chunks);
console.log('上传完成!URL:', mergeResult.url);
return mergeResult.url;
}
/**
* ⭐ 计算文件MD5
*/
async calculateMD5(file) {
return new Promise((resolve) => {
// 使用spark-md5计算MD5
const reader = new FileReader();
const spark = new SparkMD5.ArrayBuffer();
reader.onload = (e) => {
spark.append(e.target.result);
resolve(spark.end());
};
reader.readAsArrayBuffer(file);
});
}
/**
* ⭐ 检查文件是否已存在(秒传)
*/
async checkFileExists(fileMd5) {
const response = await fetch('/api/upload/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ md5: fileMd5 })
});
return await response.json();
}
/**
* ⭐ 查询已上传的分片(断点续传)
*/
async getUploadedChunks(fileMd5) {
const response = await fetch('/api/upload/chunks?md5=' + fileMd5);
const result = await response.json();
return result.data || [];
}
/**
* ⭐ 上传单个分片
*/
async uploadChunk(fileMd5, chunkIndex, chunk, totalChunks) {
const formData = new FormData();
formData.append('file', chunk);
formData.append('md5', fileMd5);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
const response = await fetch('/api/upload/chunk', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
console.log(`分片 ${chunkIndex + 1}/${totalChunks} 上传成功`);
} else {
throw new Error(`分片 ${chunkIndex} 上传失败`);
}
return result;
}
/**
* ⭐ 并发上传(控制并发数)
*/
async concurrentUpload(tasks, maxConcurrent) {
const results = [];
const executing = [];
for (const task of tasks) {
const promise = task().then(() => {
executing.splice(executing.indexOf(promise), 1);
});
results.push(promise);
executing.push(promise);
if (executing.length >= maxConcurrent) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
/**
* ⭐ 合并分片
*/
async mergeChunks(fileMd5, filename, totalChunks) {
const response = await fetch('/api/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
md5: fileMd5,
filename: filename,
totalChunks: totalChunks
})
});
return await response.json();
}
}
// 使用示例
const uploader = new FileUploader();
document.getElementById('fileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const url = await uploader.uploadFile(file);
alert('上传成功!URL: ' + url);
} catch (error) {
alert('上传失败: ' + error.message);
}
});
后端代码
java
@RestController
@RequestMapping("/api/upload")
@Slf4j
public class ChunkUploadController {
@Value("${upload.chunk.path}")
private String chunkPath; // 分片临时目录
@Value("${upload.file.path}")
private String filePath; // 最终文件目录
@Autowired
private StringRedisTemplate redisTemplate;
/**
* ⭐ 检查文件是否已存在(秒传)
*/
@PostMapping("/check")
public Result<Map<String, Object>> checkFileExists(@RequestBody Map<String, String> params) {
String md5 = params.get("md5");
// 查询Redis(缓存)
String cacheKey = "file:md5:" + md5;
String cachedUrl = redisTemplate.opsForValue().get(cacheKey);
if (cachedUrl != null) {
// ⭐ 文件已存在,秒传成功
log.info("秒传成功: md5={}", md5);
Map<String, Object> result = new HashMap<>();
result.put("exists", true);
result.put("url", cachedUrl);
return Result.success(result);
}
// 文件不存在
Map<String, Object> result = new HashMap<>();
result.put("exists", false);
return Result.success(result);
}
/**
* ⭐ 查询已上传的分片(断点续传)
*/
@GetMapping("/chunks")
public Result<List<Integer>> getUploadedChunks(@RequestParam String md5) {
String key = "chunks:" + md5;
// 从Redis获取已上传的分片列表
Set<String> chunks = redisTemplate.opsForSet().members(key);
if (chunks == null || chunks.isEmpty()) {
return Result.success(Collections.emptyList());
}
List<Integer> uploadedChunks = chunks.stream()
.map(Integer::parseInt)
.sorted()
.collect(Collectors.toList());
log.info("已上传分片: md5={}, chunks={}", md5, uploadedChunks);
return Result.success(uploadedChunks);
}
/**
* ⭐ 上传分片
*/
@PostMapping("/chunk")
public Result<?> uploadChunk(
@RequestParam("file") MultipartFile file,
@RequestParam("md5") String md5,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks) {
try {
// 1. 保存分片到临时目录
String chunkDir = chunkPath + "/" + md5;
File dir = new File(chunkDir);
if (!dir.exists()) {
dir.mkdirs();
}
String chunkFilename = chunkDir + "/" + chunkIndex;
File dest = new File(chunkFilename);
file.transferTo(dest);
log.info("分片上传成功: md5={}, chunk={}/{}", md5, chunkIndex + 1, totalChunks);
// 2. ⭐ 记录已上传的分片(Redis Set)
String key = "chunks:" + md5;
redisTemplate.opsForSet().add(key, String.valueOf(chunkIndex));
// 设置过期时间(1天)
redisTemplate.expire(key, 1, TimeUnit.DAYS);
return Result.success("分片上传成功");
} catch (IOException e) {
log.error("分片上传失败", e);
return Result.fail("上传失败");
}
}
/**
* ⭐ 合并分片
*/
@PostMapping("/merge")
public Result<Map<String, Object>> mergeChunks(@RequestBody Map<String, Object> params) {
String md5 = (String) params.get("md5");
String filename = (String) params.get("filename");
int totalChunks = (int) params.get("totalChunks");
log.info("开始合并分片: md5={}, totalChunks={}", md5, totalChunks);
try {
// 1. ⭐ 检查所有分片是否都已上传
String key = "chunks:" + md5;
Set<String> uploadedChunks = redisTemplate.opsForSet().members(key);
if (uploadedChunks == null || uploadedChunks.size() != totalChunks) {
return Result.fail("分片未全部上传");
}
// 2. ⭐ 合并分片
String extension = filename.substring(filename.lastIndexOf("."));
String finalFilename = md5 + extension;
String finalFilePath = filePath + "/" + finalFilename;
File finalFile = new File(finalFilePath);
if (finalFile.getParentFile() != null && !finalFile.getParentFile().exists()) {
finalFile.getParentFile().mkdirs();
}
// ⭐ 按顺序合并分片
try (FileOutputStream fos = new FileOutputStream(finalFile)) {
for (int i = 0; i < totalChunks; i++) {
String chunkFilename = chunkPath + "/" + md5 + "/" + i;
File chunkFile = new File(chunkFilename);
try (FileInputStream fis = new FileInputStream(chunkFile)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
}
}
}
log.info("分片合并成功: filename={}", finalFilename);
// 3. ⭐ 删除分片文件
String chunkDir = chunkPath + "/" + md5;
FileUtils.deleteDirectory(new File(chunkDir));
// 4. ⭐ 删除Redis中的分片记录
redisTemplate.delete(key);
// 5. ⭐ 缓存文件MD5和URL的映射(秒传)
String url = "/files/" + finalFilename;
String cacheKey = "file:md5:" + md5;
redisTemplate.opsForValue().set(cacheKey, url, 7, TimeUnit.DAYS);
// 6. 返回结果
Map<String, Object> result = new HashMap<>();
result.put("url", url);
result.put("filename", finalFilename);
return Result.success(result);
} catch (IOException e) {
log.error("合并分片失败", e);
return Result.fail("合并失败");
}
}
}
方案3:对象存储(OSS)☁️
为什么用OSS?
问题:
diff
自建存储:
- 成本高(服务器、带宽)💰
- 运维难(扩容、备份)⚙️
- 性能差(单机限制)❌
OSS优势:
diff
- 无限容量 ✅
- 高可用(99.9999999%)✅
- 全球CDN加速 ✅
- 按量付费 ✅
阿里云OSS示例
java
@Service
@Slf4j
public class OSSUploadService {
@Value("${aliyun.oss.endpoint}")
private String endpoint;
@Value("${aliyun.oss.accessKeyId}")
private String accessKeyId;
@Value("${aliyun.oss.accessKeySecret}")
private String accessKeySecret;
@Value("${aliyun.oss.bucketName}")
private String bucketName;
private OSS ossClient;
@PostConstruct
public void init() {
// 创建OSS客户端
ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
}
/**
* ⭐ 上传文件到OSS
*/
public String uploadFile(MultipartFile file) throws IOException {
// 1. 生成文件名
String originalFilename = file.getOriginalFilename();
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
String filename = UUID.randomUUID().toString() + extension;
// 2. 上传到OSS
ossClient.putObject(bucketName, filename, file.getInputStream());
// 3. 返回URL
String url = "https://" + bucketName + "." + endpoint + "/" + filename;
log.info("文件上传到OSS成功: filename={}, url={}", filename, url);
return url;
}
/**
* ⭐ 分片上传到OSS
*/
public String multipartUpload(File file) {
String filename = UUID.randomUUID().toString() + ".mp4";
// ⭐ 1. 初始化分片上传
InitiateMultipartUploadRequest initRequest =
new InitiateMultipartUploadRequest(bucketName, filename);
InitiateMultipartUploadResult initResult = ossClient.initiateMultipartUpload(initRequest);
String uploadId = initResult.getUploadId();
// ⭐ 2. 上传分片
List<PartETag> partETags = new ArrayList<>();
long fileLength = file.length();
long partSize = 10 * 1024 * 1024; // 10MB
int partCount = (int) (fileLength / partSize);
if (fileLength % partSize != 0) {
partCount++;
}
try (FileInputStream fis = new FileInputStream(file)) {
for (int i = 0; i < partCount; i++) {
long startPos = i * partSize;
long curPartSize = Math.min(partSize, fileLength - startPos);
// 上传分片
UploadPartRequest uploadPartRequest = new UploadPartRequest();
uploadPartRequest.setBucketName(bucketName);
uploadPartRequest.setKey(filename);
uploadPartRequest.setUploadId(uploadId);
uploadPartRequest.setInputStream(fis);
uploadPartRequest.setPartSize(curPartSize);
uploadPartRequest.setPartNumber(i + 1);
UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
partETags.add(uploadPartResult.getPartETag());
log.info("分片上传成功: {}/{}", i + 1, partCount);
}
} catch (IOException e) {
log.error("分片上传失败", e);
return null;
}
// ⭐ 3. 完成分片上传
CompleteMultipartUploadRequest completeRequest =
new CompleteMultipartUploadRequest(bucketName, filename, uploadId, partETags);
ossClient.completeMultipartUpload(completeRequest);
String url = "https://" + bucketName + "." + endpoint + "/" + filename;
log.info("分片上传完成: url={}", url);
return url;
}
@PreDestroy
public void destroy() {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
📊 架构总结
markdown
文件上传和存储系统架构
┌──────────────────────────────────────┐
│ 客户端 │
│ │
│ - 文件选择 │
│ - 分片上传 │
│ - 进度显示 │
└─────────────┬────────────────────────┘
│
↓
┌──────────────────────────────────────┐
│ 应用服务器 │
│ │
│ - 秒传判断(MD5) │
│ - 分片接收 │
│ - 分片合并 │
└───────┬──────────────┬───────────────┘
│ │
↓ ↓
┌──────────────┐ ┌──────────────────┐
│ Redis │ │ OSS/本地存储 │
│ │ │ │
│ - 分片记录 │ │ - 文件存储 │
│ - MD5缓存 │ │ - CDN加速 │
└──────────────┘ └──────────────────┘
🎓 面试题速答
Q1: 如何实现大文件上传?
A : 分片上传:
markdown
1. 文件拆分:每10MB一个分片
2. 并发上传:最多3个并发
3. 分片保存:保存到临时目录
4. 合并分片:按顺序合并
优点:
- 并发上传,速度快
- 网络中断,只需重传失败的分片
- 服务器内存占用小
Q2: 如何实现断点续传?
A : Redis记录已上传的分片:
java
// 上传分片时,记录到Redis
redisTemplate.opsForSet().add("chunks:" + md5, String.valueOf(chunkIndex));
// 继续上传时,查询已上传的分片
Set<String> uploaded = redisTemplate.opsForSet().members("chunks:" + md5);
// 跳过已上传的分片
if (uploaded.contains(chunkIndex)) {
continue;
}
Q3: 如何实现秒传?
A : MD5去重:
markdown
1. 前端计算文件MD5
2. 调用接口检查MD5是否已存在
3. 如果存在,直接返回URL(秒传)
4. 如果不存在,正常上传
5. 上传完成后,缓存MD5和URL的映射
java
// 检查文件是否已存在
String cacheKey = "file:md5:" + md5;
String cachedUrl = redisTemplate.opsForValue().get(cacheKey);
if (cachedUrl != null) {
// 秒传成功
return cachedUrl;
}
Q4: 为什么用对象存储(OSS)而不是自建存储?
A : 四个优势:
- 成本低:按量付费,无需购买服务器
- 无限容量:自动扩容
- 高可用:99.9999999%可靠性
- CDN加速:全球加速,下载快
自建存储的问题:
- 成本高(服务器、带宽)
- 运维难(扩容、备份)
- 性能差(单机限制)
Q5: 如何防止恶意上传?
A : 五种防护:
-
文件类型限制:
javaif (!isAllowedType(file.getContentType())) { return Result.fail("不支持的文件类型"); } -
文件大小限制:
javaif (file.getSize() > 1024 * 1024 * 1024) { // 1GB return Result.fail("文件过大"); } -
用户限流:
- 每个用户每天最多上传100个文件
- 使用Redis计数器
-
病毒扫描:
- 调用杀毒软件API
- 检测病毒和木马
-
登录验证:
- 必须登录才能上传
- JWT Token验证
Q6: 如何优化上传速度?
A : 五种优化:
-
分片上传 + 并发上传:
- 10MB一个分片
- 最多3个并发
-
CDN加速:
- 就近上传
- 减少延迟
-
压缩上传:
- 图片压缩(WebP格式)
- 视频压缩(H.264编码)
-
HTTP/2:
- 多路复用
- 头部压缩
-
断点续传:
- 避免重复上传
🎬 总结
markdown
文件上传核心技术
┌────────────────────────────────────┐
│ 分片上传 │
│ - 10MB一个分片 │
│ - 并发上传(3个并发) │
│ - 合并分片 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 断点续传 │
│ - Redis记录已上传的分片 │
│ - 跳过已上传的分片 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 秒传 │
│ - 计算文件MD5 │
│ - 检查MD5是否已存在 │
│ - 存在则直接返回URL │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 对象存储(OSS) │
│ - 无限容量 │
│ - 高可用 │
│ - CDN加速 │
└────────────────────────────────────┘
🎉 恭喜你!
你已经完全掌握了文件上传和存储服务的设计!🎊
核心要点:
- 分片上传:10MB一片,并发上传
- 断点续传:Redis记录已上传的分片
- 秒传:MD5去重
- 对象存储:OSS > 自建存储
下次面试,这样回答:
"文件上传服务采用分片上传 + 断点续传 + 秒传的方案。
分片上传是将大文件拆分成10MB的小分片,前端最多3个并发上传,上传完成后服务端按顺序合并分片。这样网络中断时只需重传失败的分片,服务器内存占用也小。
断点续传通过Redis记录已上传的分片,继续上传时跳过已上传的分片。秒传通过计算文件MD5实现,如果MD5已存在则直接返回URL,不需要重复上传。
存储方面使用阿里云OSS,相比自建存储有无限容量、高可用(99.9999999%)、CDN加速等优势,而且按量付费,成本更低。
我们项目的视频上传服务采用这套方案,支持2GB大文件上传,平均上传速度10MB/s,运行稳定。"
面试官:👍 "很好!你对文件上传服务的设计理解很透彻!"
本文完 🎬
上一篇 : 201-设计一个实时排行榜系统.md
下一篇 : 203-设计一个搜索引擎系统.md
作者注 :写完这篇,我都想去百度做网盘了!📁
如果这篇文章对你有帮助,请给我一个Star⭐!