iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Modern Web

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

Day 22|消失的筆數:為什麼 TypeORM 做了 JOIN 後,分頁筆數總是對不上?

  • 分享至 

  • xImage
  •  

在開發 API 時,若實體之間定義了關聯,我們經常會使用 relations 或 leftJoinAndSelect() 來一次性載入相關聯的資料。

然而,當這種一次性載入關聯資料的作法遇上「分頁」需求時,如果對 TypeORM 的底層機制不夠熟悉,很容易習慣性地使用 limit/offset。隨之而來的,往往是一連串的分頁問題:每頁回傳的使用者數量不足、關聯資料被硬生生裁切,甚至同一個使用者跨頁重複出現。

為什麼明明下了分頁限制,結果卻與預期大相逕庭?本篇將帶大家拆解 JOIN 究竟是如何干擾分頁機制的,並深入分析 TypeORM 中 take/skip 與 limit/offset 在底層運作邏輯上的本質差異。

問題怎麼發生?

本篇要實作的 API 主要功能是查詢使用者及其文章列表,並支援動態分頁。

為了觀察 TypeORM 的底層行為,我們設定了以下的測試情境:當發起 page = 1 且 pageSize = 2 的請求時,預期要取得前 2 位使用者,並一併完整載入各自的所有文章。

首先建立 User 與 Post 兩個實體,一位使用者可以擁有多篇文章:

// user.entity.ts
@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @OneToMany(() => Post, (post) => post.author)
  posts: Post[];
}
// post.entity.ts
@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @ManyToOne(() => User, (user) => user.posts, {
    nullable: false,
  })
  author: User;
}

假設資料庫中有以下測試資料:

User Post 數量
User 1 3 篇
User 2 2 篇
User 3 0 篇

定義查詢參數 DTO:

// pagination-query.dto.ts
export class PaginationQueryDto {
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page = 1;

  @Type(() => Number)
  @IsInt()
  @Min(1)
  pageSize = 2;
}

Controller 負責接收動態分頁參數,並轉交給 Service 處理:

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  getUsers(
    @Query(new ValidationPipe({ transform: true })) query: PaginationQueryDto,
  ) {
    return this.usersService.getUsers(query.page, query.pageSize);
  }
}

在 Service 中,使用 QueryBuilder 進行 LEFT JOIN,並搭配 limit/offset 處理分頁:

async getUsers(page: number, pageSize: number) {
  const offset = (page - 1) * pageSize;

  const [data, totalCount] = await this.usersRepository
    .createQueryBuilder('user')
    .leftJoinAndSelect('user.posts', 'post')
    .orderBy('user.id', 'ASC')
    .limit(pageSize)
    .offset(offset)
    .getManyAndCount();

  return this.buildResponse(data, page, pageSize, totalCount);
}

private buildResponse(
  data: User[],
  page: number,
  pageSize: number,
  totalCount: number,
) {
  return {
    data,
    meta: {
      page,
      pageSize,
      currentPageCount: data.length, // 這一頁實際回傳的 User 數量
      totalCount,                    // 符合條件的 User 總筆數
    },
  };
}

套用測試條件發起 HTTP 請求:

GET /users?page=1&pageSize=2

得到的結果卻跟預期的不一樣:

{
  "data": [
    {
      "id": 1,
      "name": "User 1",
      "posts": [
        { "id": 1, "title": "User 1 的第 1 篇文章" },
        { "id": 2, "title": "User 1 的第 2 篇文章" }
      ]
    }
  ],
  "meta": {
    "page": 1,
    "pageSize": 2,
    "currentPageCount": 1,
    "totalCount": 3
  }
}
  1. 主實體數量不足:預期要取得 2 位使用者,實際卻只拿到 1 位(currentPageCount = 1)。
  2. 關聯資料被截斷:User 1 本來有 3 篇文章,結果只有前 2 篇被載入,第 3 篇直接消失。

根因:JOIN 後的一列不再等於一位 User

這個問題的核心出在資料維度的改變。

我們期望拿到的是結構化的 User 實體,但在 SQL 資料庫層面,執行 LEFT JOIN 後,資料會先被展開成「扁平化的查詢結果」。

User 與 Post JOIN 展開後,資料庫實際產生的資料列如下:

JOIN 後的資料列 user.id user.name post.id
1 1 User 1 1
2 1 User 1 2
3 1 User 1 3
4 2 User 2 4
5 2 User 2 5
6 3 User 3 NULL

這時,第一頁執行的 SQL 帶有:

