网站Logo 开飞机的舒克

FastAPI 异步 ORM 实战:从核心特性到图书 CRUD 最佳实践

dddd
20
2026-07-18

引言

FastAPI 这几年很受 Python 后端开发者欢迎,不只是因为它“快”,更因为它把现代 Web API 开发里几个痛点处理得很漂亮:

  • 异步支持:天然支持 async def,适合数据库、缓存、HTTP 调用这类 IO 密集型服务。

  • 自动文档生成:基于类型标注和 Pydantic 模型生成 OpenAPI 文档,接口可读、可调试。

  • 依赖注入:用 Depends 管理数据库会话、鉴权、分页参数等公共逻辑。

  • 类型友好:参数校验、编辑器提示、文档展示基本都围绕类型声明展开。

我们可以看到一个典型学习型示例:它把 FastAPI 路由、SQLAlchemy 2.x 异步 ORM、MySQL 连接池、事务提交与回滚都放在了一起。对于刚开始做后端项目的同学来说,这正好是一条很实用的主线。

项目代码在做什么

db/orm.py 的核心结构可以概括为下面这张表:

模块能力

代码元素

为什么重要

异步数据库连接

create_async_engine()

不阻塞事件循环,提升高并发 IO 场景吞吐

ORM 基类

DeclarativeBaseMappedmapped_column

使用 SQLAlchemy 2.x 推荐写法定义表结构

会话工厂

async_sessionmaker

每个请求创建独立 AsyncSession

依赖注入

get_db() + Depends(get_db)

把数据库会话生命周期交给 FastAPI 管理

路由拆分

APIRouter(prefix="/db")

让数据库相关接口形成独立模块

请求体验证

BookCreate(BaseModel)

在进入业务逻辑前完成字段校验

异常处理

HTTPException

用 HTTP 状态码表达业务错误

我们先看整体请求链路:

flowchart LR
    A["客户端请求 /db/book/add"] --> B["FastAPI 路由匹配"]
    B --> C["Pydantic 校验 BookCreate"]
    C --> D["Depends(get_db) 创建 AsyncSession"]
    D --> E["执行业务逻辑和 SQLAlchemy ORM"]
    E --> F{"是否异常?"}
    F -->|否| G["commit 提交事务"]
    F -->|是| H["rollback 回滚事务"]
    G --> I["返回 JSON 响应"]
    H --> J["返回错误响应"]

我的实战感悟:FastAPI 项目写久了你会发现,真正决定代码是否好维护的,不是某个接口写得多炫,而是“请求参数、业务逻辑、数据库会话、响应模型”这些边界是否清楚。

FastAPI 核心特性

1. 异步支持:为什么用 async def

在传统同步 Web 框架里,一个请求访问数据库时,线程会等待数据库返回结果。FastAPI 基于 ASGI,可以用 async def 把等待 IO 的时间让出来,让事件循环继续处理其他请求。

这并不意味着单条 SQL 会变快。它真正提升的是:

  • 大量并发请求下的连接利用率。

  • 等待数据库、Redis、第三方 API 时的整体吞吐。

  • Web 服务在 IO 密集型场景下的响应能力。

这些接口都是异步的:

@router.get("/book/detail")
async def read_book_detail(...):
    ...

只要内部调用的是异步数据库驱动,例如 mysql+aiomysql,就可以通过 await db.execute(...) 非阻塞地等待结果。

2. 自动文档生成:类型标注就是接口说明

FastAPI 会根据函数签名、QueryBody、Pydantic 模型自动生成 OpenAPI 文档。启动项目后,我们通常可以访问:

  • http://127.0.0.1:8000/docs

  • http://127.0.0.1:8000/redoc

比如下面这个参数:

book_id: Annotated[int, Query(description="图书 ID")]

FastAPI 会知道它是一个查询参数,类型是整数,并把描述展示在接口文档里。

3. 依赖注入:把公共逻辑抽出来

数据库会话、当前登录用户、分页参数、权限校验,都可以用 Depends 注入。它的好处是:

  • 路由函数只关注业务逻辑。

  • 公共资源可以统一创建和释放。

  • 测试时更容易替换依赖。

get_db() 就是一个很典型的依赖:

async def get_db():
    async with async_session() as session:
        try:
            # 使用yield返回会话对象,等待调用方使用。
            # 使用return返回会话对象,会立即关闭会话对象(需要每一次在路由函数中commit、rollback、close ,而且容易忘)
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

