FastAPI 如何连接 MySQL

写 FastAPI 后端时,数据库连接不是只写一行连接串就结束了。

一个能长期维护的 MySQL 接入方式,通常要同时解决这些问题:

  • Python 用什么驱动真正连上 MySQL
  • ORM 用什么方式把 Python 类映射成数据库表
  • 每个 HTTP 请求如何拿到独立的数据库会话
  • 写操作如何提交事务,失败时如何回滚
  • 项目变大后,数据库连接、模型、路由、业务逻辑分别放在哪里

先看完整流程。

这张图可以拆成两条主线:

text 复制代码
应用启动
  -> 读取 DATABASE_URL
  -> 创建 Engine
  -> 创建 SessionLocal
  -> 可选:创建表、写入初始化数据

接口请求
  -> FastAPI Router 接收请求
  -> Depends 注入数据库 Session
  -> Service 处理业务规则
  -> Repository / ORM 执行数据库读写
  -> commit 或 rollback
  -> 请求结束后关闭 Session

FastAPI 不直接负责连接 MySQL。它负责 HTTP、参数校验、依赖注入和响应返回。

真正跟 MySQL 打交道的是:

text 复制代码
FastAPI
  -> SQLAlchemy
  -> PyMySQL
  -> MySQL

一、先把它们之间的配合讲清楚

如果只记一个关系,就记这条链路:

text 复制代码
用户发起 HTTP 请求
  -> FastAPI 找到对应路由函数
  -> Depends 调用 get_db 创建一个 Session
  -> 路由函数把 Session 交给 Service
  -> Service 处理业务规则和事务
  -> Repository 使用 Session 写 SQLAlchemy 查询
  -> SQLAlchemy 把 ORM 查询翻译成 SQL
  -> PyMySQL 把 SQL 发给 MySQL
  -> MySQL 执行 SQL 并返回结果
  -> SQLAlchemy 把结果转换成 Python 对象
  -> FastAPI 把响应对象转换成 JSON 返回给前端

这套关系里,每一层都有明确分工。

text 复制代码
FastAPI
  -> 管 HTTP,不管数据库底层连接

Depends / get_db
  -> 管每个请求的数据库 Session 生命周期

SQLAlchemy Engine
  -> 管数据库入口和连接池

SQLAlchemy Session
  -> 管一次业务操作里的查询、写入、提交、回滚

SQLAlchemy ORM Model
  -> 管 Python 类和数据库表之间的映射

PyMySQL
  -> 管 Python 进程和 MySQL 服务之间的底层通信

MySQL
  -> 真正存储和查询数据

二、需要哪些第三方依赖

一个同步版 FastAPI + MySQL 项目,最少需要这些依赖:

bash 复制代码
uv add "fastapi[standard]" sqlalchemy pymysql pydantic-settings

如果使用 pip

bash 复制代码
pip install "fastapi[standard]" sqlalchemy pymysql pydantic-settings

这些包分别负责不同层级的事情。

1. FastAPI

FastAPI 是 Web 框架,负责:

  • 定义 HTTP 接口
  • 解析路径参数、查询参数、请求体
  • 使用 Pydantic 做数据校验
  • 通过 Depends 注入依赖
  • 自动生成 OpenAPI 文档

这里有两个词先解释清楚。

路由 指的是"某个 HTTP 请求应该交给哪个函数处理"。例如 GET /users/1 交给 get_user 函数处理。

依赖注入 指的是"路由函数需要什么对象,由 FastAPI 在调用函数前帮你准备好"。数据库 Session 就很适合用依赖注入,因为每个请求都需要一个独立 Session,请求结束后还必须关闭。

数据库连接进入 FastAPI 的方式,通常不是在路由函数里手动创建连接,而是通过依赖注入:

python 复制代码
from typing import Annotated

from fastapi import Depends
from sqlalchemy.orm import Session


# SessionDep 是一个类型别名:
# 它告诉 FastAPI,只要路由参数标注为 SessionDep,
# 就先执行 get_db(),把得到的数据库 Session 传进来。
SessionDep = Annotated[Session, Depends(get_db)]

这样路由只声明"我需要一个数据库 Session",至于这个 Session 怎么创建、怎么关闭,交给统一的依赖函数处理。

2. SQLAlchemy

SQLAlchemy 是数据库工具和 ORM。

它负责:

  • 创建数据库连接入口 Engine
  • 管理连接池
  • 创建请求级 Session
  • 定义 ORM 模型
  • 生成 SQL
  • 执行增删改查
  • 管理事务提交和回滚

ORMObject Relational Mapping 的缩写,中文常叫"对象关系映射"。

