用 FastAPI 撑起大文件的上传下载:从流式处理到断点续传的完整实践

做后端这行,迟早会撞上一道绕不开的坎------用户要传一个几个 G 甚至几十个 G 的文件,网络还时断时续。这时候如果还用最朴素的 UploadFile 一把梭,内存爆掉是轻的,用户传到 99% 网络一抖直接从头再来才是真的崩溃。这篇文章就把 FastAPI 场景下处理大文件的几条主流路线捋一遍,顺带给出一套能直接抄作业的断点续传实现。


大文件传输的几种技术路线

面对大文件,业界基本上分化出了三条路子,各有各的适用场景。

第一条路 是让 FastAPI 亲自上阵,用流式读写扛住大文件,服务端一边接收一边写盘,全程不把整个文件塞进内存。这条路简单直接,适合中等规模、自建存储的场景,用 异步文件 IO 配合合理的分块大小(业内建议 1MB 到 4MB 之间)就能跑得很稳。不过要命的地方在于,如果文件真的很大(比如 3GB 往上),普通的 python-multipart 解析方式和客户端库都得换成支持流式传输的版本,否则内存和超时问题照样找上门。

第二条路 是把 FastAPI 从传输链路里请出去,只负责发一张预签名 URL(presigned URL),真正的大文件数据直接从客户端怼到对象存储(S3、MinIO 之类)上。有人专门做过基准测试,结论挺扎心------对于真正的大文件,最快的方式就是干脆绕过 FastAPI,让客户端直连对象存储。这也是目前云原生场景下的主流做法,服务端压力几乎为零,代码里能看到完整的实现范例,用 FastAPI 生成签名、MongoDB 记录上传状态、S3 承载实际数据。

第三条路 是搞一套标准化的断点续传协议,代表就是 tus 协议。它是一个基于 HTTP 的开放协议,专门为可恢复上传设计,客户端和服务端都有现成实现,社区里已经有人把它移植到 FastAPI 上了。如果你的产品需要跨平台、跨客户端统一的续传体验(比如既有 Web 又有移动端),走 tus 这条路能省很多重复造轮子的功夫。

三条路线放在一张图里看会更清楚:


存储后端怎么选

存储后端这一层,决定了后面断点续传怎么落地、扩容怎么扩。几个常见选项摆在一起对比一下会更直观。

存储方案 实现复杂度 扩展性 断点续传支持 适用场景
本地磁盘 低,直接写文件系统 差,受单机容量限制 需要自己实现分片+合并 小规模、内网工具、原型验证
NFS/分布式文件系统 中,需运维挂载 中等 同本地磁盘,逻辑复用 多机共享但预算有限的团队
S3 / MinIO(对象存储) 中,需要 SDK 接入 好,天然分布式 原生支持 Multipart Upload 云原生、大规模、生产环境首选
tus 服务端(可搭配任意后端) 中偏高,需部署协议层 取决于底层存储 协议层原生保证 需要标准化跨端续传能力的产品

对象存储这一档基本是当前生产环境的最优解,因为 S3 的 Multipart Upload 机制本身就是为大文件而生的------先发起一个上传任务拿到 upload_id,然后把文件切成多个分片并发上传,每片单独返回 ETag,最后调用一次合并接口把所有分片拼成完整对象。这套机制天然就带着断点续传的血统,因为哪个分片传失败了,重传那一片就行,完全不用推倒重来。


断点续传的核心原理

断点续传说白了就是解决两个问题------上传中断了怎么知道传到哪了 ,以及下载中断了怎么从断点继续拉。这两件事的技术手段完全不同,值得分开看。

上传方向:分片 + 状态记录

上传的断点续传,核心思路是把大文件切成固定大小的小块,每块独立上传,服务端记录每块的上传状态。假设文件总大小为 SS S,每片大小为 CC C,那么总分片数就是

N=⌈SC⌉ N = \lceil \frac{S}{C} \rceil N=⌈CS⌉

客户端在上传前先算好这个 NN N,然后带着文件的唯一标识(通常用文件内容的哈希值,比如 SHA-256)去问服务端这个文件传到哪一片了,服务端一查记录,把已完成的分片索引列表返回,客户端就从下一片开始接着传,这就是断点续传最朴素也最通用的实现方式。

下载方向:HTTP Range 请求