LIMIT 2 OFFSET 0

資料庫會直接裁切 JOIN 結果的前兩列:

JOIN 後的資料列 user.id post.id
1 1 1
2 1 2

這兩列資料的 user.id 全都是 1。當 TypeORM 拿到這兩列原始資料並進行實體組裝(Hydration)時,會依據主鍵將重複的資料合併為同一位 User:

https://ithelp.ithome.com.tw/upload/images/20261006/20184306VYPGqFnbDY.png

這就是為什麼明明設定了 limit(2),最後 currentPageCount 卻只有 1 的真正原因——LIMIT 2 限制的是資料庫的「資料列數量」,而不是 ORM 最終還原的「實體數量」。

當請求第二頁 LIMIT 2 OFFSET 2 時,資料庫會切出第 3、4 列:

JOIN 後的資料列 user.id post.id
3 1 3
4 2 4

這導致 User 1 再次出現在第二頁(帶著他的第 3 篇文章),而 User 2 也只被載入了第一篇文章。兩位使用者的關聯資料都被 SQL LIMIT 給截斷了。

為什麼 currentPageCount 是 1,totalCount 卻是 3?

這裡許多人會產生疑問:既然第一頁只拿到 1 位 User,為什麼總數還會是 3?

這兩個數字代表完全不同的意義:

  • currentPageCount:等於 data.length,即當前頁面實體組裝完成後產生的 User 實體數量。
  • totalCount:來自 getManyAndCount() 執行的獨立查詢,代表符合條件的主實體(User)總數。

系統中確實共有 3 位 User,因此 totalCount = 3 是正確的。

排雷指南:使用 take/skip 表達實體分頁

為解決這個問題,我們將分頁 API 改用 TypeORM 專門設計的 take/skip:

async getUsers(page: number, pageSize: number) {
  const skip = (page - 1) * pageSize;

  const [data, totalCount] = await this.usersRepository
    .createQueryBuilder('user')
    .leftJoinAndSelect('user.posts', 'post')
    .orderBy('user.id', 'ASC')
    .take(pageSize)
    .skip(skip)
    .getManyAndCount();

  return this.buildResponse(data, page, pageSize, totalCount);
}

套用 take/skip 後重新執行:

  • 第一頁:回傳 User 1、User 2,且分別帶有完整的文章。
  • 第二頁:僅回傳沒有文章的 User 3。

對 TypeORM 而言,這兩組 API 有著本質上的權責分工:

limit / offset  →  直接限制 SQL 資料列,適合以查詢結果資料列為分頁單位的情境
take / skip     →  TypeORM 提供的分頁 API,在 JOIN 等複雜查詢中會進一步處理實體分頁

TypeORM 背後做了什麼?

在目前的 TypeORM 版本(0.3.31)中,當使用 take/skip + JOIN 搭配 getManyAndCount() 時,TypeORM 並非只發送單一支 SQL,而是將請求拆解為「資料組裝」與「總數計算」兩大查詢階段:

https://ithelp.ithome.com.tw/upload/images/20261006/20184306ujOS5jQofj.png

第一階段:資料組裝

  • 步驟 1:取得不重複的分頁主鍵
SELECT DISTINCT user.id
FROM user
LEFT JOIN post ON post.authorId = user.id
ORDER BY user.id ASC
LIMIT 2 OFFSET 0;

先透過 DISTINCT 剔除 JOIN 產生的重複資料列,專注對「User ID」進行分頁,取得第一頁對應的主鍵列表:[1, 2]。

  • 步驟 2:依主鍵載入完整實體與關聯資料
SELECT user.*, post.*
FROM user
LEFT JOIN post ON post.authorId = user.id
WHERE user.id IN (1, 2)
ORDER BY user.id ASC;

拿到 2 個 User ID 後,第二支查詢使用 WHERE IN (1, 2) 載入完整資料。此時不再需要 LIMIT,這兩位 User 的所有文章均能完整載入。

第二階段:計算主實體總數

getManyAndCount() 會發起另一支獨立 SQL 來計算全域的 totalCount:

SELECT COUNT(DISTINCT user.id)
FROM user
LEFT JOIN post ON post.authorId = user.id;

透過 COUNT(DISTINCT user.id) 計算出不重複的使用者總數(3),作為 totalCount 回傳。

此運作邏輯同樣適用於 Repository 查詢 API:

const [users, total] = await this.usersRepository.findAndCount({
  relations: { posts: true },
  take: 2,
  skip: 0,
});

