16_大文件上传方案:分片上传、断点续传与秒传

大文件上传方案:分片上传、断点续传与秒传

在实际应用中,上传大文件(如 10G 视频或数据库备份)往往存在网络中断、上传超时等问题。为了提升用户体验和上传效率,可以使用 分片上传、断点续传与秒传 技术。本文整理了实现思路、表结构设计及示例代码。


一、分片上传

分片上传类似生活场景中的"搬大件家具":

假设你买了一个巨大的沙发,进不了门,你会怎么做?

答:把沙发拆成主体、靠背、沙发脚等小件,逐一搬入,再组装成完整沙发。

同理,分片上传将大文件拆成多个小文件,逐一上传,服务端再合并还原。

实现流程

  1. 前端将大文件切分为固定大小的分片(例如 10G 文件,每片 10MB);
  2. 前端并行上传每个分片,互不干扰;
  3. 服务端保存每个分片文件,待所有分片上传完成后,合并成原始大文件。

二、断点续传

断点续传类似于学习背课文:

背到一半忘记了,不必从头再背,只需要从中断的地方继续。

断点续传的原理:

  1. 服务端记录每个文件已上传的分片状态;
  2. 上传中断后,前端再次上传时,先查询服务端记录:已上传多少分片;
  3. 前端从未上传或上传失败的分片继续上传,避免重复传输。

三、秒传

秒传是指用户上传文件时,系统瞬间提示"上传成功"。其原理如下:

  1. 为每个文件生成唯一标识(如文件 MD5);
  2. 前端上传前,先询问服务端该文件是否已存在;
  3. 服务端根据 MD5 查询,如果文件已存在,则直接创建引用,前端无需上传;
  4. 用户感知为"秒传",实质是去重上传。

四、数据库表设计

为了支持上述功能,需要两张表:

  1. 文件元数据表(记录文件基本信息和上传状态);
  2. 分片信息表(记录已上传分片信息)。

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;
}

八、实现逻辑概览

  1. 检查文件
    • 根据 MD5 查询文件是否存在;
    • 若存在且上传完成,前端直接秒传;
    • 若上传中,返回已上传分片列表,支持断点续传。
  2. 上传分片
    • 检查分片是否已存在;
    • 保存分片到本地磁盘;
    • 保存分片记录到数据库;
    • 更新已上传分片数。
  3. 合并分片
    • 检查所有分片是否完整;
    • 按序合并分片生成最终文件;
    • 更新文件状态为"待处理";
    • 删除分片文件及数据库记录。

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"));
    }
}
相关推荐
天天摸鱼的java工程师4 分钟前
工作中 Java 程序员如何集成 AI?Spring AI、LangChain4j、JBoltAI 实战对比
java·后端
星辰_mya4 分钟前
RockerMQ之commitlog与consumequeue
java·开发语言
__万波__5 分钟前
二十三种设计模式(二十二)--策略模式
java·设计模式·策略模式
叫我:松哥5 分钟前
基于 Flask 框架开发的在线学习平台,集成人工智能技术,提供分类练习、随机练习、智能推荐等多种学习模式
人工智能·后端·python·学习·信息可视化·flask·推荐算法
不想上班的小吕6 分钟前
采购申请创建(BAPI_PR_CREATE/BAPI_REQUISITION_CREATE)
java·服务器·数据库
IT=>小脑虎8 分钟前
2026版 Go语言零基础衔接进阶知识点【详解版】
开发语言·后端·golang
专注VB编程开发20年9 分钟前
压栈顺序是反向(从右往左)的,但正因为是反向压栈,所以第一个参数反而离栈顶(ESP)最近。
java·开发语言·算法
椰汁菠萝9 分钟前
spring boot下使用gdal解析tif文件
java·native·gdal·0
better_liang10 分钟前
每日Java面试场景题知识点之-ELK日志分析
java·elk·微服务·面试题·日志分析·企业级开发
图南随笔14 分钟前
Spring Boot(二十三):RedisTemplate的Set和Sorted Set类型操作
java·spring boot·redis·后端·缓存