☰
SQLAlchemy relationship与加载策略:避开N+1查询的坑
2026/10/22 11:35:05 网站建设 项目流程

做了这么多年 Python Web 开发,SQLAlchemy 的relationship和加载策略(Loader Strategies,有人直译为“装载机策略”)是我见过最容易写顺手、也最容易埋雷的部分。这个系列写到第四篇,前面聊了连接与会话、核心查询、事务边界之后,这一篇专门把“相关对象”和“加载策略”从头到尾捋一遍。无论你是刚接触 ORM 的新人,还是已经在生产环境踩过 N+1 坑的老手,这篇文章要讲清楚四件事:relationship到底在映射什么、四种关系类型怎么选、五种加载策略分别在什么场景下用、以及最常见的几个坑为什么会出现、怎么排查。

1. relationship 相关对象:ORM 的对象纽带

1.1 外键和 relationship 不是一回事

很多刚接触 SQLAlchemy 的同学会把ForeignKey和relationship()混为一谈。从数据库角度看,外键约束解决的是“数据完整性”,比如不允许插入一个不存在的user_id。但从代码角度看,外键只是一个字段,它不会自动让你在 Python 里通过user.posts拿到这个用户的所有文章。

relationship()做的事情,是在两张表的 Python 对象之间建立一条虚拟的导航路径。它不一定需要数据库里真的存在外键约束,反过来也一样:数据库里可以有外键,但 ORM 里不声明relationship,那你永远只能手工拿着user_id再去查一遍。我见过一些遗留系统,数据库外键建得很完整,但 ORM 模型里全是裸字段,代码到处是二次查询,这就是没理解relationship的价值。

换句话说,ForeignKey是“库层面的约束”,relationship()是“对象层面的导航”。最佳实践是两者同时存在:数据库保证不出脏数据,ORM 保证代码不写重复查询。

1.2 双向关系与 back_populates

定义双向关系时,最规范的做法是用back_populates显式把两边的属性互相指认:

from sqlalchemy import ForeignKey, String 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) name: Mapped[str] = mapped_column(String(50)) posts: Mapped[list["Post"]] = relationship(back_populates="user") class Post(Base): __tablename__ = "posts" id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(200)) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) user: Mapped["User"] = relationship(back_populates="posts")

back_populates的好处是语义清晰,两个方向的配置各自独立,不会像backref那样隐式生成另一侧属性,排查问题时少一层魔法。我记得有一次排查线上数据不一致,发现是因为某个模型用了backref,另一处又手动定义了同名关系,两边冲突,表现出来就是莫名其妙的多余 UPDATE。后来全部改成back_populates,再没出过这类问题。

backref并不是不能用,它的便捷性在小项目里很诱人。但一旦模型复杂、关系变多,隐式生成的一方很容易让人忽略它的参数其实已经被默认值填充了。这里我建议团队规范里直接禁掉backref,统一back_populates,收益远大于多写几行代码的成本。

1.3 对象关系的四种基本形态

relationship()根据uselist和secondary参数,可以表达四种关系:

关系类型配置方式典型场景
多对一默认uselist=False标量属性文章属于作者
一对多Mapped[list[...]]集合属性作者拥有文章列表
一对一uselist=False+ 唯一外键用户与个人资料
多对多secondary指定中间表文章与标签

一对多和多对一其实是同一枚硬币的两面。User.posts是一对多,Post.user是多对一。真正需要注意的是一对一,它需要在被引用表那一侧的外键上加unique约束,否则数据库层面无法保证“唯一”,ORM 端即使设了uselist=False,查出来多条记录时也只会取第一条,这种隐患非常隐蔽。

多对多用secondary参数指向一张中间表,这张表只存两边的外键,也可以存额外字段。如果中间表需要存“关联时间”“备注”这类业务字段,那就不能只用裸的Table,而应该定义一个完整的关联模型,也就是关联对象模式。这个区别在面试里经常问,实际开发里也确实是分水岭:关联对象模式写起来更重,但扩展性远比裸中间表强。

2. 加载策略全景:lazy 参数的五种选择

2.1 lazy="select":默认的懒加载与 N+1

relationship()默认的lazy="select",意思是:当你访问user.posts时,SQLAlchemy 才发一条 SELECT 去把文章查出来。这在单个对象上没有毛病,但放在循环里就是灾难:

users = session.scalars(select(User)).all() for user in users: print(len(user.posts)) # 每个 user 都会触发一条 SELECT

