分頁、過濾與排序(Pagination & Filtering)

是什麼?

分頁(Pagination)是將大量資料分批回傳的技術。過濾(Filtering)是讓客戶端指定篩選條件。排序(Sorting)是讓客戶端決定資料順序。三者搭配使用,確保 API 在大數據量下依然高效。

ℹ️為什麼不能一次全給?

一個有百萬筆資料的 API 如果不分頁:回應時間從毫秒變成分鐘、記憶體用量暴增、網路頻寬浪費、前端渲染卡死。分頁是 API 設計的必備項目

核心觀念

常見誤區

⚠️常見誤區

  • 深層 Offset 效能差OFFSET 1000000 在資料庫中仍需掃描前 100 萬筆,Cursor 分頁沒有這個問題
  • 不回傳分頁 Metadata:前端不知道總共幾頁、有沒有下一頁,無法正確顯示分頁 UI
  • 用 Cursor 分頁但需要跳頁:Cursor 不支援「跳到第 50 頁」,如果業務需求要跳頁就得用 Offset

流程/步驟

1

決定分頁策略

資料頻繁異動用 Cursor,需要跳頁用 Offset

2

設計查詢參數

定義 page/limit/cursor/sort/filter 參數格式

3

實作資料庫查詢

將查詢參數轉換為 SQL/ORM 條件

4

組裝回應 Metadata

計算 totalCount、hasNextPage、nextCursor

5

加入效能防護

限制 pageSize 上限(如 100),防止客戶端請求過多資料

流程解讀:先根據業務場景選定分頁策略,設計統一的查詢參數格式。資料庫查詢時注意索引優化,回應中包含足夠的 Metadata 讓前端渲染分頁元件。務必限制 pageSize 上限防止濫用。

程式碼範例

C# 版本

csharp
// 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 版本

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 版本

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
sends
Query Params (cursor/sort/filter)
parsed by
API Handler
optimized query
Database (indexed query)
returns page
Paged Response + Metadata

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 |

你可能也想看

API 錯誤處理API 認證機制

按 ← → 鍵切換課程