下载的续传靠的是 HTTP 协议自带的 Range 头。客户端在请求头里写上 Range: bytes=1000000-,服务端识别到这个头以后,不再返回完整文件,而是从指定字节位置开始读取,返回状态码 206(Partial Content),配合 Content-Range 响应头告诉客户端这次返回的是文件的哪一段。下载工具(比如浏览器、迅雷、断点续传的脚本)本身就是靠这套机制实现暂停后继续下载的。

整个断点续传的时序,可以用一张图串起来:


完整案例:FastAPI + MinIO 实现分片上传/下载与断点续传

下面这套方案拿的是 MinIO(S3 协议兼容的对象存储,本地部署也很方便)当存储后端,配合 SQLite 记录分片状态,逻辑可以直接迁移到生产环境的 PostgreSQL 和真正的 S3 上。整体项目结构是这样的。

bash 复制代码
big_file_service/
├── main.py              # FastAPI入口
├── storage.py           # 存储后端封装(MinIO/S3)
├── models.py            # 分片状态数据模型
├── upload.py            # 上传相关接口
├── download.py          # 下载相关接口(支持Range)
└── requirements.txt

1. 存储后端封装

用一层抽象把具体的存储实现盖住,以后想换存储后端(比如从 MinIO 换成阿里云 OSS)只需要改这一个文件。

python 复制代码
# storage.py
from minio import Minio
from minio.error import S3Error
import os

class ObjectStorage:
    def __init__(self):
        self.client = Minio(
            "localhost:9000",
            access_key="minioadmin",
            secret_key="minioadmin",
            secure=False
        )
        self.bucket = "big-files"
        if not self.client.bucket_exists(self.bucket):
            self.client.make_bucket(self.bucket)

    def save_chunk(self, upload_id: str, chunk_index: int, data: bytes):
        """保存单个分片"""
        object_name = f"chunks/{upload_id}/{chunk_index}"
        from io import BytesIO
        self.client.put_object(
            self.bucket, object_name, BytesIO(data), length=len(data)
        )

    def merge_chunks(self, upload_id: str, total_chunks: int, final_name: str):
        """合并所有分片为完整文件, 使用S3的compose_object能力"""
        from minio.commonconfig import ComposeSource
        sources = [
            ComposeSource(self.bucket, f"chunks/{upload_id}/{i}")
            for i in range(total_chunks)
        ]
        self.client.compose_object(self.bucket, final_name, sources)
        # 清理临时分片
        for i in range(total_chunks):
            self.client.remove_object(self.bucket, f"chunks/{upload_id}/{i}")

    def get_object_stream(self, object_name: str, offset: int = 0, length: int = None):
        return self.client.get_object(
            self.bucket, object_name, offset=offset, length=length
        )

    def stat_object(self, object_name: str):
        return self.client.stat_object(self.bucket, object_name)


storage = ObjectStorage()

这里用到的 compose_object 就是对象存储版的 Multipart Upload 合并逻辑,不用自己在服务器本地拼文件,直接在存储层完成,效率高很多。

2. 分片状态记录

用一张简单的表记录每个上传任务的进度,生产环境建议换 Redis 存这类高频读写的状态数据。

python 复制代码
# models.py
import sqlite3

conn = sqlite3.connect("upload_state.db", check_same_thread=False)
conn.execute("""
CREATE TABLE IF NOT EXISTS upload_sessions (
    upload_id TEXT PRIMARY KEY,
    filename TEXT,
    file_hash TEXT,
    total_size INTEGER,
    chunk_size INTEGER,
    total_chunks INTEGER,
    uploaded_chunks TEXT DEFAULT '',
    status TEXT DEFAULT 'uploading'
)
""")
conn.commit()

def create_session(upload_id, filename, file_hash, total_size, chunk_size, total_chunks):
    conn.execute(
        "INSERT OR IGNORE INTO upload_sessions VALUES (?,?,?,?,?,?,?,?)",
        (upload_id, filename, file_hash, total_size, chunk_size, total_chunks, '', 'uploading')
    )
    conn.commit()

def get_session_by_hash(file_hash):
    row = conn.execute(
        "SELECT * FROM upload_sessions WHERE file_hash=?", (file_hash,)
    ).fetchone()
    return row

def mark_chunk_done(upload_id, chunk_index):
    row = conn.execute(
        "SELECT uploaded_chunks FROM upload_sessions WHERE upload_id=?", (upload_id,)
    ).fetchone()
    done = set(row[0].split(',')) if row[0] else set()
    done.add(str(chunk_index))
    conn.execute(
        "UPDATE upload_sessions SET uploaded_chunks=? WHERE upload_id=?",
        (",".join(done), upload_id)
    )
    conn.commit()

