JAVA网页开发中,大文件分块上传的断点续传如何实现?

Java 网页开发中,大文件分块上传 + 断点续传的完整实现方案

在现代 Web 应用中,上传几百 MB 甚至几个 GB 的文件时,一次性上传 很容易失败(超时、断网、浏览器崩溃等),所以行业标准做法是:

前端分片(chunk) + 后端支持断点续传 + 秒传

下面是目前(2025年)最主流、最实用的前后端实现方案。

一、整体流程图

前端                                      后端
  │                                         │
  1. 计算文件整体 hash (md5/sha1/sha256)     │
  2. 向后端发送 “检查文件” 请求               │
  │   fileHash、fileSize、fileName          │
  │   ────────────────────────────────────► │
  │                                         │ 3. 查询是否已存在完整文件
  │   ◄──────────────────────────────────── │   或哪些分片已上传成功
  │      {  code: 200/201/404,
  │        uploadedChunks: [0,1,3,5,...] 或秒传url }
  │
  如果秒传 → 直接显示成功
  否则 → 并发/串行上传未上传的分片
  4. 上传分片
  │   chunkIndex、chunkHash、chunkBlob、fileHash
  │   ────────────────────────────────────► │
  │                                         │ 5. 接收分片 → 存临时目录
  │                                         │    记录已上传分片(redis/db)
  │   ◄──────────────────────────────────── │    返回成功
  │
  6. 所有分片上传完成 → 发送 “合并” 请求
  │   fileHash、totalChunks、fileName 等
  │   ────────────────────────────────────► │
  │                                         │ 7. 校验分片完整性
  │                                         │    合并文件
  │                                         │    移动到正式目录
  │                                         │    保存文件元信息(db)
  │   ◄──────────────────────────────────── │    返回最终文件地址

二、前端实现(推荐使用 axios + spark-md5 或 Web Worker)

<!-- index.html -->
<input type="file" id="fileInput" />
<button onclick="uploadFile()">上传</button>

<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
// upload.js
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB

async function uploadFile() {
  const file = document.getElementById('fileInput').files[0];
  if (!file) return;

  // 1. 计算文件 hash(用于秒传和续传)
  const fileHash = await calculateHash(file);
  const fileName = file.name;
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);

  // 2. 检查文件是否已存在 / 哪些分片已上传
  const { data } = await axios.get('/api/file/check', {
    params: { fileHash, fileSize: file.size, fileName }
  });

  if (data.code === 200) {
    // 秒传成功
    console.log('秒传成功', data.url);
    return;
  }

  const uploadedChunks = new Set(data.uploadedChunks || []);

  // 3. 上传未上传的分片(可并发)
  const promises = [];
  for (let i = 0; i < totalChunks; i++) {
    if (uploadedChunks.has(i)) continue;

    const start = i * CHUNK_SIZE;
    const end = Math.min(start + CHUNK_SIZE, file.size);
    const chunk = file.slice(start, end);

    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('chunkIndex', i);
    formData.append('totalChunks', totalChunks);
    formData.append('fileHash', fileHash);
    formData.append('fileName', fileName);

    promises.push(
      axios.post('/api/file/upload/chunk', formData, {
        headers: { 'Content-Type': 'multipart/form-data' }
      })
    );
  }

  // 并发上传(可控制并发数)
  await Promise.all(promises);

  // 4. 所有分片上传完成 → 合并
  const mergeRes = await axios.post('/api/file/merge', {
    fileHash,
    totalChunks,
    fileName
  });

  console.log('上传完成', mergeRes.data.url);
}

// 使用 spark-md5 计算文件 hash
async function calculateHash(file) {
  return new Promise((resolve) => {
    const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
    const chunks = [];
    let currentChunk = 0;
    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();

    fileReader.onload = (e) => {
      spark.append(e.target.result);
      currentChunk++;

      if (currentChunk < 100) { // 采样前100块,够用了
        loadNext();
      } else {
        resolve(spark.end());
      }
    };

    function loadNext() {
      const start = currentChunk * CHUNK_SIZE;
      const end = Math.min(start + CHUNK_SIZE, file.size);
      const blob = blobSlice.call(file, start, end);
      fileReader.readAsArrayBuffer(blob);
    }

    loadNext();
  });
}

三、后端实现(Spring Boot 示例)

@RestController
@RequestMapping("/api/file")
public class FileUploadController {

    private static final String TEMP_DIR = "/upload_temp/";
    private static final String FINAL_DIR = "/upload_final/";

    // 1. 检查文件是否存在 / 已上传分片
    @GetMapping("/check")
    public Map<String, Object> checkFile(
            @RequestParam String fileHash,
            @RequestParam Long fileSize,
            @RequestParam String fileName) {

        // 假设使用 Redis 或数据库记录
        // 这里简化用文件系统判断
        File finalFile = new File(FINAL_DIR + fileHash + "_" + fileName);
        if (finalFile.exists() && finalFile.length() == fileSize) {
            return Map.of("code", 200, "url", "/files/" + fileHash + "_" + fileName);
        }

        // 返回已上传的分片索引
        Set<Integer> uploaded = getUploadedChunks(fileHash);
        return Map.of("code", 201, "uploadedChunks", uploaded);
    }

    // 2. 上传分片
    @PostMapping("/upload/chunk")
    public Map<String, Object> uploadChunk(
            @RequestParam("chunk") MultipartFile chunk,
            @RequestParam Integer chunkIndex,
            @RequestParam Integer totalChunks,
            @RequestParam String fileHash,
            @RequestParam String fileName) throws IOException {

        String chunkPath = TEMP_DIR + fileHash + "/" + chunkIndex;
        File chunkFile = new File(chunkPath);

        // 幂等:已存在则跳过
        if (chunkFile.exists()) {
            return Map.of("code", 200, "msg", "已存在");
        }

        chunk.transferTo(chunkFile);

        // 记录已上传分片(可用 redis set)
        saveUploadedChunk(fileHash, chunkIndex);

        // 如果所有分片齐了,可以自动合并(或让前端再发合并请求)
        if (isAllChunksUploaded(fileHash, totalChunks)) {
            mergeFile(fileHash, totalChunks, fileName);
        }

        return Map.of("code", 200);
    }

    // 3. 合并文件
    @PostMapping("/merge")
    public Map<String, Object> merge(
            @RequestParam String fileHash,
            @RequestParam Integer totalChunks,
            @RequestParam String fileName) throws IOException {

        if (!isAllChunksUploaded(fileHash, totalChunks)) {
            return Map.of("code", 400, "msg", "分片不完整");
        }

        File target = new File(FINAL_DIR + fileHash + "_" + fileName);
        try (FileOutputStream fos = new FileOutputStream(target, true)) {
            for (int i = 0; i < totalChunks; i++) {
                File chunk = new File(TEMP_DIR + fileHash + "/" + i);
                Files.copy(chunk.toPath(), fos);
                chunk.delete();
            }
        }

        // 可存入数据库:文件名、路径、大小、上传时间、上传人等
        saveFileRecord(fileHash, fileName, target.length());

        return Map.of("code", 200, "url", "/files/" + fileHash + "_" + fileName);
    }

    // 辅助方法(可替换为 Redis / DB)
    private Set<Integer> getUploadedChunks(String fileHash) {
        // 实际项目建议用 Redis Set: "file:chunks:" + fileHash
        return new HashSet<>();
    }

    private void saveUploadedChunk(String fileHash, int chunkIndex) {
        // redis.sadd("file:chunks:" + fileHash, chunkIndex)
    }

    private boolean isAllChunksUploaded(String fileHash, int totalChunks) {
        // 判断 redis set 长度 == totalChunks
        return true; // 简化
    }
}

四、关键优化点(生产必做)

  1. 分片大小:5MB~20MB(太小请求多,太大重传代价高)
  2. 幂等性:同一个分片重复上传不报错
  3. 秒传:文件 hash 已存在直接返回成功
  4. 断点续传:前端记录已上传分片,后端返回已上传列表
  5. 并发控制:前端可控制同时上传 3~6 个分片(用 p-limit 或 Promise.allSettled)
  6. 进度条:已上传分片数 / 总分片数
  7. 失败重试:单个分片失败自动重试 3 次
  8. 存储已上传状态:推荐 Redis Set,设置过期时间(7天或30天)
  9. 文件名冲突:建议用 hash + 原文件名后缀存储,数据库保存映射
  10. Nginx/网关:配置 client_max_body_size 10G;

五、云厂商推荐方案(更省事)

  • 阿里云 OSS:Multipart Upload + 断点续传 SDK
  • 腾讯云 COS:分块上传 + 续传
  • 七牛云 KODO:分片上传
  • AWS S3:Multipart Upload

这些云存储都提供了完整的断点续传和秒传能力,前端只需调用 SDK,后端负责签名。

希望以上内容对你有帮助!

你现在更倾向于自己实现完整方案,还是想直接对接云存储?
或者有某个具体环节(redis 记录、并发控制、前端进度条、合并校验等)需要更详细代码?

文章已创建 4455

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

相关文章

开始在上面输入您的搜索词,然后按回车进行搜索。按ESC取消。

返回顶部