iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 21 篇

Day 21|悄悄崩潰的效能:Lazy Loading 如何讓 N+1 藏進屬性存取裡

  • 分享至 

  • xImage
  •  

你在開發時是不是曾遇到這種狀況: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 趟。

https://ithelp.ithome.com.tw/upload/images/20261005/20184306KsL7QiwXB1.png

而 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 預設的關聯資料是不主動載入的

這裡有一個很重要的觀念要先分清楚:

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. 主查詢 (1):先查出 N 筆主資料。
  2. 關聯查詢 (N):再針對這 N 筆資料,各自查一次關聯。

最終就是典型的 1 + N 次查詢。

lazy: true 容易讓人忽略的地方,在於它把額外查詢藏進 user.posts 這種看似普通的屬性存取裡。程式碼表面上看不出明顯的資料庫操作,但一旦放進迴圈,每一次存取都可能再觸發一筆查詢,因此比直接在迴圈裡寫 Repository.find() 更容易躲過 code review。

更麻煩的是,這類問題在開發環境裡通常也不明顯。假設本機只有 3 筆測試資料,總共不過是 1 + 3 = 4 次查詢,幾乎感覺不到效能差異;但到了正式環境,如果一次處理 1000 筆資料,就可能膨脹成 1 + 1000 = 1001 次查詢。

排雷指南

解法一:明確載入需要的 Relation

如果這個 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,反倒造成查詢變慢。

解法二:使用 QueryBuilder (適合複雜條件)

如果需要更細緻的查詢條件、排序,或組合更多 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 成本一點都沒少。

總結

  1. Lazy Loading 會把查詢藏在屬性存取後面:await user.posts 看似只是讀取關聯,但放進 N 筆資料的迴圈後,就可能形成 N+1。
  2. 需要完整關聯資料時,應明確載入:可以使用 relations 或 leftJoinAndSelect,避免對列表中的每筆 Entity 逐一查詢關聯。
  3. 合法型別也可能隱藏地雷:TypeScript 不會強迫你對 Lazy Promise 加上 await。但一旦忘記,資料就會在轉成 JSON 前變成無用的回傳空物件 {}。
  4. 忘了 await 依然會吃效能:就算你沒 await 撈取資料,只要你在程式裡存取了 lazy 屬性,背後的 SQL 查詢就已經啟動。效能照吃,還拿不到資料。
  5. 慎用 lazy: true 設定:為避免開發時忽略 I/O 成本,非特殊場景請盡量避免在 Entity 裡啟用 lazy。讓會觸發資料庫 I/O 的操作盡量保持明確,通常更容易閱讀、維護與做 code review。

參考資料


上一篇
Day 20|隱藏的前置查詢:save() 如何在更新時悄悄吞噬你的資料庫效能?
下一篇
Day 22|消失的筆數:為什麼 TypeORM 做了 JOIN 後,分頁筆數總是對不上?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言