Phân Trang API REST: Hướng Dẫn Từng Bước Chi Tiết

Mark Ponomarev

Mark Ponomarev

19 tháng 5 2025

Phân Trang API REST: Hướng Dẫn Từng Bước Chi Tiết

Khi xây dựng các API REST trả về danh sách tài nguyên, điều quan trọng là phải xem xét cách xử lý các tập dữ liệu lớn. Việc trả về hàng nghìn hoặc thậm chí hàng triệu bản ghi trong một phản hồi API duy nhất là không thực tế và có thể dẫn đến các vấn đề hiệu suất đáng kể, tiêu thụ bộ nhớ cao cho cả máy chủ và máy khách, và trải nghiệm người dùng kém. Phân trang (Pagination) là giải pháp tiêu chuẩn cho vấn đề này. Nó bao gồm việc chia một tập dữ liệu lớn thành các phần nhỏ hơn, dễ quản lý hơn gọi là "trang" (pages), sau đó được phục vụ tuần tự. Hướng dẫn này sẽ đưa bạn qua các bước kỹ thuật để triển khai các chiến lược phân trang khác nhau trong các API REST của bạn.

💡
Bạn muốn một công cụ Kiểm thử API tuyệt vời có thể tạo Tài liệu API đẹp mắt?

Bạn muốn một nền tảng tích hợp, All-in-One cho Đội ngũ Phát triển của bạn làm việc cùng nhau với năng suất tối đa?

Apidog đáp ứng tất cả nhu cầu của bạn và thay thế Postman với mức giá phải chăng hơn nhiều!
button

Tại sao Phân trang Lại Cần Thiết?

Trước khi đi sâu vào chi tiết triển khai, hãy cùng điểm qua lý do tại sao phân trang là một tính năng không thể thiếu đối với các API xử lý các bộ sưu tập tài nguyên:

  1. Hiệu suất: Yêu cầu và truyền lượng lớn dữ liệu có thể chậm. Phân trang giảm kích thước tải trọng của mỗi yêu cầu, dẫn đến thời gian phản hồi nhanh hơn và giảm tải cho máy chủ.
  2. Tiêu thụ Tài nguyên: Các phản hồi nhỏ hơn tiêu thụ ít bộ nhớ hơn trên máy chủ tạo ra chúng và trên máy khách phân tích cú pháp chúng. Điều này đặc biệt quan trọng đối với các máy khách di động hoặc môi trường có tài nguyên hạn chế.
  3. Giới hạn Tốc độ và Hạn ngạch (Rate Limiting and Quotas): Nhiều API áp dụng giới hạn tốc độ. Phân trang giúp máy khách tuân thủ các giới hạn này bằng cách tìm nạp dữ liệu theo từng phần nhỏ theo thời gian, thay vì cố gắng lấy tất cả cùng một lúc.
  4. Trải nghiệm Người dùng: Đối với giao diện người dùng sử dụng API, việc trình bày dữ liệu theo trang thân thiện với người dùng hơn nhiều so với việc làm người dùng choáng ngợp với một danh sách khổng lồ hoặc một cuộn rất dài.
  5. Hiệu quả Cơ sở dữ liệu: Tìm nạp một tập hợp con dữ liệu nói chung ít gây áp lực lên cơ sở dữ liệu hơn so với việc truy xuất toàn bộ bảng, đặc biệt nếu việc lập chỉ mục (indexing) phù hợp đã được thực hiện.

Các Chiến lược Phân trang Phổ biến

Có một số chiến lược phổ biến để triển khai phân trang, mỗi chiến lược đều có những đánh đổi riêng. Chúng ta sẽ khám phá những chiến lược phổ biến nhất: offset/limit (thường được gọi là phân trang theo trang) và cursor-based (còn được gọi là keyset hoặc seek pagination).

1. Phân trang theo Offset/Limit (hoặc theo Trang)

