上传GB级文件到对象存储,90%的开发者都踩过这个坑!

  |   0 评论   |   45 浏览

前言

“如果用户要上传一个2GB的视频文件,你会怎么设计上传方案?”

这是我在面试中经常问的问题,也是很多后端开发者在实际工作中会遇到的真实场景。

当你面对几百MB甚至几个GB的大文件上传需求时,如果直接用简单的 putObject 方法上传,大概率会遇到这些问题:

网络一抖,上传失败,得重新传
文件太大,内存直接爆掉
上传进度看不到,用户体验极差
无法暂停,只能傻等上传完成

今天我们就来聊聊,如何优雅地解决大文件上传问题 —— 分片上传(Multipart Upload)。这不仅是生产环境的最佳实践,也是面试中的高频考点。


面试加分项:为什么要用分片上传?

在回答面试问题时,除了说"使用分片上传",更要能解释清楚为什么

分片上传的核心思想很简单:把大文件切成多个小片段,分别上传,最后在服务端合并。

分片上传示意图

为什么分片上传能解决问题?

断点续传:某个分片失败,只需重传该分片
并行上传:多个分片同时传,速度倍增
内存友好:每次只处理一个小分片
进度可见:根据分片进度计算总进度
网络容错:弱网环境下成功率更高


分片上传的完整流程

根据S3协议规范,分片上传分为三个步骤:

第一步:初始化上传

const uploadId = await s3.createMultipartUpload({
  Bucket: 'my-bucket',
  Key: 'large-file.zip'
}).uploadId;

这一步会告诉对象存储:“我要开始上传一个分片文件了”,并返回一个 uploadId,后续所有操作都要带着这个ID。

第二步:上传分片

const partSize = 5 * 1024 * 1024; // 5MB每片
const parts = [];

for (let i = 0; i < totalParts; i++) {
  const start = i * partSize;
  const end = Math.min(start + partSize, fileSize);
  const chunk = file.slice(start, end);

  const result = await s3.uploadPart({
    Bucket: 'my-bucket',
    Key: 'large-file.zip',
    UploadId: uploadId,
    PartNumber: i + 1,
    Body: chunk
  });

  parts.push({
    PartNumber: i + 1,
    ETag: result.ETag
  });
}

注意两个关键点:

  1. PartNumber 从1开始递增
  2. 必须保存每个分片的ETag,最后合并时要用

第三步:合并分片

await s3.completeMultipartUpload({
  Bucket: 'my-bucket',
  Key: 'large-file.zip',
  UploadId: uploadId,
  MultipartUpload: {
    Parts: parts
  }
});

对象存储会按照 PartNumber 顺序,把所有分片合并成完整文件。


生产级代码实现

这里给你一个完整的TypeScript实现,可以直接用于项目:

import * as AWS from 'aws-sdk';

class S3MultipartUploader {
  private s3: AWS.S3;
  private partSize = 5 * 1024 * 1024; // 5MB

  constructor() {
    this.s3 = new AWS.S3({
      endpoint: 'https://s3.amazonaws.com', // 或OSS、MinIO的endpoint
      accessKeyId: process.env.AWS_ACCESS_KEY_ID,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
    });
  }

  async uploadFile(
    bucket: string,
    key: string,
    file: Buffer,
    onProgress?: (progress: number) => void
  ): Promise<string> {
    // 1. 初始化上传
    const { UploadId } = await this.s3.createMultipartUpload({
      Bucket: bucket,
      Key: key
    }).promise();

    // 2. 计算分片数量
    const totalParts = Math.ceil(file.length / this.partSize);
    const parts: AWS.S3.Part[] = [];

    // 3. 并发上传分片
    const uploadPromises = [];
    const concurrency = 3; // 并发数

    for (let i = 0; i < totalParts; i++) {
      const partNumber = i + 1;
      const start = i * this.partSize;
      const end = Math.min(start + this.partSize, file.length);
      const chunk = file.slice(start, end);

      const uploadPromise = this.s3.uploadPart({
        Bucket: bucket,
        Key: key,
        UploadId,
        PartNumber: partNumber,
        Body: chunk
      }).promise()
        .then(result => {
          parts.push({
            PartNumber: partNumber,
            ETag: result.ETag!
          });

          // 上报进度
          if (onProgress) {
            const progress = (parts.length / totalParts) * 100;
            onProgress(progress);
          }
        });

      uploadPromises.push(uploadPromise);

      // 控制并发数
      if (uploadPromises.length >= concurrency || i === totalParts - 1) {
        await Promise.all(uploadPromises);
        uploadPromises.length = 0;
      }
    }

    // 4. 合并分片
    await this.s3.completeMultipartUpload({
      Bucket: bucket,
      Key: key,
      UploadId,
      MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) }
    }).promise();

    return `s3://${bucket}/${key}`;
  }

  // 取消上传
  async abortUpload(bucket: string, key: string, uploadId: string): Promise<void> {
    await this.s3.abortMultipartUpload({
      Bucket: bucket,
      Key: key,
      UploadId: uploadId
    }).promise();
  }
}

