Phân trang API: Offset hay Cursor-based?
Mọi endpoint danh sách cuối cùng đều phải trả lời cùng một câu hỏi: làm thế nào chia 2 triệu đơn hàng thành các trang để client duyệt qua? Phân trang offset cho SQL đơn giản, tổng số trang rõ ràng và khả năng nhảy thẳng đến trang 47. Phân trang dựa trên con trỏ (cursor-based) cho kết quả ổn định cùng độ trễ nhất quán ở mọi độ sâu, nhưng client phải duyệt tuần tự.
Nhiều nhóm bắt đầu với offset vì đây là lựa chọn mặc định trong hầu hết hướng dẫn. Nhưng khi bảng đạt vài triệu dòng, trang 4.000 có thể hết thời gian chờ và người dùng thấy bản ghi trùng lặp khi cuộn. Bài viết này giải thích cách hoạt động của cả hai kiểu, giới hạn của offset, lý do Stripe và Slack dùng cursor, cùng cách kiểm thử bằng các yêu cầu nối tiếp trong Apidog.
Nếu cần tổng quan, hãy xem hướng dẫn phân trang API. Bài viết này tập trung vào hai chiến lược phổ biến nhất.
Phân trang offset hoạt động như thế nào?
Offset ánh xạ trực tiếp với SQL. Client gửi số trang và kích thước trang; server chuyển chúng thành LIMIT và OFFSET:
SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;
Truy vấn trên trả về trang 3 với 25 dòng mỗi trang:
GET /v1/orders?page=3&per_page=25
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
}
Offset hấp dẫn vì:
- Client có thể nhảy đến bất kỳ trang nào.
- Server dễ trả về tổng số lượng và tổng số trang.
- Hầu hết developer có thể triển khai trong một buổi chiều.
Với bảng quản trị nhỏ, đây thường là lựa chọn đúng. Hướng dẫn phân trang trong REST API trình bày cách xây dựng hệ thống offset hoàn chỉnh.
Tuy nhiên, offset có hai vấn đề cấu trúc. Chúng thường không xuất hiện khi phát triển mà chỉ lộ ra trong môi trường production.
1. Lệch trang (page drift)
Offset đếm dòng từ đầu tập kết quả đã sắp xếp. Nó không biết client đã nhìn thấy dòng nào. Nếu có dòng được thêm hoặc xóa giữa hai yêu cầu, các trang sẽ dịch chuyển.
Ví dụ:
- Client tải trang 1, gồm các dòng 1–25, sắp xếp mới nhất trước.
- Trong lúc client đọc, có 3 đơn hàng mới được thêm.
- Client gọi trang 2, tương ứng với
OFFSET 25. - Các dòng 23, 24 và 25 trước đó bị đẩy xuống vị trí 26–28.
Kết quả: client thấy lại các dòng này.
Khi 3 dòng ở trang 1 bị xóa, điều ngược lại xảy ra: OFFSET 25 bỏ qua 3 dòng mà client chưa từng thấy. Dữ liệu bị mất âm thầm mà không có lỗi nào được trả về.
Với báo cáo tháng, nơi không ai duyệt dữ liệu theo thời gian thực, page drift thường không đáng kể. Nhưng với nguồn cấp dữ liệu, endpoint đồng bộ hóa hoặc script duyệt từng trang trong khi dữ liệu liên tục thay đổi, offset có thể gây trùng lặp và bỏ sót bản ghi.
2. Offset sâu phải quét mọi dòng bị bỏ qua
OFFSET 500000 không đưa cơ sở dữ liệu ngay đến dòng 500.001. Database phải duyệt qua chỉ mục, loại bỏ nửa triệu dòng rồi mới trả về 25 dòng cần thiết. Chi phí tăng tuyến tính theo độ sâu: O(n), với n là offset.
Trên bảng Postgres có 2 triệu đơn hàng và chỉ mục trên created_at:
-
LIMIT 25 OFFSET 0: đọc 25 mục nhập chỉ mục, thường mất vài mili giây. -
LIMIT 25 OFFSET 100000: đọc khoảng 100.025 mục nhập và loại bỏ 100.000, thường mất vài chục mili giây. -
LIMIT 25 OFFSET 1500000: đọc 1,5 triệu mục nhập, có thể mất vài trăm mili giây và tiêu tốn đáng kể bộ nhớ đệm cùng CPU.
Bài viết no-offset của Markus Winand minh họa chi phí này bằng các kế hoạch truy vấn. Trong production, log truy vấn chậm thường bị chi phối bởi các yêu cầu offset cao, đặc biệt từ crawler duyệt tất cả trang của API công khai.
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ỏ, hay keyset pagination, không đếm dòng. Thay vì nói “bỏ qua 50 dòng”, client nói “trả về các dòng sau bản ghi cụ thể này”.
Con trỏ xác định dòng cuối cùng client đã thấy, cho phép server tìm trực tiếp đến lô tiếp theo:
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;
Điểm quan trọng là phép so sánh trên hai cột. created_at riêng lẻ không đảm bảo duy nhất: hai đơn hàng có thể được tạo trong cùng một mili giây. Nếu khóa sắp xếp không duy nhất, bản ghi có thể bị bỏ qua hoặc lặp lại ở ranh giới trang.
Thêm id làm tiêu chí phụ để phá hòa giúp thứ tự hoàn toàn xác định. Với chỉ mục tổng hợp trên (created_at, id), database có thể tìm thẳng đến ranh giới và đọc 25 mục nhập. Trang đầu tiên và trang 60.000 có chi phí gần như tương đương.
Mã hóa con trỏ
API không nên để lộ các giá trị khóa sắp xếp thô. Hãy mã hóa chúng thành một token mờ đục, thường là Base64:
GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
Tính mờ đục là một quyết định thiết kế. Client không thể tự phân tích hoặc tạo con trỏ, nên server có thể thay đổi khóa sắp xếp, thêm shard hint hoặc chuyển hệ thống lưu trữ mà không phá vỡ hợp đồng API.
Hợp đồng chỉ cần đảm bảo: “trả về các bản ghi tiếp theo sau vị trí mà chúng tôi đã cung cấp”.
Đánh đổi là client không thể nhảy đến trang 47. Con trỏ chỉ biết vị trí “sau bản ghi này”, nên client duyệt từng trang một. Nếu cần tổng số lượng, server phải chạy một truy vấn đếm riêng. Hướng dẫn thiết kế phân trang API cho hàng triệu bản ghi phân tích sâu hơn các vấn đề mở rộng này.
So sánh nhanh
| Tiêu chí | Phân trang offset | Phân trang dựa trên con trỏ |
|---|---|---|
| Nhảy đến trang bất kỳ | Có | Không, chỉ duyệt tuần tự |
| Tổng số lượng / số trang | Dễ bao gồm | Cần truy vấn đếm riêng |
| Hiệu suất trang sâu |
O(n), giảm theo độ sâu |
Gần O(1) mỗi trang |
| Ổn định khi có ghi | Có thể trùng lặp hoặc bỏ sót | Ổn định, neo vào bản ghi |
| Chi phí xây dựng | Đơn giản | Trung bình: mã hóa, tiebreaker, chỉ mục |
| Yêu cầu sắp xếp | Hoạt động với mọi ORDER BY
|
Cần khóa sắp xếp duy nhất, được đánh chỉ mục |
| Bộ nhớ đệm URL | Dễ, URL có thể dự đoán | Khó hơn, con trỏ thay đổi theo lần duyệt |
| Độ phức tạp phía client | Thấp | Thấp nếu phản hồi rõ ràng |
Một điểm cần đặc biệt lưu ý: cursor pagination yêu cầu thứ tự sắp xếp xác định. Nếu endpoint cho phép sắp xếp theo cột có thể thay đổi và không duy nhất như status, logic keyset sẽ nhanh chóng trở nên phức tạp. Offset chấp nhận thứ tự không chặt chẽ; cursor thì không.
Nên chọn kiểu nào?
Hãy chọn theo cách dữ liệu được tiêu thụ.
Bảng quản trị và dashboard: offset
Dùng offset khi:
- Dữ liệu chỉ có vài nghìn dòng.
- Người dùng cần nhấp vào số trang.
- Giao diện hiển thị “1.848 kết quả”.
- Có nhu cầu truy cập ngẫu nhiên đến một trang.
Trong trường hợp này, page drift ít ảnh hưởng, độ sâu thấp và khả năng nhảy trang có giá trị thực tế.
Nguồn cấp dữ liệu cuộn vô hạn: cursor
Không ai nhảy đến trang 47 của một feed. Người dùng chỉ tải thêm dữ liệu trong khi các thao tác ghi vẫn tiếp tục. Cursor giúp tránh các bản ghi trùng lặp và mất dữ liệu ở ranh giới trang.
API công khai: cursor
Bạn không kiểm soát cách client sử dụng API. Sớm muộn sẽ có client duyệt toàn bộ trang. Với offset, những trang sâu trở thành vấn đề hiệu suất của bạn. Cursor giữ chi phí mỗi trang ổn định và cho phép thay đổi triển khai phía sau token mờ đục.
Xem thêm các quy ước URL và header trong phân trang REST API.
Xuất dữ liệu và đồng bộ hóa: cursor
Một tác vụ xử lý 2 triệu đơn hàng cần:
- Không bỏ sót bản ghi dù có thao tác ghi đồng thời.
- Chi phí ổn định cho mỗi trang.
- Điểm tiếp tục nếu tác vụ lỗi ở dòng 1,4 triệu.
Offset không đảm bảo được cả ba. Cursor đáp ứng tốt hơn và cung cấp điểm tiếp tục tự nhiên.
Quy tắc thực tế: dùng offset cho giao diện nhỏ do con người duyệt và cần tổng số; dùng cursor cho dữ liệu lớn, dữ liệu thay đổi liên tục hoặc API công khai.
Các API thực tế xử lý phân trang ra sao?
-
Stripe hoàn toàn dựa trên cursor. Các endpoint danh sách nhận
starting_aftervàlimit, đồng thời trả vềhas_more. Để lấy trang tiếp theo, client truyền ID của bản ghi cuối cùng đã nhận. Tài liệu phân trang của Stripe không trả về tổng số lượng — đây là lựa chọn có chủ ý để tránh chi phí đếm trên hệ thống có lưu lượng ghi lớn. -
GitHub REST API vẫn hỗ trợ
pagevàper_pagetrên nhiều endpoint, cùng headerLinktrỏ đến trang tiếp theo và trang cuối. Tài liệu phân trang của GitHub khuyến nghị client theo dõi headerLinkthay vì tự tạo URL. Các endpoint mới hơn đã chuyển dần sang cursor để tránh hiệu suất kém khi duyệt offset sâu trên kho lưu trữ lớn. -
Slack đã chuyển Web API sang cursor pagination và khuyến nghị đây là phương pháp cho các method mới.
conversations.historytrả vềresponse_metadata.next_cursor; chuỗi rỗng nghĩa là đã đến cuối. Xem tài liệu phân trang của Slack.
Xu hướng chung của các API có lưu lượng cao là chuyển từ offset sang cursor.
Thiết kế cấu trúc phản hồi
Cấu trúc phản hồi quyết định API cursor dễ dùng hay khó dùng. Hãy giữ nó đơn giản và nhất quá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 nên áp dụng:
-
Luôn trả về
has_more. Client không nên suy luận đã hết dữ liệu chỉ vì trang ngắn; bộ lọc sau khi truy vấn có thể làm trang ngắn giữa luồng. -
Dùng
next_cursor: nullở trang cuối và ghi rõ quy ước trong tài liệu. Chuỗi rỗng cũng được, nhưng không được trộn lẫn hai kiểu. -
Từ chối cursor không hợp lệ bằng HTTP 400, kèm mã lỗi có thể đọc bằng máy. Không nên trả về
200vớidata: [], vì điều đó che giấu lỗi phía client. - Ký hoặc đánh phiên bản payload cursor nếu token chứa nhiều hơn khóa sắp xếp. Điều này giúp xử lý các lần di chuyển schema sau này.
Kiểm thử offset và cursor bằng Apidog
Lỗi phân trang thường nằm ở các ranh giới: trang cuối, trang trống hoặc cursor trỏ đến bản ghi đã bị xóa. Nhấp thủ công khó phát hiện các lỗi này; một kịch bản kiểm thử nối tiếp sẽ đáng tin cậy hơn.
Kiểm thử endpoint cursor
Tạo kịch bản gồm các bước sau:
- Gọi endpoint đầu tiên và trích xuất cursor
Thêm post-processor với JSONPath $.next_cursor, sau đó lưu kết quả vào biến nextCursor. Apidog cho phép sao chép JSONPath trực tiếp từ bảng phản hồi; xem cách đặt assertion và trích xuất biến bằng JSONPath.
- Lặp qua các trang tiếp theo
Đặt yêu cầu thứ hai trong bước ForEach hoặc vòng lặp. Truyền {{nextCursor}} làm tham số cursor, trích xuất lại $.next_cursor sau mỗi lần lặp và dừng khi has_more là false.
- Thêm assertion
- Không có
idnào lặp lại giữa hai trang liên tiếp. - Kích thước trang không vượt quá
limit. - Cursor cuối cùng có giá trị đúng theo quy ước API.
Kiểm thử endpoint offset
Dùng biến đếm để tăng page sau mỗi yêu cầu:
- Assertion độ dài
databằngper_pagecho đến trang cuối. - Assertion
totalkhông thay đổi trong toàn bộ lần duyệt. - Kiểm tra trang cuối và trang vượt quá
total_pages.
Kiểm thử các trường hợp biên
Tách các trường hợp sau thành những bước có assertion rõ ràng:
-
Trang trống: dùng bộ lọc không khớp; khẳng định
datalà[],has_morelàfalsevà HTTP status là200. -
Cursor không hợp lệ: gửi
cursor=not-a-real-cursor; khẳng định status400và mã lỗi có thể đọc bằng máy. - Bản ghi neo bị xóa: tạo đơn hàng, lấy cursor neo vào đơn hàng đó, xóa đơn hàng rồi sử dụng cursor. Kết quả phải tiếp tục từ đúng vị trí thay vì báo lỗi. Keyset comparison xử lý trường hợp này tự nhiên.
Sau khi kịch bản chạy ổn định ở local, hãy chạy trong CI ở mỗi lần merge. Bạn có thể tải Apidog và thiết lập toàn bộ kịch bản duyệt cursor, vòng lặp cùng assertion trong chưa đầy nửa giờ.
Câu hỏi thường gặp
Cursor pagination luôn tốt hơn?
Không. Offset phù hợp 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 vừa phải — điển hình là công cụ quản trị nội bộ.
Cursor phù hợp hơn khi:
- Tập dữ liệu lớn.
- Dữ liệu thay đổi thường xuyên.
- API được cung cấp công khai.
- Client cần duyệt tuần tự hoặc đồng bộ hàng loạt.
Sai lầm phổ biến là dùng offset mặc định cho endpoint danh sách công khai rồi phát hiện chi phí O(n) sau khi phát hành.
Làm thế nào lấy tổng số lượng với cursor pagination?
Chạy SELECT COUNT(*) riêng với cùng bộ lọc, thông qua endpoint riêng hoặc tham số tùy chọn như include_count=true. Hãy cache kết quả mạnh tay; một số lượng gần đúng được làm mới mỗi phút thường đã đủ cho giao diện người dùng.
Stripe bỏ qua tổng số lượng hoàn toàn, cho thấy nhiều client thực tế không cần thông tin này.
Có thể cung cấp cả offset và cursor trên cùng endpoint không?
Có thể. GitHub từng làm điều này trong quá trình chuyển đổi, nhưng không nên áp dụng cho API mới. Hai kiểu phân trang đồng nghĩa với:
- Hai nhóm trường hợp biên.
- Hai ma trận kiểm thử.
- Sự nhầm lẫn về tham số mà client nên sử dụng.
Hãy chọn một kiểu cho mỗi endpoint. Nếu thiết kế hợp đồng mới, hãy dùng các mẫu trong hướng dẫn phân trang REST API để duy trì cách đặt tên nhất quán.
Điều gì xảy ra khi bản ghi neo bị xóa?
Với keyset pagination, không có gì bị hỏng. Điều kiện:
WHERE (created_at, id) < (?, ?)
không yêu cầu bản ghi neo phải còn tồn tại. Database chỉ tìm đến vị trí ranh giới rồi tiếp tục trả về dữ liệu.
Đây là lợi thế quan trọng so với thiết kế coi cursor là một tra cứu trực tiếp đến bản ghi. Hãy luôn đưa trường hợp này vào kịch bản kiểm thử Apidog trước khi người dùng phát hiện ra.
Top comments (0)