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 事务提交:
(图注:借书在同一 AsyncSession 内完成 Book.status 修改与 Borrow 行新增,await db.commit() 将两条 SQL 串在同一条底层事务中。任一步失败都会触发 rollback,内存中的改动一并撤销。)
Borrow 模型是整个图书 API 的第三张表,通过外键与 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_id 和 user_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 行。时间字段必须 UTC。
datetime.now(UTC)标注带时区的时间戳,避免跨时区部署时出现"借出时间错位"和"逾期判定不准"。不要把DateTime(timezone=True)和 naive datetime 混用。软删除图书不应被新借。
borrow_book检查book.deleted_at is not None并抛NotFoundError,与第 19 章的软删除过滤策略保持一致。
全系列异步栈回顾
异步栈下的一致规律可以收束为四条,贯穿整个系列:
按章节串起来看:
第 03 章给出
async def与def的判别准则:数据库相关必async def,纯 CPU 可def第 04-11 章在路由、参数、异常、中间件、依赖注入层按需选择 handler 形态
第 12 章建立 ORM 概念,对比原生 SQL 和 ORM 两条路径
第 13 章铺好
engine+AsyncSessionLocal+Base+get_async_db四件套第 14 章用
Mapped和mapped_column声明Author和Book模型第 15 章把路由改为
await db.execute(select(Book))——同步转异步的分水岭第 16 章覆盖过滤(
where、and_、ilike、in_)、聚合(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_db 的 async 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)方向继续拓展。