假设有 100 个用户,这一小段代码最终会发出 1 + 100 条 SQL。这就是臭名昭著的 N+1 查询问题:1 次主查询加上 N 次附加查询。架构上,数据库连接和网络往返的开销往往被低估,等到并发一上来,这种写法会直接把数据库连接池打满。

我经常跟团队讲一句话:默认懒加载只适合“你确定访问不到那个属性”的场景。只要代码里存在循环访问集合属性,就必须考虑换成下面几种预加载策略。

2.2 lazy="joined":一条 SQL 搞定,但要小心笛卡尔积

lazy="joined"使用 LEFT OUTER JOIN 把关联对象和主对象一次性查出来。对于多对一、一对一这种关联对象至多一个的关系,这是最高效的策略,因为数据行不会翻倍:

class Post(Base): __tablename__ = "posts" user: Mapped["User"] = relationship(back_populates="posts", lazy="joined")

查询select(Post)时,SQLAlchemy 直接用 JOIN 把user字段带上,一条 SQL 返回全部数据,不需要二次查询。

但joined对一对多和集合关系就不友好了。因为父对象是一行,关联子对象是多行,JOIN 之后父对象的数据会在每一行里重复出现。嵌套两层一对多之后,行数会变成多个集合大小的乘积,也就是笛卡尔积。我曾在一个报表查询里看到 100 条主记录关联了 50 条明细又关联了 20 条标签,结果返回 10 万行,内存直接吃满。这种场景下joined显然不是好选择。

2.3 lazy="selectin":集合关系的最佳默认解

lazy="selectin"是我在 2.0 时代用得最多的策略。它的执行方式是:先执行主查询把父对象全部拿到,然后根据父对象的主键列表,发一条WHERE id IN (...)的 SELECT 把所有关联对象一次性查回来。整个过程固定两条 SQL,不会像joined那样产生笛卡尔积:

class User(Base): __tablename__ = "users" posts: Mapped[list["Post"]] = relationship( back_populates="user", lazy="selectin" )

就算嵌套三层集合关系,selectin每一层也只发一条 IN 查询,SQL 数量和主表数量基本是 1:1,不会指数级膨胀。这是它对joined最大的优势。

selectin的代价在于:它要求模型必须有主键,因为要先拿到父对象主键列表才能构造 IN 查询。如果一个模型连主键都没定义,SQLAlchemy 会直接报错。另一个隐性问题是它会一次性加载全部关联对象,如果某个集合有几十万行,即使你只想要前几条,它也会全部拉进内存。对于这种超大集合,应该用lazy="dynamic"或者干脆不定义集合属性。

2.4 lazy="subquery" 与 dynamic:各自的使用边界

lazy="subquery"是selectin出现之前的旧方案。它的执行方式同样分两步查询,但不是用主查询结果去构造 IN,而是把主查询包成一个子查询,再 JOIN 关联表。在小数据量下和selectin差别不大,但嵌套子查询在某些数据库上性能不稳,而且 SQL 可读性较差。新项目我基本不会再选它,除非你维护的老代码里已经写死了,那也建议逐步迁移到selectin。

lazy="dynamic"返回的不是列表,而是一个类似查询句柄的AppenderQuery对象,可以继续链式调用.filter()、.order_by()等操作:

class User(Base): __tablename__ = "users" posts: Mapped[list["Post"]] = relationship( back_populates="user", lazy="dynamic" ) # 用法 recent_posts = user.posts.filter(Post.created_at >= some_time).all()

dynamic的好处是永远不会因为访问属性而加载整个集合,适合“用户可能有海量文章但通常只取其中一小部分”的场景。坑也很明显:动态关系返回的不是普通列表,很多 ORM 特性都不支持,比如session.delete(user.posts)这种批量操作会失效;而且在序列化场景下容易误触发懒加载。我现在只会在确实存在超大集合且分页明确的模型上使用它。

2.5 raise 模式:主动让 N+1 崩出来

lazy="raise"和lazy="raise_on_sql"是检测 N+1 的利器。访问未加载的关系时,raise_on_sql会在 SQLAlchemy 即将发出 SQL 的瞬间抛出异常。把这个策略用在模型关系上,配合测试环境跑一遍接口,凡是会触发懒加载的路径都会立刻暴露:

class Post(Base): __tablename__ = "posts" user: Mapped["User"] = relationship( back_populates="posts", lazy="raise_on_sql" )

我在一个老项目里干过类似的事:把疑似出问题的几个关系临时改成raise_on_sql,然后跑一轮压测,日志里立刻蹦出十几个触发点。修完之后再改回性能最优的策略,效率比肉眼审查代码高出太多。生产环境不建议长期开启,因为有些路径确实需要懒加载,但作为巡检工具,这个模式极其好用。

