大文件分片上传 + 断点续传完整实现:2MB 分片、MD5 秒传、合并校验

作者:忆笙智云官方 | 发布时间:2026-06-11 15:30 | 更新时间:2026-06-11 15:30

文件分片上传与断点续传实现原理

引言

在企业级应用中,大文件上传是常见需求——视频、安装包、数据备份等文件动辄数百MB甚至数GB。传统的整文件上传方案面临诸多问题:网络中断导致重新上传、服务器内存压力大、无法显示进度、超时失败等。

分片上传将大文件切割为多个小片段分别上传,结合断点续传能力,有效解决了上述问题。本文将详细讲解分片上传的完整方案,包括2MB分片策略、断点续传查询、合并校验等核心机制。

一、整体方案设计

1.1 核心流程

flowchart TB
    A[选择文件] --> B[前端计算文件MD5]
    B --> C[请求创建上传任务]
    C --> D{服务端检查}
    D -->|文件已存在| E[秒传:直接返回文件地址]
    D -->|部分分片已上传| F[返回已上传分片列表]
    D -->|首次上传| G[返回空列表]

    E --> Z[上传完成]
    F --> H[断点续传:跳过已上传分片]
    G --> I[从头开始上传]

    H --> J[并发上传未完成分片]
    I --> J

    J --> K{所有分片上传完毕?}
    K -->|否| J
    K -->|是| L[请求合并分片]
    L --> M[服务端合并+校验]
    M --> N{校验通过?}
    N -->|是| Z
    N -->|否| O[上传失败,需重新上传]

    style E fill:#d5f9d5
    style Z fill:#d5f9d5
    style O fill:#f9d5d5

1.2 设计要点

要点 方案 说明
分片大小 2MB 兼顾上传效率和重传代价
文件标识 MD5 + 文件大小 + 文件名 唯一标识一个文件
断点续传 查询已上传分片号 跳过已上传的分片
秒传 MD5匹配已有文件 相同文件不重复上传
并发控制 3-5个并发上传 避免浏览器连接数限制
合并校验 MD5一致性校验 确保文件完整性

二、前端实现

2.1 文件分片

/**
 * 文件分片上传管理器
 */
class FileUploader {
    constructor(options = {}) {
        this.chunkSize = options.chunkSize || 2 * 1024 * 1024;  // 默认2MB
        this.concurrency = options.concurrency || 3;             // 并发上传数
        this.maxRetries = options.maxRetries || 3;               // 单分片最大重试次数
        this.onProgress = options.onProgress || (() => {});
        this.onComplete = options.onComplete || (() => {});
        this.onError = options.onError || (() => {});
    }

    /**
     * 上传文件主流程
     * @param {File} file 用户选择的文件
     */
    async upload(file) {
        try {
            // 1. 计算文件MD5
            const fileMd5 = await this.calculateMd5(file);

            // 2. 创建上传任务
            const task = await this.createUploadTask(file, fileMd5);

            if (task.status === 'EXISTS') {
                // 秒传:文件已存在
                this.onComplete(task.fileUrl);
                return;
            }

            // 3. 获取未上传的分片列表
            const uploadedChunks = task.uploadedChunks || [];
            const totalChunks = Math.ceil(file.size / this.chunkSize);

            // 4. 并发上传未完成的分片
            await this.uploadChunks(file, fileMd5, totalChunks, uploadedChunks);

            // 5. 请求合并分片
            const result = await this.mergeChunks(fileMd5, file.name, totalChunks);
            this.onComplete(result.fileUrl);
        } catch (error) {
            this.onError(error);
        }
    }

    /**
     * 计算文件MD5(使用SparkMD5库)
     * 采用分片计算,避免大文件内存溢出
     */
    async calculateMd5(file) {
        return new Promise((resolve, reject) => {
            const spark = new SparkMD5.ArrayBuffer();
            const reader = new FileReader();
            const chunks = Math.ceil(file.size / this.chunkSize);
            let currentChunk = 0;

            reader.onload = (e) => {
                spark.append(e.target.result);
                currentChunk++;
                if (currentChunk < chunks) {
                    loadNext();
                } else {
                    resolve(spark.end());
                }
            };

            reader.onerror = reject;

            function loadNext() {
                const start = currentChunk * this.chunkSize;
                const end = Math.min(start + this.chunkSize, file.size);
                reader.readAsArrayBuffer(file.slice(start, end));
            }

            loadNext();
        });
    }

