5.2 管理会话事务与失败回滚
管理会话事务与失败回滚
数据库会话不是一个可以在所有请求之间共享的全局列表。它持有连接、事务状态和 ORM 对象;一次写操作应该明确从哪里开始、何时提交、失败后如何回滚以及什么时候释放会话。FastAPI 的 yield 依赖适合把一个 AsyncSession 绑定到一次请求,业务服务负责在确定写入成功后提交。
本节使用 SQLAlchemy 2 的异步 ORM 和 PostgreSQL 的 psycopg 驱动。为了不误连开发者已有数据库,代码读取专用环境变量 TEACHING_DATABASE_URL,默认只执行不连接数据库的本地自检。真正的提交、唯一冲突和回滚检查必须在独立的 PostgreSQL 练习数据库中显式启用。
一个请求一个会话
get_session() 用 yield 把会话交给路由,路由结束后 async with 会关闭会话并归还连接池资源。会话工厂和引擎可以是应用级对象,但 AsyncSession 本身不应作为全局变量,也不能被多个并发任务共享。两个并发协程要分别获得自己的会话,不能把同一个可变会话传给 asyncio.gather()。
完整示例包含一个文章创建路由。slug 有唯一约束,帮助我们观察冲突处理;真实项目的约束名称和字段来自第五章的表模型,而不是依赖应用层的重复查询。
<!-- file: ch05_tx/transaction_demo.py -->
from __future__ import annotations
import asyncio
import sys
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from os import environ
from typing import Annotated, Any
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
DATABASE_URL = environ.get("TEACHING_DATABASE_URL")
class Base(DeclarativeBase):
pass
class LessonUser(Base):
__tablename__ = "lesson_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
class LessonPost(Base):
__tablename__ = "lesson_posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
slug: Mapped[str] = mapped_column(String(160), unique=True, nullable=False)
title: Mapped[str] = mapped_column(String(120), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
author_id: Mapped[int] = mapped_column(
ForeignKey("lesson_users.id"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
class PostCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str = Field(min_length=1, max_length=160)
title: str = Field(min_length=1, max_length=120)
content: str = Field(min_length=1, max_length=10_000)
class PostResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
slug: str
title: str
content: str
author_id: int
created_at: datetime
class PostSlugConflict(Exception):
"""服务层把 PostgreSQL 唯一冲突转换成稳定的领域结果。"""
engine = (
create_async_engine(DATABASE_URL, pool_pre_ping=True)
if DATABASE_URL
else None
)
SessionFactory = (
async_sessionmaker(engine, expire_on_commit=False) if engine is not None else None
)
async def get_session() -> AsyncIterator[AsyncSession]:
if SessionFactory is None:
raise RuntimeError(
"未设置 TEACHING_DATABASE_URL;本地检查不会启动数据库路由"
)
async with SessionFactory() as session:
yield session
SessionDep = Annotated[AsyncSession, Depends(get_session)]
def is_unique_violation(error: IntegrityError) -> bool:
# psycopg 3 的 PostgreSQL SQLSTATE 23505 表示 unique_violation。
return getattr(error.orig, "sqlstate", None) == "23505"
async def persist_post(
session: AsyncSession,
payload: PostCreate,
author_id: int,
) -> LessonPost:
post = LessonPost(
slug=payload.slug,
title=payload.title,
content=payload.content,
author_id=author_id,
created_at=datetime.now(timezone.utc),
)
session.add(post)
try:
await session.commit()
except IntegrityError as exc:
# commit 失败后必须 rollback,否则这个 session 不能继续执行 SQL。
await session.rollback()
if is_unique_violation(exc):
raise PostSlugConflict from exc
# 外键、检查约束等其他完整性错误不能伪装成 slug 冲突。
raise
await session.refresh(post)
return post
app = FastAPI(title="SQLAlchemy 2 事务边界")
DEMO_CURRENT_USER_ID = 1
@app.post("/posts", response_model=PostResponse, status_code=201)
async def create_post(
payload: PostCreate,
session: SessionDep,
) -> PostResponse:
try:
post = await persist_post(
session, payload, author_id=DEMO_CURRENT_USER_ID
)
except PostSlugConflict:
raise HTTPException(status_code=409, detail="文章 slug 已存在") from None
return PostResponse.model_validate(post)
async def postgres_check() -> None:
"""只对专用 PostgreSQL 练习数据库执行,会清理 lesson_* 两张表。"""
if engine is None or SessionFactory is None:
raise RuntimeError(
"请设置 TEACHING_DATABASE_URL=postgresql+psycopg://... 后再运行"
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.drop_all)
await connection.run_sync(Base.metadata.create_all)
try:
async with SessionFactory() as session:
session.add(LessonUser(id=1, email="alice@example.com"))
await session.commit()
payload = PostCreate(
slug="first-post",
title="第一篇文章",
content="正文",
)
async with SessionFactory() as session:
await persist_post(session, payload, author_id=1)
async with SessionFactory() as session:
try:
await persist_post(session, payload, author_id=1)
except PostSlugConflict:
# 同一个会话在回滚后仍可继续查询。
remaining = await session.scalar(
select(func.count()).select_from(LessonPost)
)
assert remaining == 1
else:
raise AssertionError("重复 slug 应该触发 PostSlugConflict")
print("postgres transaction check passed")
finally:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.drop_all)
await engine.dispose()
def local_self_check() -> None:
# 不连接数据库,只验证配置边界和 PostgreSQL 冲突映射的分支。
try:
PostCreate.model_validate(
{
"slug": "forged-author",
"title": "伪造作者",
"content": "客户端不应决定作者身份",
"author_id": 999,
}
)
except ValidationError:
pass
else:
raise AssertionError("PostCreate 应拒绝客户端提交 author_id")
class Original:
sqlstate = "23505"
class Wrapped:
orig = Original()
assert is_unique_violation(Wrapped()) # type: ignore[arg-type]
class OtherOriginal:
sqlstate = "23503"
class OtherWrapped:
orig = OtherOriginal()
assert not is_unique_violation(OtherWrapped()) # type: ignore[arg-type]
print(
"transaction_demo local self-check passed; PostgreSQL commit/rollback check skipped"
)
if __name__ == "__main__":
if "--postgres-check" in sys.argv:
asyncio.run(postgres_check())
else:
local_self_check()
普通本地检查:
python ch05_tx/transaction_demo.py
它不会连接数据库,预期输出包含 PostgreSQL commit/rollback check skipped。需要真实验证时,先创建一个只供练习使用的 PostgreSQL 数据库,再显式运行:
TEACHING_DATABASE_URL='postgresql+psycopg://user:password@127.0.0.1:5432/fastapi_lesson' \
python ch05_tx/transaction_demo.py --postgres-check
集成检查只操作 lesson_users 和 lesson_posts,并会在结束时删除它们;不要对生产库或含有其他数据的共享数据库执行。当前教程环境没有执行这条命令,因此不能把 PostgreSQL 提交、冲突和回滚描述成已经通过。
提交、刷新和关闭的边界
session.add() 只是把对象加入当前会话,commit() 才会提交事务;提交前 SQLAlchemy 可能先 flush,把 INSERT 发给数据库。提交成功后 refresh() 重新读取数据库生成的值,适合需要确认服务端生成字段的场景。expire_on_commit=False 让本示例在提交后可以直接读取对象属性,但它不改变数据库事务语义。
yield 依赖负责会话生命周期,不能自动替代业务提交。读请求可以在依赖结束时回收会话,写请求则应由明确的 service 在完成一个业务操作后提交。把 commit() 藏进通用依赖的退出代码,会让路由难以判断一个操作什么时候算成功,也容易把多个不相关写入意外合并到同一个事务中。
失败后为什么一定要回滚
在 PostgreSQL 中,事务内的语句失败后,当前事务会进入失败状态;必须 ROLLBACK 或回滚到保存点,才能继续使用这个事务。示例在捕获 IntegrityError 后立即回滚,并只把 SQLSTATE 23505 映射成 slug 冲突。外键错误 23503、检查错误 23514 等其他完整性错误重新抛出,避免错误信息被错误归类。
应用层可以把 PostSlugConflict 转成 409,但不能依赖“先查询 slug”来取代唯一约束。两个并发请求仍可能同时通过预检查,只有数据库约束和冲突回滚能在最终写入处保证一致性。
示例中的 DEMO_CURRENT_USER_ID 是服务端固定的教学身份,用来代替第四章已经解释过的已验证用户依赖。客户端请求模型没有 author_id,并通过 extra="forbid" 拒绝伪造字段;真实项目应把当前用户 ID 从认证依赖传给服务层。
不要共享可变会话
AsyncSession 代表一个有状态的事务上下文。不要把同一个会话传给 asyncio.gather() 中的多个并发任务,也不要把它放在全局变量里。每个并发任务应通过独立的依赖或会话工厂获得自己的会话;如果业务必须在一个事务中顺序完成多个步骤,就保持顺序并明确事务范围,而不是用并发掩盖提交边界。
资料来源
- 主要参考:fastapi-best-practices 中文 README 关于数据库边界和异步调用的实践方向。本节把它改写为 SQLAlchemy 2 异步会话、PostgreSQL SQLSTATE 冲突处理和可选集成检查。
- 官方文档:SQLAlchemy 2.0 Session 基础、SQLAlchemy AsyncIO、PostgreSQL 事务。

免费 AI IDE


更多建议: