Phân Trang Dựa Trên Con Trỏ Hay Phân Trang Dựa Trên Offset: API Của Bạn Nên Dùng Loại Nào?

So sánh phân trang dựa trên con trỏ với phân trang dựa trên offset: hiện tượng trôi trang, chi phí offset sâu, SQL bộ khóa, ví dụ từ Stripe và Slack, và cách kiểm thử cả hai trong Apidog.

INEZA Felin-Michel

INEZA Felin-Michel

31 tháng 8 2026

Phân Trang Dựa Trên Con Trỏ Hay Phân Trang Dựa Trên Offset: API Của Bạn Nên Dùng Loại Nào?

Apidog cho doanh nghiệp

Triển khai tại chỗ

SSO & RBAC

Tuân thủ SOC 2

Khám phá Apidog Enterprise

Mọi endpoint danh sách cuối cùng đều đối mặt với cùng một câu hỏi: làm thế nào để bạn chia 2 triệu đơn hàng thành các trang để client có thể duyệt qua? Chọn phân trang offset và bạn sẽ có SQL đơn giản cùng với số trang mà người dùng dễ hiểu. Chọn phân trang dựa trên con trỏ (cursor-based) và bạn sẽ có kết quả ổn định cùng với độ trễ nhất quán ở bất kỳ độ sâu nào, nhưng bạn sẽ từ bỏ khả năng “nhảy đến trang 47.”

Hầu hết các nhóm chọn phân trang offset vì nó là mặc định trong mọi hướng dẫn. Sau đó, khi bảng đơn hàng đạt vài triệu dòng, trang 4.000 bắt đầu hết thời gian chờ, và người dùng báo cáo thấy cùng một bản ghi hai lần khi cuộn. Hướng dẫn này sẽ trình bày cách hoạt động của cả hai kiểu, nơi phân trang offset gặp vấn đề, tại sao Stripe và Slack sử dụng con trỏ, và cách kiểm tra từng kiểu bằng các yêu cầu nối tiếp trong Apidog. Đến cuối cùng, bạn sẽ biết chính xác kiểu nào phù hợp với endpoint của mình.

Nếu bạn muốn có cái nhìn tổng quan trước, hướng dẫn phân trang API của chúng tôi bao gồm mọi chiến lược một cách song song. Bài viết này đi sâu vào hai chiến lược quan trọng nhất.

Phân trang offset hoạt động như thế nào

Phân trang offset ánh xạ trực tiếp với SQL. Client gửi một số trang và kích thước trang; server dịch chúng thành LIMITOFFSET.

SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;

Truy vấn đó trả về trang 3 của danh sách đơn hàng của bạn với 25 dòng mỗi trang. Yêu cầu trông như thế này:

GET /v1/orders?page=3&per_page=25

Và một phản hồi điển hình:

{
  "data": [
    {
      "id": "ord_8821",
      "customer_id": "cus_1932",
      "total_cents": 4599,
      "created_at": "2026-08-30T14:22:07Z"
    }
  ],
  "page": 3,
  "per_page": 25,
  "total": 1848203,
  "total_pages": 73929
}

Sự hấp dẫn là hiển nhiên. Client có thể nhảy đến bất kỳ trang nào. Server có thể trả về tổng số lượng. Bất kỳ nhà phát triển nào cũng có thể xây dựng nó trong một buổi chiều. Đối với một bảng admin nhỏ, đây là lựa chọn đúng đắn, và hướng dẫn từng bước của chúng tôi về phân trang trong REST API sẽ hướng dẫn cách xây dựng một hệ thống phân trang offset hoàn chỉnh.

Nhưng phân trang offset có hai vấn đề cấu trúc, và cả hai đều không xuất hiện trong quá trình phát triển. Cả hai đều xuất hiện trong môi trường sản phẩm.

Vấn đề 1: Lệch trang (page drift)

Offset đếm các dòng từ đầu của kết quả đã được sắp xếp. Nó không biết gì về các dòng mà client đã thấy. Vì vậy, khi các dòng được chèn hoặc xóa giữa các yêu cầu, các trang sẽ bị dịch chuyển bên dưới client.

Giả sử một người dùng tải trang 1 của các đơn hàng được sắp xếp mới nhất trước, các dòng từ 1 đến 25. Trong khi họ đọc, 3 đơn hàng mới đến. Họ yêu cầu trang 2, là OFFSET 25. Các dòng 23, 24 và 25 từ phản hồi đầu tiên giờ đã bị đẩy xuống vị trí 26 đến 28. Người dùng thấy chúng một lần nữa. Trùng lặp.

Xóa thì ngược lại. Xóa 3 dòng khỏi trang 1 trong khi người dùng đang đọc, và OFFSET 25 giờ sẽ bỏ qua 3 dòng mà người dùng chưa từng thấy. Mất dữ liệu âm thầm, và không ai nhận được lỗi.

Đối với một báo cáo hàng tháng mà không ai cuộn trong thời gian thực, sự lệch trang là vô hại. Đối với một nguồn cấp dữ liệu hoạt động, một endpoint đồng bộ hóa, hoặc bất kỳ thứ gì mà một script duyệt từng trang trong khi các thao tác ghi vẫn tiếp tục, sự lệch trang có nghĩa là các bản ghi bị trùng lặp hoặc bị thiếu. Người dùng sẽ nhận ra.

Vấn đề 2: Offset sâu quét mọi thứ mà nó bỏ qua

OFFSET 500000 không dịch chuyển tức thời đến dòng 500.001. Cơ sở dữ liệu sẽ duyệt qua chỉ mục qua nửa triệu mục nhập, loại bỏ chúng, và sau đó trả về 25 dòng của bạn. Chi phí tăng tuyến tính theo độ sâu: O(n) trong đó n là offset.

Các con số cụ thể làm rõ điều này. Trên một bảng đơn hàng Postgres với 2 triệu dòng và một chỉ mục trên created_at:

Bài viết no-offset của Markus Winand trên Use The Index, Luke chứng minh chi phí này bằng các kế hoạch truy vấn và đáng đọc toàn bộ. Mô hình trong môi trường sản phẩm là một log truy vấn chậm bị chi phối bởi các yêu cầu offset cao, thường từ một trình thu thập thông tin đang cần mẫn duyệt qua mọi trang API công khai của bạn. Chỉ một client, và p99 của bạn tăng gấp đôi.

Phân trang dựa trên con trỏ hoạt động như thế nào

Phân trang dựa trên con trỏ, còn được gọi là phân trang keyset, bỏ qua bộ đếm dòng. Thay vì “bỏ qua 50 dòng”, client nói “cho tôi các dòng sau bản ghi cụ thể này.” Con trỏ xác định dòng cuối cùng mà client đã thấy, vì vậy server có thể tìm kiếm trực tiếp đến lô tiếp theo.

SQL sử dụng so sánh dòng trên khóa sắp xếp thay vì OFFSET:

SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-30T14:22:07Z', 'ord_8821')
ORDER BY created_at DESC, id DESC
LIMIT 25;

Lưu ý phép so sánh hai cột. Riêng created_at không phải là duy nhất; hai đơn hàng có thể được tạo cùng một mili giây, và một khóa sắp xếp không duy nhất có nghĩa là các dòng có thể bị bỏ qua hoặc lặp lại tại các ranh giới trang. Thêm id làm tiêu chí phụ để phá vỡ sự trùng lặp (tiebreaker) làm cho việc sắp xếp hoàn chỉnh và phân trang chính xác. Với một chỉ mục tổng hợp trên (created_at, id), cơ sở dữ liệu tìm kiếm thẳng đến ranh giới và đọc 25 mục nhập. Trang 1 và trang 60.000 có chi phí như nhau.

Tuy nhiên, API không nên để lộ các giá trị thô đó. Các triển khai thực tế mã hóa khóa sắp xếp thành một mã thông báo mờ, thường là base64:

GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0

Tính mờ đục là một quyết định thiết kế, không phải là làm khó hiểu vì bản thân nó. Client không thể phân tích cú pháp con trỏ thì không thể tự tạo URL, điều này cho phép bạn tự do thay đổi khóa sắp xếp, thêm gợi ý phân vùng (shard hint) hoặc chuyển đổi các công cụ lưu trữ mà không làm hỏng bất kỳ ai. Hợp đồng trở thành “trả lại những gì chúng tôi đã cung cấp cho bạn,” không hơn không kém.

Sự đánh đổi: không có trang 47. Một con trỏ chỉ biết “sau dòng này,” vì vậy client duyệt tiến (và lùi, nếu bạn cung cấp một con trỏ trước đó) từng trang một. Tổng số lượng cũng không đi kèm miễn phí; việc đếm là một truy vấn riêng biệt. Đối với các thiết kế mà tập dữ liệu rất lớn, hướng dẫn của chúng tôi về thiết kế phân trang API cho hàng triệu bản ghi sẽ đi sâu hơn về khía cạnh mở rộng.

Những đánh đổi tổng quan

Tiêu chí Phân trang offset Phân trang dựa trên con trỏ
Nhảy đến trang bất kỳ Có, bất kỳ số trang nào Không, chỉ duyệt tuần tự
Tổng số lượng / Số trang Dễ dàng bao gồm Truy vấn đếm riêng biệt
Hiệu suất trang sâu O(n), suy giảm theo độ sâu O(1) mỗi trang ở bất kỳ độ sâu nào
Độ ổn định khi có ghi Lệch: trùng lặp và khoảng trống Ổn định, neo vào một dòng
Chi phí xây dựng Đơn giản Trung bình: mã hóa, tiêu chí phụ, thiết kế chỉ mục
Yêu cầu sắp xếp Mọi ORDER BY đều hoạt động Cần một khóa sắp xếp duy nhất, được đánh chỉ mục
Bộ nhớ đệm cho URL trang Dễ dàng, URL có thể dự đoán Khó hơn, con trỏ thay đổi theo mỗi lần duyệt
Độ phức tạp của client Thấp Thấp, nếu cấu trúc phản hồi rõ ràng

Một điểm tinh tế trong bảng đó đáng được nhấn mạnh: phân trang con trỏ yêu cầu một sắp xếp xác định. Nếu endpoint của bạn cho phép client sắp xếp theo một cột có thể thay đổi, không duy nhất như status, logic keyset sẽ trở nên khó khăn nhanh chóng. Offset chấp nhận việc sắp xếp không chặt chẽ; con trỏ thì không.

Bạn nên chọn kiểu nào?

Hãy chọn kiểu phù hợp với cách dữ liệu được tiêu thụ.

Bảng quản trị và bảng điều khiển (dashboards): offset. Các công cụ nội bộ với vài nghìn dòng, người dùng nhấp vào số trang và một số lượng “1.848 kết quả” hiển thị rõ ràng. Lệch không thành vấn đề, độ sâu nông, và khả năng nhảy đến trang là một tính năng thực sự. Offset thắng về chi phí xây dựng.

Nguồn cấp dữ liệu cuộn vô hạn: con trỏ. Không ai nhảy đến trang 47 của một nguồn cấp dữ liệu. Người dùng chỉ tải “thêm”, thao tác ghi diễn ra liên tục và các bản ghi trùng lặp là rõ ràng và đáng xấu hổ. Đây là trường hợp điển hình cho con trỏ.