    /**
     * 创建上传任务
     */
    async createUploadTask(file, fileMd5) {
        const response = await fetch('/api/file/upload/init', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                fileMd5: fileMd5,
                fileName: file.name,
                fileSize: file.size,
                totalChunks: Math.ceil(file.size / this.chunkSize)
            })
        });
        return response.json();
    }

    /**
     * 并发上传分片
     */
    async uploadChunks(file, fileMd5, totalChunks, uploadedChunks) {
        // 过滤出未上传的分片索引
        const pendingChunks = [];
        for (let i = 0; i < totalChunks; i++) {
            if (!uploadedChunks.includes(i)) {
                pendingChunks.push(i);
            }
        }

        // 并发控制上传
        const uploadQueue = [...pendingChunks];
        const workers = [];

        for (let i = 0; i < this.concurrency; i++) {
            workers.push(this.uploadWorker(file, fileMd5, uploadQueue));
        }

        await Promise.all(workers);
    }

    /**
     * 上传工作线程
     * 从队列中取分片上传,直到队列为空
     */
    async uploadWorker(file, fileMd5, queue) {
        while (queue.length > 0) {
            const chunkIndex = queue.shift();
            const start = chunkIndex * this.chunkSize;
            const end = Math.min(start + this.chunkSize, file.size);
            const chunk = file.slice(start, end);

            let retries = 0;
            while (retries < this.maxRetries) {
                try {
                    await this.uploadSingleChunk(chunk, fileMd5, chunkIndex);
                    this.onProgress(chunkIndex);
                    break;
                } catch (error) {
                    retries++;
                    if (retries >= this.maxRetries) {
                        throw new Error(`分片${chunkIndex}上传失败,已重试${retries}次`);
                    }
                    // 指数退避等待
                    await this.sleep(1000 * Math.pow(2, retries));
                }
            }
        }
    }

    /**
     * 上传单个分片
     */
    async uploadSingleChunk(chunk, fileMd5, chunkIndex) {
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('fileMd5', fileMd5);
        formData.append('chunkIndex', chunkIndex);

        const response = await fetch('/api/file/upload/chunk', {
            method: 'POST',
            body: formData
        });

        if (!response.ok) {
            throw new Error(`分片上传失败: HTTP ${response.status}`);
        }
        return response.json();
    }

    /**
     * 请求合并分片
     */
    async mergeChunks(fileMd5, fileName, totalChunks) {
        const response = await fetch('/api/file/upload/merge', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                fileMd5: fileMd5,
                fileName: fileName,
                totalChunks: totalChunks
            })
        });
        return response.json();
    }

    sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
}

三、后端实现

3.1 上传任务初始化

/**
 * 文件上传控制器
 */
@RestController
@RequestMapping("/api/file/upload")
public class FileUploadController {

    @Autowired
    private FileUploadService fileUploadService;

    /**
     * 初始化上传任务
     * 检查文件是否已存在(秒传),返回已上传分片列表(断点续传)
     */
    @PostMapping("/init")
    public Result<UploadTaskVO> initUpload(@RequestBody UploadInitDTO dto) {
        UploadTaskVO task = fileUploadService.initUploadTask(dto);
        return Result.ok(task);
    }

    /**
     * 上传单个分片
     */
    @PostMapping("/chunk")
    public Result<Void> uploadChunk(
            @RequestParam("file") MultipartFile file,
            @RequestParam("fileMd5") String fileMd5,
            @RequestParam("chunkIndex") Integer chunkIndex) {
        fileUploadService.uploadChunk(fileMd5, chunkIndex, file);
        return Result.ok();
    }

    /**
     * 合并分片
     */
    @PostMapping("/merge")
    public Result<FileUploadResultVO> mergeChunks(@RequestBody UploadMergeDTO dto) {
        FileUploadResultVO result = fileUploadService.mergeChunks(dto);
        return Result.ok(result);
    }
}

3.2 上传任务服务

/**
 * 文件上传服务
 */
