你在開發時是不是曾遇到這種狀況:API 在本機測試時都很流暢,一旦上了正式環境,回傳時間就從毫秒暴增成好幾秒?
打開資料庫 log 一看,發現原本只是想查使用者的列表,底下卻跟著印出了 10 條結構幾乎相同的文章查詢——這就是後端開發裡常見的 N+1 查詢問題。
最基本的 N+1 很好理解,先查出一批資料,再針對每一筆資料額外查一次關聯:
const users = await this.usersRepository.find();
for (const user of users) {
const posts = await this.postsRepository.find({
where: {
author: {
id: user.id,
},
},
});
}
這種寫法雖然有效能問題,但至少在 code review 時還算顯眼,因為迴圈裡明白寫著:
postsRepository.find(...)
但今天要踩的坑是更隱蔽的效能殺手。當你在 TypeORM 的 relation 上開啟 lazy: true 之後,又在列表中逐筆存取這個關聯,原本明顯的資料庫查詢,會被藏進 await user.posts 這種看起來很普通的屬性讀取裡。
用圖書館借書的例子來比喻,就像是你先借了一批書,但附件都先不拿,等到真的要看附件時,才一本一本去索取。如若你借了 5 本書,而且每一本都需要附件,就得多跑 5 趟。

而 Lazy Loading 真正危險的地方,不是它創造了 N+1,而是它讓這些額外查詢變得更難被察覺。
接下來就讓我們來看看這個坑是怎麼發生的吧!
假設我們有 User(一)對 Post(多)的關聯,並把 User 的關聯欄位設成 lazy: true:
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
// Lazy Relation 以 Promise 型別宣告;尚未載入時,存取 posts 才會載入關聯資料
@OneToMany(() => Post, (post) => post.author, { lazy: true })
posts: Promise<Post[]>;
}
// post.entity.ts
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
// 外鍵在「多」的這側,TypeORM 在預設命名下,會產生 authorId 的外鍵欄位。
@ManyToOne(() => User, (user) => user.posts)
author: User;
}
為了直接看出每次存取 relation 到底送出了幾條 SQL,可以先把 TypeORM 的 query logging 打開:
// app.module.ts
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
synchronize: true,
autoLoadEntities: true,
logging: true, // 把每次實際送出的 SQL 印在終端機
})
假設資料庫裡已有 3 位使用者,每人各有 2~3 篇文章。現在我們要做一個列表 API,在取得使用者的同時,也帶出每個人的文章:
// users.service.ts
async findAll() {
const users = await this.usersRepository.find(); // 1 次查詢
const result = [];
for (const user of users) {
const posts = await user.posts; // 地雷:看起來只是存取屬性,其實觸發 1 次查詢
result.push({
id: user.id,
name: user.name,
posts: posts.map((post) => ({ id: post.id, title: post.title })),
});
}
return result;
}
跟手動查詢的寫法比起來,這段程式碼更難察覺背後發生了資料庫查詢——完全沒有出現 postsRepository,只有一句看似普通的 await user.posts。
光看 API response,你甚至不會發現哪裡有問題:
[
{
"id": 1,
"name": "User 1",
"posts": [
{ "id": 1, "title": "User 1 的第 1 篇文章" },
{ "id": 2, "title": "User 1 的第 2 篇文章" },
{ "id": 3, "title": "User 1 的第 3 篇文章" }
]
},
{
"id": 2,
"name": "User 2",
"posts": [
{ "id": 4, "title": "User 2 的第 1 篇文章" },
{ "id": 5, "title": "User 2 的第 2 篇文章" }
]
},
{
"id": 3,
"name": "User 3",
"posts": [
{ "id": 6, "title": "User 3 的第 1 篇文章" },
{ "id": 7, "title": "User 3 的第 2 篇文章" },
{ "id": 8, "title": "User 3 的第 3 篇文章" }
]
}
]
但問題不在結果,而在代價。執行這段方法時,SQL log 會出現:
SELECT "User"."id", "User"."name" FROM "user"
SELECT "posts".* FROM "post" "posts" WHERE "posts"."authorId" IN (1)
SELECT "posts".* FROM "post" "posts" WHERE "posts"."authorId" IN (2)
SELECT "posts".* FROM "post" "posts" WHERE "posts"."authorId" IN (3)
3 位使用者,總共 4 次查詢:1(查 User) + 3(查 Posts) = 1 + N。如果今天不是 3 位使用者,而是 1000 位使用者,就可能變成 1001 次查詢
這裡有一個很重要的觀念要先分清楚:
TypeORM 的 relation 預設不會自動載入,但「沒有自動載入」本身不等於 Lazy Loading。
例如一般的 relation:
// user.entity.ts
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
當你執行:
const users = await this.usersRepository.find();
TypeORM 預設只會查 User,這時候單純存取 user.posts 並不會自動幫你再查一次資料庫。
如果這次 find() 沒有預先載入 Posts,後續還想取得文章資料,其中一種做法就是再透過 postsRepository 查詢:
// users.service.ts
const posts = await this.postsRepository.find({
where: {
author: {
id: user.id,
},
},
});
如果把它放進迴圈:
// users.service.ts
const users = await this.usersRepository.find();
for (const user of users) {
await this.postsRepository.find({
where: {
author: {
id: user.id,
},
},
});
}
一樣會得到 1(查 User)+ N(查 Posts),這是手動逐筆查詢造成的 N+1。
而 lazy: true 做的事情,是把「再查一次關聯」這個動作藏到 relation property 裡:
// user.entity.ts
@OneToMany(() => Post, (post) => post.author, {
lazy: true,
})
posts: Promise<Post[]>;
之後只要:
await user.posts;
TypeORM 就會幫你載入這個 relation。
所以:
for (const user of users) {
await user.posts;
}
最後仍然形成 1(查 User)+ N(查 Posts)
儘管兩種寫法看起來大不相同,但引發 N+1 的運作結構其實相同:
最終就是典型的 1 + N 次查詢。
lazy: true 容易讓人忽略的地方,在於它把額外查詢藏進 user.posts 這種看似普通的屬性存取裡。程式碼表面上看不出明顯的資料庫操作,但一旦放進迴圈,每一次存取都可能再觸發一筆查詢,因此比直接在迴圈裡寫 Repository.find() 更容易躲過 code review。
更麻煩的是,這類問題在開發環境裡通常也不明顯。假設本機只有 3 筆測試資料,總共不過是 1 + 3 = 4 次查詢,幾乎感覺不到效能差異;但到了正式環境,如果一次處理 1000 筆資料,就可能膨脹成 1 + 1000 = 1001 次查詢。
如果這個 API 本來就需要完整的 posts,可以在查詢中明確要求 TypeORM 載入 relation:
// users.service.ts
async findAllPreloaded() {
const users = await this.usersRepository.find({
relations: {
posts: true,
},
});
const result = [];
for (const user of users) {
const posts = await user.posts; // 已經被 join 進來,await 不會再多查一次
result.push({
id: user.id,
name: user.name,
posts: posts.map((post) => ({ id: post.id, title: post.title })),
});
}
return result;
}
當你指定 relations: { posts: true } 之後,在 TypeORM 預設的 relationLoadStrategy: 'join' 下,會透過 JOIN 一次載入 User 與 Posts,不再對每個 User 額外查一次關聯。
對 Lazy Relation 而言,如果 posts 已經在這次查詢中載入,之後再 await user.posts 只會取得已載入的資料,不會再為每個 User 額外送出查詢。
這時候 SQL log 就只剩一條:
SELECT "User"."id", "User"."name", "posts".*
FROM "user" "User"
LEFT JOIN "post" "posts" ON "posts"."authorId" = "User"."id"
API 拿到的資料沒有改變,差別在於資料庫不再隨著 User 數量逐筆追加查詢。
⚠️ 注意:
如果專案關聯資料表很多,請留意不要毫無節制地加入無關的陣列項目,以免抓取不必要的 payload,反倒造成查詢變慢。
如果需要更細緻的查詢條件、排序,或組合更多 relation,可以改用 QueryBuilder:
// users.service.ts
const users = await this.usersRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'posts')
// 這裡可以組合更複雜的條件
.getMany();
這同樣能防堵迴圈查詢發生,讓所有資料在一趟來回內處理完畢。
await,資料不會報錯,只會悄悄消失在處理 Lazy Relation 時,若是我們在上面的程式碼中忘記 await:
// users.service.ts
async findAllWithoutAwait() {
const users = await this.usersRepository.find();
return users.map((user) => ({
id: user.id,
name: user.name,
posts: user.posts, // 地雷:忘記 await,直接拿 Promise 回傳
}));
}
如果沒有替 API response 明確定義 DTO 型別,單靠 TypeScript 的型別推導通常不會在這裡報錯。
但當這個物件最終轉換為 JSON 給前端時,結果會變成這樣:
[{ "id": 1, "name": "User 1", "posts": {} }]
原本該是陣列的文章,變成了一個空物件 {}。原生 Promise 本身沒有可被 JSON 序列化的可枚舉屬性,因此直接放進一般 JSON response 時,常見結果就是 {}。
更令人崩潰的是忘記加上 await 並沒有幫你省下效能。在讀取 user.posts 的那一刻,TypeORM 的 Lazy Relation getter 就已經開始執行 relation loading。
await 的作用是等待這個 Promise 完成,而不是決定要不要開始查詢。你雖然沒有等待它,也沒有拿到查詢結果,但資料庫的 I/O 成本一點都沒少。
await user.posts 看似只是讀取關聯,但放進 N 筆資料的迴圈後,就可能形成 N+1。relations 或 leftJoinAndSelect,避免對列表中的每筆 Entity 逐一查詢關聯。await。但一旦忘記,資料就會在轉成 JSON 前變成無用的回傳空物件 {}。await 依然會吃效能:就算你沒 await 撈取資料,只要你在程式裡存取了 lazy 屬性,背後的 SQL 查詢就已經啟動。效能照吃,還拿不到資料。lazy: true 設定:為避免開發時忽略 I/O 成本,非特殊場景請盡量避免在 Entity 裡啟用 lazy。讓會觸發資料庫 I/O 的操作盡量保持明確,通常更容易閱讀、維護與做 code review。