延伸陷阱:JOIN + take/skip + 關聯欄位排序

雖然 take/skip 解決了基本 JOIN 分頁問題,但如果引入了「關聯欄位排序」,事情會再次變得複雜。

假設我們想依「文章 ID 倒序」來排序使用者列表:

async getUsers(page: number, pageSize: number) {
  const skip = (page - 1) * pageSize;

  const [data, totalCount] = await this.usersRepository
    .createQueryBuilder('user')
    .leftJoinAndSelect('user.posts', 'post')
    .orderBy('post.id', 'DESC') // 依關聯欄位排序
    .take(pageSize)
    .skip(skip)
    .getManyAndCount();

  return this.buildResponse(data, page, pageSize, totalCount);
}

在 TypeORM 0.3.31 / SQLite 環境下執行分頁,會出現以下結果:

頁碼 (page) data 的 User IDs currentPageCount totalCount
1 [2] 1 1
2 [1] 1 3
3 [1, 3] 2 3

不僅 User 1 在第二頁和第三頁重複出現,第一頁回傳的 totalCount 甚至被錯估為 1。

DISTINCT 為什麼沒有把 User 去重?

查看第一階段產生的 SQL,會發現為了滿足 ORDER BY post.id 的需求,SQL 必須將 post.id 一併加入 SELECT 中:

SELECT DISTINCT
  distinctAlias.user_id,
  distinctAlias.post_id
FROM (... JOIN 查詢 ...) distinctAlias
ORDER BY distinctAlias.post_id DESC, distinctAlias.user_id ASC
LIMIT 2 OFFSET 0;

當 DISTINCT 作用的目標變成 (user_id, post_id) 的組合時:

  • User 2 有兩篇文章(post_id 5 與 4),產生的組合為 (2, 5) 與 (2, 4)。
  • 這兩個組合被視為不同紀錄,占滿了 LIMIT 2 的兩個名額。

當資料回到 TypeORM 進行實體組裝時,(2, 5) 與 (2, 4) 被合併回同一個 User 2 實體,導致第一頁最後只剩下 1 位 User。

此外,TypeORM 內部存在一個捷徑機制:當組裝完成後的實體數量少於 take 數量時,它會推論「已無更多資料」,並以 skip + entities.length 計算總數,進而導出 0 + 1 = 1 的錯誤 totalCount。

此現象已在 TypeORM Issue #11744 被確認為 Regression 問題。當同時滿足 QueryBuilder 分頁 + JOIN + 關聯欄位排序 + 零筆與多筆關聯資料共存 時,極易觸發分頁截斷與總數計算失真。

關聯欄位排序的處理方向

遇到此類需求時,建議採取以下調整策略:

  1. 重新定義主實體的唯一排序依據:思考能代表主實體本身順序的規則,例如「每位使用者最新建立文章的時間」或「每位使用者第一篇文章的建立時間」,而不是直接傳入一對多的關聯欄位。
  2. 確保分頁單位仍是主實體:當 JOIN、關聯排序與分頁同時出現時,隨時確認 TypeORM 最終切割的單位究竟是「主實體」還是「展開後的資料列」。
  3. 將排序與分頁邏輯拆開:當查詢變得複雜時,可以先對主實體進行獨立的排序與 ID 分頁,再發起第二次查詢載入完整的關聯資料,避免直接對 JOIN 展開後的資料列進行分頁。

總結

  1. limit/offset 算資料列,take/skip 算實體:JOIN 會將 1 個實體展開為多個 SQL 資料列。limit/offset 切割的是資料庫資料列數量,會導致主實體數量不足、關聯資料截斷與跨頁重複;take/skip 才是針對實體層級的分頁 API。
  2. take/skip 背後的兩階段查詢:TypeORM 會先透過 DISTINCT 取得當頁的主實體 ID 清單,再透過 WHERE IN 發起第二次查詢載入完整關聯資料,確保實體數量與關聯完整度。
  3. 警惕關聯欄位排序破壞去重:當排序條件包含一對多關聯欄位時,DISTINCT 會被迫針對 (主實體 ID, 關聯資料 ID) 組合去重,導致 take/skip 算出的實體數量失真與 totalCount 異常。

參考資料


上一篇
Day 21|悄悄崩潰的效能:Lazy Loading 如何讓 N+1 藏進屬性存取裡
下一篇
Day 23|消失的回滾:ROLLBACK 了資料卻還在?別讓預設的 Repository 偷溜出去
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言