@Service
public class FileUploadService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private FileStorageService fileStorageService;

    /** 分片大小:2MB */
    private static final long CHUNK_SIZE = 2 * 1024 * 1024;

    /** 上传任务Redis Key前缀 */
    private static final String TASK_KEY_PREFIX = "upload:task:";
    private static final String CHUNK_KEY_PREFIX = "upload:chunk:";

    /** 任务过期时间:24小时 */
    private static final long TASK_EXPIRE_HOURS = 24;

    /**
     * 初始化上传任务
     */
    public UploadTaskVO initUploadTask(UploadInitDTO dto) {
        String taskKey = TASK_KEY_PREFIX + dto.getFileMd5();

        // 1. 检查文件是否已存在(秒传)
        String existingFileUrl = fileStorageService.findByMd5(dto.getFileMd5());
        if (existingFileUrl != null) {
            return UploadTaskVO.builder()
                    .status("EXISTS")
                    .fileUrl(existingFileUrl)
                    .build();
        }

        // 2. 检查是否有未完成的上传任务(断点续传)
        List<Integer> uploadedChunks = getUploadedChunks(dto.getFileMd5());

        // 3. 保存/更新任务信息
        UploadTask task = UploadTask.builder()
                .fileMd5(dto.getFileMd5())
                .fileName(dto.getFileName())
                .fileSize(dto.getFileSize())
                .totalChunks(dto.getTotalChunks())
                .uploadedChunks(uploadedChunks.size())
                .createTime(LocalDateTime.now())
                .build();
        redisTemplate.opsForValue().set(taskKey, task,
                TASK_EXPIRE_HOURS, TimeUnit.HOURS);

        return UploadTaskVO.builder()
                .status("UPLOADING")
                .uploadedChunks(uploadedChunks)
                .totalChunks(dto.getTotalChunks())
                .build();
    }

    /**
     * 上传单个分片
     */
    @Transactional
    public void uploadChunk(String fileMd5, Integer chunkIndex, MultipartFile file) {
        try {
            // 1. 保存分片到临时目录
            String chunkPath = getChunkPath(fileMd5, chunkIndex);
            fileStorageService.saveChunk(chunkPath, file.getInputStream());

            // 2. 记录已上传分片
            String chunkKey = CHUNK_KEY_PREFIX + fileMd5;
            redisTemplate.opsForSet().add(chunkKey, chunkIndex);
            redisTemplate.expire(chunkKey, TASK_EXPIRE_HOURS, TimeUnit.HOURS);

        } catch (IOException e) {
            throw new RuntimeException("分片保存失败", e);
        }
    }

    /**
     * 获取已上传的分片列表
     */
    private List<Integer> getUploadedChunks(String fileMd5) {
        String chunkKey = CHUNK_KEY_PREFIX + fileMd5;
        Set<Object> chunks = redisTemplate.opsForSet().members(chunkKey);
        if (chunks == null || chunks.isEmpty()) {
            return Collections.emptyList();
        }
        return chunks.stream()
                .map(obj -> Integer.parseInt(obj.toString()))
                .sorted()
                .collect(Collectors.toList());
    }

    /**
     * 获取分片临时存储路径
     */
    private String getChunkPath(String fileMd5, Integer chunkIndex) {
        return String.format("temp/chunks/%s/%d", fileMd5, chunkIndex);
    }
}

3.3 分片合并

/**
 * 合并分片
 * 将所有分片按顺序合并为完整文件,并进行MD5校验
 */
public FileUploadResultVO mergeChunks(UploadMergeDTO dto) {
    String fileMd5 = dto.getFileMd5();
    String chunkDir = "temp/chunks/" + fileMd5;

    // 1. 检查所有分片是否已上传
    List<Integer> uploadedChunks = getUploadedChunks(fileMd5);
    if (uploadedChunks.size() < dto.getTotalChunks()) {
        throw new RuntimeException("存在未上传的分片,无法合并");
    }

    // 2. 合并分片
    String mergedFilePath = "temp/merged/" + fileMd5 + "_" + dto.getFileName();
    try (OutputStream outputStream = new FileOutputStream(mergedFilePath)) {
        // 按分片索引顺序合并
        for (int i = 0; i < dto.getTotalChunks(); i++) {
            String chunkPath = String.format("%s/%d", chunkDir, i);
            try (InputStream inputStream = new FileInputStream(chunkPath)) {
                byte[] buffer = new byte[8192];
                int bytesRead;
                while ((bytesRead = inputStream.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, bytesRead);
                }
            }
        }
    } catch (IOException e) {
        throw new RuntimeException("分片合并失败", e);
    }

    // 3. 校验合并后文件的MD5
    String actualMd5 = calculateFileMd5(mergedFilePath);
    if (!fileMd5.equals(actualMd5)) {
        // MD5不一致,删除合并文件
        new File(mergedFilePath).delete();
        throw new RuntimeException("文件校验失败,MD5不一致");
    }

    // 4. 将合并后的文件转移到正式存储
    String fileUrl = fileStorageService.store(mergedFilePath, dto.getFileName());

    // 5. 清理临时文件
    cleanupTempFiles(fileMd5, dto.getTotalChunks(), mergedFilePath);

    // 6. 清理Redis记录
    redisTemplate.delete(CHUNK_KEY_PREFIX + fileMd5);
    redisTemplate.delete(TASK_KEY_PREFIX + fileMd5);

    return FileUploadResultVO.builder()
            .fileUrl(fileUrl)
            .fileMd5(fileMd5)
            .fileName(dto.getFileName())
            .build();
}