API công khai: con trỏ. Bạn không kiểm soát người tiêu dùng của mình. Ai đó sẽ viết một vòng lặp duyệt qua mọi trang, và với phân trang offset, các trang sâu sẽ trở thành vấn đề của bạn vào lúc 3 giờ sáng. Con trỏ giữ cho mọi trang có chi phí thấp và cho phép bạn phát triển nội bộ đằng sau mã thông báo mờ. Hướng dẫn phân trang REST API của chúng tôi đề cập chi tiết đến các quy ước URL và header.

Xuất dữ liệu và tác vụ đồng bộ hóa: con trỏ. Một tác vụ xử lý hàng loạt kéo tất cả 2 triệu đơn hàng cần hai đảm bảo: không bỏ lỡ dòng nào mặc dù có các thao tác ghi đồng thời, và chi phí cố định cho mỗi trang. Offset không cung cấp cả hai. Một con trỏ cũng cung cấp cho bạn một điểm tiếp tục miễn phí khi tác vụ bị lỗi ở dòng 1,4 triệu.

Quy tắc ngón tay cái thành thật: offset cho các giao diện nhỏ, được con người duyệt, nặng về số lượng; con trỏ cho bất cứ thứ gì lớn, trực tiếp hoặc công khai.

Cách các API thực tế xử lý

Stripe hoàn toàn dựa trên con trỏ. Mọi endpoint danh sách chấp nhận starting_after (một ID đối tượng) và limit, và các phản hồi bao gồm has_more. Để tìm nạp trang thanh toán tiếp theo, bạn truyền ID của khoản thanh toán cuối cùng bạn đã nhận được. Tài liệu phân trang của Stripe minh họa mô hình này; lưu ý rằng không có tổng số lượng ở bất kỳ đâu, đây là một sự bỏ qua có chủ ý do khối lượng ghi của họ.

REST API của GitHub vẫn hiển thị pageper_page trên hầu hết các endpoint, với các header Link trỏ đến các trang tiếp theo và trang cuối. Nhưng hãy đọc kỹ tài liệu phân trang của GitHub: họ hướng dẫn client theo dõi header Link một cách chính xác thay vì tự tạo URL trang, và các endpoint mới hơn đã chuyển sang sử dụng con trỏ, chính xác là vì việc duyệt offset sâu trên các kho lưu trữ lớn gây ra hiệu suất kém.

Slack đã di chuyển Web API của mình sang phân trang con trỏ và hiện đánh dấu đây là phương pháp mà tất cả các phương thức mới sử dụng. Các phương thức như conversations.history trả về response_metadata.next_cursor, và một chuỗi con trỏ rỗng có nghĩa là bạn đã đạt đến cuối, như được mô tả trong tài liệu phân trang của Slack.

Ba API có lưu lượng truy cập cao, và hướng phát triển là một chiều: hướng tới con trỏ.

Thiết kế cấu trúc phản hồi

Một API con trỏ thành công hay thất bại phụ thuộc vào cấu trúc phản hồi của nó. Hãy giữ nó đơn giản và dễ đoán:

