SQLAlchemy 2 ORM 查询进阶笔记
下面给你整理一份 SQLAlchemy 2 ORM 查询进阶笔记,重点讲你提到的:
select(...).options(...)selectinload(...)joinedload(...)subqueryload(...)contains_eager(...)lazyload(...)noload(...)raiseload(...)- 以及老鸟常用的联表查询写法
我会尽量按 “理解原理 + 常见场景 + 推荐写法 + 易踩坑” 来讲。
因为这一块是 ORM 查询最容易“会用一点,但总感觉不踏实”的地方。
一、先说结论:你看到的 .options(...) 到底是干什么的
一句话理解:
select(...).options(...)是给 ORM 查询附加“加载策略”的地方。
它主要解决的问题不是“能不能查到数据”,而是:
- 关联对象什么时候加载
- 一次查几条 SQL
- 要不要 join
- 如何避免 N+1 查询
- 如何控制性能和结果形态
二、先建立一个模型示例
后面所有例子都基于这个一对多关系。
from typing import List, Optional
from sqlalchemy import ForeignKey, String, Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50))
profile: Mapped[Optional["Profile"]] = relationship(back_populates="user", uselist=False)
orders: Mapped[List["Order"]] = relationship(back_populates="user")
class Profile(Base):
__tablename__ = "profiles"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True)
nickname: Mapped[str] = mapped_column(String(50))
user: Mapped["User"] = relationship(back_populates="profile")
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
status: Mapped[str] = mapped_column(String(20))
user: Mapped["User"] = relationship(back_populates="orders")
三、为什么需要 .options(...)
比如你这样查:
stmt = select(User)
users = session.execute(stmt).scalars().all()
如果后面你遍历:
for user in users:
print(user.orders)
而 orders 是默认延迟加载,那么可能会发生:
- 查
users:1 次 SQL - 每个
user.orders:再发 1 次 SQL
如果有 100 个用户,就变成:
- 1 + 100 次 SQL
这就是经典的 N+1 查询问题。
所以 .options(...) 的核心价值就是:
提前设计好关系属性怎么加载,避免 ORM 在你访问属性时偷偷发很多 SQL。
四、最核心的几个加载策略
你先记住 ORM 里最重要的 4 个:
selectinload():最常用,老鸟最爱joinedload():单 SQL join 预加载subqueryload():较少见,特定场景用contains_eager():你自己写 join 时告诉 ORM 结果怎么装配
如果你先吃透这 4 个,ORM 查询已经进阶很多了。
五、selectinload():最推荐、最稳的预加载方式
1. 典型写法
from sqlalchemy import select
from sqlalchemy.orm import selectinload
stmt = select(User).options(selectinload(User.orders))
users = session.execute(stmt).scalars().all()
2. 它会发几条 SQL
通常是 2 条 SQL:
第 1 条
查用户:
SELECT users.id, users.username
FROM users
第 2 条
查这些用户对应的订单:
SELECT orders.id, orders.user_id, orders.status
FROM orders
WHERE orders.user_id IN (1, 2, 3, ...)
3. 为什么老鸟很喜欢 selectinload
因为它有几个非常强的优点:
优点 1:天然避免 N+1
不会每个用户发一次 orders 查询,而是统一批量查。
优点 2:不会像 joinedload 那样导致主对象重复膨胀
如果一个用户有 100 个订单,joinedload 会把用户行重复很多次。
而 selectinload 不会让主查询结果被放大。
优点 3:对一对多、多对多尤其友好
因为集合关系非常容易在 join 后出现重复主对象。
4. 老鸟经验结论
对一对多 / 多对多:
默认优先考虑
selectinload()
这是非常常见的实战经验。
5. 多层预加载
stmt = select(User).options(
selectinload(User.orders).selectinload(Order.items)
)
意思是:
- 先查
User - 再批量查
orders - 再批量查
items
如果关系较深,selectinload 很适合。
6. 一对一也可以用
stmt = select(User).options(selectinload(User.profile))
虽然一对一也能用,但很多时候一对一/多对一也可以考虑 joinedload。
六、joinedload():单 SQL join 预加载
1. 基本写法
from sqlalchemy.orm import joinedload
stmt = select(User).options(joinedload(User.orders))
users = session.execute(stmt).scalars().unique().all()
2. 它会干什么
会生成类似:
SELECT users.*, orders.*
FROM users
LEFT OUTER JOIN orders ON users.id = orders.user_id
即:
在主查询阶段直接 join 把关联对象带出来
3. 优点
- 一次 SQL 查出来
- 对于一对一、多对一关系有时很高效
- 如果关联对象很少,性能不错
4. 最大问题:结果膨胀
如果:
- 1 个用户有 100 个订单
- 你查 100 个用户
join 后返回行数可能暴增。
这会带来:
- 传输数据量变大
- Python 端去重装配成本增加
- 主对象重复
5. 为什么经常要 .unique()
这是重点。
users = session.execute(stmt).scalars().unique().all()
因为 joinedload(User.orders) 时,SQL 结果行里一个 User 可能重复出现多次。
.unique() 是 ORM 结果去重时的常见配套写法。
6. 老鸟经验结论
一对一 / 多对一:
joinedload()经常很合适
例如:
Order.userUser.profile
一对多:
通常先考虑
selectinload(),除非你很确定 join 更合适
七、subqueryload():知道就好,必要时再用
1. 写法
from sqlalchemy.orm import subqueryload
stmt = select(User).options(subqueryload(User.orders))
2. 它的思路
不是简单 IN (...),而是通过子查询拿主对象主键集合,再加载关联数据。
3. 现在为什么没 selectinload() 常用
因为大多数场景下:
selectinload更直观- 性能和行为更可控
- 更符合常见数据库优化习惯
所以老鸟一般经验是:
优先
selectinload(),除非特定场景再考虑subqueryload()
八、.options() 不只是加载关系,还能控制列和延迟行为
很多人以为 .options() 只能写 selectinload。其实不是。
它还可以配:
load_only()defer()undefer()raiseload()lazyload()noload()
这些也很常用。
九、load_only():只加载某些列
1. 写法
from sqlalchemy.orm import load_only
stmt = select(User).options(load_only(User.id, User.username))
users = session.execute(stmt).scalars().all()
2. 用途
如果 User 表很大,有很多你当前不需要的列,比如:
- 大文本
- JSON 字段
- 审计字段
你只想加载核心列,就可以用 load_only()。
3. 老鸟用途
- 列表页只取必要字段
- 降低对象装载成本
- 减少 SQL 传输列数
十、defer() / undefer()
1. defer()
延迟加载某个列,访问时再查。
from sqlalchemy.orm import defer
stmt = select(User).options(defer(User.big_text))
2. undefer()
把原本延迟列改成这次查询时加载。
from sqlalchemy.orm import undefer
stmt = select(User).options(undefer(User.big_text))
3. 适合场景
- 某些大字段默认不常用
- 但部分接口需要显式 加载
十一、lazyload() / noload() / raiseload()
这几个是控制“关系属性访问行为”的利器。
1. lazyload()
强制延迟加载。
from sqlalchemy.orm import lazyload
stmt = select(User).options(lazyload(User.orders))
一般不算高频,更多是覆盖默认策略。
2. noload()
完全不加载这个关系。
from sqlalchemy.orm import noload
stmt = select(User).options(noload(User.orders))
适合:
- 你明确知道当前不需要关系
- 避免无意访问触发加载
3. raiseload()
如果访问这个关系就直接报错。
from sqlalchemy.orm import raiseload
stmt = select(User).options(raiseload(User.orders))
这东西老鸟很喜欢在 “防 N+1 排查”时使用
比如你怀疑某个接口里有人无意中访问了懒加载关系,就可以加 raiseload(),让问题暴露出来。
十二、contains_eager():你自己写 join 时,告诉 ORM 如何装配关系
这个很重要,也是很多人容易忽略的“高级姿势”。
1. 为什么需要它
如果你这样写:
stmt = select(User).join(User.orders)
result = session.execute(stmt).scalars().all()
这只是 SQL 层面 join 了,不代表 ORM 自动把 orders 关系填充好了。
也就是说:
- 你 join 了
orders - 但访问
user.orders时,ORM 仍可能再发 SQL
这时你需要 contains_eager() 告诉 ORM:
这次 join 出来的数据,就是用来装
User.orders的