首页/Python 框架指南/SQLAlchemy 异步引擎与会话
Python 框架指南 · 第 04 章

SQLAlchemy 异步引擎与会话

FastAPI 是异步框架,数据库这一层自然也得「异步到底」。本章搞定两件事:

约 30 分钟12,066 字25 个代码示例

FastAPI 是异步框架,数据库这一层自然也得「异步到底」。本章搞定两件事:

  1. 引擎(engine)——数据库连接的调度中心,怎么建、怎么配;
  2. 会话(session)——ORM 操作的工作台,怎么开、怎么提交、怎么还。

主线按 PostgreSQL + asyncpg 讲(生产环境的主流选择),配套 demo 用 SQLite + aiosqlite——零安装零配置,clone 下来就能跑。两者对 SQLAlchemy 来说只差一行连接串,其余代码一个字不用改。

本章配套 demo: - demo/demo_engine.py:异步引擎 + text() 裸 SQL,建表、插入、查询 - demo/demo_session.py:会话工厂 + 三种提交方式对比

示例业务统一用「墨集 inkhub 内容平台」:channel(栏目)和 article(文章)两张表。

章节分工提醒:select()/where()/update()/delete() 这些 SQL 表达式(Core 风格)是第 06 章的主场,本章查询一律用最简形式;ORM 模型怎么声明(DeclarativeBase、Mapped)是第 05 章的内容,本章直接借用一个极简模型。


1. 安装:一个方括号引发的血案

作用:装上 SQLAlchemy 的异步能力 + 一个异步数据库驱动。

pip install "sqlalchemy[asyncio]" asyncpg
# 本章 demo 用 SQLite 则装:
pip install "sqlalchemy[asyncio]" aiosqlite

三样东西各司其职:

包 角色 说明
sqlalchemy ORM 框架本体 引擎、会话、模型、表达式全在这
[asyncio] 附加依赖(extras) 方括号语法,会额外装上 greenlet——异步引擎底层靠它做协程切换,不装必报错
asyncpg / aiosqlite 数据库驱动 光有框架没有驱动,等于有翻译没有快递员

常见坑

  • pip install sqlalchemy asyncpg 少了方括号 → 运行时抛 MissingGreenlet 或 InvalidRequestError: The asyncio extension requires greenlet。记住了:异步用法,方括号不能省。
  • psycopg2 是同步驱动,配 create_async_engine 用不了。异步引擎只认异步驱动:PostgreSQL 用 asyncpg,SQLite 用 aiosqlite,MySQL 用 asyncmy。

2. create_async_engine:先把调度中心建起来

作用:创建异步引擎。引擎是进程级单例,掌管连接池、方言翻译、SQL 日志——所有数据库操作的起点。

签名(挑常用的记):

from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    url,                    # 连接串:数据库必填
    echo=False,             # 是否打印发出的 SQL(调试神器)
    echo_pool=False,        # 是否打印连接池的借还日志
    pool_size=5,            # 池内常驻连接数(PG 等服务型数据库用)
    max_overflow=10,        # 池满后允许临时加开的连接数
    pool_pre_ping=False,    # 借出前先发 ping 探活
    pool_timeout=30,        # 借不到连接时最多等多久(秒)
)

连接串格式——数据库类型+驱动://用户:密码@主机:端口/库名:

# PostgreSQL + asyncpg(本章主线)
DATABASE_URL = "postgresql+asyncpg://inkhub:secret@localhost:5432/inkhub"

# SQLite + aiosqlite(demo 用,三根斜线接相对/绝对路径)
DATABASE_URL = "sqlite+aiosqlite:///./inkhub.db"

# 切换数据库 = 改这一行,业务代码零改动

最小示例——跑通「数据库心跳」:

import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(DATABASE_URL, echo=True)

async def main() -> None:
    async with engine.connect() as conn:          # 借一条连接
        result = await conn.execute(text("SELECT 1"))
        print(result.scalar())                    # 1,活着

    await engine.dispose()                        # 进程收尾:放光所有连接

asyncio.run(main())

