提示

返回博客列表

上传 2GB 文件别让 Django 接:分片上传、对象存储直传与秒传

我们的工具站有几个功能需要用户上传文件(视频处理、PDF 合并、格式转换)。小文件没问题,直到有人传了一个 2GB 的录屏。

当时的实现是标准的 Django 文件上传:<input type="file"> → request.FILES['file'] → 保存。后果:

  • Django 把上传内容先写到临时文件(还好,没进内存),但整个上传期间 worker 被占住;
  • 上传花了 20 多分钟(用户网络慢),worker 就占了 20 多分钟;
  • 中途网络断了 → 从头再来(用户崩溃);
  • 上传完成才发现文件类型不对 → 白传 2GB。

后来重做了整套上传:前端切片、并发传、直传对象存储、支持断点续传和秒传。服务端只在最后做一次合并和校验。这篇写完整的方案选择和实现细节。

TL;DR:大文件上传不要让应用服务器"中转"。推荐 前端直传对象存储(服务端只签发预签名 URL,字节不经过 Django)。流程:init(创建上传任务)→ 前端切片并发 PUT 到预签名 URL → complete(服务端校验 + 合并 + 入库)。断点续传靠"已上传分片列表"接口;秒传靠文件哈希查询(命中就直接完成)。三个必须做:分片要有序号和大小校验、合并后要校验总大小和哈希、服务端要清理未完成的碎片(否则对象存储里会堆一堆垃圾)。

目录

一、为什么 Django 直接接大文件不行

Django 的文件上传机制其实已经比"读进内存"好很多了:

  • < 2.5MB:内存(InMemoryUploadedFile);
  • > 2.5MB:写临时文件(TemporaryUploadedFile),磁盘。

但问题不在这里,而在整个请求模型:

问题 说明
worker 被长期占用 上传多久,worker 就占用多久(同步 worker 下尤其致命)
超时 nginx proxy_read_timeout、gunicorn timeout 都要设得比上传时间长
中断即重来 网络断了,2GB 白传
无法校验在先 传完了才知道类型不对、大小超限
多份副本 临时文件 → 目标位置 → 对象存储,搬了好几次
并发上传拖垮服务 几个大文件同时传就把 worker 占满

本质上和"大文件下载"是同一个问题:让应用服务器搬运字节。解决的思路也一样——让专业的组件做这件事(对象存储)。

二、三种架构怎么选

方案 字节路径 优点 缺点 适合
A. 服务端接收 浏览器 → nginx → Django → 磁盘 最简单 占 worker、无断点、无秒传 小文件(< 50MB)
B. 分片 → 服务端合并 浏览器 → Django(分片)→ 合并 能断点续传 字节仍经过 Django 中等规模、没有对象存储
C. 直传对象存储 浏览器 → 对象存储(预签名),Django 只签 URL 不占应用服务器、可断点、可秒传 实现复杂一些 推荐

我们的选择:

  • 小文件(< 20MB,比如头像、封面)→ 方案 A(简单,够用);
  • 大文件(视频、大 PDF)→ 方案 C。

判断标准是"上传耗时是否可能超过几秒"。几秒以内的用 A,超过的用 C。

三、方案 A:分片上传到服务端再合并

如果你的环境没有对象存储,这个方案也能解决大部分问题。

服务端接口:

import os
import hashlib
from django.conf import settings
from django.core.files.storage import default_storage


def chunk_dir(upload_id):
    return os.path.join(settings.MEDIA_ROOT, 'chunks', upload_id)


def save_chunk(upload_id, index, chunk):
    """保存一个分片。文件名用序号,保证顺序。"""
    d = chunk_dir(upload_id)
    os.makedirs(d, exist_ok=True)
    path = os.path.join(d, f'{index:06d}.part')
    with open(path, 'wb+') as f:
        for c in chunk.chunks():
            f.write(c)
    return os.path.getsize(path)


def merge_chunks(upload_id, total, filename):
    """按序号合并所有分片。"""
    d = chunk_dir(upload_id)
    out_path = os.path.join(settings.MEDIA_ROOT, 'uploads', filename)

    h = hashlib.md5()
    total_size = 0
    with open(out_path, 'wb+') as out:
        for i in range(total):
            part = os.path.join(d, f'{i:06d}.part')
            if not os.path.exists(part):
                raise FileNotFoundError(f'缺少分片 {i}')
            with open(part, 'rb') as f:
                while True:
                    buf = f.read(1024 * 1024)
                    if not buf:
                        break
                    out.write(buf)
                    h.update(buf)
                    total_size += len(buf)
            os.remove(part)          # 合并一个删一个,避免双倍磁盘占用
    os.rmdir(d)
    return out_path, total_size, h.hexdigest()