它解决的是这件事:

text 复制代码
在 Python 代码里面操作对象
在数据库里面是操作表和行
ORM 负责在两者之间做转换

例如 Python 里有一个 User 类,数据库里有一张 users 表。ORM 会把 User.username 对应到 users.username 字段。这样你可以写 Python 对象和查询表达式,而不是在业务里到处拼 SQL 字符串。

使用 SQLAlchemy 后,业务代码通常不直接拼 SQL 字符串,而是操作 Python 类:

python 复制代码
# select(User) 表示查询 users 表对应的 User 模型
# where(...) 表示追加查询条件
# scalar(...) 表示只取一条 ORM 对象结果
user = db.scalar(select(User).where(User.username == username))

这行代码最后会被 SQLAlchemy 转成 SQL,交给底层数据库驱动执行。

3. PyMySQL

PyMySQL 是 MySQL 的 Python 驱动。

它是真正负责和 MySQL 服务通信的底层库。SQLAlchemy 自己不是 MySQL 驱动,它需要通过 DBAPI 驱动连接具体数据库。

驱动 可以理解成数据库的"适配器"。不同数据库说话方式不一样,MySQL、PostgreSQL、SQLite 都有自己的协议和细节。SQLAlchemy 负责生成 SQL 和管理 ORM,但真正打开网络连接、登录 MySQL、发送 SQL、读取结果,需要驱动来做。

DBAPI 是 Python 里数据库驱动遵循的一套接口规范。PyMySQL 实现了这套规范,所以 SQLAlchemy 可以通过它连接 MySQL。

连接 MySQL 时,SQLAlchemy URL 一般写成:

text 复制代码
mysql+pymysql://用户名:密码@主机:端口/数据库名?charset=utf8mb4

这里的 pymysql 就是在告诉 SQLAlchemy:

text 复制代码
数据库类型是 MySQL
底层驱动使用 PyMySQL

如果 MySQL 账号使用了需要 RSA 的认证方式,可以安装:

bash 复制代码
uv add "pymysql[rsa]"

或者:

bash 复制代码
pip install "PyMySQL[rsa]"

普通本地开发大多数时候只需要 pymysql

4. pydantic-settings

pydantic-settings 用来读取环境变量和 .env 配置。

数据库连接串、是否打印 SQL、是否自动建表,这些都不应该写死在代码里,而应该放到环境变量中。

典型 .env

env 复制代码
DATABASE_URL="mysql+pymysql://root:password@127.0.0.1:3306/todo_api?charset=utf8mb4"
DATABASE_ECHO=false
DATABASE_AUTO_CREATE=false

读取配置:

python 复制代码
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    # 告诉 pydantic-settings 从 .env 文件读取配置
    # env_file_encoding 用来避免中文或特殊字符出现编码问题
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
    )

    # 必填配置:没有 DATABASE_URL 时,应用应该直接启动失败
    database_url: str

    # 是否打印 SQL。调试时可以改成 true,正常开发一般保持 false
    database_echo: bool = False

    # 是否在启动时自动建表。本地 demo 可以 true,正式项目一般 false
    database_auto_create: bool = False


# 创建全局配置对象。其他模块通过 settings.database_url 读取配置
settings = Settings()

5. Alembic

Alembic 不是连接 MySQL 的必需依赖,但正式项目通常需要它。

它负责数据库迁移:

bash 复制代码
uv add alembic

为什么需要迁移工具?

因为项目上线后,表结构变化不能靠 Base.metadata.create_all() 随手创建。正式环境需要可追踪、可回滚、可审查的迁移文件。

可以先记住这个区分:

text 复制代码
本地学习 / 早期 demo
  -> 可以用 create_all()

团队项目 / 正式环境
  -> 应该用 Alembic 管理迁移

三、连接串怎么写

MySQL 连接串的完整格式:

text 复制代码
mysql+pymysql://用户名:密码@主机:端口/数据库名?charset=utf8mb4

示例:

env 复制代码
DATABASE_URL="mysql+pymysql://root:password@127.0.0.1:3306/todo_api?charset=utf8mb4"

拆开看:

text 复制代码
mysql+pymysql://root:password@127.0.0.1:3306/todo_api?charset=utf8mb4
                |    |        |         |    |        |
                用户名 密码    主机      端口 数据库名  字符集

几个点要特别注意。

第一,推荐显式写 mysql+pymysql://,不要只写 mysql://

mysql+pymysql:// 的意思更明确:MySQL 方言加 PyMySQL 驱动。团队项目里不要依赖隐式默认值,否则换环境时容易出现"本机能跑,别人机器不能跑"的问题。