这个依赖在请求正常结束后提交事务,在出现异常时回滚事务。路由函数只需要这样使用:

async def read_book_count(db: AsyncSession = Depends(get_db)):
    ...

请求体验证:用 Pydantic 把错误挡在入口

定义了 BookCreate

class BookCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=10, description="书名")
    price: float = Field(..., description="价格")
    author: str = Field(..., description="作者")
    description: str = Field(..., description="描述")

这段代码的价值不是“少写几个 if”,而是把接口契约明确写出来:调用方必须传什么字段、字段类型是什么、长度限制是什么。

第二个可运行示例:

# validation_demo.py
from typing import Annotated

from fastapi import Body, FastAPI
from pydantic import BaseModel, Field

app = FastAPI(title="Book Validation Demo")


class BookCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=10, description="书名")
    price: float = Field(..., gt=0, description="价格必须大于 0")
    author: str = Field(..., min_length=1, description="作者")
    description: str = Field(default="", max_length=255, description="描述")


@app.post("/books")
async def create_book(
    book: Annotated[BookCreate, Body(description="图书信息")],
) -> dict[str, object]:
    # 请求体已经被 FastAPI + Pydantic 校验过
    return {"message": "created", "book": book.model_dump()}

运行:

uvicorn validation_demo:app --reload

请求示例:

{
  "name": "Python 入门",
  "price": 59.9,
  "author": "Alice",
  "description": "适合初学者"
}

如果 price-1,FastAPI 会直接返回 422 Unprocessable Entity,业务函数甚至不会被执行。

依赖注入:请求级数据库会话

在实际项目里,数据库连接不应该在每个路由里手动创建。更推荐的方式是:

  1. 应用启动时创建 enginesessionmaker

  2. 每个请求通过依赖创建一个 AsyncSession

  3. 请求成功提交,失败回滚。

  4. 应用关闭时释放连接池。

第三个可运行示例基于 SQLite,方便你本地直接体验异步依赖注入,不需要先安装 MySQL。

# dependency_demo.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated

from fastapi import Depends, FastAPI
from sqlalchemy import Integer, String, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

engine = create_async_engine("sqlite+aiosqlite:///./demo.db", echo=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)


class Base(DeclarativeBase):
    pass


class Book(Base):
    __tablename__ = "books"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(50), unique=True)


async def get_session() -> AsyncIterator[AsyncSession]:
    async with SessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    # 启动时建表,关闭时释放连接池
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield
    await engine.dispose()


app = FastAPI(title="Dependency Injection Demo", lifespan=lifespan)


@app.post("/books")
async def add_book(
    name: str,
    session: Annotated[AsyncSession, Depends(get_session)],
) -> dict[str, object]:
    book = Book(name=name)
    session.add(book)
    await session.flush()
    return {"id": book.id, "name": book.name}


@app.get("/books")
async def list_books(
    session: Annotated[AsyncSession, Depends(get_session)],
) -> list[dict[str, object]]:
    result = await session.execute(select(Book).order_by(Book.id.desc()))
    books = result.scalars().all()
    return [{"id": book.id, "name": book.name} for book in books]

需要依赖:

pip install fastapi "uvicorn[standard]" "sqlalchemy[asyncio]" aiosqlite

运行:

uvicorn dependency_demo:app --reload

异步 ORM 如何工作

1. 创建异步 Engine

项目里使用的是:

ASYNC_DATABASE_URL = (
    "mysql+aiomysql://root:******@localhost:3306/fastapi_test?charset=utf8"
)

async_engine = create_async_engine(
    ASYNC_DATABASE_URL,
    echo=True,
    pool_size=10,
    max_overflow=20,
)

这里有三个关键点:

  • mysql+aiomysql 表示使用 MySQL 的异步驱动。

  • //后跟账号密码还有地址已经数据库名称(注意数据库在连接的时候需要提前创建)

  • echo=True 会打印 SQL,适合开发调试,不建议生产长期打开。

  • pool_sizemax_overflow 控制连接池容量,不能盲目调大。

最佳实践:数据库账号、密码、主机、库名建议放到环境变量或 .env 中,不要硬编码在源码里。尤其是教学代码进入真实项目时,这一步要尽早做。

2. 定义 ORM 模型

Book 继承了一个包含时间字段的基类:

class DATA_TIME(DeclarativeBase):
    create_time: Mapped[datetime] = mapped_column(
        DateTime,
        insert_default=func.now(),
        default=func.now(),
        comment="创建时间",
    )
    update_time: Mapped[datetime] = mapped_column(
        DateTime,
        insert_default=func.now(),
        default=func.now(),
        onupdate=func.now(),
        comment="修改时间",
    )

