SQLAlchemy 系列(十一):从 1.x 到 2.x——渐进迁移与数据访问层治理

核心目标 :用可验证的步骤清理 1.x 遗留模式(Queryengine.execute()、autocommit、bound metadata),先在 1.4 完成 2.0 风格收敛,再切换真实 2.0,并借机建立 repository / Unit of Work 数据访问边界。

前置知识 :掌握 select() + Result 的统一执行模型(Part 1)、Session 对象状态与事务(Part 4)、声明式映射(Part 3)。没有 1.x 历史负担的读者也应读完 11.3-11.6,「为什么移除」比「怎么改」更能解释 2.0 的设计。

验证环境:Python 3.11、SQLAlchemy 2.0.51(迁移源为 1.4.x);在干净环境同时验证 1.4 与 2.0。最后复核日期:2026-07-31。


0. 问题场景:一次大版本升级如何避免变成重写项目

order-lab 是一个有四年历史的订单与库存服务。它的数据访问代码长这样:

python 复制代码
# SQLAlchemy 1.4 遗留代码(迁移前)
engine = create_engine(DATABASE_URL)
metadata = MetaData(bind=engine)  # bound metadata
customer = Table("customer", metadata, Column("id", Integer, primary_key=True), Column("email", String(320)))

def find_customer_by_email(email: str) -> dict | None:
    row = engine.execute(f"SELECT * FROM customer WHERE email = '{email}'").first()  # connectionless + 拼接
    return dict(row) if row else None

def place_order(customer_id: int, product_id: int) -> None:
    session = Session(engine, autocommit=True)  # 隐式提交依赖
    session.add(Order(customer_id=customer_id, items=[OrderItem(product_id=product_id, quantity=1)]))  # 无显式 commit

当团队决定升级到 2.0 时,第一反应往往是恐惧:session.queryengine.executeMetaData(bind=...) 会全部失效吗?一千行查询要重写吗?事务行为会不会悄悄改变?本系列前四篇建立过一个核心信念:统一执行模型下,Statement → Execute → Result 是唯一主线。2.0 所做的正是把这条主线变成唯一选择。所以本次迁移不是「学一套新 API」,而是把历史遗留的分叉收敛回同一条主线。面对升级有三种错误反应:

  1. 鸵鸟:继续停留在 1.4,错过修复、新功能与类型体系;
  2. 大爆炸:同一次发布里「升级版本 + 重写全部查询 + 改 Schema + 换驱动」,出问题无法定位回滚;
  3. 正确姿势:先在 1.4 上收敛成 2.0 风格、零弃用警告,再用真实 2.0 验证。官方 Migration Guide 称之为「先在 1.4 完成迁移」。

本篇用 order-lab 完整走一遍第三条路:先盘点,再收敛,再切换,最后建立治理边界。


11.1 先盘点,不要全局替换

迁移开始前不要打开编辑器全局搜索替换。先回答三个问题:现在是什么版本、哪些遗留模式分布在哪、哪些行为依赖是隐式的

11.1.1 版本快照

powershell 复制代码
python --version; pip show sqlalchemy
pip freeze | Select-String -Pattern "sqlalchemy|alembic|psycopg|flask-sqlalchemy|sqlalchemy-utils|asyncpg|greenlet"

要同时记录 Python、SQLAlchemy、DBAPI 驱动、ORM 框架扩展、Alembic 五个版本。flask-sqlalchemysqlalchemy-utils 有自己的版本兼容矩阵,SQLAlchemy 升级后它们可能是最早炸的;框架扩展自带的 SessionQuery 子类或事件钩子都属于「隐藏的旧行为」。

11.1.2 遗留模式搜索清单

在代码库中搜索以下模式,记录命中文件数:

