Phân trang Offset và Cursor: Khi nào nên dùng phương pháp nào?
Bài viết so sánh chi tiết hai chiến lược phân trang phổ biến nhất trong phát triển phần mềm: Offset và Cursor. Tìm hiểu lý do tại sao phương pháp truyền thống có thể gây lỗi dữ liệu và cách Cursor giải quyết vấn đề này cho các ứng dụng thời gian thực.

Phân trang Offset và Cursor: Khi nào nên dùng phương pháp nào?
Từ những ghi chú khi phỏng vấn đến thực tế triển khai sản phẩm (production), khái niệm phân trang (pagination) luôn là một chủ đề nóng hổi. Mới đây, tôi có cơ hội tiếp cận lại vấn đề này khi cần triển khai tính năng cuộn vô hạn (infinite scroll) kết hợp với ảo hóa danh sách (virtualization).
Đó là lúc tôi nhận ra việc phân trang không chỉ đơn giản là "chia nhỏ dữ liệu". Chúng ta sẽ cùng tìm hiểu sâu hơn về hai phương pháp chính: Offset và Cursor, lý do tại sao Offset đôi khi "gây rắc rối", và khi nào nên sử dụng từng cái.
Chúng ta sẽ đề cập đến:
- Tại sao phân trang dựa trên Offset lại có vấn đề về mặt kỹ thuật.
- Phân trang Cursor khác biệt như thế nào.
- Cách chuẩn hóa Cursor qua Relay.
- Làm sao để chọn đúng phương án cho dự án của bạn.
Phân trang là gì?
Trước khi so sánh, hãy thống nhất về vấn đề cần giải quyết. Khi một API có hàng ngàn bản ghi, việc trả về tất cả cùng một lúc là ý tưởng tồi — quá nhiều dữ liệu, quá chậm và tốn bộ nhớ. Thay vào đó, chúng ta trả về kết quả theo từng phần (chunks). Phân trang là cơ chế kiểm soát xem client nhận được "phần" nào trong tổng thể dữ liệu đó.
Hai phương pháp tiếp cận phổ biến nhất là offset-based (dựa trên độ dời) và cursor-based (dựa trên con trỏ). Âm thanh thì có vẻ tương đồng, nhưng chúng hoạt động hoàn toàn khác nhau.
Phân trang dựa trên Offset (Offset-Based Pagination)
Ý tưởng
Client nói với Server: "Bỏ qua N mục đầu tiên và đưa cho tôi M mục tiếp theo."
Về mặt GraphQL, query sẽ trông như sau:
query GetPosts {
posts(offset: 40, limit: 20) {
id
title
author
}
}
Tại phía máy chủ (server), logic này được chuyển hóa thành phép toán mảng thuần túy:
const getPosts = (offset: number, limit: number) => {
return ALL_POSTS.slice(offset, offset + limit);
};
// offset: 40, limit: 20
// → Trả về ALL_POSTS[40] đến ALL_POSTS[59]
Đơn giản. Dễ dự đoán. Dễ hiểu.
Client sẽ theo dõi offset và tăng nó lên limit trong mỗi lần yêu cầu:
const fetchNextPage = () => {
const nextOffset = currentItems.length;
fetchMore({ variables: { offset: nextOffset, limit: 20 } });
};
Để biết khi nào dừng lại, server trả về tổng số lượng (total):
type PostsResult {
posts: [Post!]!
total: Int! # Tổng số tất cả các trang — không chỉ trang hiện tại
}
// Client biết khi nào dừng:
const hasNextPage = currentItems.length < total;
Vấn đề: Bỏ sót dữ liệu (Skipped Item Problem)
Đây là nơi mà Offset bắt đầu bộc lộ điểm yếu. Hãy tưởng tượng một danh sách 10 sản phẩm:
Trang 1 → items [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] (offset: 0, limit: 10)
Trong khi bạn đang xem Trang 1, mục #5 bị xóa. Danh sách dịch chuyển:
Sau khi xóa → [1, 2, 3, 4, 6, 7, 8, 9, 10, 11]
Khi bạn yêu cầu Trang 2 (offset: 10):
Server bỏ qua 10 mục đầu → bắt đầu từ mục #12
Mục #11 chưa bao giờ được hiển thị cho bạn. Nó đã "lọt qua khe hở".
⚠️ Danh sách co lại → bạn bị mất dữ liệu.
Vấn đề: Dữ liệu trùng lặp (Duplicate Item Problem)
Vấn đề này phổ biến hơn và dễ nhận thấy hơn. Ví dụ như một bảng tin (newsfeed) nơi bài viết mới xuất hiện ở đầu danh sách:
Trang 1 → [post10, post9, post8, post7, post6, post5, post4, post3, post2, post1]
Trong khi bạn đang đọc, ai đó đăng bài viết mới. Nó trở thành post11 ở vị trí #1:
Danh sách cập nhật → [post11, post10, post9, post8, post7, post6, post5, post4, post3, post2, ...]
Khi bạn yêu cầu Trang 2 (offset: 10):
Server bỏ qua 10 mục → bắt đầu từ index 10
Index 10 hiện tại là post1 — mà bạn đã thấy ở cuối Trang 1
⚠️ Danh sách mở rộng → bạn thấy dữ liệu bị trùng.
Tại sao điều này xảy ra?
Offset mang tính "vị trí" — nó nói "bỏ qua N vị trí", chứ không phải "bỏ qua N mục cụ thể". Khi danh sách thay đổi hình dạng, các vị trí dịch chuyển nhưng offset không biết và không quan tâm.
// Offset không biết bạn đã xem những gì.
// Nó chỉ biết cần bỏ qua bao nhiêu ô trống.
// Sự khác biệt đó chính là vấn đề.
Khi nào Offset vẫn là lựa chọn đúng
Mặc dù có những nhược điểm này, Offset hoàn toàn ổn khi:
- Dữ liệu ít thay đổi (mutation) — danh sách đại lý, danh mục sản phẩm, kết quả tìm kiếm. Không ai chèn đại lý mới vào giữa các trang của bạn.
- Bạn cần "nhảy đến trang N" —
offset = (page - 1) * limitlà phép toán đơn giản. Cursor không làm được điều này. - Ưu tiên triển khai đơn giản — không cần mã hóa cursor, không cần chính sách cache đặc biệt, chỉ cần cắt mảng (slice).
Đối với trường hợp sử dụng trong ứng dụng thực tế của chúng tôi? Đây là phương án lựa chọn tối ưu vì dữ liệu không thay đổi trong khi người dùng cuộn trang.
Phân trang dựa trên Cursor (Cursor-Based Pagination)
Ý tưởng
Thay vì nói "bỏ qua 40 mục", client nói "hãy đưa cho tôi các mục xuất hiện sau mục cụ thể này".
Server trả về một cursor (con trỏ) đi kèm với từng kết quả — một con trỏ chỉ đến vị trí của mục đó. Client gửi lại cursor đó trong yêu cầu tiếp theo.
query GetPosts($after: String, $limit: Int) {
posts(after: $after, limit: $limit) {
items {
id
title
}
pageInfo {
endCursor # cursor trỏ đến mục cuối cùng được trả về
hasNextPage # xem còn mục nào nữa không
}
}
}
Yêu cầu đầu tiên — chưa có cursor:
// Lấy 20 mục đầu tiên
fetchPosts({ variables: { limit: 20 } })
// Server trả về:
{
items: [...20 posts],
pageInfo: {
endCursor: "YTI5Mjg3NDU=", // cursor cho mục cuối cùng
hasNextPage: true
}
}
Yêu cầu tiếp theo — gửi lại cursor:
// Lấy 20 mục tiếp theo bắt đầu sau mục đã xem cuối cùng
fetchPosts({ variables: { after: 'YTI5Mjg3NDU=', limit: 20 } });
Server không bỏ qua N vị trí — nó tìm mục mà cursor trỏ tới và trả về mọi thứ sau nó:
const getPosts = (after: string | null, limit: number) => {
if (!after) {
return ALL_POSTS.slice(0, limit);
}
const decodedCursor = decodeCursor(after); // ví dụ: timestamp hoặc ID
const startIndex = ALL_POSTS.findIndex(
(post) => post.createdAt < decodedCursor,
);
return ALL_POSTS.slice(startIndex, startIndex + limit);
};
Tại sao Cursor giải quyết được vấn đề của Offset
Quay lại ví dụ bảng tin của chúng ta:
Bạn đã xem đến: post1 (cursor: "abc123" → trỏ đến timestamp của post1)
Bài viết post11 mới được thêm vào đầu danh sách.
Yêu cầu tiếp theo: "hãy đưa cho tôi các bài viết sau post1"
Server tìm post1 bằng timestamp của nó → trả về từ post2 trở đi
Không quan trọng việc danh sách có mở rộng hay không. Cursor trỏ đến một mục cụ thể, không phải vị trí. Điểm khởi đầu được neo vào dữ liệu, không phải index.
⚠️ Cursor nói "sau mục cụ thể này" — chứ không phải "sau vị trí N". Danh sách có thể to ra hoặc co lại tùy thích. Con trỏ vẫn giữ nguyên.
Tương tự cho việc xóa dữ liệu. Nếu mục mà cursor trỏ tới bị xóa:
// Cursor dựa trên timestamp có khả năng phục hồi:
SELECT * FROM posts WHERE created_at < $cursorTimestamp ORDER BY created_at LIMIT $limit
// "Đưa cho tôi bài viết được tạo trước thời điểm này"
// Kể cả khi bài viết cụ thể đó đã bị xóa, timestamp vẫn hoạt động
Đó là lý do tại sao cursor thường là timestamp hoặc ID đã được mã hóa thay vì ID hàng thô — timestamp sống sót qua việc xóa dữ liệu, trong khi ID hàng có thể không được.
Cursor là gì và tại sao phải mã hóa (Opaque Cursor)?
Chú ý cursor chúng ta trả về ở trên: "YTI5Mjg3NDU=" — đó không phải một chuỗi có ý nghĩa ngay lập tức. Đó là một giá trị đã được mã hóa base64.
// Mã hóa một cursor (phía server)
const encodeCursor = (value: string): string =>
Buffer.from(value).toString('base64');
// Giải mã một cursor (phía server)
const decodeCursor = (cursor: string): string =>
Buffer.from(cursor, 'base64').toString('utf-8');
// "2024-01-15T10:30:00Z" → "MjAyNC0wMS0xNVQxMDozMDowMFo="
Tại sao phải ẩn nó đi? Ba lý do:
- Trừu tượng hóa (Abstraction) — Client không cần biết hay quan tâm cursor hoạt động bên trong như thế nào. Nó là một hộp đen (black box) để gửi lại.
- Bảo mật (Security) — nếu người dùng thấy
cursor = 12345, họ có thể thử tự xây dựng các cursor thủ công. Mã hóa ngăn chặn điều này. - Linh hoạt (Flexibility) — server có thể thay đổi cấu trúc bên trong của cursor (chuyển từ ID sang timestamp) mà không làm vỡ bất kỳ client nào coi nó là dạng bất biến (opaque).
type PageInfo = {
endCursor: string | null; // dạng bất biến — client không bao giờ giải mã nó
hasNextPage: boolean;
};
Sự đánh đổi bạn phải chấp nhận
Cursor mang lại sự ổn định nhưng đánh đổi một thứ — bạn không thể nhảy đến trang tùy ý.
// Offset — dễ dàng
const jumpToPage = (page: number, limit: number) => ({
offset: (page - 1) * limit,
});
// Cursor — bất khả thi nếu không fetch tất cả các trang trước đó
// "Trang 5" không có ý nghĩa — bạn cần cursor của trang 1, rồi trang 2, v.v.
Nếu UI của bạn có nút "nhảy đến trang 10", Offset là lựa chọn duy nhất. Phân trang Cursor hoàn toàn tuần tự — chỉ tiến về phía trước.
Relay: Chuẩn đã chính thức hóa Cursor
Khi Facebook open-sourced GraphQL, họ cũng giới thiệu Relay — một framework phía client với spec riêng về phân trang. Hầu hết các hệ sinh thái GraphQL ngày nay đều tham chiếu nó, kể cả khi không sử dụng chính Relay.
Relay đã chính thức hóa phân trang cursor thành một hình thức nhất quán sử dụng ba khái niệm:
- Connection → toàn bộ danh sách được phân trang
- Edge → một mục đơn + cursor của nó được gói lại với nhau
- Node → mục dữ liệu thực tế bên trong Edge
Dưới dạng schema GraphQL:
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post! # mục dữ liệu thực tế
cursor: String! # bookmark vị trí của mục đó
}
type PageInfo {
endCursor: String
hasNextPage: Boolean!
startCursor: String
hasPreviousPage: Boolean!
}
Query sẽ trông như sau:
query {
posts(first: 10, after: "opaqueCursor") {
edges {
cursor # mỗi mục có cursor RIÊNG của nó
node {
id
title
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
Đổi mới chính của Relay — mỗi mục đều có cursor riêng, không chỉ mục cuối cùng. Điều này cho phép bạn bắt đầu phân trang từ bất kỳ mục nào trong danh sách, không chỉ ở cuối.
Apollo Client có hỗ trợ sẵn tính năng này qua relayStylePagination():
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: relayStylePagination(), // xử lý gộp (merge) tự động
},
},
},
}),
});
Đối với phân trang offset, bạn phải xử lý việc gộp cache thủ công — một chủ đề riêng đáng để viết một bài khác.
So sánh trực tiếp
| Đặc điểm | Offset | Cursor |
|---|---|---|
| Tham số Query | offset, limit | after, limit |
| Logic Server | array.slice(offset, offset + limit) | tìm cursor → trả về N mục tiếp theo |
| Ổn định khi chèn dữ liệu | ❌ Bị trùng lặp | ✅ Ổn định |
| Ổn định khi xóa dữ liệu | ❌ Bỏ sót mục | ✅ Ổn định |
| Nhảy đến trang N | ✅ Dễ dàng | ❌ Bất khả thi |
| Triển khai | Đơn giản | Phức tạp hơn |
| Apollo cache | merge thủ công | relayStylePagination() |
| Phù hợp nhất cho | Dữ liệu tĩnh / ít thay đổi | Dữ liệu trực tiếp / thường xuyên thay đổi |
Làm sao để chọn?
Chỉ cần một câu hỏi để giải quyết:
Dữ liệu mới có xuất hiện ở đầu danh sách trong khi người dùng đang cuộn qua nó không?
- Có → Dùng Cursor. Bảng tin Twitter, thông báo, tin nhắn chat. Mục mới đẩy mọi thứ xuống dưới và offset sẽ hiển thị các mục trùng lặp.
- Không → Offset là ổn. Tìm đại lý, danh mục sản phẩm, kết quả tìm kiếm. Dữ liệu ổn định giữa các yêu cầu. Sự bất ổn của Offset không bao giờ xuất hiện.
Sai lầm tôi thấy thường xuyên nhất là vội vã chọn sự phức tạp của cursor trong khi offset hoàn toàn phù hợp. Một danh sách công thức nấu ăn không thay đổi trong sáu tháng không cần phân trang cursor. Một luồng hoạt động trực tiếp thì chắc chắn cần.
Tổng kết
Số trang không bị hỏng — chúng chỉ đưa ra một giả định: "danh sách không thay đổi trong khi bạn đang xem nó". Khi giả định đó đúng, chúng hoạt động hoàn hảo. Khi không, bạn sẽ thấy các "bóng ma" (dữ liệu trùng) và dữ liệu bị thiếu.
Cursor loại bỏ hoàn toàn giả định đó. Chúng không quan tâm danh sách trông như thế nào trước yêu cầu của bạn — chúng chỉ quan tâm đến "cái gì comes sau mục cụ thể này".
Việc hiểu được sự khác biệt này không chỉ giúp bạn chọn chiến lược phân trang đúng. Nó thay đổi cách bạn tư duy về việc lấy dữ liệu có trạng thái (stateful data fetching) nói chung — điều hóa ra là một trong những vấn đề thú vị hơn trong kỹ sư frontend.
Bài viết liên quan

Phần mềm
Ra mắt Rail: Ngôn ngữ lập trình tự hosting tích hợp HTTPS thuần túy
18 tháng 4, 2026

Phần mềm
Tương lai "Headless" cho AI cá nhân: Khi giao diện dòng lệnh lên ngôi
18 tháng 4, 2026

Công nghệ
Cursor đàm phán huy động hơn 2 tỷ USD với định giá 50 tỷ USD khi tăng trưởng doanh nghiệp bùng nổ
17 tháng 4, 2026