第二,字符集推荐 utf8mb4

utf8mb4 能完整支持 Unicode,包括 emoji。新项目没有必要再使用 MySQL 早期的 utf8

第三,密码里如果有特殊字符,需要 URL 编码。

例如密码里有 @#/:,连接串可能被解析错。更稳的做法是密码避免使用这些字符,或者在生成连接串时进行 URL 编码。

四、创建 Engine

Engine 是 SQLAlchemy 的数据库入口。

可以把它理解成:

text 复制代码
Engine = 数据库连接入口 + 连接池

这里的 连接池 指的是一组可复用的数据库连接。

如果每次请求都重新连接 MySQL,再断开连接,成本会很高。连接池会提前维护一些连接,请求来了就借一条,用完再还回去。这样后端服务可以更稳定地处理大量请求。

Engine 本身不是某一次查询,也不是某一个请求的连接。它更像应用级的数据库入口,通常在应用启动时创建一次,然后整个应用复用。

基础写法:

python 复制代码
from sqlalchemy import create_engine

from app.core.config import settings


engine = create_engine(
    # SQLAlchemy 根据这个 URL 判断数据库类型、驱动、账号、密码、主机和库名
    settings.database_url,

    # echo=True 会把 SQL 打到控制台,排查问题时很有用
    echo=settings.database_echo,

    # 每次从连接池取连接前先检查连接是否还活着
    # MySQL 长时间空闲后可能主动断开连接,这个参数能减少断连错误
    pool_pre_ping=True,
)

常见参数:

  • echo=True:打印 SQL,适合调试
  • echo=False:不打印 SQL,适合正常开发
  • pool_pre_ping=True:从连接池取连接前先检查连接是否可用,减少 MySQL 空闲连接断开导致的问题

MySQL 服务端可能会关闭长时间空闲的连接。后端服务如果继续拿到这条已经失效的连接,就会在执行 SQL 时失败。pool_pre_ping=True 可以让 SQLAlchemy 在使用连接前做一次轻量检查,发现连接不可用就重新连接。

五、创建 SessionLocal

Session 是 ORM 的操作上下文。

可以把它理解成:

text 复制代码
Session = 一次数据库读写上下文 + 事务边界

Session 这个名字容易误会。它不是浏览器登录态里的 Session,也不是用户会话。

在 SQLAlchemy 里,Session 表示一次数据库操作上下文:它知道你查了哪些对象、改了哪些对象、哪些对象准备新增、最后要不要提交事务。

SessionLocal 则是一个"Session 工厂"。它不是具体的 Session,而是用来创建 Session 的函数式对象。每个请求进来时,都通过它创建一个新的 Session。

创建 Session 工厂:

python 复制代码
from sqlalchemy.orm import sessionmaker


SessionLocal = sessionmaker(
    # 这个 Session 工厂创建出来的 Session,都使用前面创建的 engine
    bind=engine,

    # 不在查询前自动 flush,减少隐式数据库写入时机
    autoflush=False,

    # 不自动提交事务。写操作完成后必须手动 db.commit()
    autocommit=False,

    # commit 后对象字段仍然可读,不会立刻过期
    expire_on_commit=False,
)

参数含义:

  • bind=engine:这个 Session 工厂使用哪个 Engine
  • autoflush=False:不在查询前自动刷新变更,减少新手阶段的隐式行为
  • autocommit=False:不自动提交事务,写操作必须显式 commit
  • expire_on_commit=False:提交后对象字段仍可直接读取

使用方式:

python 复制代码
from sqlalchemy import select


# with 负责在代码块结束后关闭 Session
# 关闭 Session 不等于关闭 MySQL 服务,而是把连接还回连接池
with SessionLocal() as db:
    # select(User) 生成查询 users 表的 SQLAlchemy 查询对象
    # scalars(...) 表示返回 ORM 对象,而不是原始行对象
    users = db.scalars(select(User)).all()

with 退出时会关闭 Session,把连接归还给连接池。

六、定义 ORM 模型

ORM 模型用 Python 类描述数据库表。

模型 指的是"数据库表在 Python 代码里的表示"。表里每一行数据,读取出来后可以变成一个 Python 对象。对象的字段,对应数据库表的列。

先定义统一的 Base:

python 复制代码
from sqlalchemy.orm import DeclarativeBase


# 所有 ORM 模型都继承 Base
# SQLAlchemy 会通过 Base.metadata 收集所有表结构
class Base(DeclarativeBase):
    pass

再定义业务模型:

python 复制代码
from datetime import datetime

from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column

from app.db.base import Base