{
  "data": [
    {
      "id": "ord_8846",
      "customer_id": "cus_2201",
      "total_cents": 12900,
      "created_at": "2026-08-30T16:01:44Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNjowMTo0NFoiLCJpZCI6Im9yZF84ODQ2In0"
}

Bốn quy tắc làm cho nó vững chắc:

Kiểm tra cả hai kiểu trong Apidog

Các lỗi phân trang thường ẩn ở các ranh giới: trang cuối cùng, trang trống, con trỏ mà dòng neo của nó đã bị xóa. Việc nhấp thủ công sẽ không phát hiện ra chúng, nhưng một kịch bản kiểm thử chuỗi sẽ làm được, và đây là nơi Apidog khẳng định vị trí của mình trong quy trình làm việc.

Đối với các endpoint con trỏ, hãy xây dựng một kịch bản kiểm thử với hai bước:

  1. Gọi endpoint và trích xuất con trỏ. Thêm một bộ xử lý hậu kỳ (post-processor) vào yêu cầu đầu tiên với JSONPath $.next_cursor, và lưu nó vào một biến như nextCursor. Apidog cho phép bạn sao chép JSONPath trực tiếp từ bảng phản hồi; hướng dẫn chi tiết có trong cách đặt các xác nhận và trích xuất biến bằng JSONPath.
  2. Lặp lại yêu cầu trang tiếp theo. Gói yêu cầu thứ hai trong một bước ForEach hoặc vòng lặp, truyền {{nextCursor}} làm tham số con trỏ, trích xuất lại $.next_cursor mỗi lần lặp và thoát khi has_more là false. Khẳng định trên mỗi lần duyệt rằng không có id nào lặp lại từ trang trước và kích thước trang không bao giờ vượt quá limit.

Đối với các endpoint offset, cấu trúc tương tự áp dụng với một biến đếm: tăng page, khẳng định độ dài data bằng per_page cho đến trang cuối cùng, và khẳng định total duy trì nhất quán trong suốt quá trình duyệt.

Sau đó, thêm các trường hợp biên như các bước riêng, mỗi bước có các khẳng định rõ ràng:

Khi kịch bản đã chạy thành công cục bộ, hãy chạy nó trong CI trên mỗi lần hợp nhất. Tải Apidog miễn phí và bạn có thể có toàn bộ kịch bản duyệt con trỏ, bao gồm các vòng lặp và khẳng định, chạy trong vòng chưa đầy nửa giờ.

Câu hỏi thường gặp

Phân trang con trỏ luôn tốt hơn ư?

Không. Offset phù hợp hơn khi người dùng cần số trang, tổng số lượng và truy cập ngẫu nhiên trên một tập dữ liệu khiêm tốn, điều này mô tả hầu hết các công cụ quản trị nội bộ. Con trỏ tốt hơn khi tập dữ liệu lớn, các thao tác ghi thường xuyên hoặc API là công khai. Lỗi thường gặp là mặc định sử dụng offset cho một endpoint danh sách công khai và phát hiện chi phí O(n) sau khi ra mắt.

Làm thế nào để lấy tổng số lượng với phân trang con trỏ?

Chạy một SELECT COUNT(*) riêng biệt với cùng các bộ lọc, có thể là một endpoint riêng biệt hoặc một tham số truy vấn tùy chọn như include_count=true. Lưu vào bộ nhớ đệm (cache) một cách mạnh mẽ; một số lượng gần đúng được làm mới mỗi phút sẽ đáp ứng hầu hết các giao diện người dùng. Stripe bỏ qua hoàn toàn tổng số lượng, điều này cho bạn biết tần suất client thực sự cần chúng.

Tôi có thể cung cấp cả hai kiểu phân trang trên cùng một endpoint không?

Bạn có thể, và GitHub thực tế đã làm trong quá trình chuyển đổi của họ, nhưng hãy tránh nó trên các API mới. Hai kiểu có nghĩa là hai bộ trường hợp biên, hai ma trận kiểm thử và sự nhầm lẫn của client về việc sử dụng kiểu nào. Hãy chọn một kiểu cho mỗi endpoint. Nếu bạn đang thiết kế hợp đồng từ đầu, các mẫu trong hướng dẫn phân trang REST API của chúng tôi sẽ giữ cho việc đặt tên tham số nhất quán trên toàn bộ bề mặt của bạn.

Điều gì xảy ra nếu dòng neo của con trỏ bị xóa?

Với phân trang keyset, không có gì bị hỏng. Phép so sánh WHERE (created_at, id) < (?, ?) không yêu cầu dòng neo phải tồn tại; nó tìm kiếm đến vị trí ranh giới và tiếp tục. Đây là một lợi thế thực sự so với các thiết kế "con trỏ như tra cứu dòng", và đây chính xác là trường hợp biên đáng để khẳng định trong kịch bản kiểm thử Apidog của bạn trước khi người dùng phát hiện ra nó.

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