text 复制代码
engine.execute(
session.query(
Query.get(
MetaData(bind=
autocommit=True
subtransactions=True
select([
.execution_options(autocommit=True)
lazy="dynamic"
遗留模式 在 1.4 的状态 命中意味着什么
engine.execute(...) legacy 可用(future 模式不可用) connectionless:隐式获取连接与事务
session.query(...) 可用但 deprecated 旧式 Queryselect() 执行路径分叉
Query.get(...) deprecated 方法将移到 Session.get()
MetaData(bind= deprecated bound metadata:表结构依赖全局 engine
autocommit=True deprecated library-level autocommit 将移除
subtransactions=True deprecated 嵌套事务假象将移除
select([...]) 1.4 起 deprecated select() 构造改为位置传参
lazy="dynamic" 可用,2.0 保留 旧式查询集合,2.0 由 write_only 取代

11.1.3 行为资产盘点

版本和语法只是表层,真正决定风险的是行为资产

  • 事务所有权 :谁负责 commit()?业务代码、repository、还是 Web 框架的 Session 中间件?
  • 隐式提交依赖 :有没有人「执行完 add() 就走,从没写过 commit()」?这类代码过去靠 autocommit 才没丢数据。
  • 自定义类型、事件、Query 子类TypeDecoratorevent.listens_forBase.query 子类化等逐项对照 2.0 兼容性。
  • 动态关系加载lazy="dynamic" 集合的使用方式;文本 SQL 参数形式%s / ? / 命名参数混用情况。
  • 测试覆盖盲区:测试是否覆盖 rollback、加载策略、结果基数?没有这些断言,迁移后行为悄悄变化也发现不了。

11.1.4 盘点产出:迁移待办表

遗留模式 命中数 风险 优先级
engine.execute() 42 高(事务语义) P0
session.query() 76 高(结果形状) P0
autocommit=True 9 高(丢数据风险) P0
bound metadata 6 P1
字符串 SQL 13 中(注入/参数) P1

先处理 P0:它们直接决定事务语义与结果形状,行为变化最危险。


11.2 推荐路线:先在 1.4 完成 2.0 风格收敛

11.2.1 迁移路线图

#mermaid-svg-ZCAOV0MtgLHgw11O{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-ZCAOV0MtgLHgw11O .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZCAOV0MtgLHgw11O .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZCAOV0MtgLHgw11O .error-icon{fill:#552222;}#mermaid-svg-ZCAOV0MtgLHgw11O .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZCAOV0MtgLHgw11O .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZCAOV0MtgLHgw11O .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZCAOV0MtgLHgw11O .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZCAOV0MtgLHgw11O .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZCAOV0MtgLHgw11O .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZCAOV0MtgLHgw11O .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZCAOV0MtgLHgw11O .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZCAOV0MtgLHgw11O .marker.cross{stroke:#333333;}#mermaid-svg-ZCAOV0MtgLHgw11O svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZCAOV0MtgLHgw11O p{margin:0;}#mermaid-svg-ZCAOV0MtgLHgw11O .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZCAOV0MtgLHgw11O .cluster-label text{fill:#333;}#mermaid-svg-ZCAOV0MtgLHgw11O .cluster-label span{color:#333;}#mermaid-svg-ZCAOV0MtgLHgw11O .cluster-label span p{background-color:transparent;}#mermaid-svg-ZCAOV0MtgLHgw11O .label text,#mermaid-svg-ZCAOV0MtgLHgw11O span{fill:#333;color:#333;}#mermaid-svg-ZCAOV0MtgLHgw11O .node rect,#mermaid-svg-ZCAOV0MtgLHgw11O .node circle,#mermaid-svg-ZCAOV0MtgLHgw11O .node ellipse,#mermaid-svg-ZCAOV0MtgLHgw11O .node polygon,#mermaid-svg-ZCAOV0MtgLHgw11O .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZCAOV0MtgLHgw11O .rough-node .label text,#mermaid-svg-ZCAOV0MtgLHgw11O .node .label text,#mermaid-svg-ZCAOV0MtgLHgw11O .image-shape .label,#mermaid-svg-ZCAOV0MtgLHgw11O .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZCAOV0MtgLHgw11O .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZCAOV0MtgLHgw11O .rough-node .label,#mermaid-svg-ZCAOV0MtgLHgw11O .node .label,#mermaid-svg-ZCAOV0MtgLHgw11O .image-shape .label,#mermaid-svg-ZCAOV0MtgLHgw11O .icon-shape .label{text-align:center;}#mermaid-svg-ZCAOV0MtgLHgw11O .node.clickable{cursor:pointer;}#mermaid-svg-ZCAOV0MtgLHgw11O .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZCAOV0MtgLHgw11O .arrowheadPath{fill:#333333;}#mermaid-svg-ZCAOV0MtgLHgw11O .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZCAOV0MtgLHgw11O .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZCAOV0MtgLHgw11O .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZCAOV0MtgLHgw11O .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZCAOV0MtgLHgw11O .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZCAOV0MtgLHgw11O .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZCAOV0MtgLHgw11O .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZCAOV0MtgLHgw11O .cluster text{fill:#333;}#mermaid-svg-ZCAOV0MtgLHgw11O .cluster span{color:#333;}#mermaid-svg-ZCAOV0MtgLHgw11O 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-ZCAOV0MtgLHgw11O .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZCAOV0MtgLHgw11O rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZCAOV0MtgLHgw11O .icon-shape,#mermaid-svg-ZCAOV0MtgLHgw11O .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZCAOV0MtgLHgw11O .icon-shape p,#mermaid-svg-ZCAOV0MtgLHgw11O .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZCAOV0MtgLHgw11O .icon-shape .label rect,#mermaid-svg-ZCAOV0MtgLHgw11O .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZCAOV0MtgLHgw11O .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZCAOV0MtgLHgw11O .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZCAOV0MtgLHgw11O :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-ZCAOV0MtgLHgw11O .model>*{fill:#2563eb!important;stroke:#1e40af!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .model span{fill:#2563eb!important;stroke:#1e40af!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .model tspan{fill:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .session>*{fill:#7c3aed!important;stroke:#6d28d9!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .session span{fill:#7c3aed!important;stroke:#6d28d9!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .session tspan{fill:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .conn>*{fill:#ea580c!important;stroke:#c2410c!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .conn span{fill:#ea580c!important;stroke:#c2410c!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .conn tspan{fill:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .db>*{fill:#16a34a!important;stroke:#15803d!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .db span{fill:#16a34a!important;stroke:#15803d!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .db tspan{fill:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .error>*{fill:#dc2626!important;stroke:#991b1b!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .error span{fill:#dc2626!important;stroke:#991b1b!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .error tspan{fill:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .ok>*{fill:#65a30d!important;stroke:#4d7c0f!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .ok span{fill:#65a30d!important;stroke:#4d7c0f!important;color:#fff!important;}#mermaid-svg-ZCAOV0MtgLHgw11O .ok tspan{fill:#fff!important;} 稳定 1.4 遗留分支
升级 1.4 最终系列
开启 RemovedIn20Warning
改 Engine/Connection 事务
改 select + Session.execute
改声明式与类型
1.4 零警告基线
切换真实 2.0
全量/性能/灰度验证

图中颜色语义贯穿本篇: =Python/ORM 对象, =Session/Unit of Work, =Connection/Pool/Transaction,绿 =Database,=异常/回滚。

11.2.2 官方七步在本篇的位置

步骤 官方要求 本篇对应
1 Python 3.7+(2.0 要求 Python 3 only) 11.1.1
2 打开 RemovedIn20Warning 11.2.3
3 清掉全部 RemovedIn20Warning 11.3-11.6
4 future=True 创建 Engine 11.3
5 future=True 创建 Session 11.6
6 给带注解的模型加 __allow_unmapped__ 11.5
7 在真实 SQLAlchemy 2.0 上测试 11.7-11.9

future=True 在 1.4 是显式开关 ,在 2.0 是默认值。迁移期间可以用它「提前进入 2.0 行为」,而不是等到升级日再切换。这也是「1.4 是 2.0 的孵化场」的原因:1.4 已经把 select()Result、autobegin、2.0 风格 Declarative 全部带出来了。

11.2.3 开启 2.0 弃用警告并提升为 CI 失败

powershell 复制代码
$env:SQLALCHEMY_WARN_20 = "1"          # 环境变量全量开启(1.4)
python -W error::DeprecationWarning -m pytest -q  # 或把 DeprecationWarning 提升为错误

RemovedIn20Warning 会精确指出每一处将失效的调用点。与其在 2.0 安装后被数百个 AttributeError 淹没,不如在 1.4 上让它们以带文件名和行号的警告出现。更稳的做法是只把 RemovedIn20Warning 提升为错误,避免第三方库无关的 DeprecationWarning 误伤:

toml 复制代码
[tool.pytest.ini_options]
filterwarnings = ["error::sqlalchemy.exc.RemovedIn20Warning"]
testpaths = ["tests"]

一旦 CI 出现任何 RemovedIn20Warning 就失败,新代码就不会再引入旧式 API。这是迁移期间最重要的自动化测试:把「禁止旧 API」从口头约定变成机器检查。

11.2.4 为什么不能跳过 1.4 收敛

如果在 1.4 上做不到零警告,直接切 2.0 会同时面对三类错误:语法错误(API 不存在)、行为变化(unique 去重、事务提交)、性能退化(无法定位)。三者在同一次发布里互相干扰,回滚判断无从下手。先收敛、后切换,本质是把「迁移」和「故障排查」两个变量分离


11.3 Core 迁移:engine.execute() 与 connectionless execution

11.3.1 旧式写法在 1.4 的行为

python 复制代码
# SQLAlchemy 1.4 遗留写法
result = engine.execute("SELECT * FROM customer")                  # 字符串 SQL + connectionless
result = engine.execute(customer.insert(), email="a@example.com")  # 关键字参数 + 隐式提交

engine.execute()connectionless execution :它隐式 checkout 一条连接、执行、然后交还。配合 legacy 模式的隐式 autocommit,insert().execute() 会在执行后直接提交,写代码的人甚至意识不到「连接」和「事务」这两个概念。

11.3.2 为何被移除

官方措辞是「Implicit and Connectionless execution, bound metadata removed」。原因很直接:让连接和事务被隐式管理,等于让事务边界不可见 。一条 SQL 落库了,但没人知道它属于哪个事务、何时提交、失败时是否回滚。2.0 的哲学是「Many Choices becomes One Choice」:执行必须通过 ConnectionSession,事务必须显式。

11.3.3 失败实验:connectionless execution 报错

python 复制代码
# SQLAlchemy 2.0
engine.execute("SELECT * FROM customer")
# AttributeError: 'Engine' object has no attribute 'execute'

同理,metadata.tables["customer"].insert().execute() 在 2.0 下会报 AttributeError: 'Insert' object has no attribute 'execute'

11.3.4 迁移对照:Connection 上下文 + text()

python 复制代码
# SQLAlchemy 1.4 遗留
result = engine.execute("SELECT * FROM customer")
engine.execute(customer.insert(), email="a@example.com")

# SQLAlchemy 1.4 future / 2.0
with engine.connect() as conn:
    result = conn.execute(text("SELECT * FROM customer"))
with engine.begin() as conn:
    conn.execute(insert(customer), {"email": "a@example.com"})

这不是语法装饰,语义差异是实质的:

  • 移除 connectionless execution :每次执行都必须持有 Connection
  • 字符串 SQL 必须 text() :否则 2.0 报 ArgumentError: Textual SQL expression ... should be explicitly declared as text()
  • library-level 隐式 autocommit 被移除engine.connect() 内执行完不 commit,退出时回滚;写入事务必须显式engine.begin() 成功才提交,异常回滚;
  • execute 参数使用单字典或字典序列 :不再支持 email="..." 这种关键字参数。

11.3.5 execute() 参数严格化

python 复制代码
# SQLAlchemy 1.4 遗留(关键字参数)
engine.execute(customer.insert(), email="a@example.com", name="Alice")

# SQLAlchemy 1.4 future / 2.0(单字典;批量用字典序列)
with engine.begin() as conn:
    conn.execute(insert(customer), {"email": "a@example.com", "name": "Alice"})
    conn.execute(insert(customer), [{"email": "a@example.com", "name": "Alice"}, {"email": "b@example.com", "name": "Bob"}])

11.3.6 字符串 SQL 与位置参数迁移

python 复制代码
# SQLAlchemy 1.4 遗留:手写拼接(注入风险 + 无法缓存编译)
engine.execute(f"UPDATE product SET stock = stock - {qty} WHERE id = {product_id}")

# SQLAlchemy 1.4 future / 2.0:text() + 命名绑定参数
with engine.begin() as conn:
    conn.execute(text("UPDATE product SET stock = stock - :qty WHERE id = :pid"), {"qty": 2, "pid": 10})

如果必须保留驱动级原样 SQL,用 Connection.exec_driver_sql(),它不经过 text() 的参数样式转换。大多数场景应使用 text() + 命名绑定参数:它支持编译缓存、参数类型绑定和方言适配。

11.3.7 select() 与 DML 构造变化

python 复制代码
# SQLAlchemy 1.4 遗留
stmt = select([customer.c.id, customer.c.email])
customer.insert().values(email="a@example.com")

# SQLAlchemy 1.4 future / 2.0:位置参数;DML 用 dict
stmt = select(customer.c.id, customer.c.email)
insert(customer).values({"email": "a@example.com"})

11.3.8 迁移时的回归验证

Core 层迁移的正确性判断标准是 SQL 快照一致 :迁移前后的 SQL 结构相同、参数绑定正确、事务边界符合预期。具体做法见 11.7:先用 echo=Truebefore_cursor_execute 事件录制基线 SQL,再逐条比对。


11.4 ORM 查询迁移:session.query()select()

11.4.1 旧/新 API 映射

这是迁移中命中面最大的部分。核心一句话:Query 的链式方法变成 select() 的链式方法,执行结果统一交给 Result

1.x 旧式 Query 2.0 新式 select() 差异要点
session.query(Customer) session.scalars(select(Customer)) 结果入口从 Query 变成 Result
.filter_by(email=...) .where(Customer.email == ...) 属性过滤 → 表达式
.get(pk) session.get(Customer, pk) 方法移到 Session
.count() select(func.count()).select_from(...) 返回标量,不是 len(list)
.join(Order) .join(Order.customer) 用属性而非字符串
.options(joinedload(Customer.orders)) 同左 语法相近,但 2.0 不默认去重
.subquery() select(...).subquery() 统一到 statement
.one() / .one_or_none() .scalars().one() / .one_or_none() 基数语义不变

#mermaid-svg-UnxZBG8QFGPRFwpq{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-UnxZBG8QFGPRFwpq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UnxZBG8QFGPRFwpq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UnxZBG8QFGPRFwpq .error-icon{fill:#552222;}#mermaid-svg-UnxZBG8QFGPRFwpq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UnxZBG8QFGPRFwpq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UnxZBG8QFGPRFwpq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UnxZBG8QFGPRFwpq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UnxZBG8QFGPRFwpq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UnxZBG8QFGPRFwpq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UnxZBG8QFGPRFwpq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UnxZBG8QFGPRFwpq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UnxZBG8QFGPRFwpq .marker.cross{stroke:#333333;}#mermaid-svg-UnxZBG8QFGPRFwpq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UnxZBG8QFGPRFwpq p{margin:0;}#mermaid-svg-UnxZBG8QFGPRFwpq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UnxZBG8QFGPRFwpq .cluster-label text{fill:#333;}#mermaid-svg-UnxZBG8QFGPRFwpq .cluster-label span{color:#333;}#mermaid-svg-UnxZBG8QFGPRFwpq .cluster-label span p{background-color:transparent;}#mermaid-svg-UnxZBG8QFGPRFwpq .label text,#mermaid-svg-UnxZBG8QFGPRFwpq span{fill:#333;color:#333;}#mermaid-svg-UnxZBG8QFGPRFwpq .node rect,#mermaid-svg-UnxZBG8QFGPRFwpq .node circle,#mermaid-svg-UnxZBG8QFGPRFwpq .node ellipse,#mermaid-svg-UnxZBG8QFGPRFwpq .node polygon,#mermaid-svg-UnxZBG8QFGPRFwpq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UnxZBG8QFGPRFwpq .rough-node .label text,#mermaid-svg-UnxZBG8QFGPRFwpq .node .label text,#mermaid-svg-UnxZBG8QFGPRFwpq .image-shape .label,#mermaid-svg-UnxZBG8QFGPRFwpq .icon-shape .label{text-anchor:middle;}#mermaid-svg-UnxZBG8QFGPRFwpq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UnxZBG8QFGPRFwpq .rough-node .label,#mermaid-svg-UnxZBG8QFGPRFwpq .node .label,#mermaid-svg-UnxZBG8QFGPRFwpq .image-shape .label,#mermaid-svg-UnxZBG8QFGPRFwpq .icon-shape .label{text-align:center;}#mermaid-svg-UnxZBG8QFGPRFwpq .node.clickable{cursor:pointer;}#mermaid-svg-UnxZBG8QFGPRFwpq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UnxZBG8QFGPRFwpq .arrowheadPath{fill:#333333;}#mermaid-svg-UnxZBG8QFGPRFwpq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UnxZBG8QFGPRFwpq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UnxZBG8QFGPRFwpq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UnxZBG8QFGPRFwpq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UnxZBG8QFGPRFwpq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UnxZBG8QFGPRFwpq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UnxZBG8QFGPRFwpq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UnxZBG8QFGPRFwpq .cluster text{fill:#333;}#mermaid-svg-UnxZBG8QFGPRFwpq .cluster span{color:#333;}#mermaid-svg-UnxZBG8QFGPRFwpq 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-UnxZBG8QFGPRFwpq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UnxZBG8QFGPRFwpq rect.text{fill:none;stroke-width:0;}#mermaid-svg-UnxZBG8QFGPRFwpq .icon-shape,#mermaid-svg-UnxZBG8QFGPRFwpq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UnxZBG8QFGPRFwpq .icon-shape p,#mermaid-svg-UnxZBG8QFGPRFwpq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UnxZBG8QFGPRFwpq .icon-shape .label rect,#mermaid-svg-UnxZBG8QFGPRFwpq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UnxZBG8QFGPRFwpq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UnxZBG8QFGPRFwpq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UnxZBG8QFGPRFwpq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-UnxZBG8QFGPRFwpq .model>*{fill:#2563eb!important;stroke:#1e40af!important;color:#fff!important;}#mermaid-svg-UnxZBG8QFGPRFwpq .model span{fill:#2563eb!important;stroke:#1e40af!important;color:#fff!important;}#mermaid-svg-UnxZBG8QFGPRFwpq .model tspan{fill:#fff!important;}#mermaid-svg-UnxZBG8QFGPRFwpq .session>*{fill:#7c3aed!important;stroke:#6d28d9!important;color:#fff!important;}#mermaid-svg-UnxZBG8QFGPRFwpq .session span{fill:#7c3aed!important;stroke:#6d28d9!important;color:#fff!important;}#mermaid-svg-UnxZBG8QFGPRFwpq .session tspan{fill:#fff!important;}#mermaid-svg-UnxZBG8QFGPRFwpq .error>*{fill:#dc2626!important;stroke:#991b1b!important;color:#fff!important;}#mermaid-svg-UnxZBG8QFGPRFwpq .error span{fill:#dc2626!important;stroke:#991b1b!important;color:#fff!important;}#mermaid-svg-UnxZBG8QFGPRFwpq .error tspan{fill:#fff!important;} 旧:session.query(Customer)
新:select(Customer)
Session.execute / scalars
Result / ScalarResult
Row / Mapping / Entity
旧:Query 独有方法

get/count/from_self/join(字符串)
新:Session.get

func.count / 显式 ON

11.4.2 get()count() 的迁移

python 复制代码
# SQLAlchemy 1.4 遗留
customer = session.query(Customer).get(customer_id)
count = session.query(Customer).filter_by(active=True).count()

# SQLAlchemy 1.4 future / 2.0
customer = session.get(Customer, customer_id)
count = session.scalar(select(func.count(Customer.id)).where(Customer.active.is_(True)))

Query.count() 迁移后返回标量,不要写成 len(session.scalars(...).all())------那会全量加载行。需要总数用 func.count(),需要分页页数用带 select_from 的标量子查询。count() 是最容易被「语法替换」写错的点,见 11.4.6。

11.4.3 join 与 eager load 的迁移

python 复制代码
# SQLAlchemy 1.4 遗留
rows = session.query(Order).join(Customer).filter(Customer.email == email).options(joinedload(Order.items)).all()

# SQLAlchemy 1.4 future / 2.0
rows = (
    session.scalars(select(Order).join(Order.customer).where(Customer.email == email).options(joinedload(Order.items)))
    .unique()  # 集合 joined load 必须 unique
    .all()
)

两个变化:join 目标用属性 .join(Order.customer),不再按字符串解析;集合类 joined eager load 必须 .unique()(见 11.4.4)。

11.4.4 失败实验:Result.unique() 与 joined eager load 集合

2.0 的一个「反直觉」变化:ORM 结果不再默认去重。官方原文是「ORM Rows not uniquified by default」。

python 复制代码
# SQLAlchemy 2.0
stmt = select(Order).options(joinedload(Order.items))
session.scalars(stmt).all()
# sqlalchemy.exc.InvalidRequestError: The unique() method must be invoked on this Result,
# as it contains results that include joined eager loads against collections

原因:joinedload 用一条 LEFT OUTER JOIN 把集合子表拼进来,一个订单对应 N 个订单项,物理行数是 N。1.x 的 Query 默默按主键去重(这就是过去「SELECT COUNT(*) 和查询结果行数对不上」长期困惑的来源);2.0 要求你显式声明 要去重:orders = session.scalars(stmt).unique().all()。两条纪律:.unique() 必须在消费任何行之前调用(否则报错,这是保护机制);使用 .unique() 后行数与 COUNT(*) 不一致是预期行为,不是 bug。

官方同时建议集合 eager load 优先用 selectinload------它分两条 SQL、不产生笛卡尔积行,多数场景优于 joinedload,也不需要 .unique()

python 复制代码
# SQLAlchemy 2.0
orders = session.scalars(select(Order).options(selectinload(Order.items))).all()

11.4.5 旧式 Queryselect() 混用问题

python 复制代码
# SQLAlchemy 1.4 遗留(迁移中间态,不要这样写)
orders = session.scalars(select(Order).where(Order.id.in_(session.query(Order.id).where(Order.customer_id == 1).subquery()))).all()

# SQLAlchemy 2.0:in_() 直接把完整 statement 当子查询
orders = session.scalars(select(Order).where(Order.id.in_(select(Order.id).where(Order.customer_id == 1)))).all()

Query.subquery() 返回的 Selectable 与 2.0 statement 混用,类型和缓存语义都不可靠。迁移期规则:一个查询要么全部用旧式 Query,要么全部用 select(),不允许拼接

11.4.6 结果集回归测试:不能只做语法替换

「代码跑起来」不等于「行为一致」。实体数量、SQL 数量、排序都要断言:

python 复制代码
# tests/test_query_regression.py
def test_join_result_cardinality(session: Session) -> None:
    orders = session.scalars(
        select(Order).join(Order.customer).where(Customer.email == "alice@example.com")
        .options(selectinload(Order.items)).order_by(Order.created_at)
    ).all()
    assert len(orders) == 2 and [o.id for o in orders] == [1, 2]  # 实体数量 + 排序

def test_no_n_plus_one(session: Session, capture_sql: Callable[[], list[str]]) -> None:
    stmts = capture_sql()
    session.scalars(select(Order).options(selectinload(Order.items))).all()
    assert len(stmts) == 2  # 查询订单 + 批量加载订单项

对集合 eager load 的项目,必须把「结果基数、SQL 数量、排序」写成回归测试,而不是只验证「不抛异常」。


11.5 声明式迁移:DeclarativeBaseMapped 与类型标注

11.5.1 经典写法 → 新式写法

python 复制代码
# SQLAlchemy 1.4 遗留(经典写法)
Base = declarative_base()
class Customer(Base):
    __tablename__ = "customer"
    id = Column(Integer, primary_key=True)
    email = Column(String(320), unique=True)

# SQLAlchemy 1.4 future / 2.0
class Base(DeclarativeBase):
    pass
class Customer(Base):
    __tablename__ = "customer"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(320), unique=True)

DeclarativeBase 在 1.4 系列已可用(1.4.13+),所以声明式可以在 1.4 收敛阶段就完成 。统一模型定义见 Part 3 的 3.10 节(Customer / Product / Order / OrderItem 四张表,含命名约定)。

11.5.2 Mapped[T] 与 nullable 推断

Mapped[str] 没有 Optional 时列不可空;Mapped[str | None] 才可空。关系集合用 Mapped[list[Order]]。收益是:空值语义现在由类型表达

python 复制代码
# SQLAlchemy 1.4 future / 2.0
class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id"))
    status: Mapped[str] = mapped_column(String(32), default="PENDING")
    customer: Mapped[Customer] = relationship(back_populates="orders")
    items: Mapped[list[OrderItem]] = relationship(back_populates="order", cascade="all, delete-orphan")

11.5.3 type hints 引入时的三个陷阱

  1. Optional 不是补丁 :不要为了消除类型检查报错而到处加 Optional,那会悄悄放宽约束。按数据库约束写类型:可空列才 | None
  2. __allow_unmapped__:模型类里若有「不是映射列」的 PEP 484 注解,2.0 会报错要求声明:
python 复制代码
# SQLAlchemy 2.0
class Customer(Base):
    __tablename__ = "customer"
    __allow_unmapped__ = True   # 允许类上有非映射的类型注解
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(320), unique=True)
    api_version: str = "v1"     # 普通 Python 属性,非映射
  1. 分模块渐进 :不必一次重写所有模型。新模型直接用 Mapped/mapped_column,旧模型按模块迁移,每个模块迁移后跑对应查询回归测试。

11.5.4 模型、DTO 与领域逻辑分层

迁移是重构数据访问边界的好时机。模型类(Mapped 映射)只负责持久化形状;对外传输用 DTO;业务规则放在 service/领域层,不要在模型上写「查询其它表」的方法。

text 复制代码
持久化模型(Mapped 类)   ------ 表结构 + 关系
DTO                       ------ API 进出参数,与 ORM 解耦
service / 领域逻辑        ------ 校验、编排、事务边界
repository(可选)        ------ 查询封装,见 11.5.5

11.5.5 repository 与 Unit of Work:是否必要的判断标准

引入 repository 的信号 :同一查询逻辑在多个 service 重复;查询含复杂 join/过滤组合;需要为查询写独立单元测试。不引入的信号 :查询就是 session.get() / 简单 select();service 直连 Session 已清晰;项目很小,加抽象层只是增加间接层。

repository 的边界规则:负责查询与 flush,不负责 commit。Unit of Work 以 Session 为载体,commit 所有权属于上层的 route/command handler(见 11.6.4)。没有 repository 时,service 直接使用 Session 也应遵守同一规则。

python 复制代码
# SQLAlchemy 1.4 future / 2.0 ------ repository 示例(只查不提交)
class OrderRepository:
    def __init__(self, session: Session) -> None:
        self._session = session

    def get_by_id(self, order_id: int) -> Order | None:
        return self._session.get(Order, order_id)

    def list_by_customer(self, customer_id: int) -> Sequence[Order]:
        return self._session.scalars(
            select(Order).where(Order.customer_id == customer_id).order_by(Order.created_at)
        ).all()

11.6 事务治理:commit 所有权与失败路径

11.6.1 迁移时最危险的隐藏变化

版本升级本身很少让人丢数据,让人丢数据的是隐藏的提交语义变化。重点排查四类:

  • 旧 Core DML 依赖 implicit autocommit(engine.execute(insert(...)) 曾自动提交);
  • Session 处于 autocommit=True 模式(提交行为现在归业务代码负责);
  • repository 在内部 commit()(service 无法原子化多个步骤);
  • Web 框架扩展自动创建/提交 Session(flask-sqlalchemy 等有自己的事务钩子)。

11.6.2 失败实验:library-level autocommit 移除后的静默回滚

官方明确:library-level(非 driver-level)autocommit 在 Core 与 ORM 中都被移除。下面是迁移后最容易「悄悄丢数据」的场景:

python 复制代码
# SQLAlchemy 1.4 遗留(依赖隐式提交,当时能落库)
session = Session(engine, autocommit=True)
session.add(Order(...))
session.flush()   # 旧模式:flush 在一个自动提交的事务中

# SQLAlchemy 2.0(同逻辑,但数据不会落库)
session = Session(engine)  # autocommit 参数已被移除
session.add(Order(...))
session.flush()  # 只是发 SQL;事务没提交,进程结束即回滚,无任何异常

Session(engine, autocommit=True) 在 2.0 会直接 TypeError,提醒你参数不存在------这反而是好事 。真正危险的是把代码「照抄」过去后,flush() 后没有 commit(),而原代码依赖了旧的隐式提交:此时没有任何报错,数据就是没写进去。这就是「静默回滚」。

回归验证:断言「没有 commit 就不可见」(SQLite 内存库每个连接独立,此语义需在文件库/PostgreSQL 上复验):

python 复制代码
def test_write_requires_explicit_commit(engine: Engine) -> None:
    with Session(engine) as session:
        session.add(Customer(email="commit@example.com", name="C"))
        session.flush()
    with Session(engine) as other:
        assert other.scalar(select(Customer).where(Customer.email == "commit@example.com")) is None  # 未提交 → 回滚

11.6.3 失败实验:subtransactions 移除

session.begin(subtransactions=True) 过去允许「嵌套 begin」,但它并不产生真正的嵌套事务,只是一个「阻止内层 commit 生效」的块,容易让人误解事务边界。

python 复制代码
# SQLAlchemy 1.4 遗留
with session.begin(subtransactions=True):
    session.add(Order(...))
# SQLAlchemy 2.0 → TypeError: begin() got an unexpected keyword argument 'subtransactions'

官方推荐的替代:外层统一 begin,内层不需要也不应该管理事务

python 复制代码
# SQLAlchemy 2.0
def method_a(session: Session) -> None:
    method_b(session)  # 内层不 begin、不 commit

def method_b(session: Session) -> None:
    session.add(Order(...))

with Session(engine) as session:
    with session.begin():  # 最外层唯一的事务边界
        method_a(session)

11.6.4 Session 生命周期与 commit 所有权

#mermaid-svg-Tvov5g3biRAT4ybz{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-Tvov5g3biRAT4ybz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Tvov5g3biRAT4ybz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Tvov5g3biRAT4ybz .error-icon{fill:#552222;}#mermaid-svg-Tvov5g3biRAT4ybz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Tvov5g3biRAT4ybz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Tvov5g3biRAT4ybz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Tvov5g3biRAT4ybz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Tvov5g3biRAT4ybz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Tvov5g3biRAT4ybz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Tvov5g3biRAT4ybz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Tvov5g3biRAT4ybz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Tvov5g3biRAT4ybz .marker.cross{stroke:#333333;}#mermaid-svg-Tvov5g3biRAT4ybz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Tvov5g3biRAT4ybz p{margin:0;}#mermaid-svg-Tvov5g3biRAT4ybz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Tvov5g3biRAT4ybz .cluster-label text{fill:#333;}#mermaid-svg-Tvov5g3biRAT4ybz .cluster-label span{color:#333;}#mermaid-svg-Tvov5g3biRAT4ybz .cluster-label span p{background-color:transparent;}#mermaid-svg-Tvov5g3biRAT4ybz .label text,#mermaid-svg-Tvov5g3biRAT4ybz span{fill:#333;color:#333;}#mermaid-svg-Tvov5g3biRAT4ybz .node rect,#mermaid-svg-Tvov5g3biRAT4ybz .node circle,#mermaid-svg-Tvov5g3biRAT4ybz .node ellipse,#mermaid-svg-Tvov5g3biRAT4ybz .node polygon,#mermaid-svg-Tvov5g3biRAT4ybz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Tvov5g3biRAT4ybz .rough-node .label text,#mermaid-svg-Tvov5g3biRAT4ybz .node .label text,#mermaid-svg-Tvov5g3biRAT4ybz .image-shape .label,#mermaid-svg-Tvov5g3biRAT4ybz .icon-shape .label{text-anchor:middle;}#mermaid-svg-Tvov5g3biRAT4ybz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Tvov5g3biRAT4ybz .rough-node .label,#mermaid-svg-Tvov5g3biRAT4ybz .node .label,#mermaid-svg-Tvov5g3biRAT4ybz .image-shape .label,#mermaid-svg-Tvov5g3biRAT4ybz .icon-shape .label{text-align:center;}#mermaid-svg-Tvov5g3biRAT4ybz .node.clickable{cursor:pointer;}#mermaid-svg-Tvov5g3biRAT4ybz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Tvov5g3biRAT4ybz .arrowheadPath{fill:#333333;}#mermaid-svg-Tvov5g3biRAT4ybz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Tvov5g3biRAT4ybz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Tvov5g3biRAT4ybz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tvov5g3biRAT4ybz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Tvov5g3biRAT4ybz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tvov5g3biRAT4ybz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Tvov5g3biRAT4ybz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Tvov5g3biRAT4ybz .cluster text{fill:#333;}#mermaid-svg-Tvov5g3biRAT4ybz .cluster span{color:#333;}#mermaid-svg-Tvov5g3biRAT4ybz 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-Tvov5g3biRAT4ybz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Tvov5g3biRAT4ybz rect.text{fill:none;stroke-width:0;}#mermaid-svg-Tvov5g3biRAT4ybz .icon-shape,#mermaid-svg-Tvov5g3biRAT4ybz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tvov5g3biRAT4ybz .icon-shape p,#mermaid-svg-Tvov5g3biRAT4ybz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Tvov5g3biRAT4ybz .icon-shape .label rect,#mermaid-svg-Tvov5g3biRAT4ybz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tvov5g3biRAT4ybz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Tvov5g3biRAT4ybz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Tvov5g3biRAT4ybz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-Tvov5g3biRAT4ybz .model>*{fill:#2563eb!important;stroke:#1e40af!important;color:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .model span{fill:#2563eb!important;stroke:#1e40af!important;color:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .model tspan{fill:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .session>*{fill:#7c3aed!important;stroke:#6d28d9!important;color:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .session span{fill:#7c3aed!important;stroke:#6d28d9!important;color:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .session tspan{fill:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .conn>*{fill:#ea580c!important;stroke:#c2410c!important;color:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .conn span{fill:#ea580c!important;stroke:#c2410c!important;color:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .conn tspan{fill:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .db>*{fill:#16a34a!important;stroke:#15803d!important;color:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .db span{fill:#16a34a!important;stroke:#15803d!important;color:#fff!important;}#mermaid-svg-Tvov5g3biRAT4ybz .db tspan{fill:#fff!important;} 拥有一个业务事务
组合调用
同一 Session
autobegin / commit / rollback
route / consumer / command handler
service 编排
repository 查询 / flush
Session / Unit of Work
Connection / Transaction
Database

规则一句话:route/consumer/command handler 拥有一个业务事务;service 负责编排;repository 只查询与 flush,绝不 commit 。一个业务事务覆盖到数据库的最小原子单位------例如「扣库存 + 建订单 + 写 outbox」必须共享一个 Session、一次 commit。完整代码见 11.7.9 的 handle_place_order()

11.6.5 先补失败路径测试,再改事务代码

python 复制代码
# tests/test_transactions.py
def test_rollback_restores_stock(session: Session) -> None:
    product = session.get(Product, 1)
    original = product.stock
    try:
        with session.begin():
            product.stock -= 100   # 违反 stock >= 0 约束
            session.flush()
    except IntegrityError:
        session.rollback()
    assert session.get(Product, 1).stock == original

原则:先让失败路径有断言,再改事务代码。否则「迁移后 rollback 不生效」这类问题会在生产环境才暴露。


11.7 逐提交迁移实战:order-lab 从 1.4 收敛到 2.0

这一节把前面所有机制串成一条可回退的提交序列。每个提交都可合并、可回滚,都跑过测试与 SQL 快照。

11.7.1 提交 1:双环境测试矩阵与基线

powershell 复制代码
python -m venv .venv-14 && .venv-14\Scripts\pip install "SQLAlchemy>=1.4.0,<2" pytest  # 迁移源
python -m venv .venv-20 && .venv-20\Scripts\pip install "SQLAlchemy>=2.0,<2.1" pytest  # 目标

测试入口统一,避免测试代码本身成为迁移负担:

python 复制代码
# tests/conftest.py
@pytest.fixture()
def engine() -> Iterator[Engine]:
    eng = create_engine("sqlite+pysqlite:///:memory:")
    Base.metadata.create_all(eng)
    yield eng
    eng.dispose()

@pytest.fixture()
def session(engine: Engine) -> Iterator[Session]:
    with sessionmaker(engine, expire_on_commit=False)() as s:
        yield s

本提交同时录制三类基线:SQL 快照before_cursor_execute 事件)、性能基线 (核心查询 p50/p95)、结果快照(实体数量 + 排序)。基线建立后,迁移每进一步都有可比较对象。

11.7.2 提交 2:开启 RemovedIn20Warning 并提升为 CI 失败

powershell 复制代码
$env:SQLALCHEMY_WARN_20 = "1"
python -m pytest -q   # 记录当前所有 RemovedIn20Warning,按文件分派

在 pyproject.toml 写入 filterwarnings(见 11.2.3),从此任何新出现的 RemovedIn20Warning 都让 CI 失败。这个提交不改业务代码,只加「约束」。警告清单就是 11.1.2 迁移待办表最可靠的实时版本。

11.7.3 提交 3:清理 bound metadata 与 connectionless execution

python 复制代码
# SQLAlchemy 1.4 遗留
row = metadata.tables["customer"].insert().execute(email="a@example.com")  # MetaData(bind=engine)

# SQLAlchemy 1.4 future / 2.0
with engine.begin() as conn:
    conn.execute(insert(customer_table), {"email": "a@example.com"})

MetaData(bind=engine) 删除后,metadata 不再感知连接;所有执行都从 engine.connect() / engine.begin() 开始。运行测试:Core 相关测试全绿,SQL 快照对比无差异。

11.7.4 提交 4:engine.execute()engine.begin() / connect()

python 复制代码
# SQLAlchemy 1.4 遗留
def deduct_stock(product_id: int, qty: int) -> None:
    engine.execute("UPDATE product SET stock = stock - %s WHERE id = %s", (qty, product_id))

# SQLAlchemy 1.4 future / 2.0
def deduct_stock(product_id: int, qty: int) -> None:
    with engine.begin() as conn:
        conn.execute(text("UPDATE product SET stock = stock - :qty WHERE id = :pid"), {"qty": qty, "pid": product_id})

关键回归验证 :旧代码可能依赖「自动提交」,新代码必须显式 commit。用 11.6.2 的「无 commit 不可查」测试套到每条写路径上,凡是过去没有 commit 的地方,现在补上 engine.begin()session.commit()

SQL 快照对比:UPDATE product SET stock = stock - 2 WHERE id = 10(1.4 legacy)→ UPDATE product SET stock = stock - ? WHERE id = ?(2.0 绑定参数)。

11.7.5 提交 5:session.query()select()

按查询热点逐文件迁移,每改一个文件跑一次 11.4.6 的结果集回归测试:

python 复制代码
# SQLAlchemy 1.4 遗留
customer = session.query(Customer).filter_by(email=email).one()
product = session.query(Product).get(product_id)
count = session.query(Order).filter_by(customer_id=cid).count()

# SQLAlchemy 1.4 future / 2.0
customer = session.scalars(select(Customer).where(Customer.email == email)).one()
product = session.get(Product, product_id)
count = session.scalar(select(func.count(Order.id)).where(Order.customer_id == cid))

SQL 快照对比(SQLite 参数占位符 ?):SELECT orders.id, orders.customer_id, orders.status, orders.created_at FROM orders WHERE orders.customer_id = ?------迁移前后语句结构一致。

带集合 eager load 的查询,迁移时先改成 selectinload 避免 .unique() 争议;必须用 joinedload 的地方补 .unique() 并写基数断言。

11.7.6 提交 6:事务显式化与 Session 生命周期

处理 11.6 的所有内容:删除 Session(engine, autocommit=True)、删除所有 subtransactions=True、把 commit 所有权收敛到 route/command handler。

python 复制代码
# SQLAlchemy 1.4 遗留
def create_order(customer_id: int) -> None:
    session = Session(engine, autocommit=True)
    session.add(Order(customer_id=customer_id)); session.flush()  # 依赖 autocommit:数据"自动"落库

# SQLAlchemy 1.4 future / 2.0
def create_order(customer_id: int) -> None:
    with SessionFactory() as session:
        with session.begin():
            order = Order(customer_id=customer_id)
            session.add(order)
            return order.id

失败路径测试(11.6.5)必须在本提交之前合入。运行事务测试矩阵:正常提交、flush 失败回滚、SAVEPOINT 嵌套、重复提交幂等性。

11.7.7 提交 7:声明式类型化

模型按 11.5 迁移到 DeclarativeBase + Mapped,处理 __allow_unmapped__(如有非映射注解)。逐模块进行,每个模块跑对应查询回归测试。本提交结束时,模型层在 1.4 上已经是完整 2.0 风格。

python 复制代码
# SQLAlchemy 1.4 future / 2.0 ------ 迁移后模型(节选)
class Customer(Base):
    __tablename__ = "customer"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(320), unique=True)
    name: Mapped[str] = mapped_column(String(100))
    orders: Mapped[list[Order]] = relationship(back_populates="customer")

11.7.8 提交 8:切换真实 SQLAlchemy 2.0

.venv-20 安装 2.0,删除所有 future=True(2.0 已是默认),跑全量测试:

powershell 复制代码
.venv-20\Scripts\python -m pytest -q

预期:由于此前所有 RemovedIn20Warning 已清零,这里不会出现 API 不存在类错误。若还有差异,逐条对照官方 Migration Guide 对应小节,不要绕过。全绿后再跑性能与资源基线对比(11.9 门禁)。

11.7.9 提交 9:repository / Unit of Work 边界

按 11.5.5 的判断标准,把高频复杂查询收敛到 repository,把 commit 收敛到 handler。这不是 2.0 的强制要求,而是治理收益------迁移完成后的边界从此有测试、有文档。

python 复制代码
# SQLAlchemy 2.0 ------ 提交 9 之后的主链路
def handle_place_order(command: PlaceOrder) -> int:
    with SessionFactory() as session:
        with session.begin():
            repo = OrderRepository(session)
            product = session.get(Product, command.product_id, with_for_update=True)
            if product is None or product.stock < command.quantity:
                raise InsufficientStock(command.product_id)
            product.stock -= command.quantity
            order = Order(customer_id=command.customer_id)
            session.add(order)
            session.flush()
            session.add(OrderItem(order_id=order.id, product_id=product.id, quantity=command.quantity, unit_price=product.price))
            return order.id

11.7.10 每提交回归验证清单

每个提交合入前都跑一遍「小门禁」:功能测试全绿、RemovedIn20Warning 零新增、SQL 快照对比、核心查询性能基线。这条纪律让迁移的每一步都是「可回退的单变量变更」。


11.8 发布拆分:灰度、回滚与双环境矩阵

11.8.1 不要在同一次发布中做五件事

以下任何一项单独拿出来都是高风险变更,合并进同一次发布等于让故障无法定位:升级 SQLAlchemy 大版本、重写全部查询、修改数据库 Schema、更换驱动(如 psycopg2 → psycopg 3)、同步改异步。

11.8.2 可回退的发布阶段

text 复制代码
阶段 1  测试与观测补齐           ------ 基线 + 回归测试 + SQL 快照
阶段 2  1.4 + 弃用警告           ------ CI 硬性拦截旧 API
阶段 3  2.0 风格 API             ------ future 模式收敛(仍在 1.4 上跑)
阶段 4  SQLAlchemy 2.0           ------ 仅版本切换,代码不变
阶段 5  独立驱动 / 性能优化      ------ 与版本升级解耦
阶段 6  可选 2.1 评估            ------ 单独 CI job,见 11.10

每个阶段独立发版、独立回滚。阶段 4 之前的所有阶段都在 1.4 上完成,因此「版本升级」本身被压缩成一次低风险切换。

11.8.3 双环境测试矩阵

维度 1.4 收敛分支 2.0 生产分支 2.1 前瞻分支
SQLAlchemy 1.4.x 最终版 2.0.x 稳定版 2.1.x beta
用途 遗留代码回归 线上基线 预发布兼容探针
CI 门槛 RemovedIn20Warning 全量测试 + 性能门禁 编译 + 冒烟
数据库 测试库镜像 生产同构 测试库镜像

三个分支的应用代码同一套,只有依赖版本不同。「代码差异」和「版本差异」不会混在一起。

11.8.4 小流量灰度与回滚开关

python 复制代码
# app/config.py
USE_SQLALCHEMY_20 = os.environ.get("USE_SQLALCHEMY_20", "0") == "1"
text 复制代码
1. 关闭开关 → 全部流量走 1.4 收敛分支(行为已与 2.0 等价);
2. 打开开关 → 1% 流量走 2.0,观测 11.9 五类门禁指标;
3. 逐步放大到 10% → 50% → 100%;
4. 任何指标恶化 → 关开关即回滚,无需重新发布。

灰度前必须演练过回滚:从「开」到「关」只改一个环境变量,数据不变、Schema 不变。


11.9 兼容与性能门禁

零弃用警告只是「代码在 2.0 上不报错」,不是「迁移完成」。完整门禁覆盖五类:

text 复制代码
功能:成功/失败路径、事务回滚、关系加载、结果基数
数据:约束、默认值、Alembic 迁移前后一致性
SQL:SQL 数量、关键 SQL 结构(JOIN 形态、参数绑定)
性能:p50/p95、Pool 等待、数据库 CPU、锁等待
资源:连接数、内存、Result 消费时机、响应体大小
python 复制代码
# scripts/perf_gate.py ------ 性能门禁骨架
import time

from sqlalchemy import text
from sqlalchemy.engine import Engine

def measure_latency(engine: Engine, stmt_text: str, n: int = 50) -> float:
    stmt = text(stmt_text)
    samples: list[float] = []
    with engine.connect() as conn:
        for _ in range(n):
            start = time.perf_counter()
            conn.execute(stmt).all()
            samples.append(time.perf_counter() - start)
    samples.sort()
    return samples[int(len(samples) * 0.95)]  # p95

门禁必须在真实 2.0 版本上运行全部测试,并与 11.7.1 的基线对比:p95 退化超过阈值(比如 15%)即失败。零弃用警告只是必要条件,不是完成证明。


11.10 2.1 前瞻原则

截至 2026-07-31,SQLAlchemy 2.1.0b3 仍为 beta。原则是不预测、不提前锁定:

  • 生产基线继续使用 2.0 稳定版,不因 beta 功能动人而提前升级;
  • 单独 CI job 做预发布兼容:跑编译、冒烟与核心测试,结果不阻塞主线;
  • 不把 beta API 写进主线代码,即使功能诱人,也等正式版;
  • 2.1 正式发布后 阅读迁移说明,评估驱动/框架生态是否跟进;升级仍需灰度与回滚,执行 11.8 同样的流程。

维护三层兼容表,避免「为了 beta 写代码」:

含义 举例
稳定基线 当前生产使用的 2.0 稳定 API select() / Session.scalars()
可选增强 与 2.0 完全兼容,可提前引入 selectinload 等 2.0 稳定特性
未来候选 依赖 2.1 beta,不写主线 2.1 新增的未稳定 API

11.11 常见误区

  1. 零警告等于完成:警告只证明「API 存在」,不证明「行为等价」;结果基数、unique、事务提交仍要靠回归测试。
  2. 全局语法替换session.query(Customer)select(Customer) 是机械替换,但 count()get()、join、eager load 各有语义差异。
  3. 升级与重写同发:把「升版本 + 重写查询 + 改 Schema」塞进一次发布,任何异常都无法定位。
  4. 把 Session 当全局对象:迁移后仍跨请求共享 Session,等于把 2.0 的新边界又破坏掉。
  5. repository 内部 commit:让 service 无法原子化多步操作,回到「隐式提交」的老路。
  6. 忽略 joined eager load 的 .unique():2.0 不再默认去重,报错是保护,不是麻烦。
  7. count()len() 代替:把全表行加载进内存,性能退化却看不见。
  8. 只测成功路径:rollback、约束冲突、异常路径没有测试,事务语义变化会静默溜过 CI。
  9. 依赖 beta 功能:在 2.1 稳定前把 beta API 写进主线,正式版一变又要迁移。
  10. 迁移后不测性能与连接:SQL 数量、Pool 等待、数据库 CPU 才是治理的最终证据。

11.12 完成定义

  • 真实 2.0 环境全量测试通过;
  • RemovedIn20Warning,且 CI 已用 filterwarnings 硬性拦截;
  • engine.execute()、bound metadata 和隐式提交依赖;
  • 主查询使用 select() + Result API(scalars() / session.get());
  • 事务所有权有书面约定(handler 拥有、service 编排、repository 只 flush);
  • joined collection 结果数量正确(.unique()selectinload,且有基数断言);
  • SQL 数量和性能不退化(有基线可对比);
  • 失败路径与 rollback 有测试;
  • 驱动、框架扩展和 Alembic 兼容(双环境矩阵覆盖);
  • 灰度指标与回滚步骤已演练;
  • 2.1 保持「稳定基线 / 可选增强 / 未来候选」三层管理。

11.13 系列总结

SQLAlchemy 的生产能力不来自记住更多 API,而来自统一的推理链:

text 复制代码
对象状态
→ Session / Unit of Work
→ Statement / Result
→ Connection / Transaction / Pool
→ Dialect / Driver
→ Database Constraint / Lock / Plan

2.0 迁移最深刻的意义,是让这条链的每一环都有了唯一的、可观察的 入口:统一 select()、统一 Result、显式事务、类型化映射。当每层状态都有边界、证据和测试时,ORM 才从「方便的黑盒」变成可治理的数据访问基础设施。

迁移不是一次性的重写,而是一条纪律:先盘点、再收敛、后切换,每步可回退。这套纪律在迁移结束后依然有效------下一次换驱动、引入异步、Schema 演进,都会用到同样的「基线 → 变更 → 验证 → 灰度」节奏。

官方参考

相关推荐
魔镜前的帅比1 小时前
(开源项目)x-claw (设计)
python·ai·rust·开源
1001101_QIA1 小时前
工控机网络配置
开发语言·数据库·php
ClouGence1 小时前
加一张临时表,OceanBase Oracle 写入居然快了 30 倍!
数据库·sql·oracle
tju23332 小时前
信创产业二十年:国产基础软件走到哪一步了?
数据库·gitee
谢尔登2 小时前
分享一些我常用的Skill
java·人工智能·python·actionscript
东方护航数据恢复(深圳)2 小时前
RAID5单盘故障后阵列状态分析与重建风险评估_东方护航数据恢复深圳店
大数据·数据库
k4m7v2pz2 小时前
macOS 解压 40GB 分卷+中文密码固件镜像的五个深坑与解决方案
python·7-zip·aes加密·踩坑记录·r36s·多卷zip解压
小白学大数据2 小时前
Python 爬虫实战:抓取汽车之家二手车成交价格与里程数据
开发语言·爬虫·python·汽车