SQLAlchemy 2.x:异步 ORM 与数据库迁移 Alembic 完整指南

文章目录

    • [1. SQLAlchemy 2.0 新语法全景:统一 Core 与 ORM](#1. SQLAlchemy 2.0 新语法全景:统一 Core 与 ORM)
    • [2. 声明式模型定义:Mapped 类型注解](#2. 声明式模型定义:Mapped 类型注解)
    • [3. AsyncEngine 与 AsyncSession:生命全周期管理](#3. AsyncEngine 与 AsyncSession:生命全周期管理)
    • [4. 关系映射:lazy 策略的深度解析](#4. 关系映射:lazy 策略的深度解析)
      • [4.1 四种 lazy 策略对比](#4.1 四种 lazy 策略对比)
      • [4.2 selectin:批量加载的最佳实践](#4.2 selectin:批量加载的最佳实践)
      • [4.3 raise:生产环境的安全网](#4.3 raise:生产环境的安全网)
    • [5. CRUD 操作:2.0 风格的完整示例](#5. CRUD 操作:2.0 风格的完整示例)
    • [6. 复杂查询实战](#6. 复杂查询实战)
      • [6.1 多表联查](#6.1 多表联查)
      • [6.2 窗口函数](#6.2 窗口函数)
      • [6.3 子查询](#6.3 子查询)
      • [6.4 CTE(Common Table Expression)](#6.4 CTE(Common Table Expression))
    • [7. 事务与并发控制](#7. 事务与并发控制)
      • [7.1 事务边界管理](#7.1 事务边界管理)
      • [7.2 悲观锁](#7.2 悲观锁)
      • [7.3 乐观锁](#7.3 乐观锁)
    • [8. 连接池调优:生产环境的踩坑实录](#8. 连接池调优:生产环境的踩坑实录)
      • [8.1 连接数不足导致超时](#8.1 连接数不足导致超时)
      • [8.2 断连后不自动重连](#8.2 断连后不自动重连)
    • [9. Alembic:数据库迁移的版本管理](#9. Alembic:数据库迁移的版本管理)
      • [9.1 工作流](#9.1 工作流)
      • [9.2 多环境配置](#9.2 多环境配置)
    • [10. 实战:FastAPI + SQLAlchemy + Alembic 三件套集成](#10. 实战:FastAPI + SQLAlchemy + Alembic 三件套集成)
    • 总结

1. SQLAlchemy 2.0 新语法全景:统一 Core 与 ORM

SQLAlchemy 是 Python 生态中最强大的 ORM 工具------它的 2.0 版本完成了一次影响深远的语法统一:Core 层和 ORM 层现在使用同一套 select() API。在 1.x 时代,Core 层用 sqlalchemy.select() 构建查询,ORM 层用 session.query(User) 构建查询,两套接口各自为政,参数顺序、过滤方式、连表语法都不一致。2.0 版统一之后,只要学会一种写法,就能在 Core 和 ORM 之间自由切换。

下面是新旧语法的核心差异对比:

1.x 写法(旧,不推荐在新项目中使用):

python 复制代码
# ORM 查询
users = session.query(User).filter(User.name == "Alice").all()

# Core 查询
stmt = select([users.c.id, users.c.name]).where(users.c.name == "Alice")

2.0 写法(新,统一 API):

python 复制代码
# ORM 查询 --- 与 Core 使用同一个 select() 函数
from sqlalchemy import select

stmt = select(User).where(User.name == "Alice")
result = await session.execute(stmt)
users = result.scalars().all()

这种统一的收益不仅在代码风格上,更在工程层面:当一个查询从简单的单表操作演变为复杂的多表联查时,不需要切换到另一套 API------只需要在 select() 里追加 join()where()order_by() 即可。


2. 声明式模型定义:Mapped 类型注解

SQLAlchemy 2.0 引入了基于类型注解的模型定义方式。在 1.x 中,列是通过 Column(Integer) 定义的,类型和列定义耦合在一起。2.0 将两者分离:Mapped[int] 负责类型信息,mapped_column() 负责列的额外配置。

python 复制代码
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from datetime import date, datetime

class Base(DeclarativeBase):
    pass

class Book(Base):
    __tablename__ = "books"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    title: Mapped[str] = mapped_column(String(200), index=True)
    isbn: Mapped[str] = mapped_column(String(20), unique=True)
    author: Mapped[str] = mapped_column(String(100), index=True)
    published_date: Mapped[date]
    price: Mapped[float] = mapped_column(default=0.0)
    description: Mapped[str | None]  # 可为 NULL
    created_at: Mapped[datetime] = mapped_column(
        server_default=func.now()
    )
    updated_at: Mapped[datetime] = mapped_column(
        server_default=func.now(),
        onupdate=func.now(),
    )

要点说明:

  • Mapped[str | None] 表示该字段可以为 NULL。类型注解中的 None 会被 SQLAlchemy 自动映射为 nullable=True
  • server_default=func.now() 让数据库在插入时自动填充当前时间------这比 Python 端的 default=datetime.now 更可靠,因为不依赖应用服务器的时钟
  • onupdate=func.now() 在每次 UPDATE 时自动更新时间戳,前提是 SQLAlchemy 生成的 UPDATE 语句包含此列

对于不需要映射到数据库列的 Python 属性(如计算属性),可以用 Mappeddeferred 或直接定义 @property

python 复制代码
class Book(Base):
    # ...
    tags_json: Mapped[str | None] = mapped_column(Text)

    @property
    def tags(self) -> list[str]:
        """将数据库中的 JSON 字符串解析为列表"""
        import json
        return json.loads(self.tags_json) if self.tags_json else []

    @tags.setter
    def tags(self, value: list[str]):
        self.tags_json = json.dumps(value)

3. AsyncEngine 与 AsyncSession:生命全周期管理

异步模式是 SQLAlchemy 2.0 的重要改进。核心组件是 AsyncEngineAsyncSession

python 复制代码
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

# 创建异步引擎
engine = create_async_engine(
    "postgresql+asyncpg://user:password@localhost:5432/bookdb",
    echo=False,          # 生产环境必须设为 False
    pool_size=10,        # 基础连接数
    max_overflow=20,     # 突发溢出连接数
    pool_recycle=3600,   # 连接最大存活时间(秒)
)

# 会话工厂
AsyncSessionLocal = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,  # 提交后不过期对象,避免懒加载
)

在生产环境中,session 的生命周期必须严格遵循"一个请求一个 session"的原则。直接使用 FastAPI 的依赖注入管理是最简洁的方式:

python 复制代码
async def get_db_session():
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()  # 请求成功则提交
        except Exception:
            await session.rollback()  # 异常则回滚
            raise
        # async with 退出时自动 close

@router.get("/{book_id}")
async def get_book(
    book_id: int,
    db: AsyncSession = Depends(get_db_session),
):
    book = await db.get(Book, book_id)
    if not book:
        raise HTTPException(status_code=404)
    return book

expire_on_commit=False 这个配置值得单独说明。默认情况下,SQLAlchemy 在 session.commit() 后会将所有已加载的对象标记为"过期",之后访问对象的任何属性都会触发一次新的数据库查询。这在异步环境中尤其容易引入隐式的懒加载(不期望的额外数据库查询)。设置为 False 后,commit 后的对象仍然可读,避免了"在模板渲染阶段意外触发 SQL 查询"的问题。


4. 关系映射:lazy 策略的深度解析

关系映射是 ORM 的核心能力,也是性能问题的最大来源。SQLAlchemy 提供了多种 lazy 加载策略,每种策略对应不同的查询模式:

python 复制代码
from sqlalchemy import ForeignKey

class Book(Base):
    __tablename__ = "books"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
    category: Mapped["Category"] = relationship(back_populates="books")
    reviews: Mapped[list["Review"]] = relationship(back_populates="book")

class Category(Base):
    __tablename__ = "categories"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(unique=True)
    books: Mapped[list["Book"]] = relationship(back_populates="category")

class Review(Base):
    __tablename__ = "reviews"
    id: Mapped[int] = mapped_column(primary_key=True)
    book_id: Mapped[int] = mapped_column(ForeignKey("books.id"))
    rating: Mapped[int]
    comment: Mapped[str]
    book: Mapped["Book"] = relationship(back_populates="reviews")

4.1 四种 lazy 策略对比

策略 行为 适用场景 SQL 查询数
select 首次访问属性时发一条 SQL 绝大多数情况都不推荐 N+1
joined 通过 LEFT JOIN 在主查询中一并加载 一对一关系,且每次都需要的关联数据 1
selectin 主查询后,用 WHERE id IN (...) 批量加载 一对多/多对多关系(推荐) 2
raise 访问未加载的关系时抛出异常 生产环境的安全网 异常

#mermaid-svg-yroAdpltb2w2n0qB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yroAdpltb2w2n0qB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yroAdpltb2w2n0qB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yroAdpltb2w2n0qB .error-icon{fill:#552222;}#mermaid-svg-yroAdpltb2w2n0qB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yroAdpltb2w2n0qB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yroAdpltb2w2n0qB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yroAdpltb2w2n0qB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yroAdpltb2w2n0qB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yroAdpltb2w2n0qB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yroAdpltb2w2n0qB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yroAdpltb2w2n0qB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yroAdpltb2w2n0qB .marker.cross{stroke:#333333;}#mermaid-svg-yroAdpltb2w2n0qB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yroAdpltb2w2n0qB p{margin:0;}#mermaid-svg-yroAdpltb2w2n0qB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yroAdpltb2w2n0qB .cluster-label text{fill:#333;}#mermaid-svg-yroAdpltb2w2n0qB .cluster-label span{color:#333;}#mermaid-svg-yroAdpltb2w2n0qB .cluster-label span p{background-color:transparent;}#mermaid-svg-yroAdpltb2w2n0qB .label text,#mermaid-svg-yroAdpltb2w2n0qB span{fill:#333;color:#333;}#mermaid-svg-yroAdpltb2w2n0qB .node rect,#mermaid-svg-yroAdpltb2w2n0qB .node circle,#mermaid-svg-yroAdpltb2w2n0qB .node ellipse,#mermaid-svg-yroAdpltb2w2n0qB .node polygon,#mermaid-svg-yroAdpltb2w2n0qB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yroAdpltb2w2n0qB .rough-node .label text,#mermaid-svg-yroAdpltb2w2n0qB .node .label text,#mermaid-svg-yroAdpltb2w2n0qB .image-shape .label,#mermaid-svg-yroAdpltb2w2n0qB .icon-shape .label{text-anchor:middle;}#mermaid-svg-yroAdpltb2w2n0qB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yroAdpltb2w2n0qB .rough-node .label,#mermaid-svg-yroAdpltb2w2n0qB .node .label,#mermaid-svg-yroAdpltb2w2n0qB .image-shape .label,#mermaid-svg-yroAdpltb2w2n0qB .icon-shape .label{text-align:center;}#mermaid-svg-yroAdpltb2w2n0qB .node.clickable{cursor:pointer;}#mermaid-svg-yroAdpltb2w2n0qB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yroAdpltb2w2n0qB .arrowheadPath{fill:#333333;}#mermaid-svg-yroAdpltb2w2n0qB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yroAdpltb2w2n0qB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yroAdpltb2w2n0qB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yroAdpltb2w2n0qB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yroAdpltb2w2n0qB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yroAdpltb2w2n0qB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yroAdpltb2w2n0qB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yroAdpltb2w2n0qB .cluster text{fill:#333;}#mermaid-svg-yroAdpltb2w2n0qB .cluster span{color:#333;}#mermaid-svg-yroAdpltb2w2n0qB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-yroAdpltb2w2n0qB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yroAdpltb2w2n0qB rect.text{fill:none;stroke-width:0;}#mermaid-svg-yroAdpltb2w2n0qB .icon-shape,#mermaid-svg-yroAdpltb2w2n0qB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yroAdpltb2w2n0qB .icon-shape p,#mermaid-svg-yroAdpltb2w2n0qB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yroAdpltb2w2n0qB .icon-shape .label rect,#mermaid-svg-yroAdpltb2w2n0qB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yroAdpltb2w2n0qB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yroAdpltb2w2n0qB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yroAdpltb2w2n0qB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是,一对一
是,一对多/多对多



定义 relationship
该关系在主查询中

总是需要的?
lazy='joined'

LEFT JOIN 一次加载
lazy='selectin'

IN 批量加载
是否可以接受

额外的 SQL 查询?
lazy='select'

按需加载(不推荐生产)
lazy='raise'

忘记预加载时报错
SQL 查询数 = 1
SQL 查询数 = 2
N+1 风险
强制显式加载

4.2 selectin:批量加载的最佳实践

selectin 是最推荐的一对多加载策略。它的工作原理是:先执行主查询拿到所有父对象的 ID 列表,然后执行一次 SELECT * FROM reviews WHERE book_id IN (1, 2, 3, ...) 批量加载所有子对象。无论主查询返回 10 条还是 1000 条记录,总共只需要 2 次 SQL 查询。

python 复制代码
# 定义关系时指定策略
class Book(Base):
    reviews: Mapped[list["Review"]] = relationship(
        back_populates="book", lazy="selectin"
    )

# 或者在查询时动态选择
from sqlalchemy.orm import selectinload

stmt = select(Book).options(selectinload(Book.reviews))
result = await db.execute(stmt)
books = result.scalars().all()
# 此时访问 books[0].reviews 不会触发额外 SQL

4.3 raise:生产环境的安全网

python 复制代码
class Book(Base):
    reviews: Mapped[list["Review"]] = relationship(
        back_populates="book", lazy="raise"
    )

将关系的延迟加载策略设为 raise 意味着:如果代码中访问了一个没有被显式预加载的关系,SQLAlchemy 会直接抛出 InvalidRequestError,而不是默默执行一次额外的 SQL 查询。在开发阶段,这个错误提示开发者"这里忘了加 selectinload";在生产环境,它避免了 N+1 查询悄悄拖垮数据库。


5. CRUD 操作:2.0 风格的完整示例

创建

python 复制代码
async def create_book(db: AsyncSession, data: BookCreate) -> Book:
    book = Book(**data.model_dump())
    db.add(book)
    await db.flush()   # 发送 INSERT,获取自增 ID
    return book

flush()commit() 的区别:flush() 将待执行的 SQL 发送到数据库但不提交事务,此时其他事务看不到这些变更;commit() 提交事务并使变更对全局可见。在需要获取自增 ID 但又不希望过早提交时,使用 flush()

查询(带分页和排序)

python 复制代码
async def list_books(
    db: AsyncSession,
    search: str | None = None,
    offset: int = 0,
    limit: int = 20,
) -> tuple[list[Book], int]:
    stmt = select(Book)

    if search:
        stmt = stmt.where(
            Book.title.ilike(f"%{search}%") | Book.author.ilike(f"%{search}%")
        )

    # 总数查询
    count_stmt = select(func.count()).select_from(stmt.subquery())
    total = (await db.execute(count_stmt)).scalar_one()

    # 数据查询
    stmt = stmt.order_by(Book.created_at.desc()).offset(offset).limit(limit)
    result = await db.execute(stmt)
    books = result.scalars().all()

    return books, total

更新

python 复制代码
async def update_book(db: AsyncSession, book_id: int, data: BookUpdate) -> Book:
    stmt = select(Book).where(Book.id == book_id)
    result = await db.execute(stmt)
    book = result.scalar_one_or_none()

    if not book:
        raise HTTPException(status_code=404)

    update_data = data.model_dump(exclude_unset=True)  # 只更新实际传入的字段
    for key, value in update_data.items():
        setattr(book, key, value)

    await db.flush()
    return book

model_dump(exclude_unset=True) 是关键------Pydantic 模型内部记录了哪些字段被实际赋值(而非使用默认值),exclude_unset=True 保证只传递实际更新的字段,避免了将 None 作为"不更新"的歧义值。

删除

python 复制代码
async def delete_book(db: AsyncSession, book_id: int) -> bool:
    stmt = select(Book).where(Book.id == book_id)
    result = await db.execute(stmt)
    book = result.scalar_one_or_none()

    if not book:
        return False

    await db.delete(book)
    await db.flush()
    return True

6. 复杂查询实战

6.1 多表联查

python 复制代码
# 查询所有图书及其分类信息
stmt = (
    select(Book, Category.name)
    .join(Category, Book.category_id == Category.id)
    .where(Book.price > 50)
    .order_by(Book.published_date.desc())
)

result = await db.execute(stmt)
for book, category_name in result:
    print(f"{book.title} --- {category_name}")

6.2 窗口函数

python 复制代码
from sqlalchemy import over, func

# 为每本图书计算"同分类内的价格排名"
rank_stmt = (
    select(
        Book.id,
        Book.title,
        Book.price,
        func.rank().over(
            partition_by=Book.category_id,
            order_by=Book.price.desc(),
        ).label("price_rank"),
    )
)

result = await db.execute(rank_stmt)

6.3 子查询

python 复制代码
# 查询平均评分高于 4.0 的图书
avg_rating_subq = (
    select(Review.book_id, func.avg(Review.rating).label("avg_rating"))
    .group_by(Review.book_id)
    .having(func.avg(Review.rating) > 4.0)
    .subquery()
)

stmt = (
    select(Book)
    .join(avg_rating_subq, Book.id == avg_rating_subq.c.book_id)
)

result = await db.execute(stmt)

6.4 CTE(Common Table Expression)

python 复制代码
from sqlalchemy import literal

# 递归查询:获取某分类及其所有子孙分类
cte = (
    select(Category)
    .where(Category.id == 1)
    .cte(name="category_tree", recursive=True)
)

cte = cte.union_all(
    select(Category).where(Category.parent_id == cte.c.id)
)

stmt = select(cte)
result = await db.execute(stmt)

7. 事务与并发控制

7.1 事务边界管理

python 复制代码
# 标准事务模式
async with AsyncSessionLocal() as session:
    async with session.begin():
        # 所有操作在同一个事务中
        book = Book(title="Python 实战", author="张三")
        session.add(book)
        # 不需要显式 commit,session.begin() 的上下文管理器会自动处理

session.begin() 的上下文管理器在退出时自动提交(无异常)或回滚(有异常)。这是最推荐的事务管理模式------它将事务的边界显式地限定在 async with 块内,避免了"忘记 commit"或"commit 后继续修改数据"的问题。

7.2 悲观锁

python 复制代码
# 查询并锁定(防止并发修改)
stmt = (
    select(Book)
    .where(Book.id == book_id)
    .with_for_update(nowait=True)
)
result = await db.execute(stmt)
book = result.scalar_one_or_none()
# 在事务提交前,其他事务无法修改这条记录

with_for_update(nowait=True) 的含义是:如果目标行已被其他事务锁定,立即抛出异常而不是等待。这适合于"获取锁失败就提示用户稍后重试"的场景。如果业务需要排队等待,去掉 nowait=True

7.3 乐观锁

python 复制代码
class Book(Base):
    __tablename__ = "books"
    # ...
    version: Mapped[int] = mapped_column(default=1)

    __mapper_args__ = {"version_id_col": version}

乐观锁不需要加锁------它在 UPDATE 时自动检查 version 字段:如果 version 已经被其他事务修改(说明数据在读取后被修改过),SQLAlchemy 会自动抛出 StaleDataError,由应用层决定是重试还是报错。乐观锁适合读多写少的场景,避免了锁等待。


8. 连接池调优:生产环境的踩坑实录

SQLAlchemy 的连接池默认参数在开发环境(本地 SQLite)下看不出问题,但在生产环境(PostgreSQL + 高并发)中会出现两类典型故障:

8.1 连接数不足导致超时

当日志中频繁出现 QueuePool limit of size X overflow Y reached 时,说明连接池已耗尽:

python 复制代码
engine = create_async_engine(
    "postgresql+asyncpg://...",
    pool_size=20,        # 基础连接数,根据 "预期并发连接数 × 0.3" 设置
    max_overflow=30,     # 溢出连接数,突发流量缓冲
    pool_timeout=10,     # 获取连接的超时秒数
)

8.2 断连后不自动重连

PostgreSQL 默认在空闲超过一定时间后会断开连接。SQLAlchemy 的 pool_recycle 让连接在到达指定存活时间后自动回收,避免使用已被数据库端关闭的连接:

python 复制代码
engine = create_async_engine(
    "postgresql+asyncpg://...",
    pool_recycle=1800,          # 30 分钟后回收连接
    pool_pre_ping=True,         # 每次获取连接前先 ping 验证
)

pool_pre_ping=True 会在每次从连接池获取连接时发送一条 SELECT 1 验证连接是否有效。代价是每获取一次连接多一次网络往返,收益是彻底消除"数据库重启后连接池全是死连接"的问题。对于延迟敏感的接口,可以通过"预热连接池 + pool_pre_ping=False"来平衡可靠性和性能。


9. Alembic:数据库迁移的版本管理

Alembic 之于数据库,就像 Git 之于代码------每一次 Schema 变更都被记录为一个可追溯、可回滚的迁移版本。

9.1 工作流

#mermaid-svg-foPsMIG1sYFKYrzy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-foPsMIG1sYFKYrzy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-foPsMIG1sYFKYrzy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-foPsMIG1sYFKYrzy .error-icon{fill:#552222;}#mermaid-svg-foPsMIG1sYFKYrzy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-foPsMIG1sYFKYrzy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-foPsMIG1sYFKYrzy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-foPsMIG1sYFKYrzy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-foPsMIG1sYFKYrzy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-foPsMIG1sYFKYrzy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-foPsMIG1sYFKYrzy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-foPsMIG1sYFKYrzy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-foPsMIG1sYFKYrzy .marker.cross{stroke:#333333;}#mermaid-svg-foPsMIG1sYFKYrzy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-foPsMIG1sYFKYrzy p{margin:0;}#mermaid-svg-foPsMIG1sYFKYrzy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-foPsMIG1sYFKYrzy .cluster-label text{fill:#333;}#mermaid-svg-foPsMIG1sYFKYrzy .cluster-label span{color:#333;}#mermaid-svg-foPsMIG1sYFKYrzy .cluster-label span p{background-color:transparent;}#mermaid-svg-foPsMIG1sYFKYrzy .label text,#mermaid-svg-foPsMIG1sYFKYrzy span{fill:#333;color:#333;}#mermaid-svg-foPsMIG1sYFKYrzy .node rect,#mermaid-svg-foPsMIG1sYFKYrzy .node circle,#mermaid-svg-foPsMIG1sYFKYrzy .node ellipse,#mermaid-svg-foPsMIG1sYFKYrzy .node polygon,#mermaid-svg-foPsMIG1sYFKYrzy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-foPsMIG1sYFKYrzy .rough-node .label text,#mermaid-svg-foPsMIG1sYFKYrzy .node .label text,#mermaid-svg-foPsMIG1sYFKYrzy .image-shape .label,#mermaid-svg-foPsMIG1sYFKYrzy .icon-shape .label{text-anchor:middle;}#mermaid-svg-foPsMIG1sYFKYrzy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-foPsMIG1sYFKYrzy .rough-node .label,#mermaid-svg-foPsMIG1sYFKYrzy .node .label,#mermaid-svg-foPsMIG1sYFKYrzy .image-shape .label,#mermaid-svg-foPsMIG1sYFKYrzy .icon-shape .label{text-align:center;}#mermaid-svg-foPsMIG1sYFKYrzy .node.clickable{cursor:pointer;}#mermaid-svg-foPsMIG1sYFKYrzy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-foPsMIG1sYFKYrzy .arrowheadPath{fill:#333333;}#mermaid-svg-foPsMIG1sYFKYrzy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-foPsMIG1sYFKYrzy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-foPsMIG1sYFKYrzy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-foPsMIG1sYFKYrzy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-foPsMIG1sYFKYrzy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-foPsMIG1sYFKYrzy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-foPsMIG1sYFKYrzy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-foPsMIG1sYFKYrzy .cluster text{fill:#333;}#mermaid-svg-foPsMIG1sYFKYrzy .cluster span{color:#333;}#mermaid-svg-foPsMIG1sYFKYrzy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-foPsMIG1sYFKYrzy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-foPsMIG1sYFKYrzy rect.text{fill:none;stroke-width:0;}#mermaid-svg-foPsMIG1sYFKYrzy .icon-shape,#mermaid-svg-foPsMIG1sYFKYrzy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-foPsMIG1sYFKYrzy .icon-shape p,#mermaid-svg-foPsMIG1sYFKYrzy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-foPsMIG1sYFKYrzy .icon-shape .label rect,#mermaid-svg-foPsMIG1sYFKYrzy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-foPsMIG1sYFKYrzy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-foPsMIG1sYFKYrzy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-foPsMIG1sYFKYrzy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 正确
需要手动修改
生产事故
修改模型代码
alembic revision

--autogenerate
检查生成的迁移文件
alembic upgrade head
编辑迁移文件
数据库 Schema 更新
alembic downgrade -1

初始化环境后,修改模型的流程是:修改 models.pyalembic revision --autogenerate -m "add_book_tags" → 检查生成的迁移文件 → alembic upgrade head

关键点在于必须检查自动生成的迁移文件--autogenerate 能处理大部分场景(新增列、新增表、新增外键),但以下场景必须手动编写迁移:

  • 列重命名(autogenerate 会识别为"删除旧列 + 新增新列",导致数据丢失)
  • 数据迁移(修改列类型的同时转换已有数据)
  • 添加非空列(需要先添加为 nullable → 填充默认值 → 再改为 not null)

9.2 多环境配置

实际项目需要至少三个环境:开发(SQLite)、测试(PostgreSQL)、生产(PostgreSQL + 读写分离)。

ini 复制代码
# alembic.ini
[alembic]
script_location = alembic
sqlalchemy.url = driver://user:pass@localhost/dev

# alembic/env.py
import os
from alembic import context

def get_url():
    return os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./dev.db")

config.set_main_option("sqlalchemy.url", get_url())

每个环境通过 DATABASE_URL 环境变量指定数据库连接串。生产环境部署时,只需修改环境变量,无需修改代码或 Alembic 配置。

常用命令:

bash 复制代码
# 创建迁移
alembic revision --autogenerate -m "add publisher table"

# 升级到最新
alembic upgrade head

# 回滚一步
alembic downgrade -1

# 标记当前状态(数据库 Schema 已与代码同步,跳过所有迁移)
alembic stamp head

# 查看迁移历史
alembic history

# 查看当前版本
alembic current

10. 实战:FastAPI + SQLAlchemy + Alembic 三件套集成

将前一篇 FastAPI 文章的图书 API 与本篇的 SQLAlchemy 集成,形成完整的后端技术栈:

python 复制代码
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.database import engine, Base

@asynccontextmanager
async def lifespan(app: FastAPI):
    # 启动时不自动建表(生产环境使用 Alembic)
    yield
    await engine.dispose()

app = FastAPI(title="图书管理 API", version="1.0.0", lifespan=lifespan)
python 复制代码
# app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

DATABASE_URL = "postgresql+asyncpg://user:pass@localhost:5432/bookdb"

engine = create_async_engine(
    DATABASE_URL,
    echo=False,
    pool_size=20,
    max_overflow=30,
    pool_recycle=1800,
    pool_pre_ping=True,
)

AsyncSessionLocal = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
)

async def get_db_session():
    async with AsyncSessionLocal() as session:
        try:
            yield session
        except Exception:
            await session.rollback()
            raise
python 复制代码
# app/models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from datetime import datetime

class Base(DeclarativeBase):
    pass

class Book(Base):
    __tablename__ = "books"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(200), index=True)
    author: Mapped[str] = mapped_column(String(100), index=True)
    isbn: Mapped[str] = mapped_column(String(20), unique=True)
    published_date: Mapped[date]
    price: Mapped[float]
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(
        server_default=func.now(), onupdate=func.now()
    )

    reviews: Mapped[list["Review"]] = relationship(
        back_populates="book", lazy="raise"  # 强制显式预加载
    )
python 复制代码
# app/routes/books.py
from fastapi import APIRouter, Depends, Query, Path
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

router = APIRouter(prefix="/api/v1/books", tags=["books"])

@router.get("/")
async def list_books(
    db: AsyncSession = Depends(get_db_session),
    search: str | None = Query(None),
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100),
):
    stmt = select(Book)

    if search:
        stmt = stmt.where(
            Book.title.ilike(f"%{search}%") | Book.author.ilike(f"%{search}%")
        )

    count_stmt = select(func.count()).select_from(stmt.subquery())
    total = (await db.execute(count_stmt)).scalar_one()

    stmt = (
        stmt.order_by(Book.created_at.desc())
        .offset((page - 1) * page_size)
        .limit(page_size)
    )
    result = await db.execute(stmt)

    return {
        "items": result.scalars().all(),
        "total": total,
        "page": page,
        "page_size": page_size,
    }

@router.get("/{book_id}")
async def get_book(
    book_id: int = Path(ge=1),
    include_reviews: bool = Query(False),
    db: AsyncSession = Depends(get_db_session),
):
    stmt = select(Book).where(Book.id == book_id)

    if include_reviews:
        stmt = stmt.options(selectinload(Book.reviews))

    result = await db.execute(stmt)
    book = result.scalar_one_or_none()

    if not book:
        raise HTTPException(status_code=404, detail={
            "message": f"图书 #{book_id} 不存在",
            "code": "BOOK_NOT_FOUND",
        })
    return book

注意 get_book 接口中的 include_reviews 参数配合 selectinload 的使用模式------当客户端需要书评数据时,通过一次额外的 IN 查询批量加载所有关联的书评,只需 2 次 SQL;当客户端不需要书评时,直接返回图书基本数据,1 次 SQL 完成。


总结

SQLAlchemy 2.0 的异步模式结合声明式类型注解,为 Python 后端提供了一套从模型定义到查询执行到数据库迁移的完整工程方案。核心要点回顾:

  1. Mapped 类型注解 让模型定义更简洁,类型检查工具(mypy/pyright)能直接识别列类型
  2. lazy="raise" 是生产环境的安全网,强制开发者显式声明预加载策略
  3. selectinload 是一对多关系的最优加载方式------2 次 SQL 替代 N+1 次
  4. Alembic 的自动生成能覆盖 80% 的迁移场景,剩余 20% 需要人工审核和手动编写
  5. FastAPI 依赖注入 与 AsyncSession 的生命周期天然契合------一个请求一个 session,异常自动回滚

关于依赖注入的缓存机制和生成器的 yield 清理模式,在前一篇 FastAPI 实战文章中有更深入的分析,感兴趣的读者可以对照阅读。


如果这篇文章对 SQLAlchemy 2.x 的实际应用有帮助,欢迎点赞、收藏、关注。持续输出高质量技术内容离不开读者的支持。

相关推荐
流星白龙1 小时前
【MySQL高阶】7.MySQL日志
数据库·mysql·adb
27669582921 小时前
京东随机变速滑块拼图验证码识别(京东E卡)
java·服务器·前端·python·京东滑块·京东变速滑块·京东e卡绑卡
basketball6161 小时前
C++ static_cast 完全解析
开发语言·c++
weixin_468466851 小时前
支持向量机新手实战指南
人工智能·python·算法·机器学习·支持向量机
流星白龙1 小时前
【MySQL高阶】0.MySQL的安装
数据库·mysql·adb
子安柠1 小时前
Go语言并发编程:协程与管道详解
开发语言·后端·golang
程序大视界1 小时前
【Python系列课程】Python面向对象(下):封装、继承与多态
开发语言·python
夕小瑶1 小时前
Claude Code 保姆级上手教程(2026 版)
人工智能·python
Lumbrologist1 小时前
【C++】零基础入门 · 第 12 节:模板与 STL 入门
开发语言·c++