从 N+1 到 joinedload:理解 SQLAlchemy 关系加载的核心机制
从 N1 到 joinedload理解 SQLAlchemy 关系加载的核心机制文章摘要本文围绕 SQLAlchemy 一对多关系加载展开重点解释relationship、lazyloading、selectinload、joinedload、N1问题以及joinedload加载集合关系时为什么需要unique()。前言这篇文章解决什么问题在使用 FastAPI 和 SQLAlchemy 开发后端项目时经常会遇到一类问题两个表之间有一对多关系比如一个用户对应多条学习记录那么在代码中应该如何查询用户及其关联记录SQLAlchemy 提供了relationship来描述 ORM 对象之间的关系也提供了多种关系加载方式例如lazy loading selectinload joinedload这些方式不仅影响 SQL 查询语句的写法也会影响 ORM 如何把数据库返回的 SQL 行还原成 Python 对象。这篇文章重点整理以下几个问题relationship到底是什么lazy loading 为什么容易产生 N1 问题selectinload和joinedload有什么区别为什么joinedload加载一对多集合时通常需要unique()unique()是不是 SQL 里的DISTINCT问题背景一对多关系中的对象导航假设系统中有两个 ORM 模型User用户LearningRecord学习记录一个用户可以有多条学习记录所以这是一个典型的一对多关系。在 SQLAlchemy ORM 中通常会用类似下面的方式进行对象导航user.records record.user其中user.records表示从一个用户对象访问它的所有学习记录record.user表示从一条学习记录访问它所属的用户。需要注意的是relationship不是数据库中的真实字段。真实存在于数据库表中的通常是外键列例如learning_records.user_id而User.records这种属性是 ORM 层提供的对象关系导航能力。它方便我们在 Python 代码里用对象的方式访问关联数据但它本身不是数据库表里的字段。核心概念解释1. relationshipORM 对象之间的关系描述relationship的作用是告诉 SQLAlchemy两个 ORM 模型之间存在什么关系以及如何在 Python 对象之间进行导航。例如user.records它表达的是通过一个User对象可以访问它关联的多条LearningRecord对象。但是数据库真正识别关系依赖的是外键列比如learning_records.user_id - users.id所以可以这样理解数据库层靠外键列建立表关系 ORM 层靠 relationship 实现对象导航这两个概念有关联但不能混为一谈。2. lazy loading用到关系属性时再查lazy loading可以理解为“懒加载”。它的特点是查询父对象时不会立刻把关联对象也查出来。只有当代码真正访问关系属性时SQLAlchemy 才会再发送 SQL 查询。例如下面这句代码userssession.scalars(select(User)).all()执行完以后SQLAlchemy 只查询了User表中的数据。此时每个user对象中的普通字段已经有值比如user.iduser.name但是records这个关系属性还没有真正加载。这里容易产生一个误解既然还没有加载那user.records是不是None答案是不是。在 lazy loading 模式下未访问前的user.records不是None也不是一个已经查好的普通 Python 列表。更准确地说它是一个由 SQLAlchemy ORM 管理的“未加载关系属性”。可以这样理解user 对象已经创建 user.id、user.name 等普通字段已经加载 user.records 这个 relationship 还处于未加载状态也就是说此时 SQLAlchemy 知道User和LearningRecord之间存在关系但还没有真正去数据库中查询这个用户对应的记录。只有当代码第一次访问print(user.records)SQLAlchemy 才会发现当前访问的是一个尚未加载的 relationship。于是它会额外发送一条 SQL 查询大致相当于SELECT...FROMlearning_recordsWHERElearning_records.user_id当前用户的 id;查询完成后SQLAlchemy 会把结果填充到user.records中。如果这个用户有学习记录user.records会表现为一个包含多个LearningRecord对象的集合[LearningRecord(id10),LearningRecord(id11),]如果这个用户没有任何学习记录访问之后通常会得到空集合[]而不是None。所以 lazy loading 的关键点不是“关系属性不存在”而是关系属性存在但关联数据暂时没有加载 第一次访问关系属性时SQLAlchemy 才去数据库查询。再看下面这段代码userssession.scalars(select(User)).all()foruserinusers:print(user.records)表面上看这段代码只是查询用户然后打印每个用户的学习记录。但如果有 10 个用户实际 SQL 查询次数可能是1 次查询 users 10 次查询每个 user 对应的 records也就是总查询次数 1 N这就是常说的 N1 问题。N 表示父对象数量。用户越多额外查询次数越多性能问题越明显。因此lazy loading 的执行过程可以概括为查询 User ↓ 只加载 User 的普通字段 ↓ records 关系属性处于未加载状态 ↓ 第一次访问 user.records ↓ SQLAlchemy 额外查询该用户对应的 records ↓ 把查询结果填充到 user.records3. selectinload先查父对象再批量查子对象selectinload是一种预加载方式。它的核心思想是先查 users 再用 WHERE IN (...) 批量查询这些 users 对应的 records示例fromsqlalchemyimportselectfromsqlalchemy.ormimportselectinload stmtselect(User).options(selectinload(User.records))userssession.scalars(stmt).all()如果查到了 10 个用户SQL 查询次数通常是1 次查询 users 1 次批量查询 records也就是总查询次数 2相比 lazy loading 的1 Nselectinload可以有效避免 N1 问题。它适合一对多、多对多这类集合关系因为它不会把父表和子表强行 JOIN 成一张很宽的结果集而是用第二次查询批量拿到子对象。4. joinedload用 JOIN 一次性加载关联对象joinedload也是一种预加载方式。它的特点是使用 JOIN 风格把父对象和关联对象放在同一次 SQL 查询中加载。示例fromsqlalchemyimportselectfromsqlalchemy.ormimportjoinedload stmtselect(User).options(joinedload(User.records))userssession.scalars(stmt).unique().all()joinedload(User.records)会把users和learning_records放到同一次查询中。假设一个用户有两条学习记录底层 SQL 结果可能类似users.id | users.name | records.id | records.title 1 | Alice | 10 | SQLAlchemy CRUD 1 | Alice | 11 | Relationship Loading从 SQL 的角度看这两行不是重复行因为records.id不一样。但是从select(User)的角度看这两行都对应同一个父对象User primary_key(1,)也就是说父对象在 SQL 结果中被展开成了多行。代码示例joinedload 与 unique() 的底层过程重点来看这段代码stmtselect(User).options(joinedload(User.records))userssession.scalars(stmt).unique().all()这段代码的目标是查询用户并且同时加载每个用户的records集合。底层过程可以用下面的模型理解SQL 行 - identity map 识别对象身份 - relationship loader 填充关系集合 - Result 返回对象第一步SQL 返回多行如果User(id1)有两条学习记录SQL 结果会出现两行users.id | users.name | records.id | records.title 1 | Alice | 10 | SQLAlchemy CRUD 1 | Alice | 11 | Relationship Loading这不是 SQL 层面的重复数据而是一对多 JOIN 后的正常展开结果。第二步identity map 复用同一个 User 对象SQLAlchemy 不会简单地为这两行分别创建两个独立的User(id1)对象。更准确的过程是第 1 行 identity map 发现或创建 User(id1) relationship loader 把 Record(id10) 加入 User(id1).records 第 2 行 identity map 发现这还是 User(id1)复用同一个 Python 对象 relationship loader 把 Record(id11) 加入同一个 User(id1).records这里的identity map可以理解为 Session 内部维护的对象缓存。它会根据实体类型 主键值来判断一个 ORM 对象是否已经存在。例如(User, 1) (User, 2)如果发现当前行对应的还是(User, 1)SQLAlchemy 就会复用之前那个 Python 对象而不是重新创建一个新的用户对象。第三步relationship loader 填充集合虽然两行 SQL 都对应同一个User(id1)但是每一行里的Record不同。所以 relationship loader 会把不同的记录对象加入同一个集合中same_user.records[LearningRecord(id10),LearningRecord(id11),]这一步说明一个关键点unique()不会导致 records 丢失因为 records 的装配已经在 ORM 处理 SQL 行时完成了。第四步Result 结果流中仍然可能出现重复 User 引用在调用unique()之前逻辑上的结果流可能类似[same_user,same_user,]这两个位置指向的是同一个 Python 对象而不是两个各自只带一条 record 的对象。所以unique()的作用是把最终结果流中的重复父对象引用过滤掉[same_user,same_user,]变成[same_user,]因此unique()处理的是 ORM Result 结果层面的重复对象引用不是数据库 SQL 层面的重复行。scalars、execute 与 unique() 的关系在 SQLAlchemy 2.x 风格中下面两种写法有对应关系session.scalars(stmt)可以大致理解为session.execute(stmt).scalars()对于下面这条语句stmtselect(User).options(joinedload(User.records))execute()得到的逻辑结果是 row(User(id1),)(User(id1),)scalars()会取每个 row 的第一个元素于是得到User(id1)User(id1)所以这段代码userssession.scalars(stmt).unique().all()表达的是查询 User并对最终的 User 结果流去重也可以写成userssession.execute(stmt).unique().scalars().all()在select(User)这个场景里session.scalars(stmt).unique().all()更直接表达了意图我要的是去重后的User列表。常见误区误区一relationship 是数据库字段relationship不是数据库里的真实字段。例如user.records这是 ORM 层的对象关系属性。数据库中真正保存关系的通常是外键列例如learning_records.user_id因此不能把relationship理解成数据库表中的一列。误区二joinedload 会创建多个独立的父对象当一个用户有多条记录时JOIN 后确实会出现多行User 1 Record 10 User 1 Record 11但 SQLAlchemy 的 identity map 会根据实体类型和主键复用同一个User对象。所以它不是创建两个独立的User(id1)而是复用同一个对象并把多条记录装配到同一个records集合中。误区三unique() 会丢掉子对象unique()只过滤最终结果流里的重复父对象引用。它不会删除已经装配到user.records中的子对象。也就是说userssession.scalars(stmt).unique().all()最终users里只会出现一个User(id1)但这个用户的records仍然可以包含多条记录。误区四unique() 等于 SQL DISTINCTunique()不是 SQL 里的DISTINCT。DISTINCT是数据库层面的去重比较的是 SQL 查询结果。而unique()是 SQLAlchemy ORM 结果层面的去重主要依据实体类型 主键值对 ORM 对象身份进行过滤。对于joinedload(User.records)这种一对多集合预加载来说SQL 行必须展开成多行ORM 才能看到每一条子记录。unique()只是避免最终结果列表中重复返回同一个父对象。对比总结加载方式查询方式查询次数适用场景注意点lazy loading用到关系属性时再查可能是1 N少量对象、关系属性不一定会用到容易产生 N1selectinload先查父对象再批量查子对象通常是 2 次一对多、多对多集合关系常用于避免 N1joinedloadJOIN 一次性加载通常是 1 次一对一、多对一或需要 JOIN 预加载的场景加载集合关系时通常需要unique()对于一对多集合关系selectinload往往更容易理解也更适合作为默认选择。joinedload的优势是可以通过一次 JOIN 查询把数据带出来但它会造成父对象在 SQL 结果中被多行展开所以在 SQLAlchemy 2.x 中加载集合关系时通常需要配合unique()使用。实战建议在 FastAPI 和 SQLAlchemy 项目中可以按下面的思路选择加载方式。如果只是查询用户信息不一定需要学习记录可以保持默认的 lazy loadingstmtselect(User)userssession.scalars(stmt).all()如果接口需要返回用户及其学习记录并且用户数量可能不止一个优先考虑selectinloadstmtselect(User).options(selectinload(User.records))userssession.scalars(stmt).all()如果明确希望通过 JOIN 一次性加载关系并且加载的是一对多集合关系要记得使用unique()stmtselect(User).options(joinedload(User.records))userssession.scalars(stmt).unique().all()如果忘记unique()在 SQLAlchemy 2.x 中针对 joined eager loading 加载集合关系的场景通常会出现要求调用unique()的错误提示。这里需要注意具体 SQL 形态可能会受到数据库类型、SQLAlchemy 版本、查询条件和项目配置影响。如果要分析性能应结合实际打印出来的 SQL 和接口查询次数确认。总结SQLAlchemy 的关系加载方式本质上是在解决一个问题如何把关系型数据库中的多行数据正确还原成 Python 中的对象和对象关系。relationship负责 ORM 对象之间的导航但它不是数据库字段。数据库层面的表关系通常依赖外键列。lazy loading是用到关系属性时再查询简单但容易产生 N1 问题。selectinload会先查询父对象再批量查询子对象通常可以把1 N次查询压缩为 2 次查询适合一对多集合关系。joinedload使用 JOIN 一次性加载关联数据但在一对多场景中一个父对象会因为多条子记录在 SQL 结果中展开成多行。SQLAlchemy 会通过 identity map 复用同一个父对象再由 relationship loader 把子对象装配进集合属性。unique()的作用是过滤最终 ORM 结果流中重复出现的父对象引用。它不是 SQLDISTINCT也不会丢掉已经装配好的子对象。理解这条链路很重要SQL 行 - identity map 识别对象身份 - relationship loader 填充关系集合 - Result 返回对象掌握这条链路后再看lazy loading、selectinload、joinedload和unique()就不会只停留在“写法不同”的层面而是能理解 ORM 背后如何组织查询结果。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2610553.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!