class User(Base):
    # __tablename__ 指定这个模型对应数据库里的哪张表
    __tablename__ = "users"

    # primary_key=True 表示主键
    # autoincrement=True 表示由 MySQL 自动生成递增 ID
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # unique=True 表示数据库层面不允许重复
    # nullable=False 表示这一列不能为空
    username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
    email: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)

    # default=1 是 Python 侧默认值。创建 User 时不传 status,就默认是 1
    status: Mapped[int] = mapped_column(Integer, nullable=False, default=1)

    # server_default=func.now() 表示由数据库服务器生成默认时间
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),
        nullable=False,
    )

这段代码表示:

text 复制代码
User 类
  -> users 表

User.username
  -> users.username 字段

User.email
  -> users.email 字段

Mapped[...]mapped_column(...) 是 SQLAlchemy 2.x 推荐的声明式写法。类型标注不仅给编辑器看,也帮助 ORM 更清楚地理解字段类型。

七、执行增删改查

1. 查询

查询全部用户:

python 复制代码
from sqlalchemy import select
from sqlalchemy.orm import Session


def list_users(db: Session) -> list[User]:
    return list(db.scalars(select(User)).all())

按主键查询:

python 复制代码
from sqlalchemy.orm import Session


def get_user(db: Session, user_id: int) -> User | None:
    return db.get(User, user_id)

按条件查询:

python 复制代码
from sqlalchemy import select
from sqlalchemy.orm import Session


def get_user_by_username(db: Session, username: str) -> User | None:
    return db.scalar(select(User).where(User.username == username))

分页查询:

python 复制代码
from sqlalchemy import func, select
from sqlalchemy.orm import Session


def page_users(db: Session, page: int, page_size: int) -> dict[str, object]:
    # 第 1 页跳过 0 条,第 2 页跳过 page_size 条
    offset = (page - 1) * page_size

    # 先查总数,用来给前端显示一共有多少条数据
    total = db.scalar(select(func.count()).select_from(User)) or 0

    # 再查当前页数据
    items = db.scalars(
        select(User)
        .order_by(User.id.desc())
        .offset(offset)
        .limit(page_size)
    ).all()

    return {
        "items": list(items),
        "total": total,
        "page": page,
        "page_size": page_size,
    }

2. 新增

python 复制代码
from sqlalchemy.orm import Session


def create_user(db: Session, username: str, email: str) -> User:
    # 这里只是在 Python 内存里创建 ORM 对象,还没有写入数据库
    user = User(
        username=username,
        email=email,
        status=1,
    )

    # 加入当前 Session,表示这条数据准备新增
    db.add(user)

    # 提交事务后,INSERT 才会真正执行
    db.commit()

    # 刷新对象,拿到数据库生成的 id、created_at 等字段
    db.refresh(user)

    return user

关键点:

  • db.add(user):把对象加入当前 Session
  • db.commit():提交事务,真正写入数据库
  • db.refresh(user):从数据库刷新对象,拿到自增 ID、默认值等字段

3. 更新

python 复制代码
from sqlalchemy.orm import Session


def update_user_email(db: Session, user_id: int, email: str) -> User | None:
    # db.get 适合按主键查询
    user = db.get(User, user_id)
    if user is None:
        return None

    # 修改 ORM 对象字段后,SQLAlchemy 会记录这次变化
    user.email = email

    # commit 时 SQLAlchemy 会生成 UPDATE 语句
    db.commit()
    db.refresh(user)

    return user

只要对象是从当前 Session 查出来的,修改字段后执行 commit(),SQLAlchemy 就会生成对应的 UPDATE

4. 删除

python 复制代码
from sqlalchemy.orm import Session


def delete_user(db: Session, user_id: int) -> bool:
    user = db.get(User, user_id)
    if user is None:
        return False

    # 标记这个 ORM 对象要删除
    db.delete(user)

    # commit 时 SQLAlchemy 会生成 DELETE 语句
    db.commit()

    return True

删除数据时要考虑外键约束、关联表、业务规则和是否允许物理删除。真实项目里,用户、订单、支付记录这类数据通常不会随意物理删除。

八、事务怎么处理

数据库写操作必须有清晰的事务边界。

最常见的写法是:

python 复制代码
from sqlalchemy.orm import Session


def create_user_with_role(db: Session, user: User, role: Role) -> User:
    try:
        # 两个新增动作放在同一个事务里
        db.add(user)
        db.add(role)

        # 两个对象都写入成功,事务才提交
        db.commit()
        db.refresh(user)
        return user
    except Exception:
        # 任意一步失败,都撤销本次事务里的所有数据库变更
        db.rollback()
        raise