Mappedmapped_column 是 SQLAlchemy 2.x 推荐的 ORM 声明方式。它比老式写法更贴近 Python 类型系统,编辑器提示也更舒服。

图书表可以理解为:

class Book(DATA_TIME):
    __tablename__ = "book"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(255), unique=True)
    author: Mapped[str] = mapped_column(String(255))
    price: Mapped[float] = mapped_column(Float)
    description: Mapped[str] = mapped_column(String(255))

这里 name 设置了 unique=True,意味着数据库层面不允许书名重复。代码中 add_book() 也提前查了一次重名,用来给用户返回更友好的错误。

3. 创建表结构

async def create_db_and_tables():
    async with async_engine.begin() as conn:
        await conn.run_sync(DATA_TIME.metadata.create_all)

run_sync() 是异步 SQLAlchemy 里常见的桥接方法:有些元数据操作本质上还是同步 API,需要通过异步连接安全地运行。

生产项目里,我更建议用 Alembic 管理表结构迁移。metadata.create_all() 很适合学习和原型开发,但不适合长期维护复杂数据库结构。

4. 管理事务边界

get_db() 的写法体现了一个很重要的思想:一个请求,一个会话,一个事务边界

sequenceDiagram
    participant Client as Client
    participant API as FastAPI Endpoint
    participant Dep as get_db()
    participant DB as MySQL

    Client->>API: HTTP Request
    API->>Dep: Depends(get_db)
    Dep->>DB: Open AsyncSession
    API->>DB: execute / add / delete
    alt success
        Dep->>DB: commit()
    else exception
        Dep->>DB: rollback()
    end
    DB-->>Client: JSON Response

这样设计的好处是:路由函数不用反复写 commit() / rollback(),并且异常场景不会把半完成的数据留在数据库里。

CRUD 实战:图书接口怎么写

查询单条:get()execute() 的区别

db.get(Book, 1) 适合按主键查询:

book = await db.get(Book, 1)

如果你要按条件查询,就使用 select()

res = await db.execute(select(Book).where(Book.id == book_id))
book = res.scalar_one_or_none()

两者的使用场景如下:

方法

适合场景

返回特点

db.get(Model, pk)

按主键查一条

直接返回 ORM 对象或 None

db.execute(select(...))

条件查询、统计、分页

返回 Result,通常继续 .scalars()

条件查询:组合表达式要注意括号

项目里有类似这样的条件:

select(Book).where(
    Book.name.like(f"%{name}%") & (Book.price > 100)
    | (Book.author == "张三") & ~(Book.description == "无")
)

为了可读性,我建议写成:

select(Book).where(
    (
        Book.name.like(f"%{name}%")
        & (Book.price > 100)
    )
    | (
        (Book.author == "张三")
        & ~(Book.description == "无")
    )
)

&|~ 分别表示 SQL 条件里的 AND、OR、NOT。因为 Python 运算符优先级容易让人看错,复杂条件最好主动加括号。

分页查询:先满足可用,再考虑性能

项目中分页接口使用 offset + limit

res = await db.execute(
    select(Book)
    .offset((current - 1) * size)
    .limit(size)
)
return res.scalars().all()

这是最容易理解的分页方式,适合数据量不大的后台列表。等数据量上来后,再考虑基于游标的分页,例如 id < last_id

新增图书:为什么要 flush()refresh()

db_book = Book(**book.model_dump())
db.add(db_book)
await db.flush()
await db.refresh(db_book)
return db_book

这里的重点是:

  • add():把对象加入 Session。

  • flush():把 SQL 发到数据库,但还没有最终提交事务。

  • refresh():从数据库重新加载对象,拿到自增 ID、默认时间等字段。

  • commit():在 get_db() 依赖中统一执行。

也就是说,flush() 不是提交,它只是把当前变更同步到数据库事务里。

更新和删除:先查存在性

更新:

db_book = await db.get(Book, book_id)
if db_book is None:
    raise HTTPException(status_code=404, detail="图书不存在")

db_book.name = book.name
db_book.price = book.price
db_book.author = book.author
db_book.description = book.description
await db.flush()
await db.refresh(db_book)
return db_book

删除:

db_book = await db.get(Book, book_id)
if db_book is None:
    raise HTTPException(status_code=404, detail="图书不存在")