/**
 * 计算文件MD5
 */
private String calculateFileMd5(String filePath) {
    try (InputStream is = new FileInputStream(filePath)) {
        MessageDigest md = MessageDigest.getInstance("MD5");
        byte[] buffer = new byte[8192];
        int bytesRead;
        while ((bytesRead = is.read(buffer)) != -1) {
            md.update(buffer, 0, bytesRead);
        }
        return Hex.toHexString(md.digest());
    } catch (Exception e) {
        throw new RuntimeException("MD5计算失败", e);
    }
}

/**
 * 清理临时文件
 */
private void cleanupTempFiles(String fileMd5, int totalChunks, String mergedFilePath) {
    // 删除分片文件
    for (int i = 0; i < totalChunks; i++) {
        String chunkPath = String.format("temp/chunks/%s/%d", fileMd5, i);
        new File(chunkPath).delete();
    }
    // 删除分片目录
    new File("temp/chunks/" + fileMd5).delete();
    // 删除合并临时文件
    new File(mergedFilePath).delete();
}

四、断点续传详解

4.1 续传流程

sequenceDiagram
    participant User as 用户
    participant FE as 前端
    participant API as 后端API
    participant Redis as Redis
    participant Storage as 文件存储

    User->>FE: 重新选择同一文件上传
    FE->>FE: 计算文件MD5
    FE->>API: POST /upload/init {fileMd5, ...}

    API->>Storage: 查询文件是否已存在
    Storage-->>API: 不存在

    API->>Redis: 查询已上传分片 SMEMBERS upload:chunk:{md5}
    Redis-->>API: [0, 1, 2, 3, 5, 7]

    API-->>FE: 返回已上传分片 [0,1,2,3,5,7]

    Note over FE: 跳过分片0,1,2,3,5,7
    FE->>API: 上传分片4
    FE->>API: 上传分片6
    FE->>API: 上传分片8
    FE->>API: 上传分片9

    API->>Redis: SADD upload:chunk:{md5} 4
    API->>Storage: 保存分片4

    Note over FE: 所有分片上传完毕
    FE->>API: POST /upload/merge
    API->>API: 合并所有分片
    API->>API: MD5校验
    API-->>FE: 返回文件地址

4.2 关键实现

断点续传的核心是查询已上传分片跳过已上传分片

/**
 * 获取已上传分片(Redis Set结构)
 * Key: upload:chunk:{fileMd5}
 * Value: Set<chunkIndex>
 */
public List<Integer> getUploadedChunks(String fileMd5) {
    String key = CHUNK_KEY_PREFIX + fileMd5;
    Set<Object> chunks = redisTemplate.opsForSet().members(key);
    if (chunks == null) {
        return Collections.emptyList();
    }
    return chunks.stream()
            .map(obj -> Integer.parseInt(obj.toString()))
            .sorted()
            .collect(Collectors.toList());
}

前端根据返回的已上传分片列表,跳过已上传的分片:

// 过滤出未上传的分片
const pendingChunks = [];
for (let i = 0; i < totalChunks; i++) {
    if (!uploadedChunks.includes(i)) {
        pendingChunks.push(i);
    }
}
// 只上传未完成的分片
await this.uploadChunks(file, fileMd5, pendingChunks);

五、秒传机制

5.1 秒传原理

