分卷压缩下载10G超大文件

95 阅读5分钟

分卷压缩下载是一种将大文件分割成多个较小部分进行下载的技术,特别适用于超过10GB的超大文件下载场景。

一、分卷压缩的基本概念

分卷压缩(Multi-part archive)是将大型压缩文件拆分成多个较小部分的技术,通常用于将大文件保存到多个磁盘或可移动介质时使用。主要目的包括:

  1. 绕过文件大小限制(如邮件附件、网盘上传限制等)
  2. 提高下载可靠性(支持断点续传)
  3. 方便管理(多个小文件比一个大文件更易于管理)
  4. 加快下载速度(可并发下载多个分卷)

当文件或文件夹过大时,压缩软件可能会将其分割成多个较小的部分,以便更容易地存储、传输或共享。在这种情况下,.zip 文件通常是压缩包的主文件,而 .z01(以及可能的 .z02、.z03 等后续文件)则是压缩包的附加部分。

以下是详细的说明:

  1. 主文件(.zip)

    • 这是压缩包的主要部分,包含了文件的元数据、压缩算法信息以及可能的部分文件数据。
    • 当你尝试解压缩一个由多个部分组成的压缩包时,通常需要先找到并使用这个主文件。
  2. 附加部分(.z01, .z02, .z03 等)

    • 这些文件包含了压缩后的文件数据的其余部分。
    • 文件的命名通常遵循一定的模式,如 .z01, .z02, .z03 等,以便用户能够轻松地按顺序排列它们。
    • 在解压缩时,压缩软件会依次读取这些附加部分,并将它们与主文件中的数据合并,以恢复原始文件。
  3. 如何处理这些文件

    • 当你下载或接收到一个由多个部分组成的压缩包时,确保你拥有所有部分(包括主文件和所有附加部分)。
    • 使用支持多部分压缩包的解压缩软件(如 WinRAR、7-Zip 等)。
    • 通常,你只需要双击主文件(.zip)来开始解压缩过程。解压缩软件会自动找到并使用所有附加部分。
  4. 注意事项

    • 确保所有部分都完好无损。如果任何一个部分损坏或丢失,解压缩过程可能会失败。
    • 在传输或存储这些文件时,尽量保持它们的顺序,以避免混淆。

总之,.zip 文件是压缩包的主文件,而 .z01 等文件是压缩包的附加部分。当你需要解压缩一个由多个部分组成的压缩包时,确保你拥有所有部分,并使用支持多部分压缩包的解压缩软件。

二、后端生成分卷压缩文件

Java实现示例(使用Apache Commons Compress):