事务的规则很简单:

text 复制代码
全部成功
  -> commit

中途失败
  -> rollback

也可以使用 begin() 上下文:

python 复制代码
# begin 会自动管理事务:
# 正常退出自动 commit,出现异常自动 rollback
with SessionLocal.begin() as db:
    db.add(user)
    db.add(role)

begin() 正常退出时自动提交,发生异常时自动回滚。

在业务接口里,更常见的是把 Session 交给 Service 或 Repository,然后在明确的业务动作完成后提交。这样读起来更清楚,也方便处理业务异常。

九、在 FastAPI 中注入数据库 Session

FastAPI 推荐用依赖注入管理请求级资源。

数据库 Session 的依赖函数可以这样写:

python 复制代码
from collections.abc import Generator

from sqlalchemy.orm import Session

from app.db.session import SessionLocal


def get_db() -> Generator[Session, None, None]:
    # 每个请求进来时,创建一个新的数据库 Session
    db = SessionLocal()
    try:
        # yield 会把 db 交给路由函数使用
        # 路由函数执行完成后,代码会继续走到 finally
        yield db
    finally:
        # 请求结束后关闭 Session,把连接还回连接池
        db.close()

这段代码的行为是:

text 复制代码
请求进入
  -> 创建一个 Session

路由函数执行
  -> 使用这个 Session 查询或写入数据库

请求结束
  -> finally 关闭 Session

路由里使用:

python 复制代码
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.dependencies.database import get_db
from app.modules.users.service import UserService


# prefix 表示这个 router 下的接口都以 /users 开头
# tags 用来给 OpenAPI 文档分组
router = APIRouter(prefix="/users", tags=["users"])

# 声明一个可复用的数据库依赖类型
# 以后路由参数写 db: SessionDep,就会自动拿到 get_db 提供的 Session
SessionDep = Annotated[Session, Depends(get_db)]


@router.get("/{user_id}")
def get_user(user_id: int, db: SessionDep) -> dict[str, object]:
    # 路由层不直接写数据库查询,而是调用 Service
    user = UserService.get_user(db, user_id)
    if user is None:
        # Service 返回 None,路由层把它转换成 HTTP 404
        raise HTTPException(status_code=404, detail="User not found")

    return {
        "id": user.id,
        "username": user.username,
        "email": user.email,
    }

这里路由层只做三件事:

  • 接收 HTTP 参数
  • 调用业务逻辑
  • 返回 HTTP 响应

它不负责创建数据库连接,也不直接堆很多 SQLAlchemy 查询。

十、从零搭一个可维护的工程结构

项目小的时候,一个 main.py 就能跑。

项目变大后,不建议把配置、数据库连接、模型、路由、业务逻辑都塞在一起。更稳的结构是:

text 复制代码
todo-api/
├── app/
│   ├── main.py
│   ├── api/
│   │   └── v1/
│   │       └── router.py
│   ├── core/
│   │   └── config.py
│   ├── db/
│   │   ├── base.py
│   │   ├── models.py
│   │   └── session.py
│   ├── dependencies/
│   │   └── database.py
│   └── modules/
│       └── users/
│           ├── models.py
│           ├── schemas.py
│           ├── repository.py
│           ├── service.py
│           └── router.py
├── migrations/
├── tests/
├── .env
└── pyproject.toml

每一层的职责要清楚。

text 复制代码
app/core/config.py
  -> 读取环境变量

app/db/session.py
  -> 创建 Engine、SessionLocal、init_database

app/db/base.py
  -> 定义 SQLAlchemy Base

app/db/models.py
  -> 统一导入所有 ORM 模型,方便 create_all 或 Alembic 发现模型

app/dependencies/database.py
  -> 定义 get_db

app/modules/users/models.py
  -> 用户表 ORM 模型

app/modules/users/schemas.py
  -> 用户接口的请求体和响应体

app/modules/users/repository.py
  -> 用户相关数据库读写

app/modules/users/service.py
  -> 用户业务规则

app/modules/users/router.py
  -> 用户 HTTP 接口

这个结构的核心原则是:

text 复制代码
公共基础设施放 app/core、app/db、app/dependencies
业务代码按模块放 app/modules/{module_name}

十一、核心文件怎么写

1. app/core/config.py

python 复制代码
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    # 指定 .env 文件作为本地配置来源
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
    )

    # 应用名称,会展示在接口文档里
    app_name: str = "Todo API"

    # MySQL 连接串,例如 mysql+pymysql://root:password@127.0.0.1:3306/todo_api
    database_url: str

    # 是否打印 SQL
    database_echo: bool = False

    # 是否启动时自动 create_all 建表
    database_auto_create: bool = False