秒传的核心是文件MD5匹配:如果数据库中已存在相同MD5的文件,直接返回文件地址,无需重复上传。

flowchart LR
    A[计算文件MD5] --> B{数据库中存在<br/>相同MD5的文件?}
    B -->|是| C[直接返回文件地址<br/>✅ 秒传]
    B -->|否| D[执行分片上传]

    style C fill:#d5f9d5
    style D fill:#e8f4f8

5.2 注意事项

  1. MD5碰撞:理论上存在不同文件MD5相同的情况,可结合文件大小和文件名进一步校验
  2. 文件归属:秒传需要记录文件与用户的关联关系,确保权限正确
  3. 存储引用:同一物理文件可被多个用户引用,删除时需使用引用计数

六、性能优化

6.1 分片大小选择

分片大小 优势 劣势 适用场景
512KB 重传代价小 请求次数多,开销大 弱网环境
2MB 平衡效率与代价 - 通用推荐
5MB 请求次数少 重传代价大 稳定网络
10MB 上传快 内存压力大,重传代价大 局域网

6.2 并发上传优化

/**
 * 动态调整并发数
 * 根据网络状况自动调整上传并发数
 */
class AdaptiveFileUploader extends FileUploader {
    constructor(options) {
        super(options);
        this.minConcurrency = 1;
        this.maxConcurrency = 6;
        this.currentConcurrency = options.concurrency || 3;
        this.failureRate = 0;  // 近期失败率
    }

    /**
     * 根据失败率动态调整并发数
     */
    adjustConcurrency() {
        if (this.failureRate > 0.3) {
            // 失败率过高,降低并发
            this.currentConcurrency = Math.max(
                this.minConcurrency,
                this.currentConcurrency - 1
            );
        } else if (this.failureRate < 0.05) {
            // 失败率很低,提高并发
            this.currentConcurrency = Math.min(
                this.maxConcurrency,
                this.currentConcurrency + 1
            );
        }
    }
}

七、完整流程图

flowchart TB
    A[用户选择文件] --> B[前端计算文件MD5<br/>SparkMD5分片计算]
    B --> C[调用初始化接口<br/>POST /upload/init]
    C --> D{服务端判断}

    D -->|MD5匹配已有文件| E[🎉 秒传成功<br/>直接返回文件URL]
    D -->|部分分片已上传| F[返回已上传分片列表<br/>断点续传]
    D -->|首次上传| G[返回空列表<br/>全量上传]

    F --> H[前端跳过已上传分片]
    G --> I[前端分片文件<br/>每片2MB]
    H --> I

    I --> J[并发上传分片<br/>3-5个并发]
    J --> K{分片上传成功?}
    K -->|是| L[Redis记录分片号<br/>SADD upload:chunk:md5 index]
    K -->|否| M{重试次数<3?}
    M -->|是| N[指数退避等待后重试]
    M -->|否| O[❌ 上传失败]

    L --> P{所有分片完成?}
    P -->|否| J
    P -->|是| Q[调用合并接口<br/>POST /upload/merge]

    Q --> R[按顺序合并所有分片]
    R --> S[计算合并文件MD5]
    S --> T{MD5一致?}
    T -->|是| U[转移到正式存储<br/>清理临时文件和Redis]
    T -->|否| V[❌ 校验失败]

    U --> W[🎉 上传成功<br/>返回文件URL]

    style E fill:#d5f9d5
    style W fill:#d5f9d5
    style O fill:#f9d5d5
    style V fill:#f9d5d5

结论与建议

核心要点

  1. 2MB分片是通用推荐值,兼顾上传效率和重传代价
  2. MD5标识是秒传和断点续传的基础,前端分片计算避免内存溢出
  3. Redis记录已上传分片号,实现断点续传查询
  4. 合并校验确保文件完整性,防止分片丢失或损坏

实践建议

  1. 分片大小可配置:根据网络环境允许用户调整分片大小
  2. 上传限速:避免大文件上传占用全部带宽,影响其他用户
  3. 临时文件清理:设置定时任务清理超时的临时分片文件
  4. 断点续传时效:上传任务设置24小时过期,超时需重新上传
  5. 安全校验:对上传文件进行病毒扫描和类型校验
  6. 存储适配:支持本地存储、MinIO、阿里云OSS等多种存储后端

相关资源