Đây có lẽ là phương pháp phân trang đơn giản và được áp dụng rộng rãi nhất. Nó hoạt động bằng cách cho phép máy khách chỉ định hai tham số chính:

Ngoài ra, máy khách có thể chỉ định:

Tham số offset có thể được tính từ pagepageSize bằng công thức: offset = (page - 1) * pageSize.

Các Bước Triển khai Kỹ thuật:

Giả sử chúng ta có một điểm cuối API /items trả về danh sách các mục.

a. Tham số Yêu cầu API:
Máy khách sẽ thực hiện yêu cầu như:
GET /items?offset=20&limit=10 (tìm nạp 10 mục, bỏ qua 20 mục đầu tiên)
hoặc
GET /items?page=3&pageSize=10 (tìm nạp trang thứ 3, với 10 mục trên mỗi trang, tương đương với offset=20, limit=10).

Nên đặt các giá trị mặc định cho các tham số này (ví dụ: limit=20, offset=0 hoặc page=1, pageSize=20) nếu máy khách không cung cấp chúng. Đồng thời, áp đặt một giới hạn tối đa cho limit hoặc pageSize để ngăn máy khách yêu cầu số lượng bản ghi quá lớn, có thể gây căng thẳng cho máy chủ.

b. Logic Backend (Khái niệm):
Khi máy chủ nhận được yêu cầu này, nó cần dịch các tham số này thành một truy vấn cơ sở dữ liệu.

// Ví dụ trong Java với Spring Boot
@GetMapping("/items")
public ResponseEntity<PaginatedResponse<Item>> getItems(
    @RequestParam(defaultValue = "0") int offset,
    @RequestParam(defaultValue = "20") int limit
) {
    // Xác thực giới hạn để ngăn chặn lạm dụng
    if (limit > 100) {
        limit = 100; // Áp đặt giới hạn tối đa
    }

    List<Item> items = itemRepository.findItemsWithOffsetLimit(offset, limit);
    long totalItems = itemRepository.countTotalItems(); // Dành cho metadata

    // Xây dựng và trả về phản hồi phân trang
    // ...
}

c. Truy vấn Cơ sở dữ liệu (Ví dụ SQL):
Hầu hết các cơ sở dữ liệu quan hệ đều hỗ trợ trực tiếp các mệnh đề offset và limit.

Đối với PostgreSQL hoặc MySQL:

SELECT *
FROM items
ORDER BY created_at DESC -- Sắp xếp nhất quán là rất quan trọng cho phân trang ổn định
LIMIT 10 -- Đây là tham số 'limit'
OFFSET 20; -- Đây là tham số 'offset'

Đối với SQL Server (các phiên bản cũ hơn có thể sử dụng ROW_NUMBER()):

SELECT *
FROM items
ORDER BY created_at DESC
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY;

Đối với Oracle:

SELECT *
FROM (
    SELECT i.*, ROWNUM rnum
    FROM (
        SELECT *
        FROM items
        ORDER BY created_at DESC
    ) i
    WHERE ROWNUM <= 20 + 10 -- offset + limit
)
WHERE rnum > 20; -- offset

Lưu ý Quan trọng về Sắp xếp: Để phân trang offset/limit đáng tin cậy, tập dữ liệu cơ bản phải được sắp xếp theo một khóa nhất quán và duy nhất (hoặc gần duy nhất), hoặc một sự kết hợp các khóa. Nếu thứ tự của các mục có thể thay đổi giữa các yêu cầu (ví dụ: các mục mới được chèn hoặc các mục hiện có được cập nhật theo cách ảnh hưởng đến thứ tự sắp xếp của chúng), người dùng có thể thấy các mục trùng lặp hoặc bỏ lỡ các mục khi điều hướng giữa các trang. Một lựa chọn phổ biến là sắp xếp theo dấu thời gian tạo (creation timestamp) hoặc ID chính (primary ID).

d. Cấu trúc Phản hồi API:
Một phản hồi phân trang tốt không chỉ bao gồm dữ liệu cho trang hiện tại mà còn cả metadata để giúp máy khách điều hướng.