def get_uploaded_chunks(upload_id):
    row = conn.execute(
        "SELECT uploaded_chunks FROM upload_sessions WHERE upload_id=?", (upload_id,)
    ).fetchone()
    if not row or not row[0]:
        return set()
    return set(int(x) for x in row[0].split(','))

3. 上传接口------初始化、上传分片、查询进度、完成合并

这是整个方案的核心,四个接口配合起来就实现了完整的断点续传。

python 复制代码
# upload.py
from fastapi import APIRouter, UploadFile, Form
from storage import storage
from models import create_session, get_session_by_hash, mark_chunk_done, get_uploaded_chunks
import uuid, math

router = APIRouter(prefix="/upload")

@router.post("/init")
async def init_upload(filename: str = Form(...), file_hash: str = Form(...),
                       total_size: int = Form(...), chunk_size: int = Form(4 * 1024 * 1024)):
    """初始化上传, 若文件hash已存在记录, 说明是续传, 直接返回已完成分片"""
    existing = get_session_by_hash(file_hash)
    if existing:
        upload_id = existing[0]
        uploaded = get_uploaded_chunks(upload_id)
        return {"upload_id": upload_id, "uploaded_chunks": sorted(uploaded), "resumed": True}

    upload_id = str(uuid.uuid4())
    total_chunks = math.ceil(total_size / chunk_size)
    create_session(upload_id, filename, file_hash, total_size, chunk_size, total_chunks)
    return {"upload_id": upload_id, "uploaded_chunks": [], "resumed": False, "total_chunks": total_chunks}


@router.post("/chunk")
async def upload_chunk(upload_id: str = Form(...), chunk_index: int = Form(...),
                        file: UploadFile = None):
    """上传单个分片, 已上传过的分片直接跳过, 天然支持重复请求"""
    uploaded = get_uploaded_chunks(upload_id)
    if chunk_index in uploaded:
        return {"status": "already_uploaded", "chunk_index": chunk_index}

    data = await file.read()
    storage.save_chunk(upload_id, chunk_index, data)
    mark_chunk_done(upload_id, chunk_index)
    return {"status": "ok", "chunk_index": chunk_index}


@router.post("/complete")
async def complete_upload(upload_id: str = Form(...), filename: str = Form(...),
                           total_chunks: int = Form(...)):
    """所有分片上传完成后触发合并"""
    storage.merge_chunks(upload_id, total_chunks, filename)
    return {"status": "completed", "filename": filename}

客户端这边的配合逻辑很简单------先算文件哈希(推荐用文件内容的 SHA-256,或者为了速度只对前几MB采样计算),拿哈希去调 /upload/init,服务端要是发现这个哈希以前传过一部分,直接把已完成分片列表甩回来,客户端跳过这些分片,接着传剩下的就行。网络断了、页面刷新了都不怕,下次拿同一个哈希重新 init 一次就能接着传,这就是最朴素但极其好用的断点续传逻辑。

4. 下载接口------支持 Range 请求实现断点续传

python 复制代码
# download.py
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from storage import storage

router = APIRouter(prefix="/download")

@router.get("/{filename}")
async def download_file(filename: str, request: Request):
    stat = storage.stat_object(filename)
    file_size = stat.size

    range_header = request.headers.get("range")
    if range_header:
        # 解析 Range: bytes=1000000-
        range_value = range_header.replace("bytes=", "")
        start_str, end_str = range_value.split("-")
        start = int(start_str)
        end = int(end_str) if end_str else file_size - 1
        length = end - start + 1

        stream = storage.get_object_stream(filename, offset=start, length=length)

        def iter_stream():
            for chunk in stream.stream(32 * 1024):
                yield chunk
            stream.close()

        headers = {
            "Content-Range": f"bytes {start}-{end}/{file_size}",
            "Accept-Ranges": "bytes",
            "Content-Length": str(length),
        }
        return StreamingResponse(iter_stream(), status_code=206, headers=headers)

    # 没有Range头, 走完整下载
    stream = storage.get_object_stream(filename)

    def iter_full():
        for chunk in stream.stream(32 * 1024):
            yield chunk
        stream.close()

    headers = {"Content-Length": str(file_size), "Accept-Ranges": "bytes"}
    return StreamingResponse(iter_full(), headers=headers)

