文件分片策略:大文件分片,每个分片5MB,小文件不分片,大小文件判断标准是10MB。分片处理流程是,前端上传前判断文件大小,根据文件大小决定是否分片,分片上传时使用线程池提高效率。
断点续传、秒传:创建MySQL文件记录表(id,MD5指纹,文件路径,文件大小,状态(0未完成,1已完成))。分片索引列表用Redis的Hash存储,key是前缀加MD5指纹,field是分片序号,value是状态(0未上传,1已上传)。前端上传前请求 /upload/check 接口,携带MD5文件指纹,小文件是整个文件计算MD5,大文件是取文件头+中段+尾部的2MB数据拼接后计算MD5,后端检索MD5指纹查看分片索引列表的上传情况,如果没有,则前端需上传完整文件,如果有但是上传不完整,前端需上传缺失的分片,如果有且已完整上传,则触发秒传,直接上传成功。
以下是完整的Java后端实现方案:
一、数据库设计
文件记录表 (file_record)
CREATE TABLE file_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
file_md5 VARCHAR(32) NOT NULL COMMENT '文件指纹',
file_path VARCHAR(255) NOT NULL COMMENT '存储路径',
file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-未完成 1-已完成',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX idx_md5 (file_md5)
);
二、Redis存储设计
- Key格式:
file:chunks:{file_md5} - Value类型: Hash
- Field: 分片序号 (从0开始)
- Value: 0(未上传)/1(已上传)
三、核心实现逻辑
1. 文件检查接口 (/upload/check)
@PostMapping("/upload/check")
public Response checkFile(@RequestParam String fileMd5,
@RequestParam long fileSize) {
// 1. 检查数据库是否已存在完整文件
FileRecord record = fileRecordRepo.findByMd5(fileMd5);
if (record != null && record.getStatus() == 1) {
return Response.success("秒传成功", Map.of("exist", true));
}
// 2. 检查Redis分片信息
String redisKey = "file:chunks:" + fileMd5;
Map<Object, Object> chunkMap = redisTemplate.opsForHash().entries(redisKey);
boolean isLargeFile = fileSize >= 10 * 1024 * 1024; // 10MB判断
// 3. 首次上传:初始化分片记录
if (chunkMap.isEmpty()) {
if (isLargeFile) {
int totalChunks = (int) Math.ceil((double) fileSize / (5 * 1024 * 1024));
Map<String, Integer> initMap = new HashMap<>();
for (int i = 0; i < totalChunks; i++) {
initMap.put(String.valueOf(i), 0);
}
redisTemplate.opsForHash().putAll(redisKey, initMap);
}
// 创建文件记录
if (record == null) {
record = new FileRecord();
record.setFileMd5(fileMd5);
record.setFileSize(fileSize);
record.setStatus(0);
fileRecordRepo.save(record);
}
return Response.success("需要上传", Map.of(
"exist", false,
"needUpload", true,
"chunkMap", isLargeFile ? initMap : Collections.emptyMap()
));
}
// 4. 断点续传:返回缺失分片
List<Integer> missingChunks = new ArrayList<>();
for (Map.Entry<Object, Object> entry : chunkMap.entrySet()) {
if (Integer.parseInt(entry.getValue().toString()) == 0) {
missingChunks.add(Integer.parseInt(entry.getKey().toString()));
}
}
return Response.success("继续上传", Map.of(
"exist", false,
"needUpload", true,
"missingChunks", missingChunks
));
}
2. 分片上传接口 (/upload/chunk)
@PostMapping("/upload/chunk")
public Response uploadChunk(@RequestParam String fileMd5,
@RequestParam int chunkIndex,
@RequestParam MultipartFile chunk) {
// 1. 验证分片有效性
if (chunk.isEmpty()) {
throw new BusinessException("分片不能为空");
}
// 2. 获取Redis记录
String redisKey = "file:chunks:" + fileMd5;
Integer status = (Integer) redisTemplate.opsForHash().get(redisKey, String.valueOf(chunkIndex));
if (status != null && status == 1) {
return Response.success("分片已存在");
}
// 3. 存储分片到临时目录
String tempDir = "/tmp/uploads/" + fileMd5 + "/";
Path chunkPath = Paths.get(tempDir, chunkIndex + ".tmp");
try {
Files.createDirectories(chunkPath.getParent());
chunk.transferTo(chunkPath);
} catch (IOException e) {
throw new BusinessException("分片保存失败");
}
// 4. 更新Redis状态
redisTemplate.opsForHash().put(redisKey, String.valueOf(chunkIndex), 1);
// 5. 检查是否全部完成
if (checkAllChunksUploaded(redisKey)) {
asyncMergeFile(fileMd5); // 异步合并文件
}
return Response.success("分片上传成功");
}
private boolean checkAllChunksUploaded(String redisKey) {
Map<Object, Object> chunkMap = redisTemplate.opsForHash().entries(redisKey);
return chunkMap.values().stream().allMatch(status -> status.equals(1));
}
3. 文件合并逻辑
@Async
public void asyncMergeFile(String fileMd5) {
// 1. 获取文件记录
FileRecord record = fileRecordRepo.findByMd5(fileMd5);
// 2. 获取所有分片
String tempDir = "/tmp/uploads/" + fileMd5 + "/";
File[] chunks = new File(tempDir).listFiles();
Arrays.sort(chunks, Comparator.comparingInt(f -> Integer.parseInt(f.getName().split("\\.")[0])));
// 3. 合并文件
String finalPath = "/storage/" + LocalDate.now() + "/" + UUID.randomUUID() + ".mp4";
try (FileOutputStream fos = new FileOutputStream(finalPath)) {
for (File chunk : chunks) {
Files.copy(chunk.toPath(), fos);
}
} catch (IOException e) {
throw new BusinessException("文件合并失败");
}
// 4. 更新数据库
record.setFilePath(finalPath);
record.setStatus(1);
fileRecordRepo.save(record);
// 5. 清理临时文件
FileUtils.deleteDirectory(new File(tempDir));
redisTemplate.delete("file:chunks:" + fileMd5);
}
四、秒传指纹计算(前端示例)
async function calcFileFingerprint(file) {
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunks = [];
if (file.size <= 10 * 1024 * 1024) {
// 小文件:全量计算
return await calcMD5(file);
}
// 大文件:取头中尾三部分
const offsets = [
0, // 头部
Math.floor(file.size / 2) - chunkSize / 2, // 中部
file.size - chunkSize // 尾部
];
for (const offset of offsets) {
const chunk = file.slice(offset, offset + chunkSize);
chunks.push(await readChunk(chunk));
}
return await calcMD5(new Blob(chunks));
}
五、线程池配置
@Configuration
public class UploadConfig {
@Bean("uploadThreadPool")
public Executor uploadThreadPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("upload-");
executor.initialize();
return executor;
}
}
六、关键优化点
-
分片并发上传:
// 在Controller中使用异步处理 @PostMapping("/upload/chunk") @Async("uploadThreadPool") public Future<Response> uploadChunkAsync(...) { ... } -
断点续传优化:
- Redis记录分片状态时设置过期时间(如24小时)
- 添加分片校验机制(MD5验证分片完整性)
-
错误处理:
- 分片上传失败时重置Redis状态
- 合并失败时回滚操作
-
安全控制:
- 限制单个文件最大分片数量
- 验证分片序号有效性
七、系统架构图
+--------+ +---------+ +------------+ +----------+
| 客户端 | ----> | Nginx | ----> | 上传服务 | ----> | 文件存储 |
+--------+ +---------+ +-----+------+ +----------+
| |
| v
| +----------+
+---> | Redis |
| +----------+
|
v
+------------+
| MySQL |
+------------+
该方案实现了:
- 智能分片:根据10MB阈值自动选择分片策略
- 秒传功能:通过文件指纹快速匹配
- 断点续传:Redis记录分片状态
- 高效并发:线程池处理分片上传
- 自动合并:后台异步合并文件分片
实际部署时需注意:
- 设置合理的临时文件清理策略
- 增加文件校验机制防止数据损坏
- 对大文件合并操作添加熔断保护
- 分布式环境下需使用分布式锁控制合并操作