# 全局配置对象。其他模块只读取它,不在业务代码里手写环境变量解析
settings = Settings()

2. app/db/base.py

python 复制代码
from sqlalchemy.orm import DeclarativeBase


# 所有 ORM 模型共同继承的基类
# 后续 User、Todo、Role 等模型都会注册到 Base.metadata 中
class Base(DeclarativeBase):
    pass

3. app/modules/users/models.py

python 复制代码
from datetime import datetime

from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column

from app.db.base import Base


class User(Base):
    # users 是数据库里的真实表名
    __tablename__ = "users"

    # id 是主键,由 MySQL 自增生成
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # username 和 email 都要求唯一且不能为空
    username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
    email: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)

    # status 可以用来表示启用、禁用等业务状态
    status: Mapped[int] = mapped_column(Integer, nullable=False, default=1)

    # created_at 由数据库写入当前时间
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),
        nullable=False,
    )

4. app/db/models.py

python 复制代码
from app.modules.users.models import User

# 统一导出模型,方便其他地方一次性导入
__all__ = ["User"]

这个文件看起来简单,但很重要。

SQLAlchemy 只有在模型类被 Python 导入后,Base.metadata 才知道有哪些表。集中导入模型,可以避免"明明写了模型,create_all 或 Alembic 却找不到表"的问题。

5. app/db/session.py

python 复制代码
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.core.config import settings
from app.db.base import Base
from app.db import models


engine = create_engine(
    # 从配置读取连接串,避免把账号密码写死在代码里
    settings.database_url,

    # 控制是否打印 SQL
    echo=settings.database_echo,

    # 防止 MySQL 空闲连接失效后,请求第一次查询就报错
    pool_pre_ping=True,
)

SessionLocal = sessionmaker(
    # SessionLocal 创建出的 Session 都绑定到这个 engine
    bind=engine,
    autoflush=False,
    autocommit=False,
    expire_on_commit=False,
)


def init_database() -> None:
    # 只在明确开启时自动建表
    # 正式项目通常使用 Alembic,不依赖 create_all
    if settings.database_auto_create:
        Base.metadata.create_all(bind=engine)

from app.db import models 不是为了在代码里直接使用 models 变量,而是为了确保所有 ORM 模型都被导入,让 Base.metadata 收集到表结构。

6. app/dependencies/database.py

python 复制代码
from collections.abc import Generator

from sqlalchemy.orm import Session

from app.db.session import SessionLocal


def get_db() -> Generator[Session, None, None]:
    # 为当前请求创建 Session
    db = SessionLocal()
    try:
        # 把 Session 交给 FastAPI 路由函数
        yield db
    finally:
        # 无论接口成功还是失败,最终都关闭 Session
        db.close()

7. app/modules/users/schemas.py

python 复制代码
from datetime import datetime

from pydantic import BaseModel, EmailStr


class UserCreate(BaseModel):
    # 创建用户时,前端必须传 username 和 email
    username: str
    email: EmailStr


class UserRead(BaseModel):
    # 返回给前端的用户字段
    id: int
    username: str
    email: EmailStr
    status: int
    created_at: datetime

如果使用 EmailStr,需要安装邮箱校验依赖:

bash 复制代码
uv add "pydantic[email]"

或者:

bash 复制代码
pip install "pydantic[email]"

如果不想加这个依赖,可以先把 EmailStr 改成普通的 str

8. app/modules/users/repository.py

python 复制代码
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.modules.users.models import User


class UserRepository:
    @staticmethod
    def get_by_id(db: Session, user_id: int) -> User | None:
        # Repository 只关心数据库怎么查
        return db.get(User, user_id)

    @staticmethod
    def get_by_username(db: Session, username: str) -> User | None:
        # 根据唯一用户名查单个用户
        return db.scalar(select(User).where(User.username == username))

    @staticmethod
    def create(db: Session, username: str, email: str) -> User:
        # 这里只 add,不 commit
        # commit 由 Service 在完整业务动作结束后统一处理
        user = User(username=username, email=email)
        db.add(user)
        return user

Repository 只处理数据库读写,不写 HTTP 异常,也不关心接口怎么返回。

9. app/modules/users/service.py

python 复制代码
from sqlalchemy.orm import Session

from app.modules.users.models import User
from app.modules.users.repository import UserRepository
from app.modules.users.schemas import UserCreate