// 使用示例
const uploader = new S3MultipartUploader();
uploader.uploadFile(
  'my-bucket',
  'large-video.mp4',
  fileBuffer,
  (progress) => console.log(`上传进度: ${progress.toFixed(2)}%`)
);

最佳实践建议

💡 面试提示:在面试中,除了说出技术方案,还要能权衡各种参数的利弊,这会让面试官眼前一亮。

1. 分片大小选择

  • 推荐 5-10MB:太小会增加请求次数,太大会降低并发优势
  • S3允许 1MB-5GB 的分片大小
  • 最多支持 10000 个分片

面试加分回答

“我会根据文件大小和网络情况动态调整。对于100MB的文件,5MB分片足够;对于10GB的文件,我会设置10MB分片以减少分片数量。”

2. 并发控制

  • 推荐并发数 3-5:避免带宽打满导致超时
  • 可根据网络情况动态调整

面试加分回答

“我会实现一个动态并发控制器,根据当前网络状况和分片大小自动调整并发数,既能充分利用带宽,又不会导致超时。”

3. 错误处理与重试机制

// 添加重试机制
async uploadPartWithRetry(params, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await this.s3.uploadPart(params).promise();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
    }
  }
}

4. 清理未完成的上传

// 列出所有未完成的分片上传
const incompleteUploads = await s3.listMultipartUploads({
  Bucket: 'my-bucket',
  'Max-Uploads': 1000
}).promise();

// 清理超过7天的未完成上传
for (const upload of incompleteUploads.Uploads || []) {
  const initiatedDate = new Date(upload.Initiated);
  const daysSinceInitiated = (Date.now() - initiatedDate.getTime()) / (1000 * 60 * 60 * 24);

  if (daysSinceInitiated > 7) {
    await s3.abortMultipartUpload({
      Bucket: upload.Bucket,
      Key: upload.Key,
      UploadId: upload.UploadId
    }).promise();
  }
}

通用性验证

这套方案不仅适用于 AWS S3,所有兼容 S3 协议的对象存储都支持:

阿里云 OSS - 完全支持分片上传
腾讯云 COS - API完全兼容
MinIO - 开源对象存储,完全兼容
华为云 OBS - 支持 S3 协议
自建对象存储 - 只要是S3协议即可

只需要修改 endpoint 和认证信息即可。


总结:如何回答面试中的大文件上传问题?

如果面试官问你:“如何设计一个大文件上传系统?”

标准回答框架

  1. 问题分析(15秒)
  • 大文件上传的痛点:网络不稳定、内存占用、无法断点续传
  1. 核心方案(30秒)
  • 使用分片上传(Multipart Upload)
  • 初始化 → 分片上传 → 合并
  1. 技术亮点(1分钟)
  • 实现断点续传(保存 uploadId 和 part 信息)
  • 并发控制提升速度(3-5个并发)
  • 重试机制保证可靠性(指数退避)
  • 进度反馈优化体验
  1. 工程思维(30秒)
  • 清理未完成上传,避免浪费存储空间
  • 分片大小根据网络环境动态调整
  • 支持多种对象存储(S3/OSS/MinIO)

大文件上传的核心要点:

  1. 永远不要直接上传大文件 - 使用分片上传
  2. 合理设置分片大小 - 5-10MB 是黄金区间
  3. 控制并发数 - 避免带宽打满
  4. 实现断点续传 - 提升用户体验
  5. 记得清理未完成的上传 - 避免存储空间浪费

记住:分片上传不是可有可无的优化,而是处理大文件的标准方案。


🎯 思考题: 如果用户在上传到一半时关闭了浏览器,再次打开时如何实现断点续传?(提示:需要保存 uploadId 和已上传的 part 信息)

📚 延伸阅读:

善忘技术夹-公众号

评论

发表评论

validate