TypeORM Query Builder 性能优化实战:消除 N+1 查询、按需取数与字段裁剪
在 TypeORM 应用中,ORM 带来的便利背后往往隐藏着性能陷阱:最常见的是 N+1 查询问题——对每一行结果都额外发起若干条子查询;其次是"取太多"——加载了并不需要的完整实体与整表字段,白白增加网络传输与内存开销。本篇技术指南以仓库 performance-optimization/2-efficient-use-of-query-builder.md 为核心,围绕 Query Builder 这一工具讲解三类可立即落地的优化手段:用 leftJoinAndSelect/innerJoinAndSelect 单条 SQL 合并关联、用 getRawMany() 跳过实体映射取裸数据、用 select() 裁剪字段。读完你将掌握如何在保留 TypeORM 类型能力的同时,把查询数量与数据体积压到最低,并理解其底层 SelectQueryBuilder 的实现原理。
背景:ORM 性能问题的三大来源
在进入 Query Builder 技巧之前,先回顾 性能优化系列导言 中归纳的常见 ORM 挑战:
因此,优化的总体目标可以概括为:减少发往数据库的 SQL 条数、让复杂查询跑得更快、按需取数避免浪费。本篇文章聚焦"Query Builder 三件套"来达成前两条。
消除 N+1 查询问题:用 Join + Select 一次取全
N+1 查询问题发生在系统对检索到的每一行数据都额外执行多条子查询的场景。假设有 User 与 Post 两个实体(一对一/一对多关系),若先取 100 个用户、再逐个取每个用户的文章,数据库就要承受 1 条主查询加 100 条关联查询的压力,这就是典型 N+1。
用 leftJoinAndSelect 合并为单条 SQL
避免 N+1 的标准做法是使用 leftJoinAndSelect(或 innerJoinAndSelect)把关联表"拼"进同一条查询:
const users = await dataSource
.getRepository(User)
.createQueryBuilder("user")
.leftJoinAndSelect("user.posts", "post")
.getMany()
这里 leftJoinAndSelect 会在一条 SQL 里把 user 与 posts 做 LEFT JOIN,并把 post 的列也选出来,一次往返即可取回所有用户及其文章,而不是很多条小查询。命名上 AndSelect 的含义是"既做连接、又把被连接实体的属性加入 SELECT 列表"——从源码可见其实现就是两步叠加:先 this.addSelect(alias) 将别名注册进选择列,再执行对应的 leftJoin(参见 SelectQueryBuilder.ts 中 leftJoinAndSelect 与 innerJoinAndSelect 的实现)。
leftJoinAndSelect vs innerJoinAndSelect:按是否需要保留主行选用
两者签名完全一致,区别仅在于 JOIN 语义:
| 方法 | JOIN 语义 | 适用场景 |
|---|---|---|
leftJoinAndSelect |
LEFT JOIN,保留左侧主表所有行 | 主行必须返回、关联数据可有可无(如"用户及其可选资料") |
innerJoinAndSelect |
INNER JOIN,仅返回两侧都匹配的行 | 只需要存在关联数据的记录(如"发过文章的用户") |
innerJoinAndSelect 还会在内部先执行 this.addSelect(alias) 再 innerJoin,即同样把关联实体字段带入 SELECT。除了把实体关系作为连接目标外,这两个方法还支持三种扩展形态(源码以函数重载暴露,见 SelectQueryBuilder.ts):
- 连接实体:
innerJoinAndSelect(User, "u", "u.team = team.id"),按条件直接连接任意实体表; - 连接子查询:传入
(qb) => ...子查询工厂,把一段子查询当作被连接的数据源; - 连接裸表名:传入字符串表名。
何时不该用 Join + Select:当一对多关系的两侧都存在大量数据时,JOIN 会产生行数相乘的宽结果集(笛卡尔式膨胀),网络与内存开销反而更大。此时应回到"主查询 + 按需加载关联",或参考本系列 懒加载与预加载对比 决定加载策略。
从源码看 Join 的真正收益
从源码结构看,SelectQueryBuilder 内部用 joins 数组统一记录每个 JOIN 的 type("inner" | "left")、alias、parentAlias、relationMetadata 以及 select 标记(见 SelectQueryBuilder.ts),并在 getQuery() 中按固定顺序拼装 SQL:SELECT → JOIN → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT/OFFSET → LOCK(见 SelectQueryBuilder.ts)。这解释了为什么"join 并 select 关联字段"能真正减少往返:关联表的列被纳入同一条 SELECT 语句,实体映射阶段再据此还原对象图,而非运行时逐条补查。
只需要裸数据:用 getRawMany() 跳过实体处理
在某些场景中,你并不需要 TypeORM 还原出带类型、带关联结构的实体对象,而只是要数据库返回的原始行。此时应使用 getRawMany():
const rawPosts = await dataSource
.getRepository(Post)
.createQueryBuilder("post")
.select("post.title, post.createdAt")
.getRawMany()
与 getMany() 的差异在于:
getMany()会把查询结果映射为实体实例,并按关系元数据装配对象图(涉及大量类型转换与关联组织开销);getRawMany()则返回未加工的数据库行。其实现会在执行前显式把expressionMap.queryEntity置为false,以此跳过实体装配环节(见 SelectQueryBuilder.ts),因此更适合报表、导出、看板等只读展示场景。
配套方法还有:
getRawOne():取第一条原始行,其源码实现就是(await this.getRawMany())[0](见 SelectQueryBuilder.ts);getRawAndEntities():同时返回原始行与由它们构建出的实体,供需要两套视角的高级场景使用。
需要提示的限制:raw 结果不会套用实体的列名→属性名映射,返回的键是数据库列名;
getRawMany()也不支持乐观锁模式(源码在入口处对lockMode === "optimistic"直接抛出OptimisticLockCanNotBeUsedError)。
用 select 裁剪字段,降低内存与传输开销
默认情况下,TypeORM 会把实体的所有列都纳入 SELECT。若一张宽表有几十个字段而页面只用两个,属于明显浪费。select 能精确限定返回列:
const users = await dataSource
.getRepository(User)
.createQueryBuilder("user")
.select(["user.name", "user.email"])
.getMany()
这里 .select(["user.name", "user.email"]) 会让生成的 SQL 只查询这两列,达到降低内存占用、减少不必要数据传输的目的。
使用 select 的关键语义与坑
源码中 select 的行为对正确使用至关重要(见 SelectQueryBuilder.ts):
select()语义为 "替换":文档注释明确写着 "Replaces all previous selections if they exist"——每次调用都会清空此前的选择(源码中对字符串与数组都直接重置expressionMap.selects);- 若想追加选择列,需改用
addSelect(),它把新列追加到已有选择列表末尾(见 SelectQueryBuilder.ts); select支持三种入参形态:单个字符串(可配别名)、字符串数组、以及子查询回调(配合selectionAliasName把一段子查询结果选为一列);- 裁剪字段与实体映射存在权衡:
select只选中部分列后,getMany()返回的实体对象中未选中的属性会是undefined,因此更常用于与 raw 结果搭配,或与后续where/orderBy需要的列保持一致。
一个完整的最佳实践组合
将上述手段组合,可在单条 SQL 内同时实现"只取需要的关联、只要需要的字段":
const rows = await dataSource
.getRepository(User)
.createQueryBuilder("user")
.innerJoinAndSelect("user.posts", "post") // 单条 SQL 合并关联,规避 N+1
.select(["user.id", "user.name", "post.title"]) // 只取必需字段
.orderBy("post.createdAt", "DESC")
.getRawMany() // 纯数据输出,跳过实体装配
当结果集较大、需要切片展示时,还可叠加本系列的 分页(Pagination) 章节推荐的 take/skip 与 getManyAndCount(),从"数量"与"体积"两个维度共同压榨查询成本。
小结
| 优化手段 | 解决的问题 | 对应 API |
|---|---|---|
| 合并关联查询 | N+1 查询、查询次数过多 | leftJoinAndSelect / innerJoinAndSelect |
| 跳过实体装配 | 不必要的类型映射与对象图组织开销 | getRawMany() / getRawOne() |
| 裁剪返回字段 | 传输与内存浪费 | select() / addSelect() |
三个技巧统一回答"少发 SQL、少取数据、少做转换"三个问题:Join + Select 在单条语句内完成关联读取,直接规避 N+1;getRawMany() 面向只读场景剥离实体映射成本;select/addSelect 则把列集合收敛到业务真正需要的子集。这些 API 均定义于仓库 SelectQueryBuilder,你可以直接阅读其重载与实现加深理解;若想系统学习,建议顺序浏览 性能优化系列 中关于索引、懒加载与预加载、分页与缓存的其余章节,形成完整的 TypeORM 查询性能优化知识栈。
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 StartedRust0629
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