TypeORM 关系加载策略全解:Eager 即时加载与 Lazy 懒加载(Promise 模式)
本篇围绕 TypeORM 官方文档 5-eager-and-lazy-relations.md 展开,系统讲解关系实体加载的两大内置策略:**Eager relations(即时/急切加载)**与 Lazy relations(懒加载)。你将掌握如何用 eager: true 让 find* 查询自动携带关联数据、如何通过 relationLoadStrategy 在 JOIN 与独立查询之间取舍、如何用 loadEagerRelations 精确控制加载行为,以及基于 Promise 类型的懒加载关系在保存与读取时的正确用法,并深入源码理解其底层 JOIN 推导与 Promise 封装原理。文中代码与结论均可在当前 TypeORM 仓库中直接验证与复现。
一、两种关系加载模式概览
在 TypeORM 中,实体之间的关系(@OneToOne、@ManyToOne、@OneToMany、@ManyToMany)默认是"惰性"的——仅当你显式 join 或配置加载选项时才会取回数据。为减少手工编写关系加载代码,TypeORM 提供了两种内建模式:
- Eager relations(即时加载):每次从数据库加载实体时,关联数据被自动一起取出,无需显式指定。
- Lazy relations(懒加载):关联数据在你访问该属性时才被加载,属性类型必须声明为
Promise。
两种模式都建立在同一个核心类之上:实体的元数据 EntityMetadata 中维护了 eagerRelations: RelationMetadata[] 数组(见 src/metadata/EntityMetadata.ts),凡是声明为 eager 的关系都会被收集进该列表,供后续查询装配逻辑读取。
二、Eager relations:让关联随主实体自动加载
2.1 声明方式与核心示例
在关系装饰器的选项对象中传入 eager: true 即可声明为 eager。以文档中的 Question/Category 多对多场景为例,双向关系中只需要在希望自动加载的一侧开启 eager:
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from "typeorm"
import { Question } from "./Question"
@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@ManyToMany((type) => Question, (question) => question.categories)
questions: Question[]
}
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToMany,
JoinTable,
} from "typeorm"
import { Category } from "./Category"
@Entity()
export class Question {
@PrimaryGeneratedColumn()
id: number
@Column()
title: string
@Column()
text: string
@ManyToMany((type) => Category, (category) => category.questions, {
eager: true,
})
@JoinTable()
categories: Category[]
}
这里的要点是:Question 侧通过选项 { eager: true } 声明了其对 Category 的关系为 eager,而 Category 侧保持普通关系。查询时不需要再 join 或声明要加载哪些关系:
const questionRepository = dataSource.getRepository(Question)
// questions 会连同其 categories 一起被加载
const questions = await questionRepository.find()
对 find、findOne、findBy、findAndCount 等 find* 系列方法而言,Eager 关系都会被自动装配进查询。
2.2 三条硬性约束
Eager relations 并非万能开关,使用上受以下规则限制:
-
只对
find*方法生效:repository.find()、manager.find()等find*系列会自动加载 eager 关系;而一旦你使用QueryBuilder,eager relations 默认被禁用,必须显式调用leftJoinAndSelect(或innerJoinAndSelect)才能加载该关系。其原因是QueryBuilder给予了开发者对 SQL 的完全控制权,TypeORM 不再替你注入隐式 join。这一判断在 src/query-builder/SelectQueryBuilder.ts 等处的逻辑中得到体现:只有主查询流程且满足条件时才应用 eager join。 -
只允许在关系的一侧声明:对同一条关系,在两侧同时使用
eager: true是被禁止的。否则会导致递归加载(例如 A eager B、B eager A 时无限嵌套),TypeORM 会在元数据构建阶段对这种情况进行拦截。 -
eager 与
relations显式声明可以并存:即使关系被标记为 eager,你在find选项里仍然可以显式列出它,二者不会冲突(详见下文loadEagerRelations一节)。
2.3 默认 LEFT JOIN 与自动升级的 INNER JOIN
官方文档明确指出:默认情况下,eager relations 使用 LEFT JOIN 加载;而如果该关系同时满足两个条件——nullable: false 且拥有连接列(即关系的拥有方,典型如 @ManyToOne 或拥有方的 @OneToOne)——TypeORM 会改用 INNER JOIN,从而可能产生更高效的查询计划。
这一行为在 src/find-options/FindOptionsUtils.ts 的 getRelationJoinType 静态方法中有精确实现,其决策逻辑为:
- 若父级 join 类型为
LEFT,则所有后代关系必须继承LEFT——否则会把父别名(parent alias)为NULL的行过滤掉,导致数据丢失; - 当关系满足
!relation.isNullable && relation.isWithJoinColumn(不可空且拥有 join 列)时,进一步检查目标实体是否含软删除列(deleteDateColumn):- 若目标实体没有软删除列,或当前查询开启了
withDeleted,则返回"inner"(使用 INNER JOIN); - 否则仍然返回
"left"。
- 若目标实体没有软删除列,或当前查询开启了
随后 joinEagerRelations 递归遍历 metadata.eagerRelations,为每个 eager 关系生成基于驱动别名规则的关系别名(如 Question__categories),判断是否已存在同类 join/select(避免重复 join),再依据 getRelationJoinType 的结果调用 innerJoin/leftJoin 并把别名加入 addSelect,最后递归处理嵌套实体的 eager 关系,形成可多层的自动关联树。文档中"它们会被自动加载"的承诺,正是由这条递归装配链实现的。
2.4 关系加载策略(Relation Load Strategy):join 与 query
如果实体层级很深、嵌套 join 过多,单条 SQL 会膨胀为笛卡尔积式的大结果集(尤其 OneToMany/ManyToMany 场景会产生行倍增),带来网络与解析开销。为此 TypeORM 引入 relationLoadStrategy 选项,提供两种策略:
| 策略 | 行为 | 适用场景 |
|---|---|---|
"join"(默认) |
eager/指定关系通过向主查询追加 SQL JOIN 一次性取出 | 关系层级较浅、数据量可控 |
"query" |
关联数据通过额外的独立数据库查询分别加载 | 嵌套 join 过深、一次性数据量过大 |
两种方式均可在单次查询与 DataSource 全局默认两个粒度配置:
// 逐查询指定
const questions = await questionRepository.find({
relationLoadStrategy: "query",
})
// 或将 "query" 设为整个 DataSource 的默认策略
const dataSource = new DataSource({
// ... 其余连接配置
relationLoadStrategy: "query",
})
从源码看,relationLoadStrategy 会被写入查询表达式状态 expressionMap.relationLoadStrategy(见 src/query-builder/SelectQueryBuilder.ts),并在两处分叉:
- 在选项解析阶段,当策略为
"query"时,关系不再通过leftJoinAndSelect立即装配,而是调用concatRelationMetadata(...)把关系元数据收集起来(见 src/find-options/FindOptionsUtils.ts),留待主查询结束后统一以附加查询加载; - 在主查询完成后,若策略为
"query",会创建QueryStrategyRelationIdLoader并以递归方式对每个 eager 关系发起独立查询(见 src/query-builder/SelectQueryBuilder.ts)。源码中还能看到其对循环 eager 链(如 A→B→C→A)的防无限递归处理——每个分支维护独立的"已访问"集合,避免并行分支相互干扰。
因此,面对深嵌套关系导致的主查询膨胀,切换到 "query" 策略往往能让单条 SQL 回归精简,代价是增加数据库往返次数,需要在两者间按实际数据量与网络往返成本权衡。
2.5 用 loadEagerRelations 精确开关 Eager 加载
find* 选项还提供 loadEagerRelations 布尔开关,用于对 eager 行为做更细的裁剪:
// 完全禁用 eager 关系加载
const questions = await questionRepository.find({
loadEagerRelations: false,
})
// 只加载显式声明的 relations,抑制实体上其余嵌套 eager 关系
const questions = await questionRepository.find({
relations: { categories: true },
loadEagerRelations: false,
})
第一种写法下,即使实体属性声明了 eager: true,本次查询也不会自动 join 任何关系;第二种写法结合显式 relations 与 loadEagerRelations: false,可以实现"只取我点名的那一层,其余一律不自动展开"的精细化控制,非常适合需要控制载荷体积的场景。
实现层面,src/find-options/FindOptionsUtils.ts 会在选项类型判定时识别该字段;在查询装配流程中 loadEagerRelations === false 会直接短路 eager join 逻辑(相关判断见 src/query-builder/SelectQueryBuilder.ts),即跳过自动装配、仅保留 relations 中显式声明的部分。loadEagerRelations 选项同样存在于 src/find-options/FindOneOptions.ts 的选项类型定义中,find、findOne、findAndCount 等系列均可使用。
三、Lazy relations:基于 Promise 的按需加载
3.1 概念与类型要求
与 eager 相反,lazy relations 中的实体在你访问属性时才被加载。一个关键约束是:这类关系的属性类型必须是 Promise——保存时你把值包装进一个 Promise,读取时拿到的也是一个 Promise。
以同一对 Question/Category 为例,若希望二者互为懒加载:
import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from "typeorm"
import { Question } from "./Question"
@Entity()
export class Category {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@ManyToMany((type) => Question, (question) => question.categories)
questions: Promise<Question[]>
}
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToMany,
JoinTable,
} from "typeorm"
import { Category } from "./Category"
@Entity()
export class Question {
@PrimaryGeneratedColumn()
id: number
@Column()
title: string
@Column()
text: string
@ManyToMany((type) => Category, (category) => category.questions)
@JoinTable()
categories: Promise<Category[]>
}
注意两侧属性的类型都是 Promise<Question[]> / Promise<Category[]> 而非裸数组——categories 是一个 Promise,这正是"懒"的载体:类型系统时刻提醒你,该属性内部存放的是一个"未来才就绪的值"。
3.2 保存与读取
保存懒加载关系:需要将待关联实体用 Promise.resolve(...) 包装后赋给属性:
const category1 = new Category()
category1.name = "animals"
await dataSource.manager.save(category1)
const category2 = new Category()
category2.name = "zoo"
await dataSource.manager.save(category2)
const question = new Question()
question.categories = Promise.resolve([category1, category2])
await dataSource.manager.save(question)
读取懒加载关系:先取回主实体,再 await 该 Promise 属性:
const [question] = await dataSource.getRepository(Question).find()
const categories = await question.categories
// 此刻 question 的全部 categories 都已在 "categories" 变量中
对单值关系(@ManyToOne/@OneToOne),await 后得到的是单个实体;对多值关系(@OneToMany/@ManyToMany),await 后得到的是数组。
3.3 底层机制:属性重定义与 Promise 状态缓存
懒加载之所以能"访问即触发查询",是因为 TypeORM 在返回实体前对关系属性做了重定义。相关实现位于 src/query-builder/RelationLoader.ts 的 enableLazyLoad 方法:
- 它在实体上预留了三个内部标记位:数据缓存位
__{propertyName}__、加载 Promise 位__promise_{propertyName}__、已加载标志位__has_{propertyName}__(用于区分"数据为空"与"尚未加载"两种状态,因为空数组/null也是合法结果); - 通过
Object.defineProperty重新定义该关系属性:- getter:若数据已加载,直接返回
Promise.resolve(已缓存数据);若加载进行中,返回已存在的 Promise(避免重复发查询);否则立刻调用RelationLoader.load(...)发起一次独立的关系查询,并把结果 Promise 存入 promise 位,resolve 后写回数据缓存位并清除 Promise 位。对单值关系,还会在结果为空数组时归一化为null; - setter:接受 Promise 或直接值。若赋入的是 Promise,则在它 resolve 后再落盘(
setPromise内部带有"确保仍是当前 promise 才写入"的守卫,防止过期请求覆盖新值);若直接赋普通值则立即写入。
- getter:若数据已加载,直接返回
也就是说,每次 await question.categories 首次触发时,TypeORM 会依据 RelationMetadata 中该关系的外键/连接表信息,拼接一条按需执行的独立 SELECT 把关联数据取回,并在实体生命周期内缓存,后续访问不再重复查询。
3.4 适用注意事项
官方文档特别强调:如果你来自 Java、PHP 等同步模型语言并习惯"到处懒加载",需要格外小心——这些语言没有异步机制,其懒加载基于代理对象实现;而在 JavaScript / Node.js 中,Promise 是异步的必然载体,因此 TypeORM 的 lazy relations 必须依赖 Promise 实现,这属于非标准技术,在 TypeORM 中仍被视为**实验性(experimental)**特性。这意味着其 API 与行为可能在未来版本变化,生产环境大规模使用前建议评估风险,并将"与同步语言心智模型的差异"纳入团队约定。
四、Eager 与 Lazy 的选型建议与总结
| 维度 | Eager relations | Lazy relations |
|---|---|---|
| 触发时机 | 主实体被 find* 加载时自动触发 |
首次访问属性(await)时触发 |
| 声明方式 | 关系装饰器选项 { eager: true } |
属性类型声明为 Promise<...> |
| 生效范围 | 仅 find* 系列;QueryBuilder 需手动 leftJoinAndSelect |
访问任意 getter 均生效 |
| 查询形态 | 默认 LEFT JOIN(满足条件时自动升级 INNER JOIN),或配合 relationLoadStrategy: "query" 改走独立查询 |
每次访问走独立查询,结果在实体上缓存 |
| 主要限制 | 同一关系只允许一侧 eager;嵌套过深会撑大单条 SQL | 实验性特性;需要 async/await 心智模型 |
| 加载控制 | relationLoadStrategy、loadEagerRelations、显式 relations |
无额外开关,访问即加载 |
实用建议:数据量不大、层级固定且几乎每次都需要关联数据的场景,优先用 eager 并保持关系层级较浅;当列表页或批量导出等场景一次性数据量巨大、或关系深度不可控时,改用 relationLoadStrategy: "query" 或 loadEagerRelations: false 配合显式 relations 精确控制载荷;懒加载则更适合"主实体常读、关联数据低频偶发访问"的业务路径,但鉴于其实验性质,建议先在核心链路外验证稳定性。而无论选择哪种模式,基于 QueryBuilder 的查询始终是最可控的加载方式——它不会受到 eager 自动装配的隐式影响,所有 join 都由你显式书写。
五、继续深入:仓库内相关资源
- 官方 Relations 系列文档:关系的基础概念与装饰器总览;
- Eager and Lazy Relations 官方文档:本文的原始依据;
- Relations FAQ:关系使用中的常见问题;
- FindOptionsUtils.ts:JOIN 类型推导与 eager join 递归装配实现;
- SelectQueryBuilder.ts:
"query"策略下独立查询加载 eager 关系的实现; - RelationLoader.ts:懒加载 getter/setter 与 Promise 缓存的底层实现;
- EntityMetadata.ts:
eagerRelations关系元数据收集处。
对本文涉及的任何结论,均可通过阅读上述源码文件进一步求证,也可在本仓库的 test/ 目录下检索对应关系的功能测试用例观察实际 SQL 与行为表现。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00