{
  "data": [
    // mảng các mục cho trang hiện tại
    { "id": "item_21", "name": "Item 21", ... },
    { "id": "item_22", "name": "Item 22", ... },
    // ... tối đa 'limit' mục
    { "id": "item_30", "name": "Item 30", ... }
  ],
  "pagination": {
    "offset": 20,
    "limit": 10,
    "totalItems": 5000, // Tổng số mục có sẵn
    "totalPages": 500, // Tính bằng ceil(totalItems / limit)
    "currentPage": 3 // Tính bằng (offset / limit) + 1
  },
  "links": { // Liên kết HATEOAS để điều hướng
    "self": "/items?offset=20&limit=10",
    "first": "/items?offset=0&limit=10",
    "prev": "/items?offset=10&limit=10", // Null nếu ở trang đầu tiên
    "next": "/items?offset=30&limit=10", // Null nếu ở trang cuối cùng
    "last": "/items?offset=4990&limit=10"
  }
}

Việc cung cấp các liên kết HATEOAS (Hypermedia as the Engine of Application State) (self, first, prev, next, last) là một thực hành tốt nhất của REST. Nó cho phép máy khách điều hướng qua các trang mà không cần phải tự xây dựng URL.

Ưu điểm của Phân trang Offset/Limit:

Nhược điểm của Phân trang Offset/Limit:

2. Phân trang theo Con trỏ (Cursor-Based) (Keyset/Seek)

Phân trang theo con trỏ giải quyết một số hạn chế của offset/limit, đặc biệt là hiệu suất với các tập dữ liệu lớn và các vấn đề về tính nhất quán của dữ liệu. Thay vì dựa vào một offset tuyệt đối, nó sử dụng một "con trỏ" (cursor) trỏ đến một mục cụ thể trong tập dữ liệu. Máy khách sau đó yêu cầu các mục "sau" hoặc "trước" con trỏ này.

Con trỏ thường là một chuỗi mờ (opaque string) mã hóa giá trị của khóa sắp xếp của mục cuối cùng được truy xuất trên trang trước.

Các Bước Triển khai Kỹ thuật:

a. Tham số Yêu cầu API:
Máy khách sẽ thực hiện yêu cầu như:
GET /items?limit=10 (cho trang đầu tiên)
Và cho các trang tiếp theo:
GET /items?limit=10&after_cursor=opaquestringrepresentinglastitemid
Hoặc, để phân trang ngược (ít phổ biến hơn nhưng có thể):
GET /items?limit=10&before_cursor=opaquestringrepresentingfirstitemid

Tham số limit vẫn xác định kích thước trang.

b. Con trỏ là gì?
Một con trỏ nên:

c. Logic Backend (Khái niệm):

// Ví dụ trong Java với Spring Boot
@GetMapping("/items")
public ResponseEntity<CursorPaginatedResponse<Item>> getItems(
    @RequestParam(defaultValue = "20") int limit,
    @RequestParam(required = false) String afterCursor
) {
    // Xác thực giới hạn
    if (limit > 100) {
        limit = 100;
    }

    // Giải mã con trỏ để lấy thuộc tính của mục cuối cùng đã thấy
    // ví dụ: LastSeenItemDetails lastSeen = decodeCursor(afterCursor);
    // Nếu afterCursor là null, đó là trang đầu tiên.

    List<Item> items;
    if (afterCursor != null) {
        DecodedCursor decoded = decodeCursor(afterCursor); // ví dụ: { lastId: "some_uuid", lastCreatedAt: "timestamp" }
        items = itemRepository.findItemsAfter(decoded.getLastCreatedAt(), decoded.getLastId(), limit);
    } else {
        items = itemRepository.findFirstPage(limit);
    }

    String nextCursor = null;
    if (!items.isEmpty() && items.size() == limit) {
        // Giả sử các mục được sắp xếp, mục cuối cùng trong danh sách được sử dụng để tạo con trỏ tiếp theo
        Item lastItemOnPage = items.get(items.size() - 1);
        nextCursor = encodeCursor(lastItemOnPage.getCreatedAt(), lastItemOnPage.getId());
    }

    // Xây dựng và trả về phản hồi phân trang theo con trỏ
    // ...
}