class UserService:
    @staticmethod
    def get_user(db: Session, user_id: int) -> User | None:
        # Service 可以直接复用 Repository
        return UserRepository.get_by_id(db, user_id)

    @staticmethod
    def create_user(db: Session, data: UserCreate) -> User:
        # 业务规则:用户名不能重复
        exists = UserRepository.get_by_username(db, data.username)
        if exists is not None:
            raise ValueError("Username already exists")

        try:
            # 先执行数据库新增动作
            user = UserRepository.create(
                db,
                username=data.username,
                email=data.email,
            )

            # 业务动作完整成功后再提交事务
            db.commit()
            db.refresh(user)
            return user
        except Exception:
            # 提交前或提交时出错,都回滚本次事务
            db.rollback()
            raise

Service 负责业务规则和事务边界。

比如"用户名不能重复"属于业务规则,应该放在 Service。commitrollback 也更适合放在完成一个业务动作的位置。

10. app/modules/users/router.py

python 复制代码
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.dependencies.database import get_db
from app.modules.users.schemas import UserCreate, UserRead
from app.modules.users.service import UserService


# 这个文件只定义 users 模块自己的 HTTP 接口
router = APIRouter(prefix="/users", tags=["users"])
SessionDep = Annotated[Session, Depends(get_db)]


@router.get("/{user_id}", response_model=UserRead)
def get_user(user_id: int, db: SessionDep) -> UserRead:
    # 1. 路由层接收 user_id
    # 2. 把 db 和 user_id 交给 Service
    user = UserService.get_user(db, user_id)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="User not found",
        )

    # ORM 对象转换成响应模型,再由 FastAPI 转成 JSON
    return UserRead.model_validate(user, from_attributes=True)


@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def create_user(data: UserCreate, db: SessionDep) -> UserRead:
    try:
        # data 已经被 Pydantic 校验过,这里交给 Service 处理业务
        user = UserService.create_user(db, data)
    except ValueError as exc:
        # 业务异常在路由层转换成 HTTP 状态码
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=str(exc),
        ) from exc

    return UserRead.model_validate(user, from_attributes=True)

路由层要薄。

它应该关心 HTTP 语义,比如:

  • 什么路径
  • 什么方法
  • 什么状态码
  • 请求体是什么
  • 响应体是什么
  • 业务异常转成什么 HTTP 错误

它不应该塞满数据库查询。

11. app/api/v1/router.py

python 复制代码
from fastapi import APIRouter

from app.modules.users.router import router as users_router


api_router = APIRouter(prefix="/api/v1")

# 把 users 模块的接口挂到 /api/v1 下
api_router.include_router(users_router)

12. app/main.py

python 复制代码
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.api.v1.router import api_router
from app.core.config import settings
from app.db.session import init_database


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    # 应用启动时执行一次初始化逻辑
    init_database()

    # yield 之前是启动逻辑,yield 之后可以写关闭逻辑
    yield


app = FastAPI(
    title=settings.app_name,
    # lifespan 用来管理应用启动和关闭阶段的逻辑
    lifespan=lifespan,
)


# 注册总路由
app.include_router(api_router)

启动:

bash 复制代码
uv run fastapi dev app/main.py

也可以用 Uvicorn:

bash 复制代码
uv run uvicorn app.main:app --reload

十二、一条创建用户请求怎么走完整流程

现在把前面的文件串起来,看一条真实请求:

http 复制代码
POST /api/v1/users
Content-Type: application/json

{
  "username": "copyer",
  "email": "copyer@example.com"
}

它在后端的执行顺序是这样的。

text 复制代码
1. app/main.py
   FastAPI 应用已经 include_router(api_router)

2. app/api/v1/router.py
   /api/v1 前缀命中,总路由继续把请求交给 users_router

3. app/modules/users/router.py
   POST /users 命中 create_user 路由函数

4. FastAPI 发现路由参数里有 db: SessionDep
   于是先执行 get_db()

5. app/dependencies/database.py
   get_db() 通过 SessionLocal() 创建一个新的数据库 Session

6. router.py
   create_user(data, db) 开始执行
   data 是 Pydantic 校验后的 UserCreate
   db 是 get_db() 提供的 SQLAlchemy Session

7. service.py
   UserService.create_user(db, data) 执行业务规则
   先检查 username 是否重复

8. repository.py
   UserRepository.get_by_username(db, data.username)
   使用 SQLAlchemy 查询 User 表

9. SQLAlchemy
   把 select(User).where(...) 翻译成 SELECT SQL

10. PyMySQL
    把 SELECT SQL 发给 MySQL

11. MySQL
    执行查询,返回是否已有这个用户

12. service.py
    如果用户名不存在,调用 Repository 创建 User 对象

13. repository.py
    db.add(user)
    把 user 标记为准备新增,但此时还没有真正写入数据库

