FastAPI: 文件上传与下载 — 大文件与批量导入

最后更新:2026-08-26

文件上传像快递收件——小包裹直接签收(内存),大件物流需要分批卸货(流式写入);下载像发货——单件快递(FileResponse),批量发货走传送带(StreamingResponse)。

1. 你将学到


2. Alice 的真实故事

(1) 痛点:百万行 CSV 导入内存溢出

Bob 每月上传一个包含百万行价格的 CSV 文件到 PriceTracker,之前的实现把整个文件读入内存再解析,1GB 的 CSV 文件导致 Python 进程 OOM(Out of Memory)崩溃。更糟的是,CSV 中混杂了非法数据(负数价格、无效币种),导入一半失败后数据库处于不一致状态。

(2) 流式上传+异步处理的解法

FastAPI 的 UploadFile 默认将文件存为临时文件(不占内存),配合 Celery 异步处理百万行解析,Pydantic 逐行校验跳过非法数据,事务化批量插入确保一致性。

(3) 收益

1GB CSV 导入从 OOM 崩溃变成稳定运行,内存占用从 2GB 降到 50MB。Celery Worker 处理百万行约 10 分钟,Bob 可通过任务状态端点实时查看进度。


3. UploadFile 基础

(1) UploadFile vs bytes

▶ 示例:简单文件上传

PYTHON
from fastapi import FastAPI, UploadFile, File, HTTPException

app = FastAPI()

@app.post("/upload/single")
async def upload_single(file: UploadFile = File(...)):
    # UploadFile: file stored as temp file, NOT in memory
    content = await file.read()
    return {
        "filename": file.filename,
        "size": len(content),
        "content_type": file.content_type,
    }

输出:

TEXT 📖 仅展示
# 函数定义成功
方式 内存占用 适用文件大小 API
bytes 全部加载到内存 < 2MB file: bytes = File()
UploadFile 临时文件(流式) 无限制 file: UploadFile = File()

▶ 示例:多文件上传

PYTHON
@app.post("/upload/multiple")
async def upload_multiple(files: list[UploadFile] = File(...)):
    results = []
    for file in files:
        content = await file.read()
        results.append({
            "filename": file.filename,
            "size": len(content),
        })
    return {"uploaded": len(results), "files": results}

输出:

TEXT 📖 仅展示
# 函数定义成功

4. 大文件流式处理

(1) 分块读取与流式写入

▶ 示例:大文件流式保存

PYTHON
import shutil
from pathlib import Path
from fastapi import FastAPI, UploadFile, File

app = FastAPI()
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)

@app.post("/upload/large")
async def upload_large_file(file: UploadFile = File(...)):
    # Stream file to disk - never loads entire file into memory
    dest = UPLOAD_DIR / file.filename
    with open(dest, "wb") as buffer:
        # Copy in chunks (default 64KB buffer)
        shutil.copyfileobj(file.file, buffer)
    
    file_size = dest.stat().st_size
    return {
        "filename": file.filename,
        "size_bytes": file_size,
        "saved_to": str(dest),
    }

输出:

TEXT 📖 仅展示
# 函数定义成功

▶ 示例:自定义分块大小

PYTHON
CHUNK_SIZE = 1024 * 1024  # 1MB chunks

@app.post("/upload/chunked")
async def upload_chunked(file: UploadFile = File(...)):
    dest = UPLOAD_DIR / file.filename
    bytes_written = 0
    with open(dest, "wb") as buffer:
        while chunk := await file.read(CHUNK_SIZE):
            buffer.write(chunk)
            bytes_written += len(chunk)
    return {"filename": file.filename, "bytes_written": bytes_written}

输出:

TEXT 📖 仅展示
# 函数定义成功

5. CSV/Excel 批量导入

(1) 大文件上传+异步处理流程

