FastAPI系列-20-ORM总结

FastAPI系列-20-ORM总结

_

20 ORM 总结 —— 跨表事务与借还业务,把数据层串成闭环

关键词: Borrow, 借书事务, 还书事务, 异步栈总结
难度: ★★★★★

场景导入:跨表事务是 CRUD 的终点站

前 7 篇文章(13-19)分别处理了建表、查询、新增、更新、删除五种原子操作——但全都是单表操作。真实的图书管理 API 不止管理书目,还要管理借阅:借书时要同时改 Book.status(变为借出)和新增 Borrow 行(留下借阅记录),两步必须在同一个事务内完成。还书时要同时填 Borrow.returned_at 和把 Book.status 复位,同样不可分割。

本期引入第三张表 Borrow(借阅记录),在 AsyncSession 内完成借书和还书两个跨表事务。然后以这两段事务为引子,把第 03 章 async def/def 判别准则、第 13 章异步基础设施、第 14-19 章 CRUD 操作串成整个数据层的闭环——这是系列的终点。

原理解析:借书和还书为什么不能拆成两次 commit

借书操作如果拆成两次独立事务会怎样?

事务 A: UPDATE books SET status = 'BORROWED' WHERE id = 1;  COMMIT;
-- 此时进程崩溃或网络中断
事务 B: INSERT INTO borrows (...);  COMMIT;  -- 永远不会执行了

结果:图书被标为借出,但没有对应的 Borrow 行。另一个请求看到 status='BORROWED' 却查不到谁借的、什么时候该还——逻辑层无法判断图书的真实状态。这就是为什么跨表操作必须放进同一个 await db.commit()

正确的做法是:setattr book.status = BORROWED(把 Book 加入 Session 的 dirty 集合)、db.add(borrow)(把 Borrow 加入 Session 的 new 集合)、一次 await db.commit() 把两条 SQL(UPDATE books + INSERT INTO borrows)放入同一个 MySQL 事务提交:

sequenceDiagram participant C as 客户端 participant R as POST handler participant D as borrow_dao participant S as AsyncSession participant DB as MySQL C->>R: POST /books/1/borrow + user_id R->>D: borrow_book(db, book_id, user_id) D->>S: await db.get(Book, 1) S->>DB: SELECT * FROM books WHERE id = 1 DB-->>S: Book 实例 D->>D: 校验 status == AVAILABLE、未软删除 D->>S: book.status = BORROWED D->>S: db.add(borrow) D->>S: await db.commit() S->>DB: UPDATE books SET status='BORROWED'<br/>INSERT INTO borrows (...) DB-->>S: 提交成功 D->>S: await db.refresh(borrow) S-->>D: Borrow 实例(含自增 id / borrowed_at) D-->>R: Borrow R-->>C: 201 + JSON

(图注:借书在同一 AsyncSession 内完成 Book.status 修改与 Borrow 行新增,await db.commit() 将两条 SQL 串在同一条底层事务中。任一步失败都会触发 rollback,内存中的改动一并撤销。)

Borrow 模型是整个图书 API 的第三张表,通过外键与 Book 关联:

classDiagram class Book { +int id +str title +int author_id +str isbn +BookStatus status +int version +datetime deleted_at } class Borrow { +int id +int book_id +int user_id +datetime borrowed_at +datetime due_at +datetime returned_at } class Author { +int id +str name +str bio } Book "1" --> "*" Borrow : 借阅记录 Author "1" --> "*" Book : 著作

(图注:一本书对应多条借阅记录,Borrow.book_id 在数据库层声明 ForeignKey("books.id", ondelete="CASCADE")。借书/还书事务同时改两张表,事务边界由 await db.commit() 控制。)

还书比借书多一步状态协调。还书需要先找到"未归还"的 Borrow 行——通过 returned_at.is_(None) + book_id + id.desc() 取最近一条——然后同时写入 returned_at 和复位 Book.status。两步同样在同一个 await db.commit() 内提交。如果只更新 Borrow 而忘改 Book,图书会一直"借出中";反之只改 Book 不写 returned_at,借阅记录会永远"未归还"。

