Cursor Pagination là phương pháp tải dữ liệu theo trang sử dụng một con trỏ duy nhất để điều hướng qua tập hợp các bản ghi được sắp xếp. Theo GraphQL Specification (2025), phân trang dựa trên con trỏ là tiêu chuẩn được khuyến nghị cho các API làm việc với dữ liệu động. Cursor pagination loại bỏ các nhược điểm chính của phương pháp Offset: mất ổn định khi chèn và suy giảm hiệu suất trên các offset lớn.
Những điểm chính
Cursor Pagination là phương pháp tải theo trang trong đó máy chủ trả về cùng với dữ liệu một con trỏ đặc biệt. Máy khách sử dụng con trỏ này trong yêu cầu tiếp theo để lấy lô tiếp theo của các bản ghi. Con trỏ là định danh duy nhất của phần tử cuối cùng trên trang hiện tại.
Không giống như phân trang Offset, nơi máy khách nói “đưa tôi trang 5 với 20 bản ghi,” phân trang bằng con trỏ hoạt động khác: “đưa tôi 20 bản ghi sau bản ghi có ID = 83.” Máy chủ thực hiện truy vấn với WHERE id > 83 và LIMIT 20. Cách tiếp cận này đảm bảo mỗi bản ghi rơi chính xác vào một trang bất kể có chèn thêm.
Khái niệm phân trang bằng con trỏ đã được áp dụng rộng rãi nhờ đặc tả Relay Connection (GraphQL), đã đưa phân trang dựa trên con trỏ thành tiêu chuẩn cho các API hiện đại. Relay định nghĩa định dạng phản hồi: edges (mảng các bản ghi với con trỏ), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor).
Phân trang bằng con trỏ không phải là kỹ thuật mới — nó đã được sử dụng trong cơ sở dữ liệu từ lâu trước khi có web. Trong SQL, nó được gọi là keyset pagination hoặc seek method. Phương pháp này trở nên phổ biến trong API sau khi công bố đặc tả Relay vào năm 2015, đã chính thức hóa định dạng con trỏ dưới dạng chuỗi mã hóa base64 để thống nhất qua truyền tải HTTP.
Nguyên lý cơ bản của phân trang bằng con trỏ là truy vấn sử dụng điều kiện WHERE trên trường được đánh chỉ mục để xác định vị trí, thay vì offset. Cho hướng tiến, sử dụng WHERE id > last_id; cho hướng lùi, sử dụng WHERE id < first_id. Chỉ mục B-tree tìm bản ghi đầu tiên sau con trỏ trong O(log n), cung cấp thời gian phản hồi ổn định.
-- Lấy 20 bản ghi sau con trỏ '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- Lấy 20 bản ghi TRƯỚC con trỏ '83' (quay lại)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
Con trỏ có thể đơn giản (giá trị ID) hoặc phức tạp (được tạo từ nhiều trường). Con trỏ đơn giản là khóa chính của bản ghi, ví dụ, id tự động tăng hoặc UUID. Con trỏ tổng hợp được sử dụng để sắp xếp theo các trường không duy nhất, ví dụ (created_at, id), trong đó id đảm bảo tính duy nhất khi dấu thời gian giống nhau.
Định dạng API điển hình là con trỏ dưới dạng chuỗi mã hóa base64. Máy chủ giải mã con trỏ, trích xuất giá trị và xây dựng truy vấn SQL. Mã hóa Base64 ẩn cấu trúc bên trong của con trỏ khỏi máy khách và cho phép thay đổi định dạng mà không phá vỡ tính tương thích ngược. Máy khách nhận con trỏ trong trường endCursor của phản hồi và truyền chúng dưới dạng chuỗi trong yêu cầu tiếp theo.
Phân trang bằng con trỏ hỗ trợ điều hướng hai chiều. Để di chuyển tiến (next), sử dụng con trỏ của phần tử cuối cùng trên trang hiện tại; để lùi (previous), con trỏ của phần tử đầu tiên. Các tham số after và before trong yêu cầu xác định hướng: after lấy các bản ghi sau con trỏ, before lấy các bản ghi trước con trỏ.
Lựa chọn giữa phân trang bằng con trỏ và Offset là một trong những quyết định kiến trúc quan trọng khi thiết kế API. Mỗi phương pháp có điểm mạnh và điểm yếu quyết định khả năng áp dụng của nó. Cursor pagination chiến thắng trong các kịch bản với dữ liệu động; Offset chiến thắng trong các kịch bản với điều hướng tùy ý.
| Đặc tính | Cursor | Offset |
|---|---|---|
| Ổn định khi chèn | Cao (không trùng lặp) | Thấp (dịch chuyển trang) |
| Hiệu suất trên tập lớn | O(log n) — ổn định | O(n) — giảm khi tăng trưởng |
| Điều hướng theo số trang | Không | Có (page=5) |
| Độ phức tạp triển khai | Trung bình | Thấp |
| Hỗ trợ REST | cursor/before/after | page/offset |
| Hỗ trợ GraphQL | Tiêu chuẩn Relay | Không được khuyến nghị |
Phân trang Offset thực hiện quét toàn bộ bảng đến vị trí OFFSET. Ở offset=100000, cơ sở dữ liệu đọc và bỏ qua 100000 hàng, ngay cả khi LIMIT là 20. MySQL và PostgreSQL không thể tối ưu hóa OFFSET — đây là đặc điểm triển khai của LIMIT/OFFSET trong SQL. Phân trang bằng con trỏ sử dụng chỉ mục B-tree tìm vị trí trong O(log n).
Một vấn đề bổ sung của Offset là “bỏ qua” các bản ghi khi phân trang lùi. Nếu người dùng đã tải trang 5 và tại thời điểm đó các bản ghi mới được thêm vào, khi yêu cầu trang 6, họ sẽ thấy lại bản ghi từ trang 5 hoặc bỏ lỡ các bản ghi mới. Cursor pagination loại bỏ hoàn toàn kịch bản này: con trỏ trỏ đến một vị trí cụ thể trong tập và việc chèn không làm thay đổi vị trí.
Hãy xem xét việc triển khai phân trang bằng con trỏ trên backend (Kotlin + Spring) và trên client (Android + Retrofit). Máy chủ chấp nhận các tham số after, before, limit và trả về danh sách các bản ghi với con trỏ và pageInfo. Phản hồi điển hình chứa hasNextPage và hasPreviousPage để quản lý giao diện phân trang.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
Trên client, phân trang bằng con trỏ được triển khai qua PagingSource từ Paging 3, nơi khóa là con trỏ (Long). PagingSource.load nhận LoadParams.key — con trỏ của bản ghi được tải cuối cùng. LoadResult.Page trả về dữ liệu và nextKey — con trỏ cho trang tiếp theo. Khi nextKey = null, phân trang hoàn tất.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource with cursor key
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
Trong GraphQL, phân trang bằng con trỏ được triển khai thông qua mô hình Relay Connection. Mỗi loại có một Connection (với pageInfo và edges) và một Edge (node + cursor). Truy vấn truyền các tham số first, after, last, before. Máy chủ trả về mảng các edges với con trỏ và pageInfo với hasNextPage/hasPreviousPage.
Phân trang bằng con trỏ được khuyến nghị cho các API làm việc với dữ liệu động nơi các bản ghi thường xuyên được thêm hoặc xóa. Ví dụ kinh điển: bảng tin trong mạng xã hội, tin nhắn trò chuyện, lịch sử giao dịch, bình luận bài viết. Trong tất cả các kịch bản này, tính nhất quán và không có trùng lặp là quan trọng.
Có những kịch bản mà phân trang Offset thuận tiện hơn: bảng quản trị nơi cần điều hướng theo số trang; tìm kiếm với phân trang nơi kết quả có thể thay đổi; báo cáo và phân tích nơi cần liên kết cố định đến trang 5. Trong những trường hợp này, lợi ích của con trỏ không vượt quá độ phức tạp triển khai.
Phân trang bằng con trỏ không hỗ trợ “nhảy” đến một trang tùy ý — người dùng không thể nhấp vào “Trang 5” và đi đến đó. Đây là một hạn chế về kiến trúc: đếm tổng số trang yêu cầu một truy vấn COUNT riêng, có thể tốn kém cho các bảng lớn. Trong những trường hợp như vậy, một cách tiếp cận kết hợp: con trỏ cho dữ liệu + count cho phân trang.
Câu hỏi thường gặp
Con trỏ là định danh bản ghi duy nhất trỏ đến một vị trí trong tập dữ liệu. Nó có thể đơn giản (ID bản ghi) hoặc tổng hợp (nhiều trường). Máy khách nhận con trỏ của bản ghi cuối cùng trên trang và truyền nó trong yêu cầu tiếp theo để lấy lô tiếp theo.
Cursor pagination không bị ảnh hưởng bởi sự dịch chuyển khi thêm bản ghi mới — mỗi phần tử rơi chính xác vào một trang. Nó cũng duy trì tốc độ trên khối lượng lớn bằng cách sử dụng chỉ mục thay vì quét n hàng đầu tiên. Offset đơn giản hơn nhưng không ổn định cho dữ liệu động.
Có, phân trang bằng con trỏ không bị ràng buộc với GraphQL. Nó có thể được triển khai trong bất kỳ API REST nào bằng cách truyền con trỏ dưới dạng tham số truy vấn ?after=83&limit=20. Phản hồi phải chứa pageInfo với endCursor và hasNextPage — điều này cho phép máy khách quản lý việc tải mà không cần biết cấu trúc bên trong của con trỏ.
ID tự động tăng là lựa chọn tối ưu: tăng đơn điệu, không thay đổi, được đánh chỉ mục hiệu quả. UUID v7 (sắp xếp theo thời gian) cũng phù hợp. Timestamp có thể tạo trùng lặp ở cùng thời điểm, vì vậy hãy kết hợp nó với ID: (created_at, id) để đảm bảo tính duy nhất của con trỏ.
Phân trang bằng con trỏ không cung cấp tổng số trang — đây là hạn chế của nó. Nếu bạn cần thông tin tổng, hãy thực hiện truy vấn COUNT riêng với các bộ lọc giống nhau. Đối với các bảng lớn, hãy sử dụng đếm xấp xỉ qua EXPLAIN hoặc tổng được lưu trong bộ nhớ đệm từ phân tích.
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm