SQLAlchemy 系列(八):AsyncIO、并发与 Web 生命周期------让每个并发任务持有自己的 Session
核心目标:理解异步 SQLAlchemy 的适用条件,正确创建 AsyncEngine/AsyncSession,让每个并发任务持有独立 Session,并消除隐式 I/O、请求生命周期泄漏与连接归还问题。
前置知识:掌握 Part 1 的 Statement → Execute → Result 执行链与 Part 4 的 Session 状态模型;了解 FastAPI/ASGI 依赖注入与 asyncio 协程基本概念。
验证环境:Python 3.11、SQLAlchemy 2.0.51;异步示例以兼容 async 驱动(如 asyncpg/aiosqlite)复验。最后复核日期:2026-07-31。
0. 问题场景:await 之后为什么会出现 MissingGreenlet
python
# async
async def show_items(session: AsyncSession, order_id: int) -> None:
order = await session.get(Order, order_id)
print(order.items) # 访问订单项
它看起来完全合理,却会在运行时抛出:
text
sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called;
can't call await_only() here. Was IO attempted in an unexpected place?
(Background on this error at: https://sqlalche.me/e/20/xd2s)
报错的位置不是 session.get(),而是看起来无害的 order.items。session.get() 只加载了 Order 的标量列,items 集合并未加载;访问它本应触发 lazy load,而在异步上下文里,这次"偷偷发生的 I/O"没有 await 可依附,于是抛出 MissingGreenlet。
这只是冰山一角,同样隐蔽的问题还有:两个并发协程共享同一个 AsyncSession 得到交错或错乱的结果;请求结束、Session 关闭后后台任务继续访问依赖懒加载的 ORM 实体;请求被取消时连接没有归还连接池最终把池耗尽;响应序列化阶段访问属性再次触发谁也没预料的 SQL。
本篇围绕三条主线:异步不会让 SQL 更快 (8.1)、隐式 I/O 是异步世界最危险的默认行为 (8.3)、AsyncSession per task 与请求级生命周期 (8.4--8.6)。示例统一使用 order-lab:客户 Customer 下单 Order,订单项 OrderItem 引用 Product;扣减库存与创建订单同一事务;领域事件写入 OutboxEvent,由后台任务通过 transactional outbox 可靠投递。
0.1 order-lab 异步模型
正文复用 Part 3 的声明式模型(Customer/Product/Order/OrderItem 定义不变),并补上 Payment、InventoryMovement、OutboxEvent。为了让 AsyncAttrs.awaitable_attrs(8.3.3)可用,异步映射基类继承 AsyncAttrs,且必须写在 DeclarativeBase 之前:
python
# sync(映射定义):AsyncAttrs 必须在 DeclarativeBase 之前
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import DateTime, ForeignKey, JSON, MetaData, Numeric, String, UniqueConstraint, func
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(AsyncAttrs, DeclarativeBase):
metadata = MetaData() # naming_convention 见 Part 3
class Payment(Base):
__tablename__ = "payment"
__table_args__ = (UniqueConstraint("external_key", name="uq_payment_external_key"),)
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"))
external_key: Mapped[str] = mapped_column(String(128))
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
status: Mapped[str] = mapped_column(String(32), default="SUCCEEDED")
order: Mapped[Order] = relationship(back_populates="payment")
class InventoryMovement(Base):
__tablename__ = "inventory_movement"
id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(ForeignKey("product.id"))
quantity: Mapped[int]
reason: Mapped[str] = mapped_column(String(32))
product: Mapped[Product] = relationship(back_populates="movements")
class OutboxEvent(Base):
__tablename__ = "outbox_event"
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"))
topic: Mapped[str] = mapped_column(String(64))
payload: Mapped[dict[str, Any]] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(16), default="PENDING")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
order: Mapped[Order] = relationship(back_populates="events")
同时给 Order 补两条关系:payment: Mapped[Payment | None] = relationship(back_populates="order", uselist=False) 与 events: Mapped[list[OutboxEvent]] = relationship(back_populates="order")。Order 1---1 Payment、Product 1---N InventoryMovement、Order 1---N OutboxEvent 覆盖支付幂等与 outbox 投递场景。
8.1 异步不会让 SQL 更快:先决定要不要上 asyncio
8.1.1 心智模型:异步解决的是"等待时出让控制权"
数据库访问的耗时由三部分组成:网络往返 + 数据库执行时间 + 结果传输 。它们都不会因为程序改成 async 而变短。异步的价值在于:一个协程在等待数据库 I/O 时,事件循环可以去运行其他协程。如果应用有大量并发请求、每个请求大部分时间在等待,异步能让同一个进程同时服务更多请求;单条 SQL 本身,该多慢还是多慢。
8.1.2 同步与异步的调用链对比
同步模型为每个请求分配线程,等待时线程阻塞;异步模型用少量线程跑事件循环,等待时协程挂起、控制权交还:
#mermaid-svg-iloubkYjkhh2UKYT{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-iloubkYjkhh2UKYT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iloubkYjkhh2UKYT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iloubkYjkhh2UKYT .error-icon{fill:#552222;}#mermaid-svg-iloubkYjkhh2UKYT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iloubkYjkhh2UKYT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iloubkYjkhh2UKYT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iloubkYjkhh2UKYT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iloubkYjkhh2UKYT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iloubkYjkhh2UKYT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iloubkYjkhh2UKYT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iloubkYjkhh2UKYT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iloubkYjkhh2UKYT .marker.cross{stroke:#333333;}#mermaid-svg-iloubkYjkhh2UKYT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iloubkYjkhh2UKYT p{margin:0;}#mermaid-svg-iloubkYjkhh2UKYT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-iloubkYjkhh2UKYT .cluster-label text{fill:#333;}#mermaid-svg-iloubkYjkhh2UKYT .cluster-label span{color:#333;}#mermaid-svg-iloubkYjkhh2UKYT .cluster-label span p{background-color:transparent;}#mermaid-svg-iloubkYjkhh2UKYT .label text,#mermaid-svg-iloubkYjkhh2UKYT span{fill:#333;color:#333;}#mermaid-svg-iloubkYjkhh2UKYT .node rect,#mermaid-svg-iloubkYjkhh2UKYT .node circle,#mermaid-svg-iloubkYjkhh2UKYT .node ellipse,#mermaid-svg-iloubkYjkhh2UKYT .node polygon,#mermaid-svg-iloubkYjkhh2UKYT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iloubkYjkhh2UKYT .rough-node .label text,#mermaid-svg-iloubkYjkhh2UKYT .node .label text,#mermaid-svg-iloubkYjkhh2UKYT .image-shape .label,#mermaid-svg-iloubkYjkhh2UKYT .icon-shape .label{text-anchor:middle;}#mermaid-svg-iloubkYjkhh2UKYT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iloubkYjkhh2UKYT .rough-node .label,#mermaid-svg-iloubkYjkhh2UKYT .node .label,#mermaid-svg-iloubkYjkhh2UKYT .image-shape .label,#mermaid-svg-iloubkYjkhh2UKYT .icon-shape .label{text-align:center;}#mermaid-svg-iloubkYjkhh2UKYT .node.clickable{cursor:pointer;}#mermaid-svg-iloubkYjkhh2UKYT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iloubkYjkhh2UKYT .arrowheadPath{fill:#333333;}#mermaid-svg-iloubkYjkhh2UKYT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iloubkYjkhh2UKYT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iloubkYjkhh2UKYT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iloubkYjkhh2UKYT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iloubkYjkhh2UKYT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iloubkYjkhh2UKYT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iloubkYjkhh2UKYT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iloubkYjkhh2UKYT .cluster text{fill:#333;}#mermaid-svg-iloubkYjkhh2UKYT .cluster span{color:#333;}#mermaid-svg-iloubkYjkhh2UKYT 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-iloubkYjkhh2UKYT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iloubkYjkhh2UKYT rect.text{fill:none;stroke-width:0;}#mermaid-svg-iloubkYjkhh2UKYT .icon-shape,#mermaid-svg-iloubkYjkhh2UKYT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iloubkYjkhh2UKYT .icon-shape p,#mermaid-svg-iloubkYjkhh2UKYT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iloubkYjkhh2UKYT .icon-shape .label rect,#mermaid-svg-iloubkYjkhh2UKYT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iloubkYjkhh2UKYT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iloubkYjkhh2UKYT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iloubkYjkhh2UKYT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-iloubkYjkhh2UKYT .py>*{fill:#dbe7ff!important;stroke:#1f4e9c!important;color:#1f4e9c!important;}#mermaid-svg-iloubkYjkhh2UKYT .py span{fill:#dbe7ff!important;stroke:#1f4e9c!important;color:#1f4e9c!important;}#mermaid-svg-iloubkYjkhh2UKYT .py tspan{fill:#1f4e9c!important;}#mermaid-svg-iloubkYjkhh2UKYT .sess>*{fill:#e8dcff!important;stroke:#5b2ea6!important;color:#5b2ea6!important;}#mermaid-svg-iloubkYjkhh2UKYT .sess span{fill:#e8dcff!important;stroke:#5b2ea6!important;color:#5b2ea6!important;}#mermaid-svg-iloubkYjkhh2UKYT .sess tspan{fill:#5b2ea6!important;}#mermaid-svg-iloubkYjkhh2UKYT .conn>*{fill:#ffe9d1!important;stroke:#c96a00!important;color:#c96a00!important;}#mermaid-svg-iloubkYjkhh2UKYT .conn span{fill:#ffe9d1!important;stroke:#c96a00!important;color:#c96a00!important;}#mermaid-svg-iloubkYjkhh2UKYT .conn tspan{fill:#c96a00!important;}#mermaid-svg-iloubkYjkhh2UKYT .db>*{fill:#d9f2e4!important;stroke:#1e7d46!important;color:#1e7d46!important;}#mermaid-svg-iloubkYjkhh2UKYT .db span{fill:#d9f2e4!important;stroke:#1e7d46!important;color:#1e7d46!important;}#mermaid-svg-iloubkYjkhh2UKYT .db tspan{fill:#1e7d46!important;} 异步事件循环:请求是协程,等待时让出
让出控制权
协程 A:await session.execute()
await 挂起,事件循环切换
Database
协程 B:在 A 等待期间执行
同步线程模型:一个请求一个线程,等待即阻塞
线程 A:session.execute()
线程 A 阻塞等待 DBAPI 响应
Database
同样的 SQL 在两种模型里执行时间几乎相同,区别在并发能力:同步方案需要足够的线程数,线程过多时切换开销上升;异步方案把"等待"交给事件循环,进程内可以同时挂起成千上万个请求。
8.1.3 单条慢 SQL 仍然是慢 SQL
慢查询(1.2 秒)改成 async 不会变成 0.2 秒------它只是让事件循环在这 1.2 秒里服务其他请求。如果一个请求的事务内串行执行 20 条 SQL,异步也不会把它们变成并行。缩短单条响应仍靠索引、减少往返、批量化写入与执行计划分析------这些与是否异步无关。
8.1.4 异步驱动是硬要求,URL 必须配套
AsyncEngine 不能使用同步 DBAPI 驱动,驱动和 URL 必须成对选择:
| 数据库 | 同步 URL | 异步 URL | 异步驱动 |
|---|---|---|---|
| SQLite | sqlite+pysqlite:///order_lab.db |
sqlite+aiosqlite:///order_lab.db |
aiosqlite |
| PostgreSQL | postgresql+psycopg://app:secret@localhost/order_lab |
postgresql+asyncpg://app:secret@localhost/order_lab |
asyncpg |
三条铁律:postgresql+asyncpg:// 不能传给 create_engine();sqlite+pysqlite:// 不能传给 create_async_engine();安装 SQLAlchemy 不会自动安装驱动,aiosqlite、asyncpg 要单独安装。
8.1.5 什么时候同步方案更简单可靠
选择异步前先回答三个问题:并发形态 (是否主要被数据库/网络等待占用且需要高并发?并发低则收益有限);团队栈 (是否熟悉事件循环、协程取消与异步调试?异步的错误栈更复杂);依赖生态 (第三方库是否有异步版本?同步 SDK 在异步路由里会阻塞事件循环,等于放弃收益)。同步方案(如 FastAPI 的 def 路由 + psycopg + 线程池)在并发不高、写操作多、团队以同步经验为主时往往更简单可靠。异步不是"更高级",而是另一个取舍。决策方式是压测后同时记录应用吞吐、池等待与数据库执行时间:若吞吐瓶颈在数据库执行时间,上异步解决不了根因;若连接在等待、数据库很闲,异步才是有效方向。
8.2 创建异步基础设施:create_async_engine、async_sessionmaker 与 async with
8.2.1 create_async_engine:AsyncEngine 是同步内核的异步代理
create_async_engine() 返回 AsyncEngine。它不是一套全新的数据库内核,而是对同步 Engine 的代理:Dialect、Pool、编译、事务管理全部复用同步实现,只在真正需要数据库 I/O 的地方,通过 greenlet 在事件循环中挂起/恢复,把 async 驱动的 await 桥接到同步 SQLAlchemy 代码。
python
# async · 显式 I/O 只发生在你 await 的地方
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(
"postgresql+asyncpg://app:secret@localhost/order_lab",
pool_pre_ping=True,
pool_size=5,
max_overflow=5,
pool_timeout=10,
)
连接池参数与 Part 2 的同步 create_engine() 完全一致(底层是同一个 Pool);create_async_engine() 同样惰性连接。两个易错点:inspect() 不能直接作用于 AsyncEngine/AsyncConnection(没有 awaitable 版本的 Inspector),要在 run_sync() 里获取同步对象;Base.metadata.create_all() 同样通过 run_sync() 执行。
8.2.2 async_sessionmaker 与 expire_on_commit=False 的工程取舍
python
# async · 仅创建配置对象,不发生 I/O
from sqlalchemy.ext.asyncio import async_sessionmaker
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
为什么默认关闭 expire_on_commit? Part 4 讲过默认 commit() 会 expire 对象属性、下次访问触发重新查询。在异步场景这有双重危害:commit 后视图层访问属性 → 隐式刷新 → 抛 MissingGreenlet;即使不抛错,序列化时多出来的查询也会拖慢响应。关闭后对象保持事务结束时的值,序列化阶段不再访问数据库。代价是对象是快照 ,不是其他事务提交后的最新值,因此必须配合短生命周期 Session 使用。官方文档在 Web 应用场景中同样建议 expire_on_commit=False。
8.2.3 async with 生命周期:Session、事务、连接三件事各归其位
python
# async · 显式 I/O:flush 与 begin 退出时的 commit
async def create_order(command: PlaceOrder) -> int:
async with SessionFactory() as session:
async with session.begin():
order = Order(customer_id=command.customer_id)
session.add(order)
await session.flush()
return order.id
| 上下文 | 职责 | 退出时发生什么 |
|---|---|---|
async with SessionFactory() as session |
创建并关闭 AsyncSession | session.close():释放对象引用、归还连接 |
async with session.begin() |
定义事务边界 | 正常退出 COMMIT;异常退出 ROLLBACK |
async with engine.connect() |
管理单条 AsyncConnection | 结束事务并归还连接 |
与同步 Session 的差异只有一个:所有可能产生 I/O 的方法都要 await (get、execute、scalars、flush、commit、rollback、refresh 等)。两段上下文可合并为 async with SessionFactory.begin() as session:,等价于"创建 Session → 进入事务 → 正常退出 commit → 关闭 Session"。
8.2.4 应用关闭与生命周期速查
python
# async · 显式 I/O:dispose 关闭池内连接
async def shutdown() -> None:
await engine.dispose()
不调用也不代表泄漏------进程退出后连接自然关闭------但优雅关闭能让服务端日志干净。完整生命周期:应用进程启动 创建 AsyncEngine(惰性,不连接);每个请求/任务 由 async_sessionmaker() 创建 AsyncSession;事务边界 由 async with session.begin() 管理;请求结束 由 async with 退出并归还连接;应用关闭 执行 await engine.dispose()。一句话:Engine 应用级复用,Session 请求/任务级创建。
8.3 隐式 I/O 与属性访问:异步世界最危险的默认行为
8.3.1 失败实验:lazy load 在 asyncio 中的复现
python
# async · 有隐式 I/O 风险:order.items 是 lazy load
async def unsafe_show_items(session: AsyncSession, order_id: int) -> None:
order = await session.get(Order, order_id)
print(order.items) # 未加载 → MissingGreenlet
官方文档对 MissingGreenlet 的定义:
A call to the async DBAPI was initiated outside the greenlet spawn context usually setup by the SQLAlchemy AsyncIO proxy classes. ... When using the ORM this is nearly always due to the use of lazy loading.
也就是说:ORM 的 lazy load 在 asyncio 下不被直接支持 。MissingGreenlet 的常见根因有四类:访问未加载的 lazy relationship;访问已 expire 的属性;在同步回调(run_sync 内、事件监听器、__repr__、序列化器)中触发数据库 I/O;Session 已离开正确的异步上下文。
8.3.2 修复一:显式 eager loading
python
# async · 显式 I/O:selectinload 在 await 内完成
from sqlalchemy import select
from sqlalchemy.orm import selectinload
async def load_order_with_items(session: AsyncSession, order_id: int) -> Order:
stmt = select(Order).where(Order.id == order_id).options(selectinload(Order.items))
return (await session.scalars(stmt)).one()
selectinload(Order.items) 生成第二条 IN (...) 查询把全部订单项取回,此后访问 order.items 是纯内存操作。优先使用 selectinload 而非 joinedload:后者对大集合会产生行倍增(Part 6 有完整对比)。
8.3.3 修复二:AsyncAttrs.awaitable_attrs
如果确实需要"先拿对象,再按需取关系",用 AsyncAttrs mixin 把懒加载变成显式 await(映射基类继承 AsyncAttrs,见 0.1):
python
# async · 显式 I/O:awaitable_attrs 让 lazy load 显式 await
order = await session.get(Order, order_id)
items = await order.awaitable_attrs.items
它把隐式 I/O 变成显式 await,但仍是逐条 lazy load,N 个订单各加载一次 items 就是 N+1 问题,批量场景应优先 selectinload;它也不能补救"Session 已关闭"的 detached 对象问题。
8.3.4 run_sync 的用途与边界
AsyncSession.run_sync() 接收同步函数,并把同步 Session 作为第一个参数传入,用于在 asyncio 中运行传统同步 SQLAlchemy 代码:
python
# async · 显式 I/O 发生在 run_sync 内部,调用点 await
from sqlalchemy import func, select
from sqlalchemy.orm import Session
def count_orders_sync(session: Session) -> int:
return session.scalars(select(func.count(Order.id))).one()
async def report() -> int:
async with SessionFactory() as session:
return await session.run_sync(count_orders_sync)
适合:复用同步业务函数/监听器、使用同步事件或扩展、获取 Inspector、执行 metadata.create_all()。边界:run_sync 内的同步函数不能调用任何 async API ;它不是"把同步代码塞进 async 项目"的万能胶------把整个业务逻辑塞进去会丢掉 await 结构;同步函数内的非 SQLAlchemy 阻塞(time.sleep、同步 HTTP)会真实阻塞事件循环。
8.3.5 已 expire 的属性同样隐式 I/O
commit 后访问已 expire 的属性(expire_on_commit=True 时)同样触发隐式刷新并抛 MissingGreenlet;__repr__、序列化器、事件监听器里触发的查询都是"不在 await 位置"的 I/O。排查时先打印完整异常栈,看 SQL 是从哪个属性访问触发的。
8.3.6 隐式 I/O 判定速查
| 操作 | 是否可能隐式 I/O | 正确姿势 |
|---|---|---|
await session.get(Order, 1) |
否,显式 | ------ |
访问 order.id(已加载标量) |
否 | ------ |
访问 order.items(未加载) |
是 | selectinload 或 awaitable_attrs |
commit 后访问属性(expire_on_commit=True) |
是 | expire_on_commit=False |
在 __repr__/序列化器里访问关系 |
是 | 先加载,或只读 DTO |
run_sync 内触发查询 |
是,但被正确适配 | 只放同步函数 |
最稳妥的 API 边界是:事务内加载响应所需数据,转换为 DTO,然后关闭 Session;不要让序列化器临时发现还要查数据库。
8.4 AsyncSession per task:并发隔离
8.4.1 官方并发模型
官方文档的表述非常明确:
The Session is a mutable, stateful object that represents a single database transaction. An instance of Session therefore cannot be shared among concurrent threads or asyncio tasks without careful synchronization.
The concurrency model for SQLAlchemy's Session and AsyncSession is therefore Session per thread, AsyncSession per task.
AsyncSession 只是同步 Session 的薄代理,同一套规则适用:它是未加锁、可变、有状态的对象,代表单个逻辑数据库事务,一次只允许一个 task 操作它。并发场景的正确单位是"多个并发事务",而不是"并发共享一个事务"。
8.4.2 失败实验:gather 共享 AsyncSession
python
# async · 错误:两个 task 共享同一个 AsyncSession
async def unsafe_parallel_load() -> None:
async with SessionFactory() as session:
await asyncio.gather(
load_order(session, 1),
load_order(session, 2),
)
运行结果不是确定性的 :两个 task 在同一 Session 上交错执行 session.get(),底层事务状态机被并发触碰,可能出现 MissingGreenlet、错误的结果配对,甚至个别运行"看起来正常"然后偶发错乱。不要用"我试了几次都没报错"证明它安全------未定义行为正是它最危险的地方。
8.4.3 正确做法:Session per task
python
# async · 显式 I/O:每个 task 独立 Session
async def load_one(order_id: int) -> Order:
async with SessionFactory() as session:
return await session.get(Order, order_id)
async def load_many() -> list[Order]:
return list(await asyncio.gather(load_one(1), load_one(2)))
要点:SessionFactory(工厂)是共享的、无状态的;工厂生成的 AsyncSession 才是 task 私有的;每个 task 的 Session 拥有自己的事务和连接,数据库层通过锁与隔离级别保证一致性。
#mermaid-svg-IyVllZVj2RQZrgeX{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-IyVllZVj2RQZrgeX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IyVllZVj2RQZrgeX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IyVllZVj2RQZrgeX .error-icon{fill:#552222;}#mermaid-svg-IyVllZVj2RQZrgeX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IyVllZVj2RQZrgeX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IyVllZVj2RQZrgeX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IyVllZVj2RQZrgeX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IyVllZVj2RQZrgeX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IyVllZVj2RQZrgeX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IyVllZVj2RQZrgeX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IyVllZVj2RQZrgeX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IyVllZVj2RQZrgeX .marker.cross{stroke:#333333;}#mermaid-svg-IyVllZVj2RQZrgeX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IyVllZVj2RQZrgeX p{margin:0;}#mermaid-svg-IyVllZVj2RQZrgeX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IyVllZVj2RQZrgeX .cluster-label text{fill:#333;}#mermaid-svg-IyVllZVj2RQZrgeX .cluster-label span{color:#333;}#mermaid-svg-IyVllZVj2RQZrgeX .cluster-label span p{background-color:transparent;}#mermaid-svg-IyVllZVj2RQZrgeX .label text,#mermaid-svg-IyVllZVj2RQZrgeX span{fill:#333;color:#333;}#mermaid-svg-IyVllZVj2RQZrgeX .node rect,#mermaid-svg-IyVllZVj2RQZrgeX .node circle,#mermaid-svg-IyVllZVj2RQZrgeX .node ellipse,#mermaid-svg-IyVllZVj2RQZrgeX .node polygon,#mermaid-svg-IyVllZVj2RQZrgeX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IyVllZVj2RQZrgeX .rough-node .label text,#mermaid-svg-IyVllZVj2RQZrgeX .node .label text,#mermaid-svg-IyVllZVj2RQZrgeX .image-shape .label,#mermaid-svg-IyVllZVj2RQZrgeX .icon-shape .label{text-anchor:middle;}#mermaid-svg-IyVllZVj2RQZrgeX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IyVllZVj2RQZrgeX .rough-node .label,#mermaid-svg-IyVllZVj2RQZrgeX .node .label,#mermaid-svg-IyVllZVj2RQZrgeX .image-shape .label,#mermaid-svg-IyVllZVj2RQZrgeX .icon-shape .label{text-align:center;}#mermaid-svg-IyVllZVj2RQZrgeX .node.clickable{cursor:pointer;}#mermaid-svg-IyVllZVj2RQZrgeX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IyVllZVj2RQZrgeX .arrowheadPath{fill:#333333;}#mermaid-svg-IyVllZVj2RQZrgeX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IyVllZVj2RQZrgeX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IyVllZVj2RQZrgeX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IyVllZVj2RQZrgeX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IyVllZVj2RQZrgeX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IyVllZVj2RQZrgeX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IyVllZVj2RQZrgeX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IyVllZVj2RQZrgeX .cluster text{fill:#333;}#mermaid-svg-IyVllZVj2RQZrgeX .cluster span{color:#333;}#mermaid-svg-IyVllZVj2RQZrgeX 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-IyVllZVj2RQZrgeX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IyVllZVj2RQZrgeX rect.text{fill:none;stroke-width:0;}#mermaid-svg-IyVllZVj2RQZrgeX .icon-shape,#mermaid-svg-IyVllZVj2RQZrgeX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IyVllZVj2RQZrgeX .icon-shape p,#mermaid-svg-IyVllZVj2RQZrgeX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IyVllZVj2RQZrgeX .icon-shape .label rect,#mermaid-svg-IyVllZVj2RQZrgeX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IyVllZVj2RQZrgeX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IyVllZVj2RQZrgeX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IyVllZVj2RQZrgeX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-IyVllZVj2RQZrgeX .py>*{fill:#dbe7ff!important;stroke:#1f4e9c!important;color:#1f4e9c!important;}#mermaid-svg-IyVllZVj2RQZrgeX .py span{fill:#dbe7ff!important;stroke:#1f4e9c!important;color:#1f4e9c!important;}#mermaid-svg-IyVllZVj2RQZrgeX .py tspan{fill:#1f4e9c!important;}#mermaid-svg-IyVllZVj2RQZrgeX .sess>*{fill:#e8dcff!important;stroke:#5b2ea6!important;color:#5b2ea6!important;}#mermaid-svg-IyVllZVj2RQZrgeX .sess span{fill:#e8dcff!important;stroke:#5b2ea6!important;color:#5b2ea6!important;}#mermaid-svg-IyVllZVj2RQZrgeX .sess tspan{fill:#5b2ea6!important;}#mermaid-svg-IyVllZVj2RQZrgeX .conn>*{fill:#ffe9d1!important;stroke:#c96a00!important;color:#c96a00!important;}#mermaid-svg-IyVllZVj2RQZrgeX .conn span{fill:#ffe9d1!important;stroke:#c96a00!important;color:#c96a00!important;}#mermaid-svg-IyVllZVj2RQZrgeX .conn tspan{fill:#c96a00!important;}#mermaid-svg-IyVllZVj2RQZrgeX .db>*{fill:#d9f2e4!important;stroke:#1e7d46!important;color:#1e7d46!important;}#mermaid-svg-IyVllZVj2RQZrgeX .db span{fill:#d9f2e4!important;stroke:#1e7d46!important;color:#1e7d46!important;}#mermaid-svg-IyVllZVj2RQZrgeX .db tspan{fill:#1e7d46!important;}#mermaid-svg-IyVllZVj2RQZrgeX .err>*{fill:#ffdcdc!important;stroke:#c62828!important;color:#c62828!important;}#mermaid-svg-IyVllZVj2RQZrgeX .err span{fill:#ffdcdc!important;stroke:#c62828!important;color:#c62828!important;}#mermaid-svg-IyVllZVj2RQZrgeX .err tspan{fill:#c62828!important;} 未定义行为
asyncio.gather
Task 1
Task 2
AsyncSession #1(私有)
AsyncSession #2(私有)
AsyncEngine(共享工厂)
Database
错误:Task1/Task2 共享一个 AsyncSession
8.4.4 必须原子时怎么办:同一 Session 顺序执行
gather 的并发换来的是独立事务,代价是不再原子。如果两个操作必须全部成功或全部失败,它们不能被拆成两个并发事务,而应在同一 Session 中顺序执行:
python
# async · 显式 I/O:顺序执行保证原子性
async with SessionFactory.begin() as session:
order = await create_order(session, command) # 建订单
payment = await mark_paid(session, order.id) # 记账
# 任一步失败,整个事务回滚
并发拆事务的前提是每个事务独立可提交、可重试。业务不变量横跨多个步骤时,先问"这个并发是否值得"。
8.4.5 并发边界结论
- 同步:Session per thread;异步:AsyncSession per task;请求结束后不要把 Session 或依赖懒加载的实体交给其他 task/后台线程;
- 官方还提供
async_scoped_session(scopefunc=asyncio.current_task),适合不想显式传 Session 的场景,但配置更复杂,默认仍推荐显式传 Session。
8.5 FastAPI/ASGI 请求级 Session 生命周期
8.5.1 dependency/yield 模式
FastAPI 的依赖注入支持 yield:yield 前创建资源,之后清理资源。请求级 Session 的标准做法:
python
# async · 显式 I/O:session 在 yield 中交给路由,请求结束后关闭
from collections.abc import AsyncIterator
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
@router.post("/orders")
async def place_order(
command: PlaceOrderRequest,
session: AsyncSession = Depends(get_session),
) -> OrderResponse:
async with session.begin():
order = await service.place_order(session, command)
return OrderResponse.from_entity(order)
async with session.begin() 负责提交:正常退出 COMMIT,异常退出 ROLLBACK;get_session 的 async with 负责关闭 Session 与归还连接。请求级生命周期因此完整:一个请求 = 一个 AsyncSession = 一个事务边界(可能多个) 。代码块判定:async ;service 内的 await 都是显式 I/O,路由返回前 OrderResponse.from_entity(order) 若访问未加载关系会隐式 I/O,因此实体必须在事务内转换为响应值(见 8.5.3)。
8.5.2 commit 所有权:路由层还是 service 层
"谁负责 commit"必须显式决定,否则会出现重复提交、提交时机失控或事务缺失:
| 策略 | 优点 | 代价 |
|---|---|---|
| 路由层持有事务 | 边界直观,一个请求一个事务 | 多个入口调用同一 service 时容易漏包事务 |
| service 层持有事务 | 业务事务集中,跨入口一致 | 调用契约必须清楚:service 自己提交,调用方不能再 commit |
| middleware 自动提交 | 写起来少 | 异常处理、流式响应与长任务会被迫放大事务,最不推荐 |
order-lab 的约定:service 只做工作单元内的变更,路由层负责事务边界 。配套纪律:repository 禁止自行 commit,否则路由层无法把"扣库存 + 建订单 + 写 outbox"组装成一个原子操作。同一项目选一种主策略,跨层混合是最常见的 bug 来源。
8.5.3 序列化阶段的 detached/lazy-load 问题:转 DTO
如果实体直接进 JSON 序列化:Session 已关闭时访问未加载属性抛 DetachedInstanceError(同步)或 MissingGreenlet(异步);Session 仍活着但属性已 expire 时,序列化会悄悄多一次查询。统一解法:在事务内加载数据并转换为 DTO,关闭 Session 后再序列化。
python
# async · 显式 I/O:加载与转换都在事务内完成
@dataclass(frozen=True)
class OrderDTO:
id: int
customer_id: int
status: str
items: list[OrderItemDTO]
@classmethod
def from_entity(cls, order: Order) -> "OrderDTO":
return cls(
id=order.id,
customer_id=order.customer_id,
status=order.status,
items=[OrderItemDTO.from_entity(item) for item in order.items],
)
@dataclass(frozen=True)
class OrderItemDTO:
product_id: int
quantity: int
unit_price: Decimal
@classmethod
def from_entity(cls, item: OrderItem) -> "OrderItemDTO":
return cls(product_id=item.product_id, quantity=item.quantity, unit_price=item.unit_price)
async def get_order_dto(session: AsyncSession, order_id: int) -> OrderDTO:
order = await session.scalars(
select(Order).where(Order.id == order_id).options(selectinload(Order.items))
).one()
return OrderDTO.from_entity(order)
DTO 是不携带 Session 引用的纯值对象,离开 Session 后不再触发任何数据库 I/O,响应序列化可以安全地发生在 Session 关闭之后。
8.5.4 请求级生命周期时序图
Database AsyncEngine/Pool AsyncSession get_session(dependency) FastAPI 路由 Client Database AsyncEngine/Pool AsyncSession get_session(dependency) FastAPI 路由 Client #mermaid-svg-VPTxCLQxytlWVPPQ{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-VPTxCLQxytlWVPPQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VPTxCLQxytlWVPPQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VPTxCLQxytlWVPPQ .error-icon{fill:#552222;}#mermaid-svg-VPTxCLQxytlWVPPQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VPTxCLQxytlWVPPQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VPTxCLQxytlWVPPQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VPTxCLQxytlWVPPQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VPTxCLQxytlWVPPQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VPTxCLQxytlWVPPQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VPTxCLQxytlWVPPQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VPTxCLQxytlWVPPQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VPTxCLQxytlWVPPQ .marker.cross{stroke:#333333;}#mermaid-svg-VPTxCLQxytlWVPPQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VPTxCLQxytlWVPPQ p{margin:0;}#mermaid-svg-VPTxCLQxytlWVPPQ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VPTxCLQxytlWVPPQ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-VPTxCLQxytlWVPPQ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VPTxCLQxytlWVPPQ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-VPTxCLQxytlWVPPQ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-VPTxCLQxytlWVPPQ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-VPTxCLQxytlWVPPQ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-VPTxCLQxytlWVPPQ .sequenceNumber{fill:white;}#mermaid-svg-VPTxCLQxytlWVPPQ #sequencenumber{fill:#333;}#mermaid-svg-VPTxCLQxytlWVPPQ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-VPTxCLQxytlWVPPQ .messageText{fill:#333;stroke:none;}#mermaid-svg-VPTxCLQxytlWVPPQ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VPTxCLQxytlWVPPQ .labelText,#mermaid-svg-VPTxCLQxytlWVPPQ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-VPTxCLQxytlWVPPQ .loopText,#mermaid-svg-VPTxCLQxytlWVPPQ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-VPTxCLQxytlWVPPQ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VPTxCLQxytlWVPPQ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-VPTxCLQxytlWVPPQ .noteText,#mermaid-svg-VPTxCLQxytlWVPPQ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-VPTxCLQxytlWVPPQ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VPTxCLQxytlWVPPQ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VPTxCLQxytlWVPPQ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VPTxCLQxytlWVPPQ .actorPopupMenu{position:absolute;}#mermaid-svg-VPTxCLQxytlWVPPQ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-VPTxCLQxytlWVPPQ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VPTxCLQxytlWVPPQ .actor-man circle,#mermaid-svg-VPTxCLQxytlWVPPQ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-VPTxCLQxytlWVPPQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /orders 进入依赖 SessionFactory() 创建 Session yield session async with session.begin() await service.place_order(...) checkout connection BEGIN SELECT product FOR UPDATE UPDATE product.stock INSERT orders / order_item / outbox_event 返回 order begin 块正常退出 → COMMIT checkin connection 序列化 OrderDTO(不再触发 SQL) 200 OK yield 之后 Session.close()
关键观察:连接占用窗口 = 从第一个 SQL 到 commit 。所有会阻塞的远程调用、重计算都不应放在这个窗口里。同理,流式响应不要挟持数据库事务:若把连接、事务与流绑定(例如边查边流),慢客户端会把连接占用很久------正确做法是事务内查询并缓存/落盘,事务结束后再从缓存流式返回。
8.5.5 order-lab 实战:async 下单服务 + FastAPI 路由
把 Part 4 的同步 place_order 升级为异步版本,并写入 transactional outbox:
python
# async · 显式 I/O:await 覆盖所有数据库操作
from dataclasses import dataclass
from decimal import Decimal
from sqlalchemy import select
@dataclass(frozen=True)
class OrderLineCommand:
product_id: int
quantity: int
async def place_order(
session: AsyncSession, *, customer_id: int, lines: list[OrderLineCommand]
) -> Order:
if not lines:
raise ValueError("order must contain at least one item")
order = Order(customer_id=customer_id, status="PENDING")
session.add(order)
for line in lines:
product = await session.scalars(
select(Product).where(Product.id == line.product_id).with_for_update()
).one()
if line.quantity <= 0:
raise ValueError("quantity must be positive")
if product.stock < line.quantity:
raise ValueError(f"insufficient stock: {product.sku}")
product.stock -= line.quantity
session.add(InventoryMovement(product=product, quantity=-line.quantity, reason="ORDER"))
order.items.append(OrderItem(product=product, quantity=line.quantity, unit_price=product.price))
session.add(
OutboxEvent(order=order, topic="order.created", payload={"customer_id": customer_id})
)
await session.flush()
return order
路由层保持薄:
python
# async · 显式 I/O
@router.post("/orders")
async def create_order(
command: PlaceOrderRequest,
session: AsyncSession = Depends(get_session),
) -> OrderResponse:
async with session.begin():
order = await place_order(session, customer_id=command.customer_id, lines=command.lines)
dto = OrderDTO.from_entity(order) # 新订单的 items 已在内存
return OrderResponse.from_dto(dto)
若第二个商品库存不足,前面已修改的库存、订单项、InventoryMovement 与 OutboxEvent 会一起回滚------这正是"扣库存与创建订单同一事务"的落地。
8.6 Celery 与同步后台任务
8.6.1 后台任务为什么需要自己的 Engine 与 Session
请求结束后,AsyncSession 及其连接属于请求的事务。把请求的 Session 或依赖懒加载的实体传给后台任务,等于让另一段生命周期复用一段已结束的资源。纪律很明确:不把 Session 传给后台任务;不传依赖 lazy load 的 ORM 实体;传不可变标识(order_id、event_id)和必要快照;后台任务自己创建 Session。
8.6.2 Celery worker fork 后 Engine 重建
Celery 默认的 prefork pool 在 worker 启动时通过 fork() 产生子进程。若 Engine 在模块导入时创建,fork 后子进程会继承父进程的池与连接,而继承的连接没有经过 pool 的正常生命周期 ,两个进程同时持有同一连接会产生奇怪的数据库错误。可靠做法是在 task 内创建 Engine (或 fork 后先 engine.dispose() 再使用):
python
# sync(Celery task):task 内部创建 Engine,用完 dispose
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
def dispatch_pending() -> int:
engine = create_engine(database_url, pool_pre_ping=True)
SyncSessionFactory = sessionmaker(engine)
try:
with SyncSessionFactory() as session:
...
finally:
engine.dispose()
若坚持模块级 Engine,则在每个 task 开头调用 engine.dispose() 丢弃 fork 继承的连接,配合 pool_pre_ping=True 兜底。
8.6.3 任务级 Session 模板
后台任务的生命周期是"一个任务 = 一个 Session = 一个事务边界",与请求完全同构,只是没有 HTTP 上下文:
python
# sync(Celery task)
@celery_app.task(name="order.mark_paid", bind=True, max_retries=3)
def mark_paid(self: Task, order_id: int) -> None:
with SyncSessionFactory() as session:
with session.begin():
order = session.scalars(
select(Order).where(Order.id == order_id).with_for_update()
).one()
order.status = "PAID"
8.6.4 Transactional Outbox:业务数据与 outbox 同事务提交
直接发布消息的可靠性问题在于"两个事务无法原子":先写库后发消息,发送失败则漏发;先发消息后写库,消费者可能看到不存在的订单。transactional outbox 把"发消息"变成"写一张表",与业务写同事务:业务写 + INSERT outbox_event 原子提交 → 后台投递器轮询 PENDING 事件 → 投递成功后置为 SENT;失败保持 PENDING 等待重试。下单事务(8.5.5)已实现前半段。
8.6.5 后台投递器与幂等键
python
# sync(Celery task)
from collections.abc import Callable
from sqlalchemy import select
def deliver_pending_events(deliverer: Callable[[OutboxEvent], None]) -> int:
with SyncSessionFactory() as session:
events = session.scalars(
select(OutboxEvent).where(OutboxEvent.status == "PENDING")
.order_by(OutboxEvent.id).limit(50)
).all()
delivered = 0
for event in events:
deliverer(event) # 外部投递:消息总线/推送
event.status = "SENT" # 成功才标记
delivered += 1
session.commit()
return delivered
投递失败(异常或进程崩溃)时事件保持 PENDING,下一轮自动重试。为防止"投递成功但标记失败"造成重复投递,消费者侧用外部幂等键去重------order-lab 的支付回调正是这一模式:
python
# sync(支付回调):按外部幂等键去重,唯一约束兜底
def record_payment(*, external_key: str, order_id: int, amount: Decimal) -> None:
with SyncSessionFactory() as session:
with session.begin():
existing = session.scalars(
select(Payment).where(Payment.external_key == external_key)
).one_or_none()
if existing is not None:
return # 幂等:同一回调已处理
session.add(Payment(order_id=order_id, external_key=external_key, amount=amount))
external_key 由唯一约束 uq_payment_external_key 兜底:两个并发回调同时通过查询时,后插入者撞 IntegrityError,捕获后同样视为"已处理"。
8.6.6 重试与提交顺序
- 重试必须重放完整、幂等的业务事务,而不是在失败事务上下文里继续;
- 提交顺序遵守"先业务、后 outbox 标记、再外部投递"的闭环,PENDING 事件年龄是 outbox 的黄金指标。
8.7 超时、取消与连接归还
8.7.1 取消协程时 async with 负责 rollback 与归还
asyncio 任务被取消(task.cancel()、asyncio.wait_for 超时、FastAPI 客户端断开)时,协程抛出 CancelledError。只要事务和 Session 的生命周期由 async with(或 try/finally)管理,取消就走正常清理路径:CancelledError 在 await 点抛出 → async with session.begin() 退出 ROLLBACK → async with SessionFactory() 退出 close,连接归还池。
代码块判定:async 。真正要警惕的是"取消发生在驱动 I/O 中途":底层连接的当前语句被中断,连接状态取决于驱动(asyncpg 会中断查询并回滚服务端事务,aiosqlite 的游标可能失效)。此时连接归还后可能仍带脏状态,pool_pre_ping 能在下次 checkout 时发现失效连接。不要让任何代码路径绕过 async with 手工持有连接。
8.7.2 失败实验:取消请求后连接归还验证
构造一个只允许 1 条连接的池,模拟"长事务中被取消",验证连接回到池中:
python
# async · 显式 I/O
import asyncio
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
async def long_transaction(engine: AsyncEngine) -> None:
factory = async_sessionmaker(engine)
async with factory() as session:
async with session.begin():
session.add(Customer(email="cancel@example.com", name="Cancel"))
await asyncio.sleep(30) # 模拟事务内长时间等待(错误示范)
async def verify_cancel_returns_connection(engine: AsyncEngine) -> None:
pool = engine.sync_engine.pool
assert pool.checkedout() == 0
task = asyncio.create_task(long_transaction(engine))
await asyncio.sleep(0.1)
assert pool.checkedout() == 1 # 连接被事务占用
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert pool.checkedout() == 0 # 取消后连接已归还
只要生命周期由 async with 管理,取消时 rollback + close + checkin 都会执行。反过来,session = factory() 从不 close 的代码,取消后连接会一直挂在池外,最终触发 QueuePool limit of size ... connection timed out。
8.7.3 不要在数据库事务中等待远程 HTTP
text
错误:BEGIN → 锁库存 → await 支付网关 10s → COMMIT
改进:短事务记录意图 → 外部调用 → 幂等短事务确认
在事务内等待远程服务,等于"占用连接 + 持有行锁"等一个与自己无关的响应。10 秒的网关超时会把连接和库存行锁占用 10 秒,其他请求全部排队。改进分三段:短事务 (BEGIN → 锁库存、扣减、写 outbox → COMMIT);外部调用 (事务已结束,连接已归还,安全等待网关);幂等确认 (回调或轮询后用新短事务标记结果,按外部幂等键去重)。事务越长,连接占用与锁竞争越严重:事务时长 ↑ → 单连接占用 ↑ → 池内可用连接 ↓ → 并发请求等待 checkout ↑ → 整体吞吐 ↓。排查"池耗尽"时优先怀疑慢 SQL、事务内的远程调用、未关闭的 Result、未归还的 Session。
8.7.4 连接池观测
python
# async · 显式 I/O:异步 Engine 的事件监听挂在 sync_engine 上
from sqlalchemy import event
@event.listens_for(engine.sync_engine, "checkout")
def on_checkout(dbapi_connection, connection_record, connection_proxy) -> None:
...
@event.listens_for(engine.sync_engine, "checkin")
def on_checkin(dbapi_connection, connection_record) -> None:
...
事件目标是 engine.sync_engine(AsyncEngine 的同步内核)。生产观测应记录 checkout/checkin 次数、占用时长、等待时长,与数据库侧连接数和慢查询对照。
8.8 自动化测试
本节用 pytest + pytest-asyncio 验证本篇关键结论。测试基于 aiosqlite(文件库,避免内存库连接隔离问题);数据库级并发与锁的最终结论仍需在 PostgreSQL 复验。依赖安装:
powershell
python -m pip install pytest pytest-asyncio aiosqlite
在 pyproject.toml 中设置 asyncio_mode = "auto"。
测试文件 tests/test_part8.py(完整模型定义见 0.1,此处从简):
python
import asyncio
from collections.abc import AsyncIterator
from decimal import Decimal
import pytest
import pytest_asyncio
from sqlalchemy import func, select
from sqlalchemy.exc import MissingGreenlet
from sqlalchemy.ext.asyncio import (
AsyncAttrs, AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase, selectinload
class Base(AsyncAttrs, DeclarativeBase):
"""与正文 0.1 一致。"""
# 完整模型见正文 0.1:Customer/Product/Order/OrderItem/Payment/InventoryMovement/OutboxEvent。
@pytest_asyncio.fixture
async def engine(tmp_path) -> AsyncIterator[AsyncEngine]:
test_engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'part8.db'}")
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield test_engine
await test_engine.dispose()
@pytest_asyncio.fixture
async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(engine, expire_on_commit=False)
测试一:gather 并发下单,每个 task 独立 Session :正确性由 8.4.3 的 Session per task 模式与 8.5.5 的 place_order 共同保证;SQLite 会把写串行化,行锁竞争需在 PostgreSQL 用 with_for_update() 复验,自动化测试不重复该演示。
测试二:MissingGreenlet 复现与消除:
python
async def test_lazy_access_raises_missing_greenlet(
session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with session_factory() as session:
product = Product(sku="BOOK-001", name="Guide", price=Decimal("99.90"), stock=10)
order = Order(
customer_id=1,
status="PENDING",
items=[OrderItem(product=product, quantity=1, unit_price=product.price)],
)
session.add_all([product, order])
await session.commit()
order_id = order.id
async with session_factory() as session:
loaded = await session.get(Order, order_id)
with pytest.raises(MissingGreenlet):
_ = loaded.items # 未加载 → 隐式 I/O → 抛错
# 消除:selectinload 显式加载后正常访问
async with session_factory() as session:
loaded = await session.scalars(
select(Order).where(Order.id == order_id).options(selectinload(Order.items))
).one()
assert len(loaded.items) == 1
测试三:取消后连接归还 :该结论已由 8.7.2 的 pool.checkedout() 实验验证,pytest 版本与之等价(task.cancel() 后 async with 退出、连接归还池),此处不重复。
测试四:transactional outbox 投递幂等:
python
async def test_outbox_dispatch_is_idempotent(
session_factory: async_sessionmaker[AsyncSession],
) -> None:
async def create_event() -> None:
async with session_factory() as session:
async with session.begin():
order = Order(customer_id=1, status="PENDING")
session.add(order)
session.add(OutboxEvent(order=order, topic="order.created", payload={"customer_id": 1}))
await create_event()
calls: list[int] = []
async def deliver_pending(deliverer) -> int:
async with session_factory() as session:
events = (
await session.scalars(
select(OutboxEvent)
.where(OutboxEvent.status == "PENDING")
.order_by(OutboxEvent.id)
)
).all()
for event in events:
await deliverer(event)
event.status = "SENT"
await session.commit()
return len(events)
async def fake_deliverer(event) -> None:
calls.append(event.id)
assert await deliver_pending(fake_deliverer) == 1
assert await deliver_pending(fake_deliverer) == 0
assert len(calls) == 1 # 第二次投递被跳过
async with session_factory() as session:
statuses = (await session.scalars(select(OutboxEvent.status))).all()
assert statuses == ["SENT"]
运行 python -m pytest tests/test_part8.py -q,预期 2 个测试全部通过。CI 中可追加 PostgreSQL 版本:用 postgresql+asyncpg 复验并发下单与行锁语义。
8.9 常见误区与排障入口
- 误区一:改成 async 后单条 SQL 会变快。 不会。异步改变的是等待期间是否还能服务其他请求,不改变 SQL 耗时。
- 误区二:create_async_engine 可以用同步 URL。 不行。驱动必须异步:
sqlite+aiosqlite、postgresql+asyncpg,且单独安装。 - 误区三:AsyncSession 像线程安全的全局对象。 它是可变、有状态对象,一次只允许一个 task 操作。
- 误区四:访问属性会自动加载。 同步成立,异步抛
MissingGreenlet。用selectinload、awaitable_attrs,事务内转 DTO。 - 误区五:run_sync 是万能胶。 内部不能调用 async API;非 SQLAlchemy 阻塞会卡住事件循环。
- 误区六:expire_on_commit=False 可以掩盖一切。 它消除 commit 后隐式刷新,但让对象停留在快照,必须配合短生命周期 Session。
- 误区七:后台任务可以复用请求的 Session 和实体。 请求结束后事务与连接已结束;后台只接收 ID/DTO,自己建 Session。
- 误区八:请求被取消后连接会自动归还。 只有
async with/try-finally 正确包裹才会;绕过上下文管理的代码会把连接留在池外。 - 误区九:SQLite 跑通就等于异步并发正确。 SQLite 串行化写、忽略
FOR UPDATE;并发隔离与锁语义必须在 PostgreSQL 复验。
8.10 本篇验收清单
- 能解释"异步不加速单条 SQL,只提高等待并发";
- 能从 URL 指出 dialect 与 driver,并说明为什么不能混用同步/异步;
- Engine 应用级复用、Session 请求/任务级创建;
- 用
create_async_engine+async_sessionmaker(expire_on_commit=False)搭建基础设施; -
gather()中每个 task 独立 AsyncSession; - 故意共享 AsyncSession,能记录错误与根因(未定义行为);
- 消除一次
MissingGreenlet(selectinload 或 awaitable_attrs); - 响应序列化不触发 SQL(事务内转 DTO);
- 取消请求后连接归还池(用
pool.checkedout()验证); - FastAPI 请求级 Session 生命周期与 commit 所有权明确(路由层 vs service 层选一);
- 后台任务只接收 ID/DTO,自己建 Session,fork 后处理 Engine;
- 下单写 outbox,后台投递幂等去重;
- 压测同时观察应用吞吐、池等待和数据库执行时间。
8.11 最佳实践清单
- 先测量再决定:并发形态、团队栈、依赖生态都不支持时,同步更可靠;
create_async_engine与async_sessionmaker应用级创建,AsyncSession 请求级创建;- 默认
expire_on_commit=False,并保持 Session 短生命周期; - 显式加载关系:批量用
selectinload,单对象按需用awaitable_attrs; - 事务内完成数据加载并转 DTO,序列化阶段绝不触发 SQL;
- 每个并发 task 独立 AsyncSession;必须原子的操作在同一 Session 顺序执行;
- FastAPI 用 dependency/yield 管理 Session,路由层或 service 层选一个事务策略,repository 不 commit;
- 后台任务只接收 ID/DTO,任务内自建 Session;fork 后 dispose Engine;
- 外部调用放在事务之外;短事务 + 幂等确认;
- 取消与异常都通过
async with清理连接,生产用池事件观测归还情况。
8.12 本篇小结
异步 SQLAlchemy 的全部复杂性可以浓缩为三句话:
- 异步不加速 SQL,它改变的是等待并发;上不上异步要先测量。
- 隐式 I/O 是异步世界最大的坑 :lazy load、expire 后访问、同步回调里的查询,都会变成
MissingGreenlet。用 eager load、awaitable_attrs、DTO 边界把它消除。 - 并发模型是 AsyncSession per task :Session 代表单个事务,不能共享;请求级生命周期由
async with完整接管提交、回滚与连接归还;后台任务独立建 Session,业务写与 outbox 同事务,投递幂等去重。
贯穿始终的设计原则与 Part 4 一脉相承:Session 生命周期短、所有权明确、失败立即回滚、事务覆盖完整业务不变量、并发任务绝不共享。异步只是把"线程"换成了"task",把"隐式 I/O"从无声的隐患变成显式的报错------报错其实是好事,它强迫你把数据加载边界想清楚。
下一篇将进入 Schema 演进:用 Alembic 让模型变更可审查、可部署、可回滚。