import org.apache.commons.compress.archivers.zip.*;
import org.apache.commons.compress.utils.IOUtils;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class SplitZipCreator {

    public static void createSplitZip(String sourcePath, String outputPath, long splitSize) throws IOException {
        Path source = Paths.get(sourcePath);
        long totalSize = Files.size(source);
        long currentSize = 0;

        try (OutputStream outputStream = new FileOutputStream(outputPath + ".zip");
             ZipArchiveOutputStream zipArchiveOutputStream = new ZipArchiveOutputStream(outputStream)) {

            ZipArchiveEntry entry = new ZipArchiveEntry(source.getFileName().toString());
            zipArchiveOutputStream.putArchiveEntry(entry);

            try (InputStream inputStream = Files.newInputStream(source)) {
                byte[] buffer = new byte[1024];
                int bytesRead;
                while ((bytesRead = inputStream.read(buffer)) != -1) {
                    zipArchiveOutputStream.write(buffer, 0, bytesRead);
                    currentSize += bytesRead;
                    if (currentSize >= splitSize) {
                        zipArchiveOutputStream.closeArchiveEntry();
                        outputStream.close();
                        currentSize = 0;
                        int partNumber = (int) (totalSize / splitSize) + 1;
                        outputStream = new FileOutputStream(outputPath + ".z" + String.format("%02d", partNumber));
                        zipArchiveOutputStream = new ZipArchiveOutputStream(outputStream);
                        entry = new ZipArchiveEntry(source.getFileName().toString());
                        zipArchiveOutputStream.putArchiveEntry(entry);
                    }
                }
            }
            zipArchiveOutputStream.closeArchiveEntry();
        }
    }

    public static void main(String[] args) {
        try {
            createSplitZip("path/to/large/file", "path/to/output/file", 1024 * 1024 * 100); // Split into 100MB parts
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

前端代码实现

1. downloadFile封装

# request.js

import axios from 'axios';

axios.defaults.timeout = 10000; 
axios.defaults.withCredentials = true;
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';


// 下载文件
export const downloadFile = async (
  url,
  data,
  onDownloadProgress,
  setProgress,
  setProgressStatus,
  filename
) => {
  try {
    setProgressStatus('default');
    setProgress(0);
    const response = await axios({
      method: 'POST',
      url: process.env.VUE_APP_BASE_URL + url,
      responseType: 'blob',
      data: data,
      onDownloadProgress: (progressEvent) => {
        if (progressEvent.lengthComputable) {
          const progress = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total
          );
          setProgress(progress);
        } else {
          console.log('Download progress: unknown');
        }
        if (onDownloadProgress) {
          onDownloadProgress(progressEvent);
        }
      },
    });
    setProgressStatus('success');
    const disposition = response.headers['content-disposition'] || '';
    const resFileName = disposition
      ? decodeURI(
        disposition
          .split('=')[1]
          .replace(/'/g, '')
          .replace(/UTF-8/g, '')
          .replace(/utf-8/g, '')
      )
      : '';
    saveFile(response.data, resFileName);
  } catch (error) {
    setProgressStatus('exception');
  } finally {
    setProgress(100);
  }
};

// 保存文件到本地
const saveFile = (blob, filename) => {
  const url = window.URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.setAttribute('download', filename);
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
};

2. API

import { downloadFile } from '@/api/request.js';

// 会议下载
export const getMeetZip = (
  data,
  onDownloadProgress,
  setProgress,
  setProgressStatus,
  filename
) =>
  downloadFile(
    'xxx/xxx/xxx/downloadZip',
    data,
    onDownloadProgress,
    setProgress,
    setProgressStatus,
    filename
  );

3. 分批处理任务batchProcessor.js

/**
 * 分批处理任务
 * @param {Array} items - 要处理的任务列表
 * @param {Function} processorFn - 处理单个任务的函数
 * @param {number} maxConcurrent - 最大并发数(默认6)浏览器限制6-8
 * @returns {Promise<Array>} - 返回所有任务的结果
 */
export const processInBatches = async (
  items,
  processorFn,
  maxConcurrent = 6
) => {
  const results = [];
  let currentIndex = 0;

  while (currentIndex < items.length) {
    // 取出当前批次(最多maxConcurrent个)
    const batch = items.slice(currentIndex, currentIndex + maxConcurrent);

    // 并行处理当前批次的所有任务
    const batchPromises = batch.map(
      (item, index) => processorFn(item, currentIndex + index) // 传递索引以便跟踪
    );

    // 等待当前批次的所有任务完成
    const batchResults = await Promise.allSettled(batchPromises);
    results.push(...batchResults);

    // 移动到下一批次
    currentIndex += maxConcurrent;
  }

  return results;
};

# sleep.js
export const sleep = (time) => new Promise((resolve) => setTimeout(() => resolve(), time));

4. vue 代码

<template>

<el-button type="text" @click="downloadMeetZip(scope.row)">下载</el-button>

<el-dialog
        title="提示"
        :visible.sync="visZip"
        width="500px"
        :destroy-on-close="true"
        :close-on-click-modal="false"
        :close-on-press-escape="false"
        :show-close="false"
        top="calc(50vh - 200px)"
        @close="handleDialogClose"
      >
        <section class="meet-tips" v-for="(item, index) in zipList" :key="item">
          <section class="tips-box">
            <p v-if="progressStatus[index] === 'default'">
              <span v-if="progress[index] > 0">
                <i class="el-icon-loading"></i>
                正在下载
              </span>
              <span v-else>
                <i class="el-icon-loading"></i>
                等待中
              </span>
            </p>
            <p v-if="progressStatus[index] === 'success'" class="icon">
              <i class="el-icon-success"></i>
              下载完成
            </p>
            <p v-if="progressStatus[index] === 'exception'" class="icon">
              <i class="el-icon-error"></i>
              下载出错
            </p>
            <p v-if="progressStatus[index] === 'warning'" class="icon">
              <i class="el-icon-info" v-if="!!progressText[index]"></i>
              {{ progressText[index] }}
            </p>
            <section v-if="progressStatus[index] !== 'warning'">
              {{
                item.match(/videoDownload\/(.+)/)
                  ? item.match(/videoDownload\/(.+)/)[1]
                  : ''
              }}
            </section>
          </section>
          <section>
            <el-progress
              style="margin: 10px 0"
              :percentage="progress[index] || 0"
              :status="progressStatus[index] || 'default'"
              :stroke-width="18"
              :text-inside="true"
            ></el-progress>
          </section>
        </section>

        <div slot="footer" class="dialog-footer">
          <el-button type="text" v-if="this.zipList.length > 1">
            {{ successCount }} / {{ progressStatus.length }}
          </el-button>
          <el-button
            type="primary"
            :disabled="progressStatus.some((item) => item === 'default')"
            @click="confirmDownload"
          >
            确定
          </el-button>
        </div>
      </el-dialog>
</template>

<script>
import { getMeetZip } from '@/api/index.js';
import { processInBatches } from '@/utils/batchProcessor';
import { sleep } from '@/utils/sleep';

# 核心代码
downloadMeetZip(meet) {
      this.$confirm('确定下载超大文件么?', '超大文件下载', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        showClose: false,
        closeOnClickModal: false,
        closeOnPressEscape: false,
        type: 'warning',
      })
        .then(async () => {
          const data = {
            meetId: meet.meetId,
            meetCode: meet.meetCode,
          };
          const filename = `${meet.meetCode}_${this.formatTimestamp(
            Date.now()
          )}`;
          await sleep(200);
          this.visZip = true;
          this.zipList = ['0'];
          this.setProgressStatus(0, 'default');
          this.setProgress(0, 0);
          this.setProgressText(0, '');
          // 查询状态接口 3 下载
          try {
            const meetStatus = await getMeetStatus(data);
            if (meetStatus.success && meetStatus.data.statusCode === '3') {
              const resFileList = await getSplitFilePaths(data);
              this.zipList = resFileList.data || [];
              this.progress = Array(this.zipList.length).fill(0);
              this.progressStatus = Array(this.zipList.length).fill('default');
              this.progressText = Array(this.zipList.length).fill(
                '正在下载...'
              );

              try {
                // 分批处理下载
                const results = await processInBatches(
                  this.zipList,
                  (item, index) => {
                    return getMeetZip(
                      { partFilePath: item },
                      (value) => this.handleProgress(index, value),
                      (value) => this.setProgress(index, value),
                      (status) => this.setProgressStatus(index, status),
                      `${filename}${index}`
                    );
                  },
                  3
                );

                if (this.successCount === this.zipList.length) {
                  saveDownloadLog(data)
                    .then((res) => {
                      if (res.success) {
                        console.log('res', res.message);
                      }
                    })
                    .catch((err) => {
                      console.log('err', err);
                    });
                }
                console.log('所有下载完成:', results);
              } catch (error) {
                console.error('批量下载失败:', error);
              }
            } else {
              this.zipList = ['0'];
              this.setProgressStatus(0, 'warning');
              this.setProgressText(
                0,
                meetStatus?.data?.message || meetStatus?.message
              );
            }
          } catch (error) {
            this.zipList = ['0'];
            this.setProgressStatus(0, 'warning');
            this.setProgressText(0, error.message || '下载失败,请稍后重试');
          }
        })
        .catch(() => {
          this.$message({
            type: 'info',
            message: '操作已取消',
          });
        });
    },
    handleProgress(index, progressEvent) {
      // 可以在这里处理额外的进度逻辑
      // console.log('Custom progress handler:', progressEvent);
      // console.log('progressEvent.loaded', progressEvent.loaded);
    },
    setProgress(index, value) {
      this.$set(this.progress, index, value);
    },
    setProgressStatus(index, status) {
      this.$set(this.progressStatus, index, status);
    },
    setProgressText(index, text) {
      this.$set(this.progressText, index, text);
    },
</script>

5. UI展示

微信截图_20250522204336.png

  1. 后端分卷大小为800M左右,总大小11个G左右
  2. 前端分批最大并发数设置为3. 可以设置为其他值,但浏览器有限制6-8,启用http/2 可以突破限制,需要服务端设置。

6. 浏览器并发限制

  • 同一域名下的限制:浏览器对同一域名的并发连接数量有限制,通常为6-8个(不同浏览器和HTTP版本可能有所不同)。当并发请求超过这个限制时,后续的请求会被置入队列等待,直到前面的请求完成并释放连接。
  • 队头阻塞:当浏览器达到并发连接数限制时,后续的请求会排队等待前面的请求完成。这个过程被称为“队头阻塞”,它会导致页面加载速度变慢,影响用户体验。