100%
sequenceDiagram
    participant Bob as Bob Frontend
    participant API as FastAPI
    participant Temp as Temp File
    participant Celery as Celery Worker
    participant DB as PostgreSQL

    Bob->>API: POST /import/csv (UploadFile)
    API->>Temp: Save to temp file
    API-->>Bob: 202 Accepted + task_id
    API->>Celery: Trigger parse task
    Celery->>Temp: Read CSV in chunks
    Celery->>Celery: Validate each row (Pydantic)
    Celery->>DB: Batch insert valid rows
    Celery-->>Celery: Report progress
    Bob->>API: GET /tasks/{task_id}
    API-->>Bob: Progress: 75%
    Celery-->>Celery: Task complete
    Bob->>API: GET /tasks/{task_id}
    API-->>Bob: Status: SUCCESS, imported: 950000

▶ 示例:CSV 上传端点 + Celery 任务触发

PYTHON
import csv
import io
from fastapi import FastAPI, UploadFile, File, Depends
from app.tasks import import_prices_from_csv

app = FastAPI()
UPLOAD_DIR = Path("uploads")

@app.post("/api/v1/import/csv")
async def import_csv(
    file: UploadFile = File(..., description="CSV file with price data"),
    user=Depends(require_subscription("pro")),
):
    if not file.filename.endswith(".csv"):
        raise HTTPException(status_code=400, detail="Only CSV files accepted")
    
    # Save uploaded file
    dest = UPLOAD_DIR / f"{uuid4()}.csv"
    with open(dest, "wb") as buffer:
        shutil.copyfileobj(file.file, buffer)
    
    # Trigger Celery task for async processing
    task = import_prices_from_csv.delay(str(dest), user_id=user.id)
    
    return {
        "task_id": task.id,
        "filename": file.filename,
        "status": "processing",
        "message": "File uploaded. Check task status for progress.",
    }

输出:

TEXT 📖 仅展示
# 函数定义成功

▶ 示例:Celery 任务逐行解析 CSV

PYTHON
# app/tasks/import_tasks.py
from app.core.celery_app import celery_app
from pydantic import BaseModel, Field, field_validator
import csv

class PriceRow(BaseModel):
    product_id: int = Field(gt=0)
    price: float = Field(gt=0)
    currency: str = Field(default="USD", pattern=r"^[A-Z]{3}$")
    source: str = Field(max_length=100, default="csv_import")

    @field_validator("price")
    @classmethod
    def round_price(cls, v: float) -> float:
        return round(v, 2)

@celery_app.task(bind=True)
def import_prices_from_csv(self, file_path: str, user_id: int):
    valid_rows = []
    invalid_rows = []
    total_rows = 0
    
    with open(file_path, "r") as f:
        reader = csv.DictReader(f)
        for row in reader:
            total_rows += 1
            try:
                validated = PriceRow(**row)
                valid_rows.append(validated.model_dump())
            except Exception as e:
                invalid_rows.append({"row": total_rows, "error": str(e)})
            
            # Report progress every 10000 rows
            if total_rows % 10000 == 0:
                self.update_state(
                    state="PROGRESS",
                    meta={"current": total_rows, "valid": len(valid_rows), "invalid": len(invalid_rows)},
                )
    
    # Batch insert valid rows
    batch_insert_prices(valid_rows, batch_size=5000)
    
    # Clean up temp file
    Path(file_path).unlink(missing_ok=True)
    
    return {
        "total": total_rows,
        "imported": len(valid_rows),
        "skipped": len(invalid_rows),
    }

输出:

TEXT 📖 仅展示
# 函数定义成功

(2) 文件格式处理矩阵

格式 解析库 优点 缺点
CSV csv / pandas 轻量、流式 无类型、编码问题
XLSX openpyxl / pandas 有类型、多 Sheet 内存占用高
JSON json / orjson 结构化、Pydantic 友好 文件较大
Parquet pyarrow 列式存储、压缩率高 需额外库