// Phương thức trợ giúp để mã hóa/giải mã con trỏ
// private DecodedCursor decodeCursor(String cursor) { ... }
// private String encodeCursor(Timestamp createdAt, String id) { ... }

d. Truy vấn Cơ sở dữ liệu (Ví dụ SQL):
Điểm mấu chốt là sử dụng mệnh đề WHERE để lọc các bản ghi dựa trên khóa sắp xếp từ con trỏ. Mệnh đề ORDER BY phải phù hợp với cấu tạo của con trỏ.

Giả sử sắp xếp theo created_at (giảm dần) và sau đó theo id (giảm dần) làm khóa phụ để đảm bảo thứ tự ổn định nếu created_at không duy nhất:

Đối với trang đầu tiên:

SELECT *
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 10;

Đối với các trang tiếp theo, nếu con trỏ được giải mã thành last_created_at_from_cursorlast_id_from_cursor:

SELECT *
FROM items
WHERE (created_at, id) < (CAST('last_created_at_from_cursor' AS TIMESTAMP), CAST('last_id_from_cursor' AS UUID)) -- Hoặc các kiểu dữ liệu phù hợp
-- Đối với thứ tự tăng dần, sẽ là >
-- So sánh tuple (created_at, id) < (val1, val2) là một cách viết ngắn gọn cho:
-- WHERE created_at < 'last_created_at_from_cursor'
--    OR (created_at = 'last_created_at_from_cursor' AND id < 'last_id_from_cursor')
ORDER BY created_at DESC, id DESC
LIMIT 10;

Loại truy vấn này rất hiệu quả, đặc biệt nếu có chỉ mục trên (created_at, id). Cơ sở dữ liệu có thể trực tiếp "tìm kiếm" (seek) đến điểm bắt đầu mà không cần quét các hàng không liên quan.

e. Cấu trúc Phản hồi API:

{
  "data": [
    // mảng các mục cho trang hiện tại
    { "id": "item_N", "createdAt": "2023-10-27T10:05:00Z", ... },
    // ... tối đa 'limit' mục
    { "id": "item_M", "createdAt": "2023-10-27T10:00:00Z", ... }
  ],
  "pagination": {
    "limit": 10,
    "hasNextPage": true, // boolean cho biết có còn dữ liệu nữa không
    "nextCursor": "base64encodedcursorstringforitem_M" // chuỗi mờ
    // Có thể có "prevCursor" nếu hỗ trợ con trỏ hai chiều
  },
  "links": {
    "self": "/items?limit=10&after_cursor=current_request_cursor_if_any",
    "next": "/items?limit=10&after_cursor=base64encodedcursorstringforitem_M" // Null nếu không có trang tiếp theo
  }
}

Lưu ý rằng phân trang theo con trỏ thường không cung cấp totalPages hoặc totalItems vì việc tính toán chúng sẽ yêu cầu quét toàn bộ bảng, làm mất đi một số lợi ích về hiệu suất. Nếu những thông tin này thực sự cần thiết, có thể cung cấp một điểm cuối riêng biệt hoặc một ước tính.

Ưu điểm của Phân trang theo Con trỏ:

Nhược điểm của Phân trang theo Con trỏ:

Chọn Chiến lược Phù hợp

Việc lựa chọn giữa phân trang offset/limit và cursor-based phụ thuộc vào các yêu cầu cụ thể của bạn:

Trong một số hệ thống, một cách tiếp cận kết hợp cũng được sử dụng, hoặc các chiến lược khác nhau được cung cấp cho các trường hợp sử dụng hoặc điểm cuối khác nhau.

Các Thực hành Tốt nhất khi Triển khai Phân trang