代码实现:Borrow 模型 + 借书/还书事务

Borrow 模型在 app/models/borrow.py 中声明:

# app/models/borrow.py
from datetime import datetime

from sqlalchemy import DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column

from app.db import Base


class Borrow(Base):
    __tablename__ = "borrows"

    id: Mapped[int] = mapped_column(primary_key=True)
    book_id: Mapped[int] = mapped_column(
        ForeignKey("books.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
    )
    user_id: Mapped[int] = mapped_column(nullable=False, index=True)
    borrowed_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False
    )
    due_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False
    )
    returned_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True)
    )
# app/models/__init__.py
from app.models.author import Author
from app.models.book import Book
from app.models.borrow import Borrow

__all__ = ["Author", "Book", "Borrow"]

book_id 声明 ForeignKey("books.id", ondelete="CASCADE")——删除图书时数据库自动清理关联借阅记录。book_iduser_id 都设了 index=True 以支持按图书或按用户的查询。时间字段全部 DateTime(timezone=True) 保证 UTC 一致性。

借书和还书事务在 app/dao/borrow_dao.py 中实现:

# app/dao/borrow_dao.py
from datetime import datetime, timedelta, UTC

from sqlalchemy.ext.asyncio import AsyncSession

from app.models.book import Book, BookStatus
from app.models.borrow import Borrow
from app.errors import BookUnavailableError, NotFoundError


async def borrow_book(
    db: AsyncSession,
    *,
    book_id: int,
    user_id: int,
    loan_days: int = 14,
) -> Borrow:
    """借书事务:校验图书可借、改 Book.status、新增 Borrow 行,单次 commit。"""
    book = await db.get(Book, book_id)
    if book is None or book.deleted_at is not None:
        raise NotFoundError(book_id)
    if book.status != BookStatus.AVAILABLE:
        raise BookUnavailableError(book_id)

    now = datetime.now(UTC)
    borrow = Borrow(
        book_id=book_id,
        user_id=user_id,
        borrowed_at=now,
        due_at=now + timedelta(days=loan_days),
    )
    book.status = BookStatus.BORROWED
    db.add(borrow)
    await db.commit()
    await db.refresh(borrow)
    return borrow


async def return_book(
    db: AsyncSession,
    *,
    borrow_id: int,
) -> Borrow:
    """还书事务:填充 Borrow.returned_at 与复位 Book.status,单次 commit。"""
    borrow = await db.get(Borrow, borrow_id)
    if borrow is None or borrow.returned_at is not None:
        raise NotFoundError(borrow_id)
    book = await db.get(Book, borrow.book_id)
    if book is not None:
        book.status = BookStatus.AVAILABLE
    borrow.returned_at = datetime.now(UTC)
    await db.commit()
    await db.refresh(borrow)
    return borrow

领域异常:

# app/errors.py
class BookUnavailableError(Exception):
    """图书当前不可借阅(状态非 AVAILABLE 或并发被占用)。"""

    def __init__(self, book_id: int) -> None:
        super().__init__(f"book {book_id} is not available")
        self.book_id = book_id

路由层:

# app/api/books.py
from typing import Annotated

from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.dao import borrow_dao
from app.db import get_async_db
from app.errors import NotFoundError
from app.models.book import Book
from app.models.borrow import Borrow

router = APIRouter(prefix="/books", tags=["books"])
DBDep = Annotated[AsyncSession, Depends(get_async_db)]


@router.post("/{book_id}/borrow", status_code=201)
async def borrow_book(
    book_id: int,
    user_id: Annotated[int, Query(ge=1)],
    db: DBDep,
) -> Borrow:
    """借书,失败返回 404/409。"""
    return await borrow_dao.borrow_book(db, book_id=book_id, user_id=user_id)