常见坑

  • 每次请求 create_async_engine 一个新引擎——连接池就废了,每个池都是一次性的,连接风暴直接把数据库打挂。正确姿势:模块级建一个,全进程共用。
  • echo=True 只该出现在调试时。开着它,每条 SQL 连参数一起打进日志,线上既吵又可能泄敏感数据。
  • 连接串里密码有特殊字符(@、:、/)要 URL 编码,否则解析必乱。可用 sqlalchemy.engine.URL.create() 逐字段安全拼装。
  • SQLite 是嵌入式文件库,没有「池」的概念,pool_size/max_overflow 传了也不起作用;这两位是给 PostgreSQL 这类服务型数据库准备的。

3. 引擎、连接池、驱动:一个柜台三家店

一张图讲清三者关系(记这张图,本章就懂一半了):

              你的异步代码(await session.execute(...))
                        │
                        ▼
        ┌──────────────────────────────┐
        │          引擎 engine            │  柜台:接单、排队、翻译方言,
        │      create_async_engine()      │  把 Python 调用变成 SQL
        └──────────────┬───────────────┘
                       │ 借出 / 归还
                       ▼
        ┌──────────────────────────────┐
        │          连接池 pool            │  充电宝柜子:连接用完不扔、
        │   pool_size / max_overflow     │  放回柜子,下一位直接复用
        └──────────────┬───────────────┘
                       │ 持有真正的连接
                       ▼
        ┌──────────────────────────────┐
        │        驱动 driver            │  线:asyncpg 只会插 PostgreSQL,
        │   asyncpg / aiosqlite / ...   │  aiosqlite 只会插 SQLite,各认各的口
        └──────────────┬───────────────┘
                       ▼
                ┌────────────┐
                │   数据库服务器   │
                └────────────┘

用共享充电宝再念一遍:

  • 连接池是柜子:借出(connect())、归还(退出 async with),避免每次都「造一根新线」(TCP 握手 + 认证,很贵);
  • 驱动是那根线:它只负责和特定数据库通信,不懂 SQL 怎么拼、事务怎么管——那是框架的事;
  • 引擎是柜子的控制系统:决定借谁、还谁、池子多大、断线的充电宝要不要扔。

常见坑

  • 连接池不是越大越好。pool_size + max_overflow 的总连接数要对着数据库的 max_connections 规划,池子开太大只会让数据库排队排到超时。
  • 数据库重启后,池里的旧连接全成了「僵尸」。pool_pre_ping=True 让每次借出前先 ping 一下,僵尸自动换新——代价是每次多一个轻量往返,生产环境建议开。

4. text():裸 SQL 的正确姿势

作用:把一个 SQL 字符串包装成 SQLAlchemy 认识的语句对象。引擎和连接不直接吃字符串,喂之前必须 text() 一下。

签名:text(sql: str) -> TextClause,配合 :参数名 占位符 + 字典传参。

最小示例:

from sqlalchemy import text

async with engine.connect() as conn:
    # 1) 无参数:直接包一层
    result = await conn.execute(text("SELECT COUNT(*) FROM article"))
    print(result.scalar())                                   # 单值:scalar()

    # 2) 命名参数:占位符 + 字典,框架自动做参数化绑定
    result = await conn.execute(
        text("SELECT * FROM article WHERE channel_id = :cid"),
        {"cid": 1},
    )
    for row in result.fetchall():                            # 多行:fetchall()
        print(row.id, row.title)                             # 按列名取值

    # 3) 批量插入:传字典列表,一条语句插多行(executemany)
    await conn.execute(
        text("INSERT INTO channel (name) VALUES (:name)"),
        [{"name": "前沿速递"}, {"name": "深度长文"}],
    )

常见坑(头号大坑:SQL 注入)

# ❌ 千万别这样:用户输入直接进 SQL
keyword = "' OR 1=1 --"
await conn.execute(text(f"SELECT * FROM article WHERE title LIKE '%{keyword}%'"))
# 拼出来的 SQL 恒真,全表数据裸奔;换成 DELETE 就是删库

# ✅ 永远用占位符
await conn.execute(
    text("SELECT * FROM article WHERE title LIKE :kw"),
    {"kw": f"%{keyword}%"},   # 整段被当成纯文本,注入无效
)