await db.delete(db_book)
await db.flush()
return {"msg": "删除成功"}

我的习惯是:更新、删除都先查对象是否存在。这样接口返回会更清楚,也方便后续加权限判断、审计日志等业务逻辑。

自动文档:让接口天然可调试

FastAPI 的自动文档来自三个地方:

来源

示例

文档效果

路由装饰器

@router.post("/book/add")

生成接口路径和 HTTP 方法

参数声明

Query(ge=1, description="当前页码")

生成参数约束和说明

Pydantic 模型

BookCreate

生成请求体 Schema

比如分页接口:

@router.get("/book/page")
async def read_book_page(
    current: int = Query(1, ge=1, description="当前页码"),
    size: int = Query(10, ge=1, description="每页数量"),
    db: AsyncSession = Depends(get_db),
):
    ...

/docs 里,currentsize 会自动显示默认值、最小值和描述。对于前后端协作来说,这比单独维护一份接口文档省心很多。

最佳实践清单

1. 路由层不要变成“大杂烩”

当前 db/orm.py 为了学习方便,把模型、数据库连接、依赖、路由都放在了一个文件里。真实项目建议拆分:

app/
  db/
    engine.py        # engine 和 sessionmaker
    models.py        # ORM 模型
    dependencies.py  # get_db 依赖
  schemas/
    book.py          # Pydantic 请求/响应模型
  routers/
    book.py          # API 路由

拆分不是为了“显得高级”,而是为了让每个文件职责更单一。等项目变大时,你会感谢自己早一点做了这件事。

2. 不要在源码里硬编码数据库密码

推荐使用环境变量:

import os

DATABASE_URL = os.environ["DATABASE_URL"]

启动时传入:

DATABASE_URL="mysql+aiomysql://user:password@127.0.0.1:3306/app?charset=utf8mb4" uvicorn main:app --reload

另外,MySQL 字符集建议优先使用 utf8mb4,比 utf8 更完整,能正确支持 emoji 和更多 Unicode 字符。

3. 给接口加 response_model

直接返回 ORM 对象在学习阶段很方便,但真实项目里我更推荐加响应模型:

from pydantic import BaseModel


class BookRead(BaseModel):
    id: int
    name: str
    price: float
    author: str
    description: str


@router.get("/book/detail", response_model=BookRead | None)
async def read_book_detail(...):
    ...

这样可以避免把不该暴露的字段返回给客户端,也能让 OpenAPI 文档更准确。

4. 生产环境使用 Alembic 管理迁移

create_all() 适合入门学习,但生产环境需要可追踪、可回滚、可审核的迁移脚本。推荐引入 Alembic:

alembic init migrations
alembic revision --autogenerate -m "create book table"
alembic upgrade head

5. 事务要短,外部 IO 不要塞进事务

下面这种模式要尽量避免:

开启事务 -> 查数据库 -> 调第三方接口 -> 写数据库 -> 提交事务

第三方接口慢的时候,数据库连接和锁都会被长期占用。更好的做法是:事务里只做必要的数据库操作,外部 IO 放到事务外,复杂场景再考虑消息队列或 outbox pattern。

常见问题

flush()commit() 有什么区别

flush() 是把变更发送到数据库事务里,常用于拿自增 ID 或触发约束检查;commit() 是真正提交事务。一个请求里可以多次 flush(),但通常只在事务边界提交一次。

为什么 expire_on_commit=False

异步 ORM 下,提交后如果对象属性过期,再访问属性可能触发隐式 IO。隐式 IO 在异步环境里容易引发 MissingGreenlet 一类问题。设置 expire_on_commit=False 能减少这种意外。

为什么我建议显式写 QueryBody

因为它们不仅影响运行时校验,也会影响自动文档。对团队项目来说,文档准确本身就是生产力。

小结

通过 db/orm.py,我们其实已经把 FastAPI 后端项目里最重要的一组能力串起来了:

  • APIRouter 组织接口模块。

  • async def 和异步数据库驱动支撑高并发 IO。

  • 用 Pydantic 把请求体校验前置。

  • Depends(get_db) 管理请求级数据库会话。

  • 用 SQLAlchemy 2.x ORM 完成增删改查。

  • HTTPException 表达业务错误。

如果你刚开始学习 FastAPI,我建议先把这套图书 CRUD 跑通,再逐步把配置、模型、Schema、路由拆分出去。先跑通,再变优雅,这是我自己做后端项目时一直很相信的节奏。

参考资料