核心目标 :用可验证的步骤清理 1.x 遗留模式(
Query、engine.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.query、engine.execute、MetaData(bind=...) 会全部失效吗?一千行查询要重写吗?事务行为会不会悄悄改变?本系列前四篇建立过一个核心信念:统一执行模型下,Statement → Execute → Result 是唯一主线。2.0 所做的正是把这条主线变成唯一选择。所以本次迁移不是「学一套新 API」,而是把历史遗留的分叉收敛回同一条主线。面对升级有三种错误反应:
- 鸵鸟:继续停留在 1.4,错过修复、新功能与类型体系;
- 大爆炸:同一次发布里「升级版本 + 重写全部查询 + 改 Schema + 换驱动」,出问题无法定位回滚;
- 正确姿势:先在 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-sqlalchemy、sqlalchemy-utils 有自己的版本兼容矩阵,SQLAlchemy 升级后它们可能是最早炸的;框架扩展自带的 Session、Query 子类或事件钩子都属于「隐藏的旧行为」。
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 | 旧式 Query 与 select() 执行路径分叉 |
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 子类 :
TypeDecorator、event.listens_for、Base.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」:执行必须通过 Connection 或 Session,事务必须显式。
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=True 或 before_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 旧式 Query 与 select() 混用问题
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 声明式迁移:DeclarativeBase、Mapped 与类型标注
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 引入时的三个陷阱
Optional不是补丁 :不要为了消除类型检查报错而到处加Optional,那会悄悄放宽约束。按数据库约束写类型:可空列才| None;__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 属性,非映射
- 分模块渐进 :不必一次重写所有模型。新模型直接用
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 常见误区
- 零警告等于完成:警告只证明「API 存在」,不证明「行为等价」;结果基数、unique、事务提交仍要靠回归测试。
- 全局语法替换 :
session.query(Customer)→select(Customer)是机械替换,但count()、get()、join、eager load 各有语义差异。 - 升级与重写同发:把「升版本 + 重写查询 + 改 Schema」塞进一次发布,任何异常都无法定位。
- 把 Session 当全局对象:迁移后仍跨请求共享 Session,等于把 2.0 的新边界又破坏掉。
- repository 内部 commit:让 service 无法原子化多步操作,回到「隐式提交」的老路。
- 忽略 joined eager load 的
.unique():2.0 不再默认去重,报错是保护,不是麻烦。 count()用len()代替:把全表行加载进内存,性能退化却看不见。- 只测成功路径:rollback、约束冲突、异常路径没有测试,事务语义变化会静默溜过 CI。
- 依赖 beta 功能:在 2.1 稳定前把 beta API 写进主线,正式版一变又要迁移。
- 迁移后不测性能与连接: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 演进,都会用到同样的「基线 → 变更 → 验证 → 灰度」节奏。