6月5日 13:55

TypeORM N+1查询:relations、leftJoinAndSelect和DataLoader方案对比

N+1 查询是 ORM 里最经典的性能坑:查 100 个用户,再查 100 次每个用户的文章——1 次主查询 + N 次关联查询 = N+1 次数据库往返。TypeORM 默认不加载关联数据,所以 N+1 不是 bug 而是默认行为——你得主动告诉 TypeORM 你要哪些关联数据。这篇文章讲清楚 N+1 怎么产生的、怎么解决、以及各种方案的取舍。

N+1 是怎么产生的

typescript
// 查 100 个用户:1 次 SQL const users = await userRepository.find(); // 每个用户查文章:100 次 SQL for (const user of users) { user.posts = await postRepository.find({ where: { authorId: user.id } }); } // 总共 101 次数据库查询

100 个用户 101 次查询,1000 个用户 1001 次——线性增长。数据库连接池很快耗尽,API 响应从 50ms 飙到 5s。

解决方案一:relations 选项

最简单的方案,在 find 时声明要加载的关联:

typescript
const users = await userRepository.find({ relations: ['posts', 'profile'], });

生成的 SQL:

sql
SELECT * FROM user; SELECT * FROM post WHERE authorId IN (1, 2, 3, ...); SELECT * FROM profile WHERE userId IN (1, 2, 3, ...);

3 条 SQL——不管有多少用户。TypeORM 先查主表,拿 ID 列表,再用 IN 查关联表,最后在内存里组装关系。

嵌套关联

typescript
const users = await userRepository.find({ relations: ['posts', 'posts.comments'], });

加载用户的文章,以及文章的评论。每多一层嵌套多一条 SQL,但仍是固定数量。

只加载部分关联字段

typescript
const users = await userRepository.find({ relations: ['posts'], select: { id: true, name: true, posts: { id: true, title: true, // 只加载文章的 id 和 title }, }, });

解决方案二:leftJoinAndSelect

QueryBuilder 版本,更灵活:

typescript
const users = await userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .leftJoinAndSelect('user.profile', 'profile') .getMany();

relations 的区别:

relationsleftJoinAndSelect
SQL 数量多条(主查询 + 每个关联一条)一条(JOIN 合并)
大数据量性能更好(IN 查询,无重复行)可能差(JOIN 产生笛卡尔积)
灵活性只能加载,不能过滤可以加 WHERE、ORDER BY
去重自动自动(TypeORM 处理)

什么时候用 leftJoinAndSelect:需要对关联数据做过滤或排序时。

typescript
// 只加载已发布的文章 const users = await userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post', 'post.published = :published', { published: true }) .getMany();

第三个参数是 JOIN 条件——只有 published=true 的文章会被加载。relations 做不到这个。

什么时候用 relations:只是加载数据不过滤时。relations 在数据量大时性能更好——JOIN 1000 个用户每人 10 篇文章会产生 10000 行中间结果,IN 查询只有 1000 + 10 = 1010 行。

解决方案三:Eager 加载

在实体定义里设置 eager: true,每次查用户自动加载文章:

typescript
@Entity() export class User { @OneToMany(() => Post, post => post.author, { eager: true }) posts: Post[]; }
typescript
// 自动加载 posts,不需要显式声明 const users = await userRepository.find();

不推荐eager: true 让你失去控制——有时候你只需要用户名,结果把文章也查出来了。而且 eager 关联嵌套时,层层加载,性能不可预测。

只在一种场景下用 eager:关联数据总是需要一起加载。比如 UserUserProfile(一对一,用户资料总是要一起展示的)。

解决方案四:DataLoader(GraphQL 场景)

GraphQL 的查询深度不确定——客户端可能查用户文章,也可能不查。用 relationsleftJoinAndSelect 会过度加载。DataLoader 按需批量加载:

typescript
import DataLoader from 'dataloader'; const postLoader = new DataLoader(async (authorIds: number[]) => { // 一次查出所有作者的文章 const posts = await postRepository.find({ where: { authorId: In(authorIds) }, }); // 按 authorId 分组 return authorIds.map(id => posts.filter(p => p.authorId === id)); }); // 在 resolver 里使用 const resolvers = { User: { posts: (user) => postLoader.load(user.id), }, };

DataLoader 自动合并同一个请求里的多次 load() 调用——10 个用户查文章,只触发 1 次数据库查询。

常见陷阱

忘记加 relations 就访问关联属性

typescript
const users = await userRepository.find(); console.log(users[0].posts); // undefined 或 []

没加 relations,关联属性不会自动加载。TypeORM 不会报错——它返回 undefined 或空数组,你的代码以为没有数据。

leftJoin vs leftJoinAndSelect

typescript
// leftJoin:只 JOIN 不过 SELECT,关联数据不返回 .leftJoin('user.posts', 'post') // leftJoinAndSelect:JOIN 并且 SELECT,关联数据返回 .leftJoinAndSelect('user.posts', 'post')

用了 leftJoin 但没 addSelect,关联属性始终为空——很多人卡在这里。

过度加载

typescript
// ❌ 加载了所有关联,但只需要文章数 const users = await userRepository.find({ relations: ['posts'] }); const postCounts = users.map(u => u.posts.length); // ✅ 用子查询只拿计数 const users = await userRepository .createQueryBuilder('user') .loadRelationCountAndMap('user.postCount', 'user.posts') .getMany();

loadRelationCountAndMap 只查 COUNT,不加载完整的关联数据——1000 个用户只需 1 条额外的 COUNT 查询。

标签:TypeORM