什么是数据库查询 N + 1 问题? 如何有效避免?
2026年1月25日
在做后端的数据库查询时 (特别是使用 ORM 时),许多人可能会不小心犯了 N + 1 问题。在这篇文章我们会讨论什么是 N + 1 问题、N + 1 问题会造成什么影响,以及可以如何重构来避免这种问题。
什么是 N + 1 问题?
N + 1 问题是常见的查询性能问题,假如在写数据库查询时,发现在数据量大时,查询花特别多时间、特别慢,N + 1 就会是一个值得优先检查的方向 (备注:数据库查询慢可能有其他原因,N + 1 问题只是其中一个可以检视的方向,推荐可以多方检查来排除问题)。
让我们透过一个具体案例来理解什么是 N + 1 问题。下面是一个使用 Drizzle ORM 的例子,在这个例子中,我们要跟数据库拿社群贴文,以及要拿每个贴文对应的发文者,这样才能同时显示贴文与发文者。
要同时拿到所有贴文与对应的发文者,一个直观的做法,是先有一个查询拿到所有的贴文 allPosts,然后接着迭代过每一则贴文 for (const post of allPosts) ,在迭代时根据贴文的发文者 id,去拿到完整的发文者资讯。
const allPosts = await db.select().from(posts);
for (const post of allPosts) {
const author = await db
.select()
.from(authors)
.where(eq(authors.id, post.authorId))
.limit(1);
}
在上面这段查询中,1 是第一次拿所有贴文的查询,N 则是后面针对每则贴文,再去拿作者详细资讯的查询。这种典型的 N + 1 问题,会有一个显而易见的问题,如果今天贴文数量增加,针对发文者的查询就会线性增加。
在 ORM 背后具体会跑的 SQL 查询大致如下 (以 5 篇贴文为例),在贴文数少的状况,这可能不会带来太多性能成本。不过,如果今天有 10 篇贴文,那么需要额外再发 10 个查询去拿这 10 篇贴文的发文者资讯 (n = 10);假如有 1000 篇贴文,就等于要额外发 1000 次查询去拿贴文发文者的资讯 (n = 1000)。这种状况下,贴文一多,查询自然会变慢很多。
Post Load (0.5ms) SELECT "posts".* FROM "posts"
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 1
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 2
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 5
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 5
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 3
如何重构来避免 N + 1 问题?
之所以会有这种 N + 1 问题,是因为 N + 1 的查询是很直观可以想到,且查询结果本身无误的方法。因为「简单又正确」,会选这种方式并不会让人感到意外。不过身为一名后端工程师,只是写出正确的查询还不够,在正确之余还要确保查询的效率高、拿数据的时间短,才不会让查询变成性能的瓶颈。
如何辨识 N + 1 问题?
要有效避免 N + 1 问题,首先需要先能辨识出 N + 1 问题。如同上面看到的,通常 N + 1 问题出现在某段循环的程序码中,因此假如你发现程序码中有某一段循环,且该循环当中每次迭代都有发送查询,这种状况就值得多加留意。
另一个可以看的,是实际发出的 SQL 查询。如上面有展示的,N + 1 查询的一个特点是有很多 SELECT 语句搭配 WHERE ,但是 WHERE 大量重复,只有 ID 不同而已,这种状况也极可能可以透过重构改善。
除了看程序码本身,从请求的性能监控来看,假如在少量数据时性能没问题,但当数据量变大时,性能是线性方式变差,这很可能也意味着有 N + 1 问题。
虽然上面这些表徵,不必然代表有 N + 1 问题,但是如果有发现类似问题,还是推荐可以花点时间查看,如果确认是 N + 1 问题,就推荐重构,以下让我们谈几个可以重构的方式。
透过 JOIN 重构
单看 SQL,假如想要拿贴文以及发文者的资讯,只要透过 JOIN 就能够用一次查询做到,例如下面这样。
SELECT
posts.id,
posts.title,
posts.content,
authors.id as author_id,
authors.name as author_name,
authors.email as author_email
FROM posts
INNER JOIN authors ON posts.author_id = authors.id;
透过 JOIN,我们能够把查询次数从 N + 1 降低至 1。不过这个做法可能会有个问题,大家可以观察下方这个范例结果,觉得假如透过 JOIN 拿到下面这样的数据,有哪邊可以改进的?
post_id | title | content | author_id | author_name | author_email
--------|-----------------|--------------|-----------|-------------|------------------
1 | "First Post" | "..." | 5 | "Alice" | "alice@..."
2 | "Second Post" | "..." | 5 | "Alice" | "alice@..."
3 | "Third Post" | "..." | 12 | "Bob" | "bob@..."
4 | "Fourth Post" | "..." | 7 | "Charlie" | "charlie@..."
眼尖的读者可能会发现,由于第一个贴文跟第二个贴文,都是 author_id 是 5 的发文者发的,所以数据库在回传数据时,这部分的数据就回传了两次。假如今天的贴文都是少数几位作者发的,这等于数据库会回传大量重复的数据,佔用频宽也让传输时间多了额外成本 (备注:从 SQL 与数据库的角度来看,JOIN 是合理且常见的做法;这裡讨论的问题,主要是站在数据传输量与应用层数据处理成本的角度)。
透过 IN 子句重构
假如要避免使用 JOIN 会拿到重复的数据,我们可以改成用 IN 的子句来查询,在避免 N + 1 问题,拿到独特不重复的数据。
具体来说可以这么做:
-- 第一次查询先拿贴文
SELECT id, title, content, author_id
FROM posts;
-- 根据贴文的发文者 id 来拿发文者资讯
SELECT id, name, email
FROM authors
WHERE id IN (5, 12, 7, 23, 34);
-- 备注:在拿完数据后,应用层需要在程序码中写额外的逻辑,把两次查询的数据拼装一起
虽然比起用 JOIN,用 IN 需要两次查询,但本质上仍能避免 N + 1 问题,因为不管贴文或发文者数量增加多少,这个做法也都只需要两次查询。
虽然上面这两种做法,都能解决 N + 1 问题,但要选哪一种,需要依照所在情境考量。举例来说,假如数据量小,用 JOIN 的方法多拿到重复数据,这个成本通常是可以接受的,与此同时 JOIN 的查询性能往往分两次查询更好。
总结
以上,希望透过这篇文章,读者们对于 N + 1 问题有更具体的认识。如文中提到的,在开发时如果发现查询异常的慢,可以试着检查是否有 N + 1 问题。透过简单的方式重构,查询性能会有很显著的改善。