Bất kể chiến lược đã chọn là gì, hãy tuân thủ các thực hành tốt nhất sau:

  1. Đặt tên Tham số Nhất quán: Sử dụng tên rõ ràng và nhất quán cho các tham số phân trang của bạn (ví dụ: limit, offset, page, pageSize, after_cursor, before_cursor). Tuân thủ một quy ước (ví dụ: camelCase hoặc snake_case) trong toàn bộ API của bạn.
  2. Cung cấp Liên kết Điều hướng (HATEOAS): Như đã trình bày trong các ví dụ phản hồi, bao gồm các liên kết cho self, next, prev, first, và last (nếu áp dụng). Điều này làm cho API dễ khám phá hơn và tách rời máy khách khỏi logic xây dựng URL.
  3. Giá trị Mặc định và Giới hạn Tối đa:
  1. Tài liệu API Rõ ràng: Tài liệu hóa chiến lược phân trang của bạn một cách kỹ lưỡng:
  1. Sắp xếp Nhất quán: Đảm bảo rằng dữ liệu cơ bản được sắp xếp nhất quán cho mọi yêu cầu phân trang. Đối với offset/limit, điều này là rất quan trọng để tránh lệch dữ liệu. Đối với cursor-based, thứ tự sắp xếp quyết định cách con trỏ được xây dựng và giải thích. Sử dụng một cột phụ duy nhất (như ID chính) nếu cột sắp xếp chính có thể có giá trị trùng lặp.
  2. Xử lý các Trường hợp Ngoại lệ (Edge Cases):
  1. Cân nhắc về Tổng số (Total Count):
  1. Xử lý Lỗi: Trả về các mã trạng thái HTTP phù hợp cho các lỗi (ví dụ: 400 cho đầu vào xấu, 500 cho lỗi máy chủ trong quá trình tìm nạp dữ liệu).
  2. Bảo mật: Mặc dù không trực tiếp là cơ chế phân trang, hãy đảm bảo rằng dữ liệu đang được phân trang tuân thủ các quy tắc ủy quyền. Người dùng chỉ nên có thể phân trang qua dữ liệu mà họ được phép xem.
  3. Bộ nhớ đệm (Caching): Các phản hồi phân trang thường có thể được lưu vào bộ nhớ đệm. Đối với phân trang dựa trên offset, GET /items?page=2&pageSize=10 có khả năng lưu bộ nhớ đệm cao. Đối với phân trang dựa trên con trỏ, GET /items?limit=10&after_cursor=XYZ cũng có thể lưu bộ nhớ đệm. Đảm bảo chiến lược bộ nhớ đệm của bạn hoạt động tốt với cách các liên kết phân trang được tạo và sử dụng. Cần xem xét các chiến lược vô hiệu hóa nếu dữ liệu cơ bản thay đổi thường xuyên.

Các Chủ đề Nâng cao (Đề cập Ngắn gọn)

Kết luận

Triển khai phân trang một cách chính xác là nền tảng để xây dựng các API REST có khả năng mở rộng và thân thiện với người dùng. Mặc dù phân trang offset/limit đơn giản hơn để bắt đầu, phân trang theo con trỏ mang lại hiệu suất và tính nhất quán vượt trội cho các tập dữ liệu lớn, động. Bằng cách hiểu chi tiết kỹ thuật của từng chiến lược, chọn chiến lược phù hợp nhất với nhu cầu ứng dụng của bạn và tuân thủ các thực hành tốt nhất cho việc triển khai và thiết kế API, bạn có thể đảm bảo rằng API của mình phân phối dữ liệu hiệu quả đến máy khách, bất kể quy mô nào. Hãy nhớ luôn ưu tiên tài liệu rõ ràng và xử lý lỗi mạnh mẽ để mang lại trải nghiệm mượt mà cho người dùng API.


Thực hành thiết kế API trong Apidog

Khám phá cách dễ dàng hơn để xây dựng và sử dụng API