14. service.py
    db.commit()
    提交事务,SQLAlchemy 生成 INSERT SQL

15. PyMySQL
    把 INSERT SQL 发给 MySQL

16. MySQL
    写入 users 表,生成自增 id 和默认时间

17. service.py
    db.refresh(user)
    把数据库生成的新字段刷新回 Python 对象

18. router.py
    把 ORM 对象转换成 UserRead

19. FastAPI
    把 UserRead 转成 JSON 响应

20. get_db()
    请求结束,finally 执行 db.close()
    Session 关闭,连接归还连接池

这条链路里,最重要的是不要把职责混在一起。

text 复制代码
Router
  -> 只处理 HTTP 输入输出

Service
  -> 处理业务规则和事务

Repository
  -> 处理数据库读写细节

Session
  -> 记录本次数据库操作,并负责 commit / rollback

Engine
  -> 提供数据库连接入口和连接池

PyMySQL
  -> 真正和 MySQL 通信

如果创建用户失败,流程会变成:

text 复制代码
Service 执行中出错
  -> except 捕获异常
  -> db.rollback()
  -> 撤销本次事务里已经准备写入的变更
  -> raise 把异常继续抛出
  -> Router 或全局异常处理把异常转换成 HTTP 响应
  -> get_db finally 关闭 Session

所以 commitrollbackclose 是三个不同动作:

text 复制代码
commit
  -> 确认写入数据库

rollback
  -> 撤销本次事务

close
  -> 关闭当前 Session,把连接还回连接池

不要用 close 代替 rollback,也不要以为 add 之后数据已经写进数据库。真正落库发生在 commit

十三、同步还是异步

这篇使用的是同步 SQLAlchemy + PyMySQL。

也就是说:

text 复制代码
SQLAlchemy create_engine
  -> PyMySQL
  -> 同步数据库调用

这种方式适合大多数入门项目和普通后台系统,代码简单,资料多,问题也容易排查。

如果要做全链路异步,依赖会变成另一套:

text 复制代码
SQLAlchemy create_async_engine
  -> asyncmy 或 aiomysql
  -> AsyncSession
  -> async def 路由

不要把同步 Session 和异步 Engine 混在一起,也不要只因为 FastAPI 支持 async def 就强行把数据库层改成异步。先把同步版本写清楚,比一开始就堆异步概念更重要。

十四、常见错误

1. 忘记提交事务

只写:

python 复制代码
db.add(user)

数据不会真正落库。写操作要执行:

python 复制代码
db.commit()

如果还要拿到自增 ID 或数据库默认值,再执行:

python 复制代码
db.refresh(user)

2. 异常后不回滚

写操作失败后,应该:

python 复制代码
db.rollback()

否则当前 Session 可能处于失败状态,后续继续使用会报错。

3. 在路由里直接创建 Engine

不要这样写:

python 复制代码
@router.get("/users")
def list_users():
    engine = create_engine(...)

Engine 应该在应用启动时创建一次,复用连接池。每个请求里只创建和关闭 Session。

4. 把数据库密码写进代码

不要把连接串硬编码在 Python 文件里。

更好的方式:

text 复制代码
.env
  -> 本地开发配置

生产环境变量
  -> 生产配置

.env 通常不提交到 Git。

5. 模型没有被导入

如果调用 Base.metadata.create_all(bind=engine) 后没有生成表,常见原因是模型类没有被导入。

解决方式是建立一个统一的模型导入文件:

python 复制代码
from app.modules.users.models import User

__all__ = ["User"]

并在初始化数据库前导入它。

相关推荐
IT_陈寒2 小时前
Vite打包时踩的坑:静态资源为啥突然404了?
前端·人工智能·后端
葫芦和十三3 小时前
图解 MongoDB 25|分片架构三件套:mongos、config server 和 shard
后端·mongodb·agent
葫芦和十三9 小时前
图解 MongoDB 26|片键设计:决定集群命运的一个决定
后端·mongodb·agent
Avan_菜菜10 小时前
使用 Docker + rclone 自建 WebDAV
后端·agent·claude
阳光是sunny12 小时前
别再被 worktree 绕晕了!AI 编程时代你必须掌握的 Git 隔离神器
前端·人工智能·后端
万少13 小时前
万少的博客 - 技术分享与解决方案
前端·javascript·后端
咖啡八杯13 小时前
GoF设计模式——备忘录模式
java·后端·spring·设计模式
苍何14 小时前
腾讯再放大招,企微 Agent 大圆开启内测
后端
ethantan14 小时前
一篇讲解AI Agent 组成:像人一样思考的智能体
人工智能·后端·程序员