4.4 关系加载策略与 N+1 问题
这是新手项目上线后变慢的第一大元凶。本节搞懂:什么是 N+1 问题、selectinload / joinedload 怎么救、DetachedInstanceError 的根源和解法。
一、复现 N+1 问题
沿用 4.1 节的 User / Post 模型(一个用户多篇文章)。写一个看似人畜无害的"文章列表":
from sqlalchemy import select
with SessionLocal() as session:
users = session.scalars(select(User)).all() # 1 条 SQL:查所有用户
for user in users:
print(user.name, len(user.posts)) # 每个 user.posts 又是 1 条 SQL!
打开 echo=True 看日志,假设有 100 个用户:
SELECT ... FROM users -- 1 条
SELECT ... FROM posts WHERE user_id = 1 -- 第 1 个用户
SELECT ... FROM posts WHERE user_id = 2 -- 第 2 个用户
... -- ...
SELECT ... FROM posts WHERE user_id = 100 -- 第 100 个用户
共 1 + 100 = 101 条 SQL! 这就是 N+1 查询问题:查列表 1 条 + 每行触发 1 条懒加载 = N+1 条。
数据少时毫无感觉,用户量上来后接口从 50ms 恶化到 5 秒——而且代码看起来"完全没问题",极难察觉。
根源:懒加载(lazy loading)
relationship 默认策略是 lazy="select":关系属性平时不加载,第一次访问时才发 SQL。单看一个对象很合理,放进循环就是灾难。
二、解药1:selectinload(列表关系首选)
预先声明"我待会要用 posts",SQLAlchemy 就会用第二条 IN 查询批量取回所有关联数据:
from sqlalchemy import select
from sqlalchemy.orm import selectinload
with SessionLocal() as session:
stmt = select(User).options(selectinload(User.posts))
users = session.scalars(stmt).all()
for user in users:
print(user.name, len(user.posts)) # 不再触发任何 SQL!
日志里只有 2 条 SQL,无论多少用户:
SELECT ... FROM users -- 第 1 条:查用户
SELECT ... FROM posts WHERE user_id IN (1, 2, ..., 100) -- 第 2 条:IN 一次全捞
101 条 → 2 条,这就是优化的全部。
三、解药2:joinedload(单对象关系首选)
用 JOIN 把关联数据"拼"在同一条 SQL 里查回来:
from sqlalchemy.orm import joinedload
# 查文章列表,同时带出每篇的作者
stmt = select(Post).options(joinedload(Post.author))
posts = session.scalars(stmt).all()
for post in posts:
print(post.title, "——", post.author.name) # 不触发额外 SQL
生成 1 条 SQL:
SELECT posts.*, users.*
FROM posts LEFT OUTER JOIN users ON users.id = posts.user_id
selectinload vs joinedload 怎么选?
| selectinload | joinedload | |
|---|---|---|
| 原理 | 额外发一条 IN 查询 | JOIN 进同一条查询 |
| 适合 | 一对多 / 多对多(列表关系) | 多对一 / 一对一(单对象关系) |
| 原因 | JOIN 一对多会产生大量重复行,IN 更高效 | 单对象 JOIN 不膨胀行数,1条SQL最省 |
记忆口诀:关系是列表用 selectinload,关系是单个对象用 joinedload。
⚠️
joinedload用于一对多列表时,若再配合limit会有行数膨胀问题(SQLAlchemy 会要求加.unique())。新手不确定时,全用 selectinload 也完全没问题,只是单对象场景多一条 SQL 而已。
四、嵌套与多重加载
from sqlalchemy.orm import selectinload, joinedload
# 同时预加载多个关系
stmt = select(Post).options(
joinedload(Post.author),
selectinload(Post.tags),
)
# 链式嵌套:查用户 → 预载其文章 → 再预载每篇文章的标签
stmt = select(User).options(
selectinload(User.posts).selectinload(Post.tags)
)
五、在模型上配置默认策略
如果某个关系"每次都要预加载",可以直接在模型上配默认,免得每个查询都写 options:
class Post(Base):
# ...
author: Mapped["User"] = relationship(
back_populates="posts",
lazy="joined", # 查 Post 时默认 JOIN 带出作者
)
lazy 常用取值:
| 值 | 含义 |
|---|---|
"select"(默认) | 懒加载:访问时才查 |
"joined" | 默认 joinedload |
"selectin" | 默认 selectinload |
"raise" | 访问未预加载的关系直接抛错——强迫所有查询显式声明预加载,大型项目防 N+1 的猛药 |
建议:模型上保持默认,在查询处按需 options()——同一个关系在不同接口需要的策略往往不同。
六、DetachedInstanceError:懒加载的另一个坑
2.3 节埋过的伏笔,现在彻底讲清:
def get_user(user_id: int):
with SessionLocal() as session:
return session.get(User, user_id)
user = get_user(1)
print(user.name) # ✅ 普通属性已加载,没问题
print(user.posts) # ❌ DetachedInstanceError!
原因链条:user.posts 是懒加载 → 访问时需要发 SQL → 发 SQL 需要 Session → Session 已经关了 → 抛异常。
三种解法:
# 解法1(推荐):关 session 前把要用的关系预加载好
def get_user(user_id: int):
with SessionLocal() as session:
stmt = select(User).options(selectinload(User.posts)).where(User.id == user_id)
return session.scalar(stmt)
# 解法2:调整代码结构,在 session 存活期间用完对象
with SessionLocal() as session:
user = session.get(User, 1)
print(user.posts) # session 还活着,没问题
# 解法3(FastAPI 的方案,第6章见):让 session 的生命周期覆盖整个请求处理过程
七、实战检查清单
写完一个带关系的查询后,问自己三个问题:
- 循环里访问了关系属性吗? → 有就必须加
options(selectinload/joinedload) - 对象会在 session 关闭后使用吗? → 会就把要用的关系预加载好
- echo 日志里 SQL 条数正常吗? → 列表接口的 SQL 条数应该是常数(2~4条),不该随数据量线性增长
💡 开发阶段保持
echo=True并瞄一眼 SQL 数量,是性价比最高的性能习惯。
📝 本节小结
- 懒加载默认策略 + 循环访问关系 = N+1 问题(1 条变 101 条 SQL)
- 解药:
options(selectinload(User.posts))——列表关系用 selectinload,单对象关系用 joinedload - 可嵌套:
selectinload(User.posts).selectinload(Post.tags) DetachedInstanceError= session 关闭后访问未加载的关系;解法是提前预加载- 上线前看一眼 echo 日志的 SQL 条数
表关系全部学完!进入进阶篇 → 5.1 连接查询与聚合统计