原理一句话:参数化绑定让数据库先解析 SQL 骨架、再单独收参数值,值永远不会被当成指令执行。另外两个小坑:text() 忘包(conn.execute("SELECT 1") 直接抛 ArgumentError);PG 的 % 模糊匹配在 text 里不用像 psycopg2 那样写成 %%,占位符统一 :name 风格。

裸 SQL 灵活但要手拼字符串、无类型检查、换数据库方言可能不兼容——所以第 06 章会请出 SQL 表达式。但在复杂聚合、特调优化的场景,裸 SQL 依然是最后一道底牌。


5. connect() vs begin():借连接的两种方式

从引擎手里拿连接,两个入口:

入口 事务行为 适用场景
async with engine.connect() as conn 借一条连接,不自动提交;要提交得手动 await conn.commit() 只读查询;想精细控制事务边界
async with engine.begin() as conn 进入即开事务,正常退出自动 commit,异常自动 rollback 写操作(INSERT/UPDATE/DELETE)的默认选择
# 查询:connect 就够
async with engine.connect() as conn:
    result = await conn.execute(text("SELECT * FROM channel"))

# 写入:begin 省心,块里挂了自动回滚,整段同生共死
async with engine.begin() as conn:
    await conn.execute(
        text("INSERT INTO article (title, channel_id) VALUES (:t, :c)"),
        {"t": "异步编程入门:事件循环是怎么回事", "c": 1},
    )
    await conn.execute(
        text("UPDATE channel SET name = :n WHERE id = :id"),
        {"n": "前沿速递·修订", "id": 1},
    )
# 走到这,两条改动一起提交;中间任何一步抛异常,两条一起回滚

常见坑:connect() 里执行了 INSERT 却忘了 commit(),退出上下文只还连接不提交,改动全部蒸发——而且不报错,纯静默丢失,排查起来最气人。


6. async_sessionmaker:会话工厂

引擎和 text() 解决了「能和数据库说话」,但真正干 ORM 活的是会话(session)。会话不能凭空 new,得从工厂里造——这就是 async_sessionmaker。

作用:把「引擎 + 一堆默认配置」打包成一个会话工厂,之后随取随用,配置不散落各处。

签名:

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

session_factory = async_sessionmaker(
    engine,                   # 绑定的引擎
    class_=AsyncSession,      # 明确用异步会话类(写上,不靠默认)
    expire_on_commit=False,   # 提交后对象属性是否过期(见第 10 节,FastAPI 场景常关)
    autoflush=True,           # 查询前自动把挂起的改动 flush 掉
)

最小示例:

# 工厂是进程级单例,建一次用到进程结束
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

# 用的时候像调用函数一样「造」一个会话
async with session_factory() as session:
    ...

常见坑

  • 旧代码里的 sessionmaker 是同步版;异步世界认准 async_sessionmaker(从 sqlalchemy.ext.asyncio 导入)。
  • 会话 ≠ 连接。会话是「工作台」,用到数据库时才从引擎的池里临时借一条连接,用完就还。所以「多开会话」比「多开连接」便宜得多。
  • 一个会话同一时刻只服务一个协程,别多个请求共享同一个会话(FastAPI 里靠依赖注入保证一人一个,见第 11 节)。

7. 会话上的增删查改

先借用一个极简模型(怎么建的第 05 章细讲,本章只要知道「Channel/Article 是两张表对应的两个 Python 类」即可):

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

class Base(DeclarativeBase):
    pass

class Channel(Base):
    __tablename__ = "channel"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]

class Article(Base):
    __tablename__ = "article"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    channel_id: Mapped[int] = mapped_column(ForeignKey("channel.id"))

增:add()

async with session_factory.begin() as session:
    article = Article(title="拆解一个电商系统的订单链路", channel_id=2)
    session.add(article)        # 只是挂到工作台,还没发 SQL
    # flush 之后主键自动回填:article.id 有值了

add() 像「把商品放进购物车」——只登记,不结账。SQL 要等 flush/commit(第 9 节)才真正发出。

查:execute() + scalars()

from sqlalchemy import select

