跳到主要内容

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 怎么选?

selectinloadjoinedload
原理额外发一条 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 的生命周期覆盖整个请求处理过程

七、实战检查清单

写完一个带关系的查询后,问自己三个问题:

  1. 循环里访问了关系属性吗? → 有就必须加 options(selectinload/joinedload)
  2. 对象会在 session 关闭后使用吗? → 会就把要用的关系预加载好
  3. 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 连接查询与聚合统计