这里的关键就是识别客户端带没带 Range 头,带了就切片读取并返回 206,没带就照常全量返回。像迅雷、浏览器自带下载器、wget -ccurl -C - 这些工具,全都是靠识别服务端返不返回 Accept-Ranges: bytes 来判断能不能续传的。

5. 挂载路由与部署要点

python 复制代码
# main.py
from fastapi import FastAPI
from upload import router as upload_router
from download import router as download_router

app = FastAPI(title="Big File Service")
app.include_router(upload_router)
app.include_router(download_router)

真正上线前还有几个坑得提前填。

  • 反向代理限制 ------如果前面挂了 Nginx,记得把 client_max_body_size 调大,不然大文件请求直接被 Nginx 拦在门外,报 413 错误,跟 FastAPI 代码写得好不好完全没关系。
  • 超时设置------分片上传单次请求通常很快,但完整文件的合并操作(尤其本地磁盘方案)可能耗时较长,网关和反代的超时时间要相应放宽。
  • 客户端并发控制------多个分片可以并发上传提高速度,但不建议无限并发,一般 3 到 6 个并发连接是比较均衡的选择,能兼顾速度和服务端压力。
  • 清理策略------上传中断后遗留的孤儿分片得有个定时任务清理,不然对象存储的存储空间会被这些垃圾数据慢慢吃掉。
  • 如果不想自己实现分片协议------直接上 tus 服务端方案是个稳妥的选择,协议成熟、客户端库齐全(JS、iOS、Android都有),社区维护的 FastAPI 集成方案也已经跑通了实际案例。

几条路线该怎么选

把上面讲的东西收个尾,几种方案的取舍其实可以简化成一句话------文件不大、内网工具,用流式直传就够文件很大、上云生产、追求省心,直接上预签名URL让客户端直连对象存储产品形态复杂、多端统一、需要标准化断点续传体验,上 tus 。三条路没有绝对的优劣,关键看你的团队规模、运维能力和产品对续传体验的要求。自己写一套分片+状态记录的逻辑(就像上面的案例)好处是完全可控,坏处是维护成本要自己扛;用现成的协议或云服务好处是省心,坏处是多了一层依赖。这事儿说到底还是工程上永恒的那道选择题------自己造轮子 还是 站在巨人肩膀上


参考资料

I benchmarked 5 different FastAPI file upload methods, Reddit r/Python, www.reddit.com/r/Python/co...

Streaming File Uploads and Downloads with FastAPI, Python in Plain English, python.plainenglish.io/streaming-f...

How to Upload a large File (≥3GB) to FastAPI backend?, Stack Overflow, stackoverflow.com/questions/7...

FastAPI File Uploads --- Clean, Fast, and Foolproof, Medium, medium.com/@ThinkingLo...

liviaerxin/fastapi-tusd, GitHub, github.com/liviaerxin/...

tus - resumable file uploads, tus.io, tus.io/

Why Chunked & Resumable Uploads Are a Game Changer for Video Processing, Medium, aditya007.medium.com/why-chunked...

Python server tus implementation with FastAPI, Transloadit Community, community.transloadit.com/t/python-se...

nicholasadamou/s3-large-file-uploader, GitHub, github.com/nicholasada...

Uploading large objects to Amazon S3 using multipart upload and transfer acceleration, AWS Blog, aws.amazon.com/blogs/compu...

How to Implement File Uploads in FastAPI, OneUptime Blog, oneuptime.com/blog/post/2...

相关推荐
铁皮饭盒42 分钟前
网页端, 6.5MB人脸识别模型, 谷歌框架, 又快又准
前端·javascript·后端
长大19881 小时前
jQuery AJAX 完整封装教程:告别重复写请求代码
后端
张龙6871 小时前
别再裸调大模型了:用 60 行 Python 给 LLM 调用加上「重试 + 超时 + 降级」
python
菜冻鱼1 小时前
Python-sklearn-评估指标
开发语言·人工智能·python·机器学习·numpy·pandas·sklearn
guyiICtestsocket1 小时前
国内支持定制的手机LPDDR芯片测试座工厂多种结构
人工智能·python·智能手机
for_ever_love__1 小时前
python基础语法学习: 变量, 输入输出, 运算符
网络·python·学习
努力搬砖的咸鱼2 小时前
AI Agent测试全景图:它到底改变了什么
人工智能·python·ai·集成测试·pytest·agent·ai编程
worxfr2 小时前
Go 并发控制:从 Channel 方向约束到实战模式
开发语言·后端·golang