3. 实操:从建模到加载策略选型

3.1 一个真实的博客模型

空谈不太容易落地,我拿一个典型的博客系统来做完整演示。三张核心表:用户、文章、标签,其中文章和标签是多对多关系,用户和文章是一对多关系。

from datetime import datetime from sqlalchemy import Column, DateTime, ForeignKey, String, Table, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass post_tags = Table( "post_tags", Base.metadata, Column("post_id", ForeignKey("posts.id"), primary_key=True), Column("tag_id", ForeignKey("tags.id"), primary_key=True), ) class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(50)) posts: Mapped[list["Post"]] = relationship( back_populates="user", cascade="all, delete-orphan", passive_deletes=True, ) class Post(Base): __tablename__ = "posts" id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] = mapped_column(String(200)) created_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now() ) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE") ) user: Mapped["User"] = relationship(back_populates="posts") tags: Mapped[list["Tag"]] = relationship( secondary=post_tags, back_populates="posts", lazy="selectin", ) class Tag(Base): __tablename__ = "tags" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(50), unique=True) posts: Mapped[list["Post"]] = relationship( secondary=post_tags, back_populates="tags", )

这里我用cascade="all, delete-orphan"表示删除用户时同步处理其文章,passive_deletes=True则是提示 ORM:数据库已经设置了ON DELETE CASCADE,不用再手工发出 UPDATE 把外键置空。这个组合能有效避免删除大集合时的逐条操作性能问题。

3.2 查询级覆盖:options 的妙用

模型上的lazy是全局默认,但你完全可以在具体查询里用options()覆盖它,这是加载策略最灵活的地方。假设模型里User.posts默认是selectin,但你这次查询只需要用户列表而不想加载文章,就可以手动关掉:

from sqlalchemy.orm import lazyload, selectinload, joinedload # 只查用户,不加载文章 users = session.scalars( select(User).options(lazyload(User.posts)) ).all() # 查文章,并且连用户和用户的其他文章一起加载 stmt = ( select(Post) .options( joinedload(Post.user), selectinload(Post.user).selectinload(User.posts), ) ) posts = session.scalars(stmt).all()

嵌套加载的写法很容易让人头晕,我一般用缩进加注释的方式来组织,避免把selectinload和joinedload混在同一层造成重复加载。另外记住一点:如果同一个关系被多个选项同时命中,SQLAlchemy 会采用后写的那个策略,调试代码时要留意顺序。

3.3 通过 SQL 日志理解加载行为

学习加载策略最直接的方法,是把引擎的echo打开,观察真实发出的 SQL 语句。演示一下三种查询的效果。

默认懒加载,循环访问属性:

users = session.scalars(select(User)).all() for user in users: print(len(user.posts))

日志会看到SELECT ... FROM users之后,跟着 N 条SELECT ... FROM posts WHERE posts.user_id = ?。

改用selectinload:

users = session.scalars( select(User).options(selectinload(User.posts)) ).all()

日志变成两条:先查 users,再查WHERE posts.user_id IN (?, ?, ?, ...)。数据量越大,这种策略的优势越明显。

改用joinedload查多对一:

posts = session.scalars( select(Post).options(joinedload(Post.user)) ).all()

日志只有一条,一条 JOIN 全出来了。多对一场景下这是最优解。

我把一个经验列在这里:多对一和一对一,优先joinedload;一对多和多对多,优先selectinload。这条经验覆盖了 80% 以上的业务场景,剩下的再用dynamic或raise_on_sql去补充。

4. 常见问题与排查技巧实录

4.1 最经典的 N+1:怎么快速定位

N+1 的问题不只出现在循环里,序列化时也经常发生。比如把用户列表转 JSON,序列化工具访问了user.posts,然后又访问了post.tags,一眼望去全是懒加载。

定位办法有三个:

  • 调大日志级别,把 SQLAlchemy 的echo打开,统计一次请求发出的 SQL 条数。如果条数和数据量明显不成比例,基本可以确定存在 N+1。
  • 用lazy="raise_on_sql"临时替换可疑关系,让隐患直接以异常形式暴露出来,跑一遍测试接口,报错路径就是问题路径。
  • 第三方工具如sqlalchemy-toolbar或elastic-apm,能自动统计请求对应的 SQL 执行数量,加到开发环境里即可。

我在生产环境见过最隐蔽的 N+1:主查询只涉及十几条记录,看起来不严重,但因为每条记录里都嵌套访问了三级关系,实际发出了一百多条查询。接口响应时间从 80 毫秒飙到 1.8 秒,而问题代码就写在一个不起眼的列表推导式里。

