N+1 查询:ORM 最典型的性能陷阱
取 100 条记录再逐条查关联,就是 101 次查询。修法只有三种:预加载、批量查、或者干脆用一次 JOIN。
N+1 的形状固定:先查出一批主记录,再对每一条单独查关联。N 条主记录就是 N+1 次往返。
const posts = await db.post.findMany(); // 1 次
for (const post of posts) {
post.author = await db.user.findUnique({ // N 次
where: { id: post.authorId },
});
}
100 篇文章就是 101 次查询。单次查询很快,但网络往返的累积延迟会把页面拖到几秒。
三种修法
预加载(ORM 的 include / with)。 多数 ORM 能一条语句搞定:
const posts = await db.post.findMany({ include: { author: true } });
ORM 内部通常发两条查询(一条取文章、一条 WHERE id IN (...) 取作者),而不是 N+1 条。这是首选。
手动批量查询。 收集所有外键,一次查回来再在内存里拼接:
const posts = await db.post.findMany();
const ids = [...new Set(posts.map((p) => p.authorId))];
const authors = await db.user.findMany({ where: { id: { in: ids } } });
const byId = new Map(authors.map((a) => [a.id, a]));
for (const post of posts) post.author = byId.get(post.authorId);
JOIN。 关系简单且不需要嵌套结构时,一次查询最直接。代价是主记录会被关联表放大,需要去重。
怎么发现
N+1 在开发环境看不出来 —— 本地数据库没有网络延迟,100 次查询只要几毫秒。到了生产环境才暴露。
| 手段 | 做法 |
|---|---|
| 查询日志 | 打开 SQL 日志,看同一形状的语句是否重复出现 |
| APM | 统计单请求的查询次数,设阈值告警 |
| ORM 钩子 | 在测试里断言「一个请求的查询数 ≤ K」 |
第三条最有效:把查询次数写成测试断言,N+1 会在 CI 阶段就被拦住,不用等到线上。
什么时候可以不管
- 主记录数量有硬上限(比如一次最多 5 条)
- 关联数据在缓存里,不产生查询
- 这个接口本来就不在关键路径上
判断依据是实际的数据规模。列表接口如果有分页、每页 20 条,20+1 次查询通常可以接受 —— 前提是别再嵌套一层关联。
N+1 不慢在单条查询,慢在往返次数。发现靠查询计数,修复靠一次批量。

评论
…