async with session_factory() as session:
    result = await session.execute(select(Article))   # select 的完整玩法见 06 章
    articles = result.scalars().all()                 # scalars():剥掉 Row 壳,直接拿模型对象
    for a in articles:
        print(a.id, a.title)

一句话记住:result 装的是一行行的 Row(像小元组),scalars() 把每行的第一列抽出来——ORM 查询里第一列就是模型对象,所以 scalars().all() 到手的就是 list[Article]。

查一个:scalar_one() / scalar_one_or_none()

    article = (
        await session.execute(select(Article).where(Article.id == 1))
    ).scalar_one()        # 恰好一行 → Article 对象
方法 0 行 1 行 多行
scalar_one() 抛 NoResultFound 返回对象 抛 MultipleResultsFound
scalar_one_or_none() 返回 None 返回对象 抛 MultipleResultsFound

按主键查、业务上「必须存在」的场景用 scalar_one(),让缺失直接炸出来而不是悄悄传 None 往下走。

改:查出来,直接改属性

async with session_factory.begin() as session:
    article = (await session.execute(select(Article).where(Article.id == 1))).scalar_one()
    article.title = "异步编程入门:从事件循环到 uvloop"   # 改对象 = 购物车里换货
    # 提交时 session 自动对比出差异,生成精准的 UPDATE

ORM 的爽点:没有 update() 这个调用。会话记着你查过的对象,改属性即改数据,提交时自动算 UPDATE。

删:delete()

async with session_factory.begin() as session:
    article = (await session.execute(select(Article).where(Article.id == 1))).scalar_one()
    await session.delete(article)      # 注意 await,异步方法
    # 提交时自动生成 DELETE

常见坑:session.add() 和 session.delete() 长得像,但前者是同步方法、后者在异步会话里必须 await——漏了 await 删除根本没执行,还不一定报错。


8. 三种提交方式:从手动挡到自动挡

写操作最终都要 commit() 才落库。三种写法,从「全手动」到「全自动」:

方式一:手动挡——commit / rollback / close 全自己管

session = session_factory()
try:
    session.add(Article(title="...", channel_id=1))
    await session.commit()          # 提交
except Exception:
    await session.rollback()        # 出错回滚
    raise
finally:
    await session.close()           # 无论如何都要关

能看懂就行,日常不建议写——仪式感太重,漏掉任何一个环节都是坑(忘 rollback 占着连接、忘 close 连接泄漏)。

方式二:上下文管关闭——事务仍手动

async with session_factory() as session:   # 退出时自动 close
    session.add(Article(title="...", channel_id=1))
    await session.commit()                 # 提交还得自己来

async with 只包了「关」,没包「提交」。适合一个会话里干好几摊事、每摊单独提交的精细控制场景。

方式三:自动挡——begin() 一起管

async with session_factory.begin() as session:
    session.add(Article(title="...", channel_id=1))
# 正常走到头 → 自动 commit;中途抛异常 → 自动 rollback + close

一行上下文,提交/回滚/关闭全包。日常业务代码的默认选择。

方式 关闭 提交 回滚 一句话
手动 手动 手动 手动 老式写法,懂原理用
factory() 自动 手动 手动 多事务分段提交
factory.begin() 自动 自动 自动 无脑首选

常见坑:方式二里忘了 commit()——退出上下文干干净净、不报任何错,数据没了。这是新手丢数据第一名的原因。


9. flush vs commit:先发 SQL ≠ 先落库

这俩最容易混。用「发文章」打比方:

  • flush = 点了「保存草稿」:SQL 真的发到数据库了(INSERT/UPDATE 都执行了),但事务没提交——只有你自己(当前事务)看得见,别人看不见,随时可以反悔;
  • commit = 点了「发布」:先自动 flush 所有挂起的改动,再提交事务——从此所有人可见,覆水难收。
async with session_factory.begin() as session:
    article = Article(title="本周框架动态", channel_id=1)
    session.add(article)
    await session.flush()               # INSERT 已发出,article.id 已回填

    print(article.id)                   # ✅ 主键能用了(数据库生成的)
    # 此刻换一个会话去查这条记录 → 查不到(事务未提交)
