Cursor Pagination trong phát triển di động — khái niệm, nguyên lý và thực hiện

Tác giả: IT Sectr Đã đăng: 2026-03-11 Thời gian đọc: 9 phút

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 phân trang trong đó mỗi bản ghi có một định danh con trỏ duy nhất để điều hướng.
  • Con trỏ là điểm đánh dấu vị trí duy nhất trong tập dữ liệu (thường là ID, UUID, timestamp) không thay đổi khi chèn.
  • Ổn định — các bản ghi mới được thêm giữa các yêu cầu không làm dịch chuyển con trỏ, loại bỏ trùng lặp và thiếu sót.
  • Hiệu suất — truy vấn WHERE id > cursor sử dụng chỉ mục hiệu quả mà không mất tốc độ trên các tập dữ liệu lớn.
  • Hạn chế — phân trang bằng con trỏ không hỗ trợ điều hướng theo số trang (không thể nhảy đến trang 5).

Cursor Pagination là gì?

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).

Lịch sử

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.

Cách hoạt động của phân trang bằng con trỏ

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.

sql
-- 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;

Định dạng con trỏ

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.

Điều hướng tiến và lùi

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ỏ.

Cursor và Offset Pagination

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ínhCursorOffset
Ổn định khi chènCao (không trùng lặp)Thấp (dịch chuyển trang)
Hiệu suất trên tập lớnO(log n) — ổn địnhO(n) — giảm khi tăng trưởng
Điều hướng theo số trangKhôngCó (page=5)
Độ phức tạp triển khaiTrung bìnhThấp
Hỗ trợ RESTcursor/before/afterpage/offset
Hỗ trợ GraphQLTiêu chuẩn RelayKhông được khuyến nghị

Tại sao Offset thất bại ở quy mô lớn

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í.

Triển khai Cursor Pagination

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.

kotlin
@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
        )
    )
}

Triển khai phía client trên Android

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.

kotlin
// 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)
    }
}

Triển khai GraphQL qua Relay

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.

Khi nào sử dụng Cursor Pagination

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.

  • Trò chuyện và nhắn tin — mỗi tin nhắn mới được thêm vào đầu danh sách. Phân trang Offset bị gián đoạn với mỗi tin nhắn mới.
  • Mạng xã hội và bảng tin — bài viết được xuất bản liên tục. Phân trang bằng con trỏ đảm bảo người dùng không bỏ lỡ bất kỳ bài viết nào.
  • Lịch sử đơn hàng và giao dịch — dữ liệu thay đổi ít thường xuyên hơn, nhưng tính nhất quán rất quan trọng cho báo cáo tài chính.
  • API với khối lượng dữ liệu lớn — hàng triệu bản ghi. Cursor pagination duy trì hiệu suất ở nơi Offset bắt đầu chậm lại.
  • API GraphQL — tiêu chuẩn Relay yêu cầu phân trang dựa trên con trỏ để tuân thủ đặc tả.
  • Ứng dụng di động với cuộn vô hạn — người dùng cuộn xuống để tải các lô mới. Cách tiếp cận bằng con trỏ mang lại UX mượt mà không trùng lặp.

Khi nào phân trang bằng con trỏ không phù hợp

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ỏ trong Cursor Pagination là gì?

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.

Tại sao Cursor Pagination tốt hơn Offset?

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ó thể triển khai phân trang bằng con trỏ mà không cần GraphQL khô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ỏ.

Nên sử dụng con trỏ nào — ID, UUID hay timestamp?

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ỏ.

Làm cách nào để biết tổng số trang với phân trang bằng 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

  • Cursor Pagination là phương pháp phân trang với điều hướng bằng định danh bản ghi duy nhất thay vì offset.
  • Con trỏ đảm bảo tính ổn định của tập khi chèn: các bản ghi mới không làm dịch chuyển các trang đã tải.
  • Hiệu suất trên khối lượng dữ liệu lớn vẫn cao (O(log n)) nhờ sử dụng chỉ mục B-tree.
  • Cursor pagination phù hợp cho dữ liệu động: trò chuyện, bảng tin, giao dịch, bình luận.
  • Hạn chế chính là thiếu điều hướng theo số trang và không thể nhảy đến trang tùy ý.
  • Triển khai sử dụng WHERE id sau con trỏ, tham số after/before và pageInfo trong phản hồi.
  • Tiêu chuẩn — Relay Connection GraphQL, nhưng các API REST với tham số con trỏ cũng được sử dụng rộng rãi.

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.

Thảo luận dự án

Đọc thêm