4.2 笛卡尔积:joinedload 的副作用

joinedload一对多场景下的行数膨胀,比 N+1 更隐蔽,因为 SQL 日志看去只有一条,但应用层接收到的行数是相乘后的结果。如果外层还用了分页,那分页会把 JOIN 后的行数当总数,导致所有页都出现同一批父记录。

排查技巧:在应用层打印实际返回的 ORM 对象数量,再和数据库统计行数对比。如果两者不一致,就说明 JOIN 导致了行重复。解决方式很直接:一对多集合不要用joinedload,改用selectinload;如果非要用 JOIN 并且有去重需求,可以考虑Query.distinct(),但要小心分页函数在数据库层面的行为。

4.3 序列化循环:User 里面有 posts,posts 里面又有 user

几乎所有初用relationship的人都会撞上一次循环引用:JSON 序列化user时,连带序列化user.posts,每篇post又序列化post.user,然后无限递归下去。

解决思路有几种:

  • 序列化服务里手动指定字段白名单,永远不自动展开整个关系图。这是最稳定的做法。
  • 定义 DTO(数据传输对象),单独组装接口要返回的字典,而不是直接把 ORM 对象交给序列化库。
  • 如果用了某些支持深度限制的序列化库,可以限制递归深度,但这只是把问题延后。

我个人的态度很明确:ORM 对象是给业务逻辑用的,不是给外部接口用的。生产项目里,我会在写接口时增加 DTO 层,把“数据模型”和“传输模型”解耦,这样无论关系怎么变,接口的返回结构始终可控。

4.4 删除大集合:cascade 和 passive_deletes 的配合

直接session.delete(user)并且User.posts配置了cascade="all, delete-orphan"时,SQLAlchemy 默认会先把所有子对象查出来再逐个 DELETE。如果这个用户有一百万篇文章,这条删除操作会直接把数据库打挂。

正确做法是同时配置passive_deletes=True,并把数据库外键设置为ON DELETE CASCADE。这样删除用户时,ORM 只发出DELETE FROM users WHERE id=?,数据库自己负责把关联文章删掉。如果需要保留一些审计记录,只把外键设为SET NULL,效果也是同样的逻辑:让数据库去处理关联字段,而不是让 ORM 一条条查再一条条改。

4.5 全局默认 lazy 的配置建议

如果项目大量使用懒加载导致 N+1,可以考虑在关系定义时把集合关系的默认lazy改为selectin,而不是靠每个查询手工写options()。这样虽然牺牲了一点灵活性,但能保证大多数路径不会踩 N+1 的坑。

代价也要说清楚:selectin是“查了就用”,如果业务上经常只取主对象而不关心集合属性,那每一条主查询都会附加一次集合查询,浪费反而是多余的了。我的做法是:默认保持lazy="select",在建模阶段就逐条审视每个relationship,凡是明确会在列表页、详情页被访问的关系,直接在模型上指定lazy="selectin"或lazy="joined";凡是超大集合或很少访问的关系,保留懒加载或改用dynamic。把关系属性当成接口的一部分来设计,而不是写完模型就不管了。

5. 性能选型速查与总结

最后放一张速查表,这是我每次评审代码都会对照的标准:

场景推荐策略SELECT 次数注意点
多对一 / 一对一joinedload1集合场景慎用
一对多 / 多对多(常规)selectinload1 + 集合数需有主键
嵌套多级集合selectinload 逐层每层 1 条最省心
超大集合dynamic / 分页按需不要整集合加载
排查 N+1raise_on_sql触发即报错测试环境用
明确不加载lazyload0覆盖默认策略

我个人在实际操作中的体会是,加载策略选型和业务接口的返回结构强相关。你如果知道这个接口要返回什么,就知道该用什么策略。不要把关系映射当成数据库结构的复刻,它是你业务读取路径的一部分。建模之前先列一遍所有高频查询,再回头定义relationship的lazy参数,会少走很多弯路。

还有一个很容易被忽略的小技巧:在 CI 环境里跑一个“SQL 条数断言”,对关键接口的 SQL 数量做上限检查。这比任何代码评审都有效,因为数字会说话——某次有人往一个接口里塞了一个循环访问懒加载属性,CI 直接让 SQL 数量从 2 变成 30,立刻打回,问题根本到不了生产环境。

这个系列后续如果有时间,我打算专门写一篇关于cascade生命周期和会话粒度控制的文章。加载策略只是关系映射的一半,另一半是数据到底怎么变成 INSERT、UPDATE、DELETE,那些坑不比 N+1 少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询