分頁、過濾與排序(Pagination & Filtering)
是什麼?
分頁(Pagination)是將大量資料分批回傳的技術。過濾(Filtering)是讓客戶端指定篩選條件。排序(Sorting)是讓客戶端決定資料順序。三者搭配使用,確保 API 在大數據量下依然高效。
ℹ️為什麼不能一次全給?
一個有百萬筆資料的 API 如果不分頁:回應時間從毫秒變成分鐘、記憶體用量暴增、網路頻寬浪費、前端渲染卡死。分頁是 API 設計的必備項目。
核心觀念
- Offset 分頁:用
page+pageSize或offset+limit,如?page=3&pageSize=20。簡單直覺,但資料異動時可能漏資料或重複 - Cursor 分頁:用上一頁最後一筆的標識(cursor)當起點,如
?cursor=abc123&limit=20。效能好、不漏資料,但不能跳頁 - 過濾:用查詢參數表示條件,如
?status=active&role=admin。複雜過濾可用filter[field]=value格式 - 排序:用
sort參數,如?sort=createdAt升序、?sort=-createdAt降序(減號代表降序) - 回應 Metadata:分頁回應必須包含
totalCount、hasNextPage、nextCursor等資訊
常見誤區
⚠️常見誤區
- 深層 Offset 效能差:
OFFSET 1000000在資料庫中仍需掃描前 100 萬筆,Cursor 分頁沒有這個問題 - 不回傳分頁 Metadata:前端不知道總共幾頁、有沒有下一頁,無法正確顯示分頁 UI
- 用 Cursor 分頁但需要跳頁:Cursor 不支援「跳到第 50 頁」,如果業務需求要跳頁就得用 Offset
流程/步驟
決定分頁策略
資料頻繁異動用 Cursor,需要跳頁用 Offset
設計查詢參數
定義 page/limit/cursor/sort/filter 參數格式
實作資料庫查詢
將查詢參數轉換為 SQL/ORM 條件
組裝回應 Metadata
計算 totalCount、hasNextPage、nextCursor
加入效能防護
限制 pageSize 上限(如 100),防止客戶端請求過多資料
流程解讀:先根據業務場景選定分頁策略,設計統一的查詢參數格式。資料庫查詢時注意索引優化,回應中包含足夠的 Metadata 讓前端渲染分頁元件。務必限制 pageSize 上限防止濫用。
程式碼範例
C# 版本
// Cursor 分頁實作
public record PagedResponse<T>(
IEnumerable<T> Data,
string? NextCursor,
bool HasNextPage,
int TotalCount
);
[HttpGet]
public async Task<ActionResult<PagedResponse<UserDto>>> GetUsers(
[FromQuery] string? cursor,
[FromQuery] int limit = 20,
[FromQuery] string? sort = "createdAt",
[FromQuery] string? status = null)
{
limit = Math.Min(limit, 100); // 防護:上限 100
var query = _dbContext.Users.AsQueryable();
// 過濾
if (!string.IsNullOrEmpty(status))
query = query.Where(u => u.Status == status);
// Cursor 分頁
if (!string.IsNullOrEmpty(cursor))
{
var cursorDate = DecodeCursor(cursor);
query = query.Where(u => u.CreatedAt > cursorDate);
}
// 排序
query = sort.StartsWith("-")
? query.OrderByDescending(u => EF.Property<object>(u, sort[1..]))
: query.OrderBy(u => EF.Property<object>(u, sort));
var totalCount = await query.CountAsync();
var users = await query.Take(limit + 1).ToListAsync();
var hasNext = users.Count > limit;
return Ok(new PagedResponse<UserDto>(
Data: users.Take(limit).Select(u => u.ToDto()),
NextCursor: hasNext ? EncodeCursor(users[limit - 1].CreatedAt) : null,
HasNextPage: hasNext,
TotalCount: totalCount
));
}TypeScript 版本
// Cursor 分頁 + 過濾 + 排序
router.get("/api/users", async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined;
const sort = (req.query.sort as string) || "createdAt";
const status = req.query.status as string | undefined;
let query: any = {};
if (status) query.status = status;
if (cursor) query.createdAt = { $gt: decodeCursor(cursor) };
const sortDir = sort.startsWith("-") ? -1 : 1;
const sortField = sort.replace(/^-/, "");
const users = await User.find(query)
.sort({ [sortField]: sortDir })
.limit(limit + 1);
const hasNext = users.length > limit;
const data = users.slice(0, limit);
res.json({
data,
nextCursor: hasNext ? encodeCursor(data[data.length - 1].createdAt) : null,
hasNextPage: hasNext,
});
});Python 版本
# FastAPI — Offset 分頁 + 過濾
from fastapi import FastAPI, Query
@app.get("/api/users")
async def get_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str | None = None,
sort: str = "created_at",
):
query = db.query(User)
if status:
query = query.filter(User.status == status)
# 排序
if sort.startswith("-"):
query = query.order_by(getattr(User, sort[1:]).desc())
else:
query = query.order_by(getattr(User, sort))
total = query.count()
offset = (page - 1) * page_size
users = query.offset(offset).limit(page_size).all()
return {
"data": users,
"page": page,
"page_size": page_size,
"total_count": total,
"has_next_page": offset + page_size < total,
}架構圖/概念圖
Client 將分頁、排序、過濾條件以 Query Params 傳入。API Handler 解析參數後組成優化的資料庫查詢。回應包含該頁資料和 Metadata,讓 Client 知道如何取得下一頁。
實戰補充
Q: 什麼時候用 Cursor、什麼時候用 Offset?
A: Cursor:社群動態、聊天紀錄、即時更新的資料列表 — 資料頻繁新增刪除,不需要跳頁。Offset:後台管理介面、搜尋結果 — 使用者需要「跳到第 N 頁」的功能,資料異動頻率低。
Q: Cursor 要用什麼值?
A: 常見選擇:自增 ID、時間戳、或兩者組合。用 Base64 編碼讓 cursor 變成不透明的字串(opaque token),客戶端不需要理解其內部結構。
理解測驗
🤔 Offset 分頁在深層頁面(如第 10,000 頁)有什麼問題?
🤔 API 回應的分頁 Metadata 為什麼重要?
🤔 為什麼要限制 pageSize 的上限?
重點整理
💡一句話記住
分頁 = 把大資料切小塊,Cursor 效能好、Offset 能跳頁。 口訣:「即時用 Cursor,跳頁用 Offset」
| 概念 | 說明 |
|------|------|
| Offset 分頁 | page + pageSize,簡單但深層效能差 |
| Cursor 分頁 | 用上一筆的標識定位,效能穩定但不能跳頁 |
| 過濾 | 查詢參數篩選,如 ?status=active |
| 排序 | ?sort=field 升序、?sort=-field 降序 |
| 防護 | 限制 pageSize 上限,必須回傳 Metadata |