# 退出 begin 块,自动 commit,别的会话才看得见

什么时候需要手动 flush?想在提交前拿到数据库生成的主键(比如插入文章后立刻要用 article.id 去建关联记录)。其余时候交给 commit 里的自动 flush 就行。

另外记住 autoflush=True(默认):每次 session.execute() 查询前,会话会先把挂起的改动 flush 掉——所以你在同一个会话里 add 完马上查,能查到自己的改动,这不是玄学,是 autoflush 在干活。


10. 字段过期与 refresh:commit 之后属性还能读吗

async_sessionmaker 里那个 expire_on_commit,专管这件事。

默认 expire_on_commit=True:提交即「贴封条」。commit 之后,会话把所有关联对象的字段标记为过期——你再一读属性,它就发一条 SELECT 去数据库重新对账。相当于结账后超市把小票上每件商品都盖了「待复核」章,摸一下就得回货架核价。

async with session_factory.begin() as session:
    article = Article(title="字段过期演示", channel_id=1)
    session.add(article)
    await session.flush()

# 会话已关闭,对象脱离会话
print(article.title)   # 💥 DetachedInstanceError:想过期属性,但没会话帮你查了

为什么 FastAPI 里几乎都关掉:Web 请求的典型节奏是「commit → 把对象塞进 Pydantic 响应模型 → 返回」,这中间读属性时事务早关了,开着过期只会炸。所以社区惯例:

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

关掉之后提交完属性照常可读(读的是提交前的内存快照)。

那数据库万一变了呢?用 refresh()——在会话还活着的时候,主动把数据库的最新值同步回对象:

async with session_factory.begin() as session:
    article = (await session.execute(select(Article).where(Article.id == 1))).scalar_one()
    await session.refresh(article)    # 发一条 SELECT,用数据库当前值覆盖对象
    print(article.title)              # 一定是此刻数据库里的真值

一句话总结:expire_on_commit=True 是「自动定期对账」(费查询、异步里易炸),False + 手动 refresh() 是「平时信缓存、要新就刷新」(省事、可控)。异步开发选后者。


11. get_db 依赖的雏形

FastAPI 每个请求处理函数都需要一个会话,用完就还。靠什么做到「一个请求一个会话,全自动」?——生成器 + yield:

from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession

async def get_db() -> AsyncIterator[AsyncSession]:
    """一个请求一个会话:请求进来造一个,请求结束自动关"""
    async with session_factory() as session:
        yield session     # yield 处把会话交给路由函数;路由跑完后才执行退出

在 FastAPI 里配合 Depends(依赖注入,第 08 章的主角):

from fastapi import Depends

@app.get("/articles")
async def list_articles(db: AsyncSession = Depends(get_db)):
    articles = (await db.execute(select(Article))).scalars().all()
    return [a.title for a in articles]     # 请求结束,会话自动关闭、连接自动归还

现在只需要记住形状:yield 前是「借」,yield 后是「还」。为什么这么设计、异常时怎么保证归还、怎么统一换成 begin() 模式——第 08 章依赖注入见。


12. 本章速查表

要点 一句话
安装 pip install "sqlalchemy[asyncio]" asyncpg,方括号不能丢
连接串 postgresql+asyncpg://user:pwd@host:port/db;换库只改这一行
引擎 进程级单例;echo=True 调试用;用完 dispose()
池参数 pool_size/max_overflow/pool_pre_ping,服务型数据库专用
三层关系 引擎(柜台)→ 池(充电宝柜子)→ 驱动(线)
裸 SQL 必须 text() 包裹;参数一律 :占位符 + 字典,杜绝注入
借连接 查询 connect();写入 begin()(自动提交/回滚)
会话工厂 async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
增删查改 add / execute + scalars().all() / scalar_one() / 改属性 / await delete()
提交三式 手动挡 → factory() 管关 → factory.begin() 全包(首选)
flush vs commit flush 保存草稿(自己可见),commit 发布(全局可见)
过期与刷新 异步关 expire_on_commit,要最新值用 refresh()
get_db 雏形 async with ... yield session,yield 前借、后还(08 章展开)

下一章:05 · ORM 建模