在開發 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
}
}
currentPageCount = 1)。這個問題的核心出在資料維度的改變。
我們期望拿到的是結構化的 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:

這就是為什麼明明設定了 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 後重新執行:
對 TypeORM 而言,這兩組 API 有著本質上的權責分工:
limit / offset → 直接限制 SQL 資料列,適合以查詢結果資料列為分頁單位的情境
take / skip → TypeORM 提供的分頁 API,在 JOIN 等複雜查詢中會進一步處理實體分頁
在目前的 TypeORM 版本(0.3.31)中,當使用 take/skip + JOIN 搭配 getManyAndCount() 時,TypeORM 並非只發送單一支 SQL,而是將請求拆解為「資料組裝」與「總數計算」兩大查詢階段:

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]。
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,
});
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) 的組合時:
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 + 關聯欄位排序 + 零筆與多筆關聯資料共存 時,極易觸發分頁截斷與總數計算失真。
遇到此類需求時,建議採取以下調整策略:
limit/offset 算資料列,take/skip 算實體:JOIN 會將 1 個實體展開為多個 SQL 資料列。limit/offset 切割的是資料庫資料列數量,會導致主實體數量不足、關聯資料截斷與跨頁重複;take/skip 才是針對實體層級的分頁 API。take/skip 背後的兩階段查詢:TypeORM 會先透過 DISTINCT 取得當頁的主實體 ID 清單,再透過 WHERE IN 發起第二次查詢載入完整關聯資料,確保實體數量與關聯完整度。DISTINCT 會被迫針對 (主實體 ID, 關聯資料 ID) 組合去重,導致 take/skip 算出的實體數量失真與 totalCount 異常。