6. 文件下载

(1) FileResponse 与 StreamingResponse

▶ 示例:FileResponse 下载文件

PYTHON
from fastapi import FastAPI
from fastapi.responses import FileResponse
from pathlib import Path

app = FastAPI()

@app.get("/download/prices/csv")
async def download_prices_csv(
    user=Depends(require_subscription("pro")),
):
    # Generate CSV file (or use pre-generated)
    file_path = Path("exports/prices.csv")
    return FileResponse(
        path=file_path,
        filename="price_data.csv",
        media_type="text/csv",
    )

输出:

TEXT 📖 仅展示
# 函数定义成功

▶ 示例:StreamingResponse 流式生成 CSV

PYTHON
from fastapi.responses import StreamingResponse
import csv
import io
from app.core.deps import get_db

@app.get("/api/v1/export/prices")
async def export_prices(
    category: str | None = None,
    db: AsyncSession = Depends(get_db),
    user=Depends(require_subscription("pro")),
):
    async def generate_csv():
        output = io.StringIO()
        writer = csv.writer(output)
        writer.writerow(["product_id", "name", "price", "currency", "recorded_at"])
        yield output.getvalue()
        output.seek(0)
        output.truncate(0)
        
        # Stream rows in batches
        offset = 0
        batch_size = 5000
        while True:
            rows = await fetch_price_batch(db, category, offset, batch_size)
            if not rows:
                break
            for row in rows:
                writer.writerow([
                    row.product_id, row.name,
                    row.price, row.currency, row.recorded_at,
                ])
                yield output.getvalue()
                output.seek(0)
                output.truncate(0)
            offset += batch_size
    
    return StreamingResponse(
        generate_csv(),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=prices.csv"},
    )

输出:

TEXT 📖 仅展示
# 函数定义成功
响应类型 适用场景 内存占用
FileResponse 已有文件 低(OS 层面流式)
StreamingResponse 动态生成 极低(逐行生成)

❓ 常见问题

Q UploadFile 的临时文件什么时候删除?
A 请求结束后自动删除。如需保留,在上传处理中将其复制到持久目录。
Q 上传文件大小有限制吗?
A FastAPI 无框架限制,但 Uvicorn 默认限制请求体大小。生产环境用 Nginx 的 client_max_body_size 控制。
Q CSV 导入中部分行无效怎么办?
A 策略取决于业务——严格模式(任何无效行全部回滚)或宽松模式(跳过无效行,导入有效行)。PriceTracker 用宽松模式,返回导入数和跳过数。
Q Excel 文件怎么处理?
A 用 pandas 的 read_excel() 解析,但 XLSX 不能流式读取(必须全加载)。百万行建议转 CSV 格式上传。
Q StreamingResponse 的 generator 必须是 async 吗?
A 不必须,同步 generator 也可以。但 async generator 不会阻塞事件循环,推荐使用。
Q 如何限制上传文件类型?
A 检查 file.content_type(如 text/csv)和文件扩展名。注意 content_type 可被伪造,仅做前端提示,服务端仍需解析校验。

📖 小节


📝 作业

  1. 基础题(难度⭐):实现 CSV 上传端点,接收 UploadFile,读取内容并返回行数和列名。提示:file: UploadFile = File(...) + csv.DictReader(io.StringIO(content))
  2. 进阶题(难度⭐⭐):实现 CSV 上传 → Celery 异步解析流程:上传文件保存到临时目录,触发 Celery 任务,返回 task_id,另一个端点查询任务状态和导入结果。提示:shutil.copyfileobj + import_prices.delay(file_path)
  3. 挑战题(难度⭐⭐⭐):实现 StreamingResponse 导出端点:从数据库流式读取价格数据,逐行生成 CSV 返回,内存占用不超过 1MB,支持按品类过滤。提示:async def generate() + yield + StreamingResponse(generate(), media_type="text/csv")

---|

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