大文件上传解决方案

项目地址:https://github.com/webwss/Large-File-Upload-Template

在日常开发中,前端上传大文件是一个常见却易被忽略的性能瓶颈和用户体验问题

传统上传方式在面对100MB甚至GB级别的文件时,常会出现传输中断、上传失败等问题

因此,我引入了分片上传 + 断点续传 + 秒传机制,配合后端Spring Boot实现了一套高性能上传方案

项目技术选型

  • 前端:Vue3 + Web Worker
  • 后端:Spring Boot + MySQL
  • 核心技术点

    • 分片上传
    • 断点续传
    • 秒传
    • Web Worker多线程加速计算
    • 文件合并与临时文件清理

功能介绍

  • 分片上传

将大文件拆分为多个小文件(chunk)上传,降低失败风险,提高稳定性

  • 断点续传

支持中断后继续上传未完成的分片,避免重复上传

  • 秒传

通过文件唯一标识(如MD5或Hash)判断服务器是否已存在该文件,若存在直接跳过上传,返回文件地址

上传流程

用户选择文件 文件预处理(分片 + hash) checkFile → 是否已上传 → 是 → 秒传完成 ↓ 否 循环: checkChunk → 是否已上传 → 是 → 跳过 ↓ 否 uploadChunk(上传分片) 全部分片完成 mergeChunks(合并)存入数据库 上传完成 ✅

前端实现关键点

文件分片&hash计算

我们使用Web Worker在子线程中计算fileHash,避免主线程阻塞

// 使用 crypto.subtle 进行 hash 计算
const hash = await crypto.subtle.digest("SHA-256", fileBuffer);

文件分片

const chunkSize = 1024 * 1024 * 2; // 2MB
const chunks = [];
for (let i = 0; i < file.size; i += chunkSize) {
  chunks.push(file.slice(i, i + chunkSize));
}
​

上传参数格式

{
  "chunk": Blob, // 分片内容
  "index": "0",  // 分片索引
  "fileHash": "xxxxxxx" // 文件唯一标识
}
​

注意:chunk不能为空

后端实现关键点

分片上传

@Override
    public String uploadChunk(MultipartFile chunk, String fileHash, String indexStr) {
        int index = Integer.parseInt(indexStr);
        File dir = new File(uploadPath + fileHash);
        if (!dir.exists()) dir.mkdirs();
        File chunkFile = new File(dir, String.valueOf(index));
        try {
            chunk.transferTo(chunkFile);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return "chunk saved";
    }
​

分片合并

/**
     * 合并分片
     * @param fileHash
     * @param total
     * @param fileName
     * @return
     */
    @Override
    public Map<String, Object> merge(String fileHash, int total, String fileName) throws IOException {
        File dir = new File(uploadPath + fileHash);
​
        //  获取文件后缀名
        String suffix = "";
        int dotIndex = fileName.lastIndexOf('.');
        if (dotIndex != -1) {
            suffix = fileName.substring(dotIndex); // 例如 ".mp4"
        }
​
        //  生成唯一文件名
        String randomFileName = fileHash + suffix;
​
        //  构造合并后的目标文件
        File mergedFile = new File(uploadPath, randomFileName);
​
        try (FileOutputStream fos = new FileOutputStream(mergedFile, true)) {
            for (int i = 0; i < total; i++) {
                File chunkFile = new File(dir, String.valueOf(i));
                if (!chunkFile.exists()) {
                    return Map.of("error", "Missing chunk: " + i);
                }
                try (FileInputStream fis = new FileInputStream(chunkFile)) {
                    StreamUtils.copy(fis, fos);
                }
            }
        }
​
        // 删除分片文件和目录
        for (int i = 0; i < total; i++) {
            new File(dir, String.valueOf(i)).delete();
        }
        dir.delete();
​
        //  返回文件名给前端
        Map<String, Object> response = new HashMap<>();
        response.put("message", "merge complete");
        response.put("fileName", randomFileName);
        response.put("url", "/files/" + randomFileName); // 如果你支持静态访问的话
//        保存到数据库
        UploadFile uploadFile = new UploadFile();
        uploadFile.setFileHash(fileHash);
        uploadFile.setFileUrl("/files/" + randomFileName);
        save(uploadFile); // 保存到数据库
        return response;
    }

数据库

image-20250808174709364.png

主要记录文件hash与对应位置,每次上传前查询是否存在,存在则跳过上传(秒传)

最后修改:2025 年 08 月 08 日
如果觉得我的文章对你有用,请随意赞赏