一、核心原理剖析

在实现大文件上传前,先理解核心设计思路,这是实现断点续传和分片上传的基础:

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对比,确保文件完整性。

五、总结

核心关键点回顾
  1. 分片上传:将大文件切割为固定大小的分片,逐个上传,核心是文件MD5标识分片归属,临时目录存储分片。
  2. 断点续传:上传前通过MD5查询已上传分片,只上传未完成的分片,依赖数据库记录分片状态。
  3. 多文件适配:复用单文件的分片/断点逻辑,前端循环处理每个文件即可,后端无需额外开发。
实现核心
  • 前端:文件MD5计算、分片切割、断点校验请求、分片上传、合并请求。
  • 后端:分片接收与存储、分片状态记录、分片合并、最终文件保存。
  • 核心依赖:文件唯一标识(MD5)、分片状态记录(数据库)、临时目录(分片存储)。

这套方案是云盘大文件上传的标准化实现,可直接落地,也可根据实际需求(如分布式部署、云存储适配)进行扩展。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