【java】【断点续传】----java实现大文件断点续传和分片上传原理和详细全过程
·
一、核心原理剖析
在实现大文件上传前,先理解核心设计思路,这是实现断点续传和分片上传的基础:
1. 分片上传(Chunk Upload)
- 核心思想:将一个大文件(比如1GB)切割成多个固定大小的小分片(比如10MB/片),分别上传,服务端接收所有分片后再合并成完整文件。
- 解决问题:避免单次上传大文件时网络中断导致全部重传、请求超时、服务器内存溢出等问题。
2. 断点续传(Resumable Upload)
- 核心思想:上传前先校验文件的唯一标识(如MD5/文件大小+修改时间),服务端查询该文件已上传的分片,只上传未完成的分片。
- 关键依赖:需要一个“分片记录器”(数据库/缓存),记录每个文件的分片上传状态(已上传分片索引、总分片数、文件唯一标识等)。
3. 多文件上传
本质是对单文件上传逻辑的复用,前端传递文件列表,后端循环处理每个文件的分片/断点续传逻辑即可。
二、整体实现流程
整个流程分为前端预处理和后端核心逻辑两部分,以下是标准化的实现步骤:
步骤1:文件唯一标识生成(前端+后端)
为了区分不同文件、校验分片归属,首先生成文件的唯一标识(推荐MD5):
- 前端:读取文件内容,计算文件的MD5(可使用SparkMD5等库),同时指定分片大小(如10MB),计算总分片数。
- 后端:接收MD5后,可作为文件的唯一ID,用于查询分片上传状态。
步骤2:断点续传校验(前端请求,后端响应)
前端上传前先请求后端:“文件MD5为xxx的文件,哪些分片已经上传完成?”
- 后端:查询分片记录(数据库/缓存),返回已上传的分片索引列表。
- 前端:根据返回结果,只上传未完成的分片(实现断点续传)。
步骤3:分片上传(核心)
前端将文件切割成分片,逐个/并发上传分片,每个分片携带:
- 文件MD5(文件唯一标识)
- 分片索引(第几个分片,从0开始)
- 总分片数
- 分片内容
后端接收分片后,保存到临时目录,并更新分片上传状态。
步骤4:合并分片(所有分片上传完成后)
前端确认所有分片上传完成后,请求后端合并分片:
- 后端:按分片索引顺序读取所有临时分片文件,写入最终文件路径,删除临时分片,更新文件上传状态为“完成”。
步骤5:多文件上传适配
前端传递文件列表,后端为每个文件生成独立的MD5和分片记录,循环执行上述1-4步骤即可。
三、Java后端具体实现(基于Spring Boot)
1. 环境依赖(pom.xml)
<dependencies>
<!-- Spring Boot核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 数据库(记录分片状态) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 文件操作工具 -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.15.0</version>
</dependency>
<!-- 校验MD5 -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
</dependencies>
2. 数据库设计(记录分片上传状态)
创建file_upload_record表,存储文件和分片的上传状态:
CREATE TABLE `file_upload_record` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`file_md5` varchar(64) NOT NULL COMMENT '文件唯一标识(MD5)',
`file_name` varchar(255) NOT NULL COMMENT '文件名',
`file_size` bigint NOT NULL COMMENT '文件总大小(字节)',
`chunk_size` bigint NOT NULL COMMENT '分片大小(字节)',
`total_chunks` int NOT NULL COMMENT '总分片数',
`uploaded_chunks` varchar(1000) DEFAULT '' COMMENT '已上传分片索引(逗号分隔,如0,1,2)',
`file_path` varchar(500) DEFAULT NULL COMMENT '最终文件保存路径',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0-未完成 1-已完成',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_file_md5` (`file_md5`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件上传记录表';
3. 实体类(FileUploadRecord.java)
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("file_upload_record")
public class FileUploadRecord {
@TableId(type = IdType.AUTO)
private Long id;
// 文件MD5
private String fileMd5;
// 文件名
private String fileName;
// 文件总大小(字节)
private Long fileSize;
// 分片大小(字节)
private Long chunkSize;
// 总分片数
private Integer totalChunks;
// 已上传分片索引(逗号分隔)
private String uploadedChunks;
// 最终文件路径
private String filePath;
// 上传状态:0-未完成 1-已完成
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
4. Mapper层(FileUploadRecordMapper.java)
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import com.example.demo.entity.FileUploadRecord;
@Mapper
public interface FileUploadRecordMapper extends BaseMapper<FileUploadRecord> {
}
5. 核心服务类(FileUploadService.java)
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.demo.entity.FileUploadRecord;
import com.example.demo.mapper.FileUploadRecordMapper;
import org.apache.commons.io.FileUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.*;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class FileUploadService {
// 临时分片存储目录(配置在application.yml)
@Value("${file.upload.temp-path}")
private String tempPath;
// 最终文件存储目录
@Value("${file.upload.final-path}")
private String finalPath;
@Resource
private FileUploadRecordMapper fileUploadRecordMapper;
/**
* 步骤1:断点续传校验,返回已上传的分片索引
*/
public Set<Integer> checkUploadedChunks(String fileMd5) {
LambdaQueryWrapper<FileUploadRecord> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(FileUploadRecord::getFileMd5, fileMd5);
FileUploadRecord record = fileUploadRecordMapper.selectOne(queryWrapper);
// 无记录:返回空集合(所有分片都未上传)
if (record == null) {
return new HashSet<>();
}
// 已完成:返回所有分片索引(前端无需再上传)
if (record.getStatus() == 1) {
Set<Integer> allChunks = new HashSet<>();
for (int i = 0; i < record.getTotalChunks(); i++) {
allChunks.add(i);
}
return allChunks;
}
// 未完成:解析已上传分片索引
String uploadedChunks = record.getUploadedChunks();
if (uploadedChunks == null || uploadedChunks.isEmpty()) {
return new HashSet<>();
}
return Arrays.stream(uploadedChunks.split(","))
.map(Integer::parseInt)
.collect(Collectors.toSet());
}
/**
* 步骤2:上传单个分片
*/
public boolean uploadChunk(String fileMd5, String fileName, Long fileSize,
Long chunkSize, Integer chunkIndex, Integer totalChunks,
MultipartFile chunkFile) throws IOException {
// 1. 校验分片文件
if (chunkFile.isEmpty()) {
throw new RuntimeException("分片文件为空");
}
// 2. 创建临时目录(按文件MD5分目录,避免分片冲突)
File tempDir = new File(tempPath + File.separator + fileMd5);
if (!tempDir.exists()) {
FileUtils.forceMkdir(tempDir);
}
// 3. 保存分片文件(命名规则:fileMd5 + "_" + chunkIndex)
File chunkFileObj = new File(tempDir, fileMd5 + "_" + chunkIndex);
FileUtils.copyInputStreamToFile(chunkFile.getInputStream(), chunkFileObj);
// 4. 更新分片上传记录
LambdaQueryWrapper<FileUploadRecord> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(FileUploadRecord::getFileMd5, fileMd5);
FileUploadRecord record = fileUploadRecordMapper.selectOne(queryWrapper);
if (record == null) {
// 首次上传:新增记录
record = new FileUploadRecord();
record.setFileMd5(fileMd5);
record.setFileName(fileName);
record.setFileSize(fileSize);
record.setChunkSize(chunkSize);
record.setTotalChunks(totalChunks);
record.setUploadedChunks(chunkIndex.toString());
record.setStatus(0);
fileUploadRecordMapper.insert(record);
} else {
// 非首次上传:追加分片索引
Set<Integer> uploadedSet = checkUploadedChunks(fileMd5);
uploadedSet.add(chunkIndex);
String newUploadedChunks = uploadedSet.stream()
.sorted()
.map(String::valueOf)
.collect(Collectors.joining(","));
record.setUploadedChunks(newUploadedChunks);
fileUploadRecordMapper.updateById(record);
}
return true;
}
/**
* 步骤3:合并所有分片
*/
public boolean mergeChunks(String fileMd5) throws IOException {
// 1. 查询文件记录
LambdaQueryWrapper<FileUploadRecord> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(FileUploadRecord::getFileMd5, fileMd5);
FileUploadRecord record = fileUploadRecordMapper.selectOne(queryWrapper);
if (record == null || record.getStatus() == 1) {
return true; // 无记录或已合并,直接返回成功
}
// 2. 校验所有分片是否上传完成
Set<Integer> uploadedChunks = checkUploadedChunks(fileMd5);
if (uploadedChunks.size() != record.getTotalChunks()) {
throw new RuntimeException("分片未全部上传,无法合并");
}
// 3. 创建最终文件目录
File finalDir = new File(finalPath);
if (!finalDir.exists()) {
FileUtils.forceMkdir(finalDir);
}
// 4. 合并分片到最终文件
File finalFile = new File(finalDir, record.getFileName());
try (FileOutputStream fos = new FileOutputStream(finalFile, true);
BufferedOutputStream bos = new BufferedOutputStream(fos)) {
// 按分片索引顺序读取并写入
for (int i = 0; i < record.getTotalChunks(); i++) {
File chunkFile = new File(tempPath + File.separator + fileMd5, fileMd5 + "_" + i);
if (!chunkFile.exists()) {
throw new RuntimeException("分片" + i + "不存在");
}
// 写入分片内容
FileUtils.copyFile(chunkFile, bos);
// 删除临时分片文件
FileUtils.delete(chunkFile);
}
}
// 5. 更新记录:标记为已完成,保存最终文件路径
record.setFilePath(finalFile.getAbsolutePath());
record.setStatus(1);
fileUploadRecordMapper.updateById(record);
// 6. 删除临时目录
File tempDir = new File(tempPath + File.separator + fileMd5);
FileUtils.deleteDirectory(tempDir);
return true;
}
}
6. 控制器(FileUploadController.java)
支持单文件/多文件的分片上传、断点校验、分片合并:
import com.example.demo.service.FileUploadService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@RestController
@RequestMapping("/file/upload")
public class FileUploadController {
@Resource
private FileUploadService fileUploadService;
/**
* 断点续传校验接口
*/
@GetMapping("/check")
public ResponseEntity<Map<String, Object>> checkUpload(@RequestParam String fileMd5) {
Set<Integer> uploadedChunks = fileUploadService.checkUploadedChunks(fileMd5);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("uploadedChunks", uploadedChunks);
return ResponseEntity.ok(result);
}
/**
* 分片上传接口(单文件分片)
*/
@PostMapping("/chunk")
public ResponseEntity<Map<String, Object>> uploadChunk(
@RequestParam String fileMd5,
@RequestParam String fileName,
@RequestParam Long fileSize,
@RequestParam Long chunkSize,
@RequestParam Integer chunkIndex,
@RequestParam Integer totalChunks,
@RequestParam MultipartFile chunkFile) {
try {
boolean success = fileUploadService.uploadChunk(fileMd5, fileName, fileSize,
chunkSize, chunkIndex, totalChunks, chunkFile);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "分片上传成功");
result.put("success", success);
return ResponseEntity.ok(result);
} catch (IOException e) {
Map<String, Object> result = new HashMap<>();
result.put("code", 500);
result.put("msg", "分片上传失败:" + e.getMessage());
return ResponseEntity.internalServerError().body(result);
}
}
/**
* 分片合并接口
*/
@PostMapping("/merge")
public ResponseEntity<Map<String, Object>> mergeChunks(@RequestParam String fileMd5) {
try {
boolean success = fileUploadService.mergeChunks(fileMd5);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "文件合并成功");
result.put("success", success);
return ResponseEntity.ok(result);
} catch (IOException e) {
Map<String, Object> result = new HashMap<>();
result.put("code", 500);
result.put("msg", "文件合并失败:" + e.getMessage());
return ResponseEntity.internalServerError().body(result);
}
}
/**
* 多文件上传适配:前端循环调用上述接口即可,后端无需额外修改
* (前端可传递文件列表,逐个处理每个文件的MD5、分片上传、合并)
*/
}
7. 配置文件(application.yml)
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/cloud_disk?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
# 文件上传配置
file:
upload:
temp-path: D:/cloud-disk/temp # 临时分片存储路径
final-path: D:/cloud-disk/final # 最终文件存储路径
max-size: 10737418240 # 最大文件大小(10GB)
四、关键细节补充
1. 前端配合要点
- 计算文件MD5:使用
SparkMD5库,支持大文件分片计算MD5(避免浏览器内存溢出)。 - 分片切割:使用
File.slice(start, end)方法切割文件。 - 并发上传:可限制并发数(如3-5个),避免同时上传过多分片导致网络拥堵。
- 多文件处理:循环遍历文件列表,为每个文件执行“校验-上传分片-合并”流程。
2. 后端优化建议
- 分片大小选择:推荐10MB-50MB,过小会增加请求数,过大则失去分片意义。
- 幂等性处理:分片上传接口需支持幂等(重复上传同一分片不报错)。
- 过期分片清理:定时清理临时目录中长时间未合并的分片(如超过24小时)。
- 分布式场景适配:
- 临时分片存储:使用分布式文件系统(如MinIO、OSS)代替本地目录。
- 分片记录:使用Redis代替数据库,提升查询性能;分布式锁保证合并分片的原子性。
- 文件校验:合并后可重新计算文件MD5,与前端传递的MD5对比,确保文件完整性。
五、总结
核心关键点回顾
- 分片上传:将大文件切割为固定大小的分片,逐个上传,核心是
文件MD5标识分片归属,临时目录存储分片。 - 断点续传:上传前通过MD5查询已上传分片,只上传未完成的分片,依赖数据库记录分片状态。
- 多文件适配:复用单文件的分片/断点逻辑,前端循环处理每个文件即可,后端无需额外开发。
实现核心
- 前端:文件MD5计算、分片切割、断点校验请求、分片上传、合并请求。
- 后端:分片接收与存储、分片状态记录、分片合并、最终文件保存。
- 核心依赖:文件唯一标识(MD5)、分片状态记录(数据库)、临时目录(分片存储)。
这套方案是云盘大文件上传的标准化实现,可直接落地,也可根据实际需求(如分布式部署、云存储适配)进行扩展。
更多推荐

所有评论(0)