注意"合并一个删一个":2GB 的文件,如果所有分片 + 合并后的文件同时在磁盘上,就是 4GB。边合并边删能省一半空间。

这个方案的局限:字节还是经过了 Django,上传期间仍然占用 worker(虽然每个分片很短)。适合"没有对象存储"的场景。

四、方案 B:直传对象存储(推荐)

流程

1. 前端选择文件 → 计算 hash(可选,用于秒传)→ 调 /upload/init/
2. 服务端:创建 UploadTask(upload_id),返回 upload_id + 分片大小
3. 前端:按分片大小切文件
4. 对每个分片:调 /upload/presign/ 拿一个预签名 PUT URL
5. 前端:直接 PUT 到对象存储(不经过 Django)
6. 全部传完:调 /upload/complete/
7. 服务端:校验分片完整性 → 合并(或在对象存储侧合并)→ 入库

预签名 URL(MinIO / S3 协议)

用 boto3(兼容 S3 协议,MinIO 可以用):

import boto3
from botocore.config import Config

s3 = boto3.client(
    's3',
    endpoint_url=settings.S3_ENDPOINT,          # 例如 http://127.0.0.1:9000
    aws_access_key_id=settings.S3_ACCESS_KEY,
    aws_secret_access_key=settings.S3_SECRET_KEY,
    config=Config(signature_version='s3v4'),
    region_name='us-east-1',
)


def presign_put(key: str, expires_in: int = 3600, content_type: str = '') -> str:
    """生成一个允许 PUT 上传的预签名 URL。"""
    params = {
        'Bucket': settings.S3_BUCKET,
        'Key': key,
    }
    if content_type:
        params['ContentType'] = content_type
    return s3.generate_presigned_url(
        'put_object',
        Params=params,
        ExpiresIn=expires_in,
        HttpMethod='PUT',
    )

MinIO 官方 SDK(minio-py)也提供:

from minio import Minio
from datetime import timedelta

client = Minio(
    '127.0.0.1:9000',
    access_key=..., secret_key=..., secure=False,
)
url = client.presigned_put_object('bucket', 'object-name', expires=timedelta(hours=1))

安全要点:

  1. 过期时间要短(1 小时以内,够传就行);
  2. key 由服务端生成(不要让前端决定存到哪里,否则能覆盖别人的文件);
  3. 限制大小:S3 的预签名不支持内容长度限制(除非用 POST policy),所以要在 complete 时校验实际大小;
  4. 只允许 PUT 这一个操作(generate_presigned_url('put_object', ...) 生成的 URL 只能 PUT)。

大文件用 Multipart Upload

对象存储原生支持分片上传(S3 Multipart Upload),比"自己切片 + 自己合并"更好:

def init_multipart(key):
    resp = s3.create_multipart_upload(Bucket=settings.S3_BUCKET, Key=key)
    return resp['UploadId']


def presign_part(key, upload_id, part_number, expires_in=3600):
    return s3.generate_presigned_url(
        'upload_part',
        Params={
            'Bucket': settings.S3_BUCKET,
            'Key': key,
            'UploadId': upload_id,
            'PartNumber': part_number,
        },
        ExpiresIn=expires_in,
        HttpMethod='PUT',
    )


def complete_multipart(key, upload_id, parts):
    """parts: [{'ETag': '...', 'PartNumber': 1}, ...] 必须按 PartNumber 升序"""
    s3.complete_multipart_upload(
        Bucket=settings.S3_BUCKET,
        Key=key,
        UploadId=upload_id,
        MultipartUpload={'Parts': sorted(parts, key=lambda p: p['PartNumber'])},
    )

Multipart 的三个好处:

  1. 合并是在对象存储内部做的,不消耗你的带宽和 CPU;
  2. 每个分片可以独立重传(天然支持断点续传);
  3. 可以并发传分片(我们实测 4 并发能跑满家用带宽)。

代价:

  • 分片最小 5MB(最后一片除外);
  • 未完成的分片会占着空间,必须清理(见下)。

五、断点续传

核心是一个"已上传分片列表"接口:

