大文件上传方案:分片上传、断点续传与秒传
在实际应用中,上传大文件(如 10G 视频或数据库备份)往往存在网络中断、上传超时等问题。为了提升用户体验和上传效率,可以使用 分片上传、断点续传与秒传 技术。本文整理了实现思路、表结构设计及示例代码。
一、分片上传
分片上传类似生活场景中的"搬大件家具":
假设你买了一个巨大的沙发,进不了门,你会怎么做?
答:把沙发拆成主体、靠背、沙发脚等小件,逐一搬入,再组装成完整沙发。
同理,分片上传将大文件拆成多个小文件,逐一上传,服务端再合并还原。
实现流程
- 前端将大文件切分为固定大小的分片(例如 10G 文件,每片 10MB);
- 前端并行上传每个分片,互不干扰;
- 服务端保存每个分片文件,待所有分片上传完成后,合并成原始大文件。
二、断点续传
断点续传类似于学习背课文:
背到一半忘记了,不必从头再背,只需要从中断的地方继续。
断点续传的原理:
- 服务端记录每个文件已上传的分片状态;
- 上传中断后,前端再次上传时,先查询服务端记录:已上传多少分片;
- 前端从未上传或上传失败的分片继续上传,避免重复传输。
三、秒传
秒传是指用户上传文件时,系统瞬间提示"上传成功"。其原理如下:
- 为每个文件生成唯一标识(如文件 MD5);
- 前端上传前,先询问服务端该文件是否已存在;
- 服务端根据 MD5 查询,如果文件已存在,则直接创建引用,前端无需上传;
- 用户感知为"秒传",实质是去重上传。
四、数据库表设计
为了支持上述功能,需要两张表:
- 文件元数据表(记录文件基本信息和上传状态);
- 分片信息表(记录已上传分片信息)。
1. 文件元数据表
CREATE TABLE t_ai_customer_service_file_storage (
id BIGSERIAL PRIMARY KEY,
file_name VARCHAR(160) NOT NULL COMMENT '原始文件名',
file_md5 VARCHAR(32) NOT NULL COMMENT '文件MD5,用于秒传去重',
file_path VARCHAR(500) NOT NULL COMMENT '文件存储路径',
file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
total_chunks INT NOT NULL COMMENT '总分片数',
uploaded_chunks INT DEFAULT 0 NOT NULL COMMENT '已上传分片数',
status SMALLINT DEFAULT 0 COMMENT '处理状态:0-上传中 1-待处理 2-已完成 3-失败',
remark VARCHAR(200) COMMENT '备注',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '更新时间'
);
-- 文件 MD5 唯一索引,用于秒传
CREATE UNIQUE INDEX uk_file_md5 ON t_ai_customer_service_file_storage(file_md5);
-- 查询加速索引
CREATE INDEX idx_status ON t_ai_customer_service_file_storage(status);
CREATE INDEX idx_create_time ON t_ai_customer_service_file_storage(create_time);
CREATE INDEX idx_file_name ON t_ai_customer_service_file_storage(file_name);
2. 文件分片表
CREATE TABLE t_file_chunk_info (
id BIGSERIAL PRIMARY KEY,
file_md5 VARCHAR(32) NOT NULL COMMENT '关联文件MD5',
chunk_number INT NOT NULL COMMENT '分片序号(从0开始)',
chunk_path VARCHAR(500) NOT NULL COMMENT '分片存储路径',
chunk_size BIGINT NOT NULL COMMENT '分片大小(字节)',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间'
);
-- 查询加速索引
CREATE INDEX idx_file_md5 ON t_file_chunk_info(file_md5);
五、Java 实体类设计
1. 文件元数据实体
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_ai_customer_service_file_storage")
public class AiCustomerServiceFileStorageDO {
@TableId(type = IdType.AUTO)
private Long id;
private String fileMd5;
private String fileName;
private String filePath;
private Long fileSize;
private Integer totalChunks;
private Integer uploadedChunks;
private Integer status;
private String remark;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
2. 分片信息实体
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_file_chunk_info")
public class FileChunkInfoDO {
@TableId(type = IdType.AUTO)
private Long id;
private String fileMd5;
private Integer chunkNumber;
private String chunkPath;
private Long chunkSize;
private LocalDateTime createTime;
}
3. 文件状态枚举
@Getter
@AllArgsConstructor
public enum AiCustomerServiceFileStatusEnum {
UPLOADING(0, "上传中"),
PENDING(1, "上传成功,待处理"),
VECTORIZING(2, "向量化中"),
COMPLETED(3, "已完成"),
FAILED(4, "失败");
private final Integer code;
private final String description;
public static AiCustomerServiceFileStatusEnum fromCode(Integer code) {
if (code == null) return null;
for (AiCustomerServiceFileStatusEnum status : values()) {
if (status.code.equals(code)) return status;
}
return null;
}
}
六、接口设计
@RestController
@RequestMapping("/customer-service")
@Slf4j
public class AiCustomerServiceController {
@Resource
private CustomerService customerService;
@PostMapping("/file/check")
@ApiOperationLog(description = "检查文件是否存在")
public Response<CheckFileRspVO> checkFile(@RequestBody @Validated CheckFileReqVO checkFileReqVO) {
return customerService.checkFile(checkFileReqVO);
}
@PostMapping("/file/upload-chunk")
public Response<?> uploadChunk(@ModelAttribute UploadChunkReqVO uploadChunkReqVO) {
return customerService.uploadChunk(uploadChunkReqVO);
}
@PostMapping("/file/merge-chunk")
@ApiOperationLog(description = "文件分片合并")
public Response<?> mergeChunk(@RequestBody @Validated MergeChunkReqVO mergeChunkReqVO) {
return customerService.mergeChunk(mergeChunkReqVO);
}
}
七、VO 设计
1. 检查文件请求/响应
@Data @Builder
public class CheckFileReqVO {
@NotBlank(message = "文件 MD5 不能为空")
private String fileMd5;
}
@Data @Builder
public class CheckFileRspVO {
private Boolean exists; // 文件是否存在
private Boolean needUpload; // 是否需要上传
private List<Integer> uploadedChunks; // 已上传分片序号
}
2. 分片上传请求
@Data @Builder
public class UploadChunkReqVO {
private String fileMd5;
private String fileName;
private Long fileSize;
private Integer chunkNumber;
private Integer totalChunks;
private MultipartFile chunk;
}
3. 分片合并请求
@Data @Builder
public class MergeChunkReqVO {
@NotBlank(message = "文件 MD5 不能为空")
private String fileMd5;
}
八、实现逻辑概览
- 检查文件
- 根据 MD5 查询文件是否存在;
- 若存在且上传完成,前端直接秒传;
- 若上传中,返回已上传分片列表,支持断点续传。
- 上传分片
- 检查分片是否已存在;
- 保存分片到本地磁盘;
- 保存分片记录到数据库;
- 更新已上传分片数。
- 合并分片
- 检查所有分片是否完整;
- 按序合并分片生成最终文件;
- 更新文件状态为"待处理";
- 删除分片文件及数据库记录。
1.service 接口
public interface CustomerService {
/**
* 检查文件是否存在
* @param checkFileReqVO
* @return
*/
Response<CheckFileRspVO> checkFile(CheckFileReqVO checkFileReqVO);
/**
* 文件分片上传
* @param uploadChunkReqVO
* @return
*/
Response<?> uploadChunk(UploadChunkReqVO uploadChunkReqVO);
/**
* 文件分片合并
* @param mergeChunkReqVO
* @return
*/
Response<?> mergeChunk(MergeChunkReqVO mergeChunkReqVO);
}
2.impl 实现类
@Service
@Slf4j
public class CustomerServiceImpl implements CustomerService {
@Resource
private FileChunkInfoMapper fileChunkInfoMapper;
@Resource
private AiCustomerServiceFileStorageMapper aiCustomerServiceFileStorageMapper;
@Value("${customer-service.file-storage-path}")
private String fileStoragePath;
@Value("${customer-service.chunk-path}")
private String chunkPath;
/**
* 检查文件是否存在
*
* @param checkFileReqVO
* @return
*/
@Override
public Response<CheckFileRspVO> checkFile(CheckFileReqVO checkFileReqVO) {
String fileMd5 = checkFileReqVO.getFileMd5();
// 查询对应 MD5 值的文件记录是否已经存在
AiCustomerServiceFileStorageDO fileStorageDO = aiCustomerServiceFileStorageMapper
.selectByMd5(fileMd5);
// 文件记录不存在,需要上传
if (Objects.isNull(fileStorageDO)) {
return Response.success(CheckFileRspVO.builder()
.exists(false)
.needUpload(true)
.build());
}
// 若文件记录已存在
Integer status = fileStorageDO.getStatus();
AiCustomerServiceFileStatusEnum statusEnum = AiCustomerServiceFileStatusEnum.fromCode(status);
// 判断当前处理状态
// 文件已完整上传,支持秒传
if (!Objects.equals(statusEnum, AiCustomerServiceFileStatusEnum.UPLOADING)) {
return Response.success(CheckFileRspVO.builder()
.exists(true)
.needUpload(false)
.build());
}
// 文件正在上传中,返回已上传的分片序号
List<FileChunkInfoDO> chunks = fileChunkInfoMapper.selecChunkedtList(fileMd5);
List<Integer> uploadedChunks = chunks.stream()
.map(FileChunkInfoDO::getChunkNumber)
.toList();
return Response.success(CheckFileRspVO.builder()
.exists(true)
.needUpload(true)
.uploadedChunks(uploadedChunks)
.build());
}
/**
* 文件分片上传
*
* @param uploadChunkReqVO
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Response<?> uploadChunk(UploadChunkReqVO uploadChunkReqVO) {
String fileMd5 = uploadChunkReqVO.getFileMd5();
Integer chunkNumber = uploadChunkReqVO.getChunkNumber();
MultipartFile chunk = uploadChunkReqVO.getChunk();
// 检查当前分片是否已上传
Long count = fileChunkInfoMapper.selectCountByMd5AndChunkNum(fileMd5, chunkNumber);
if (count > 0) {
log.info("## 分片已存在: fileMd5={}, chunkNumber={}", fileMd5, chunkNumber);
return Response.success();
}
// 创建分片目录(确保父目录也存在)
String chunkDir = chunkPath + File.separator + fileMd5;
File chunkDirFile = new File(chunkDir);
try {
FileUtils.forceMkdir(chunkDirFile);
} catch (IOException e) {
log.error("## 创建分片目录失败: {}", chunkDir);
throw new RuntimeException(e);
}
// 保存分片文件到本地
String chunkFileName = chunkNumber + ".chunk";
File chunkFile = new File(chunkDirFile, chunkFileName);
try {
chunk.transferTo(chunkFile);
} catch (IOException e) {
log.error("## 保存分片文件失败: {}", chunkFileName);
throw new RuntimeException(e);
}
// 保存分片记录
FileChunkInfoDO chunkInfo = FileChunkInfoDO.builder()
.fileMd5(fileMd5)
.chunkNumber(chunkNumber)
.chunkPath(chunkFile.getAbsolutePath()) // 分片文件存储路径
.chunkSize(chunk.getSize())
.build();
fileChunkInfoMapper.insert(chunkInfo);
// 查询当前 MD5 对应的文件是否存在
AiCustomerServiceFileStorageDO fileStorageDO = aiCustomerServiceFileStorageMapper.selectByMd5(fileMd5);
// 已上传的分片数,默认为 1
int uploadedChunks = 1;
// 若不存在,写入数据
if (Objects.isNull(fileStorageDO)) {
fileStorageDO = AiCustomerServiceFileStorageDO.builder()
.fileMd5(fileMd5)
.fileName(uploadChunkReqVO.getFileName())
.fileSize(uploadChunkReqVO.getFileSize()) // 原始文件大小
.totalChunks(uploadChunkReqVO.getTotalChunks())
.uploadedChunks(uploadedChunks) // 默认已上传分片数为 1
.status(AiCustomerServiceFileStatusEnum.UPLOADING.getCode()) // 状态:上传中...
.filePath(Strings.EMPTY)
.build();
aiCustomerServiceFileStorageMapper.insert(fileStorageDO);
} else { // 存在,则进行更新操作,将已上传分片数 +1
aiCustomerServiceFileStorageMapper.incrementUploadedChunks(fileStorageDO.getId());
uploadedChunks += fileStorageDO.getUploadedChunks();
}
log.info("## 分片上传成功: fileMd5={}, chunkNumber={}, progress={}/{}",
fileMd5, chunkNumber, uploadedChunks, fileStorageDO.getTotalChunks());
return Response.success();
}
/**
* 文件分片合并
*
* @param mergeChunkReqVO
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Response<?> mergeChunk(MergeChunkReqVO mergeChunkReqVO) {
String fileMd5 = mergeChunkReqVO.getFileMd5();
// 检查文件元记录是否存在
AiCustomerServiceFileStorageDO fileStorageDO = aiCustomerServiceFileStorageMapper.selectByMd5(fileMd5);
// 要合并的目标文件不存在
if (Objects.isNull(fileStorageDO)) {
throw new BizException(ResponseCodeEnum.MERGE_CHUNK_NOT_FOUND);
}
// 查询所有已上传分片
List<FileChunkInfoDO> chunks = fileChunkInfoMapper.selecChunkedtList(fileMd5);
// 若已上传分片数不等于总分片数,说明分片数不完整
if (chunks.size() != fileStorageDO.getTotalChunks()) {
throw new BizException(ResponseCodeEnum.CHUNK_NUM_NOT_COMPLETE);
}
// 创建文件目录
File uploadDir = new File(fileStoragePath);
try {
FileUtils.forceMkdir(uploadDir);
} catch (IOException e) {
log.error("## 创建文件合并目录失败: {}", uploadDir);
throw new RuntimeException(e);
}
// 合并文件的名称
String finalFileName = System.currentTimeMillis() + "_" + fileStorageDO.getFileName();
// 新建合并文件
File finalFile = new File(uploadDir, finalFileName);
// 合并分片
try (FileOutputStream fos = new FileOutputStream(finalFile);
BufferedOutputStream bos = new BufferedOutputStream(fos)) {
for (FileChunkInfoDO chunkInfo : chunks) {
// 读取分片文件
File chunkFile = new File(chunkInfo.getChunkPath());
try (FileInputStream fis = new FileInputStream(chunkFile);
BufferedInputStream bis = new BufferedInputStream(fis)) {
// 分块读取(8kb 缓冲区),减少IO操作次数,同时避免一次性加载所有分片到内存,导致内存占满
byte[] buffer = new byte[8192];
int len;
while ((len = bis.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
}
}
} catch (Exception e) {
log.error("## 合并文件失败: ", e);
throw new RuntimeException(e);
}
// 更新文件信息
aiCustomerServiceFileStorageMapper.updateById(AiCustomerServiceFileStorageDO.builder()
.id(fileStorageDO.getId())
.status(AiCustomerServiceFileStatusEnum.PENDING.getCode()) // 合并完成,等待向量化
.filePath(finalFile.getAbsolutePath())
.build());
// 删除分片文件所在的目录和记录
String chunkDir = chunkPath + File.separator + fileMd5;
try {
FileUtils.forceDelete(new File(chunkDir));
} catch (IOException e) {
log.error("## 删除分片文件失败: ", e);
throw new RuntimeException(e);
}
fileChunkInfoMapper.deleteByMd5(fileMd5);
log.info("## 文件合并成功: fileMd5={}, filePath={}", fileMd5, finalFile.getAbsolutePath());
// 合并完成后,发布事件,进行向量化处理
// 获取主键 ID
Long id = fileStorageDO.getId();
// 元数据
Map<String, Object> metadatas = Maps.newHashMap();
metadatas.put("mdStorageId", id); // 关联的文件存储表主键 ID
metadatas.put("originalFileName", fileStorageDO.getFileName()); // 文件原始名称
// 发布事件
// eventPublisher.publishEvent(AiCustomerServiceMdUploadedEvent.builder()
// .id(id)
// .filePath(finalFile.getAbsolutePath())
// .metadatas(metadatas)
// .build());
return Response.success();
}
}
九、Mapper 示例
java
public interface FileChunkInfoMapper extends BaseMapper<FileChunkInfoDO> {
default List<FileChunkInfoDO> selecChunkedtList(String fileMd5) {
return selectList(Wrappers.<FileChunkInfoDO>lambdaQuery()
.eq(FileChunkInfoDO::getFileMd5, fileMd5)
.orderByAsc(FileChunkInfoDO::getChunkNumber));
}
default Long selectCountByMd5AndChunkNum(String fileMd5, Integer chunkNum) {
return selectCount(Wrappers.<FileChunkInfoDO>lambdaQuery()
.eq(FileChunkInfoDO::getFileMd5, fileMd5)
.eq(FileChunkInfoDO::getChunkNumber, chunkNum));
}
default int deleteByMd5(String fileMd5) {
return delete(Wrappers.<FileChunkInfoDO>lambdaQuery()
.eq(FileChunkInfoDO::getFileMd5, fileMd5));
}
}
public interface AiCustomerServiceFileStorageMapper extends BaseMapper<AiCustomerServiceFileStorageDO> {
default AiCustomerServiceFileStorageDO selectByMd5(String fileMd5) {
return selectOne(Wrappers.<AiCustomerServiceFileStorageDO>lambdaQuery()
.eq(AiCustomerServiceFileStorageDO::getFileMd5, fileMd5));
}
default int incrementUploadedChunks(Long id) {
return update(Wrappers.<AiCustomerServiceFileStorageDO>lambdaUpdate()
.eq(AiCustomerServiceFileStorageDO::getId, id)
.setSql("uploaded_chunks = uploaded_chunks + 1"));
}
}