@router.post("/{book_id}/return", status_code=200)
async def return_book(book_id: int, db: DBDep) -> Borrow:
    """还书:定位该书当前未归还的 Borrow 记录,调用 DAO 复位状态。"""
    result = await db.execute(
        select(Borrow)
        .where(Borrow.book_id == book_id, Borrow.returned_at.is_(None))
        .order_by(Borrow.id.desc())
    )
    record = result.scalar_one_or_none()
    if record is None:
        raise NotFoundError(book_id)
    return await borrow_dao.return_book(db, borrow_id=record.id)

避坑指南

  • 跨表事务的核心是同一个 await db.commit()。分两次 commit 在任何一步失败后都会产生悬空状态——"Book 标为借出但 Borrow 行缺失"或反之。这是借还类业务的前提。

  • 高并发借书要加 SELECT ... FOR UPDATE 行锁。在 borrow_book 入口用 db.execute(select(Book).where(...).with_for_update()) 替代 await db.get(Book, book_id),让 InnoDB 在事务结束前持有该行排他锁,防止两个请求同时读到 AVAILABLE 然后双双写入 Borrow 行。

  • 时间字段必须 UTCdatetime.now(UTC) 标注带时区的时间戳,避免跨时区部署时出现"借出时间错位"和"逾期判定不准"。不要把 DateTime(timezone=True) 和 naive datetime 混用。

  • 软删除图书不应被新借borrow_book 检查 book.deleted_at is not None 并抛 NotFoundError,与第 19 章的软删除过滤策略保持一致。

全系列异步栈回顾

异步栈下的一致规律可以收束为四条,贯穿整个系列:

规律

说明

所有 DAO 函数都是 async def

db.executedb.commitdb.refreshdb.get 都必须 await;只有 db.add 是同步 API

Engine 应用级单例

create_async_engine(DATABASE_URL, pool_size=10, ...) 在模块加载时创建

Session 请求级作用域

AsyncSessionLocal() 按请求创建,get_async_dbasync def yield 依赖负责关闭

事务边界显式控制

await db.commit() 提交,await db.rollback() 回滚,async with 退出阶段兜底

按章节串起来看:

  • 第 03 章给出 async defdef 的判别准则:数据库相关必 async def,纯 CPU 可 def

  • 第 04-11 章在路由、参数、异常、中间件、依赖注入层按需选择 handler 形态

  • 第 12 章建立 ORM 概念,对比原生 SQL 和 ORM 两条路径

  • 第 13 章铺好 engine + AsyncSessionLocal + Base + get_async_db 四件套

  • 第 14 章Mappedmapped_column 声明 AuthorBook 模型

  • 第 15 章把路由改为 await db.execute(select(Book))——同步转异步的分水岭

  • 第 16 章覆盖过滤(whereand_ilikein_)、聚合(func.count)和分页(OFFSET)

  • 第 17 章db.add + await db.commit() + await db.refresh() 实现新增

  • 第 18 章update(...).values(version=Book.version + 1) + rowcount 实现乐观锁更新

  • 第 19 章update(...).values(deleted_at=now) + 全局过滤实现软删除

  • 第 20 章Borrow 模型 + 借书/还书跨表事务收束数据层

小结

第 20 章把 12 至 19 章的 ORM 知识收束为一张清晰的图景。Engine 由 create_async_engine 托管,Session 由 AsyncSessionLocal 按请求创建,依赖注入由 get_async_dbasync def yield 形态提供。借书把 Book.status 修改和 Borrow 行新增放进同一个 await db.commit(),还书把 Borrow.returned_at 填充和 Book.status 复位放进同一个 await db.commit()——跨表事务的原子性是整个数据层的最后一块拼图。

回看全系列,第 03 章的 async def/def 判别准则是入口,第 13-19 章的 CRUD 是主干,第 20 章的跨表事务是终点。数据层的闭环到此结束,后续可向 Alembic 迁移、中间件鉴权与分布式事务(outbox pattern)方向继续拓展。

FastAPI系列-19-数据操作之删除 2026-07-08

评论区