def uploaded_parts(request, upload_id):
    """返回已上传的分片序号列表,前端据此跳过。"""
    task = get_object_or_404(UploadTask, upload_id=upload_id, user=request.user)

    # 方式 1:查自己的数据库记录
    parts = UploadPart.objects.filter(task=task).values_list('part_number', flat=True)

    # 方式 2:直接问对象存储(更权威)
    resp = s3.list_parts(
        Bucket=settings.S3_BUCKET,
        Key=task.object_key,
        UploadId=task.upload_id,
    )
    done = [{'PartNumber': p['PartNumber'], 'ETag': p['ETag'], 'Size': p['Size']}
            for p in resp.get('Parts', [])]

    return JsonResponse({'parts': done})

前端逻辑:

async function uploadFile(file, uploadId) {
    const CHUNK = 5 * 1024 * 1024;                     // 必须 >= 5MB(S3 要求)
    const total = Math.ceil(file.size / CHUNK);

    // 1. 问服务端哪些已经传过了
    const done = await fetch(`/upload/parts/${uploadId}/`).then(r => r.json());
    const doneSet = new Set(done.parts.map(p => p.PartNumber));

    const etags = [];
    // 2. 并发上传未完成的分片
    const queue = [];
    for (let i = 1; i <= total; i++) {
        if (!doneSet.has(i)) queue.push(i);
    }

    const CONCURRENCY = 4;
    async function worker() {
        while (queue.length) {
            const i = queue.shift();
            const start = (i - 1) * CHUNK;
            const blob = file.slice(start, Math.min(start + CHUNK, file.size));
            const {url} = await fetch(`/upload/presign/`, {
                method: 'POST',
                body: JSON.stringify({upload_id: uploadId, part: i}),
            }).then(r => r.json());
            const resp = await fetch(url, {method: 'PUT', body: blob});
            etags.push({PartNumber: i, ETag: resp.headers.get('ETag').replace(/"/g, '')});
        }
    }
    await Promise.all(Array.from({length: CONCURRENCY}, worker));

    // 3. 通知服务端完成
    await fetch('/upload/complete/', {
        method: 'POST',
        body: JSON.stringify({upload_id: uploadId, parts: etags}),
    });
}

断点续传的关键:upload_id 要持久化(存数据库),用户关掉页面再打开,用同一个 upload_id 继续——而不是重新开始。

我们的做法:上传任务记录关联用户 + 文件 hash + 大小,用户回到页面时前端先调 /upload/resume/?hash=xxx&size=xxx,服务端找出未完成的同 hash 任务返回,前端接着传。

六、秒传:同一个文件不用再传一遍

原理:上传前先算文件的哈希,问服务端"这个文件是不是已经有了"。有了就直接完成。

def check_exists(request):
    """秒传检查。"""
    file_hash = request.GET.get('hash')
    size = int(request.GET.get('size', 0))

    obj = UploadedFile.objects.filter(hash=file_hash, size=size, status='READY').first()
    if obj:
        # 已存在:直接给用户建一条引用,不用再传
        user_file = UserFile.objects.create(
            user=request.user,
            file=obj,
            filename=request.GET.get('filename'),
        )
        return JsonResponse({'exists': True, 'file_id': user_file.id})
    return JsonResponse({'exists': False})

哈希怎么算:

  • 小文件(< 100MB):全文件 MD5/SHA1,前端用 SparkMD5 增量算;
  • 大文件:抽样哈希(首 1MB + 中间 1MB + 末 1MB + 文件大小),速度快,碰撞概率足够低;
  • 更快:xxhash(比 MD5 快 5~10 倍),但前端要用 WASM 版本。
// 抽样哈希(大文件用)
async function sampleHash(file) {
    const size = file.size;
    const chunks = [
        file.slice(0, 1024 * 1024),
        file.slice(Math.floor(size / 2), Math.floor(size / 2) + 1024 * 1024),
        file.slice(Math.max(0, size - 1024 * 1024), size),
    ];
    const bufs = await Promise.all(chunks.map(c => c.arrayBuffer()));
    const combined = new Uint8Array(bufs.reduce((n, b) => n + b.byteLength, 0));
    let offset = 0;
    bufs.forEach(b => { combined.set(new Uint8Array(b), offset); offset += b.byteLength; });
    const digest = await crypto.subtle.digest('SHA-256', combined);
    return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('') + '_' + size;
}

安全考虑:

  1. 哈希 + 大小双重匹配(只匹配哈希可能被碰撞攻击);
  2. 秒传成功的文件要做权限检查——不能因为"文件已存在"就把别人的文件关联给当前用户(我们只在"该文件对当前用户可见"或者"是公开素材"时才秒传,否则老老实实传);
  3. 敏感文件不做秒传(哈希本身会泄露"你有这个文件")。

实测效果:我们的工具站里,同一批素材被反复上传的比例大概 15%,秒传让这部分上传时间从"几十秒"变成"瞬间"。

七、校验:分片、总量、类型

三道校验:

1. 上传前(前端):文件名、大小、扩展名(只是友好提示,不是安全校验)。

2. 分片时(服务端):

def presign_part(request):
    upload_id = request.POST['upload_id']
    part_number = int(request.POST['part'])
    size = int(request.POST['size'])

    task = UploadTask.objects.get(upload_id=upload_id, user=request.user)
    # 分片大小必须在合理范围
    if not (5 * 1024 * 1024 <= size <= 100 * 1024 * 1024):
        return JsonResponse({'error': '分片大小不合法'}, status=400)
    # 分片序号不能超范围
    if not (1 <= part_number <= task.total_parts):
        return JsonResponse({'error': '分片序号不合法'}, status=400)
    ...

3. 完成后(服务端,最重要):

def complete_upload(upload_id, parts):
    task = UploadTask.objects.get(upload_id=upload_id)

    # a. 向对象存储核实(不能只信前端传来的 etag 列表)
    resp = s3.list_parts(Bucket=..., Key=task.object_key, UploadId=task.upload_id)
    actual = {p['PartNumber']: p['ETag'] for p in resp.get('Parts', [])}
    for p in parts:
        if actual.get(p['PartNumber']) != p['ETag']:
            raise ValueError(f'分片 {p["PartNumber"]} 校验失败')

    # b. 合并(对象存储侧)
    s3.complete_multipart_upload(...)

    # c. 合并后核实总大小
    head = s3.head_object(Bucket=..., Key=task.object_key)
    real_size = head['ContentLength']
    if real_size != task.declared_size:
        # 大小不符 → 删除并拒绝
        s3.delete_object(Bucket=..., Key=task.object_key)
        raise ValueError(f'大小不符:{real_size} != {task.declared_size}')

    # d. 类型校验(读文件头)
    raw = s3.get_object(Bucket=..., Key=task.object_key, Range='bytes=0-2047')['Body'].read()
    mime = magic.from_buffer(raw, mime=True)
    if mime not in ALLOWED_MIME:
        s3.delete_object(Bucket=..., Key=task.object_key)
        raise ValueError(f'不支持的文件类型:{mime}')

    task.status = 'READY'
    task.save()

关键点:不能只信前端传来的信息。前端说"我传了 100 片、每片 5MB",服务端一定要向对象存储核实(或者直接 head_object 看实际大小)。否则有人可以直接调 complete 接口伪造。

八、前端那些必须处理好的细节

1. 并发数:3~5 个比较合适。太多会被限流、也会让进度条跳得厉害。我们实测 4 并发在大部分网络下能跑满带宽。

2. 失败重试:分片上传失败很常见(网络抖动),要指数退避重试:

async function putWithRetry(url, blob, maxRetry = 3) {
    for (let i = 0; i < maxRetry; i++) {
        try {
            const resp = await fetch(url, {method: 'PUT', body: blob});
            if (resp.ok) return resp;
            throw new Error(`HTTP ${resp.status}`);
        } catch (e) {
            if (i === maxRetry - 1) throw e;
            await new Promise(r => setTimeout(r, Math.min(1000 * 2 ** i, 8000) + Math.random() * 500));
        }
    }
}

3. 哈希计算要放 Web Worker:主线程算 2GB 文件的哈希会卡死页面。

4. 进度显示:按"已完成分片数 / 总分片数",并且要考虑断点续传(已传的分片直接算 100%)。

5. 暂停/继续:用 AbortController 中断当前请求,已传的分片保留(下次续传)。

6. 页面刷新/关闭:上传状态要持久化(upload_id 存 localStorage),回来继续。另外 beforeunload 提醒用户"上传还没完成"。

7. 分片大小:S3 的 multipart 要求每片 ≥ 5MB(最后一片除外),且最多 10000 片。所以:

2GB 文件、5MB 分片 → 410 片(OK)
100GB 文件、5MB 分片 → 20480 片(超限!要用更大的分片,比如 50MB → 2048 片)

前端应该根据实际大小动态计算分片大小:

function chunkSizeFor(size) {
    const MIN = 5 * 1024 * 1024;
    let cs = MIN;
    while (size / cs > 10000) cs *= 2;     // 保证不超过 10000 片
    return cs;
}

九、服务端接口设计

三个接口 + 一个清理任务:

接口 方法 入参 返回
/upload/init/ POST filename, size, hash, mime upload_id, chunk_size, 是否秒传
/upload/presign/ POST upload_id, part_number, size url
/upload/parts/ GET upload_id 已上传分片列表
/upload/complete/ POST upload_id, parts file_id
/upload/cancel/ POST upload_id 中止并删除碎片

清理未完成的任务(非常重要):

@shared_task
def cleanup_abandoned_uploads():
    """清理超过 24 小时未完成的上传任务(对象存储里的碎片会一直占空间)。"""
    cutoff = timezone.now() - timedelta(hours=24)
    tasks = UploadTask.objects.filter(status='INIT', created_at__lt=cutoff)[:500]

    for t in tasks:
        try:
            if t.upload_id:
                s3.abort_multipart_upload(
                    Bucket=settings.S3_BUCKET, Key=t.object_key, UploadId=t.upload_id,
                )
            else:
                s3.delete_object(Bucket=settings.S3_BUCKET, Key=t.object_key)
        except Exception as e:
            logger.warning('abort failed %s: %s', t.upload_id, e)
        t.status = 'ABORTED'
        t.save(update_fields=['status'])

这个任务不做的话,对象存储里会堆积大量"半截文件"——我们有次发现桶里有 200GB 都是没人认领的碎片。

对象存储侧也可以配生命周期规则自动清理(比如给 tmp/ 前缀设 1 天过期),双保险。

十、实测数据与坑清单

实测(2GB 文件,家用 50Mbps 上行)

方案 耗时 Django worker 占用 中断后
服务端直接接收 6 分 10 秒 全程占用 从头再来
分片 + 服务端合并 6 分 30 秒 分片期间占用(短)+ 合并时 CPU 续传
直传对象存储(4 并发) 5 分 40 秒 几乎为 0(只签 URL) 续传
秒传(命中) < 1 秒 忽略不计 —

直传方案最大的收益不是速度(差不多),而是 worker 占用从 6 分钟降到接近 0——这意味着上传功能再也不会拖垮站点。

坑清单

  1. 用 Django 直接接大文件 → worker 被占、超时、无断点。改直传。
  2. 分片小于 5MB → S3 multipart 报错(EntityTooSmall)。
  3. 分片数超过 10000 → 超限,要动态调大分片。
  4. 让前端决定 object key → 能覆盖别人的文件。key 必须服务端生成。
  5. complete 只信前端传的 etag → 可伪造。向对象存储核实。
  6. 合并后不校验总大小 → 大小不符的文件被接受。
  7. 只校验扩展名/Centent-Type → 内容可以是任何东西。读文件头判断。
  8. 没有清理未完成的分片 → 对象存储堆几百 GB 垃圾。定时任务 + 生命周期规则。
  9. 预签名 URL 有效期太长 → 泄露后被滥用。1 小时以内。
  10. 秒传不检查权限 → A 用户的文件被关联给 B 用户(隐私泄露)。
  11. 秒传只用 hash 不用 size → 碰撞风险。hash + size 双匹配。
  12. 哈希计算在主线程 → 页面卡死。Web Worker。
  13. 没有失败重试 → 网络抖动导致整个上传失败。每个分片独立重试。
  14. 并发数太大 → 触发对象存储限流,或者进度条跳变。3~5 个。
  15. 前端没做暂停/继续 → 用户关了页面就全丢。upload_id 要持久化。
  16. 上传接口没做鉴权和配额 → 任何人能传任何东西塞满你的桶。必须鉴权 + 按用户限制总量。
  17. nginx 的 client_max_body_size 没调 → 即使是方案 A 也会 413。直传方案不受影响(这也是它的好处之一)。

最后说说这次重构的整体感受。

上传这个功能,看起来是"前端的事"——选个文件、发个请求、完事。但做过大文件之后会发现,它其实是个分布式系统问题:有状态(哪些分片传了)、有失败(网络中断)、有并发(同时传多个分片)、有资源限制(磁盘、带宽、配额)、有安全(伪造、越权、垃圾文件)。

所以我的建议是:如果你的上传文件可能超过几十 MB,一开始就用直传方案。虽然前期多花一两天,但省掉的是后面无数次"上传失败"的客诉和"服务器又被上传拖垮"的救火。

还有一点:上传和下载是镜像的问题,解法也是镜像的。

  • 下载:不要让 Django 发字节 → 用 X-Accel-Redirect 交给 nginx;
  • 上传:不要让 Django 收字节 → 用预签名 URL 交给对象存储。

背后的原则是同一条:应用服务器负责"决策"(鉴权、校验、记录),专业组件负责"搬运"(nginx、对象存储)。想清楚这一点,很多架构问题都会变得简单。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部