
Ngừng Dùng OFFSET: Phân Trang Siêu Tốc Với Cursor
Tìm hiểu lý do LIMIT và OFFSET làm chậm database khi dữ liệu lớn, và cách tối ưu hiệu năng bằng Cursor Pagination qua các ví dụ thực tế.
Hầu hết chúng ta đều bắt đầu làm tính năng phân trang bằng bộ đôi kinh điển: LIMIT và OFFSET. Đây gần như là cách mặc định được dạy trong mọi tutorial lập trình web. Bạn query lấy dữ liệu, nhận tham số page=2 từ client, nhân với page_size, và bùm—bạn có offset. Code chạy mượt mà trên localhost, deploy lên production mấy tháng đầu cũng chẳng thấy lỗi lầm gì.
Nhưng câu chuyện thay đổi khi app của bạn lớn lên, bảng trong database bắt đầu phình ra hàng triệu dòng. Một ngày đẹp trời, user than phiền rằng app load rất chậm khi lướt xuống các trang cũ hoặc cuộn sâu trong news feed. Mở log database lên check, bạn sẽ thấy chính những câu query phân trang ngây thơ ngày nào đang vắt kiệt sức cái server.
Hôm nay, mình sẽ chỉ ra lý do tại sao OFFSET lại "kém sang" khi scale, và làm thế nào Cursor Pagination (Phân trang bằng con trỏ) có thể giải quyết triệt để vấn đề này.
Sát Thủ Giấu Mặt Mang Tên OFFSET
Để hiểu vấn đề, chúng ta cần nhìn vào cách các hệ quản trị CSDL như PostgreSQL hay MySQL thực thi một câu lệnh chứa OFFSET.
Giả sử bạn viết query thế này:
SELECT * FROM articles
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
Nhiều anh em lầm tưởng rằng database engine sẽ thông minh "nhảy" một phát đến dòng thứ 100,000 rồi lấy 20 dòng tiếp theo. Đáng tiếc là không. Database không hề có phép màu để biết dòng thứ 100,000 nằm chính xác ở đâu trên ổ cứng. Để trả về kết quả, nó buộc phải duyệt, đọc, và sau đó ném sọt rác 100,000 dòng đầu tiên chỉ để lấy 20 dòng bạn cần.
Đồng nghĩa với việc hiệu năng của OFFSET sẽ giảm tuyến tính (tệ dần đều). Trang 1 thì nhanh như chớp. Trang 10 vẫn ổn định. Nhưng trang 5,000 thì database sẽ phải cày ải một lượng dữ liệu khổng lồ, đẩy các cache hữu ích ra khỏi RAM, và làm CPU spike dựng đứng. Với một hệ thống high-traffic, đây là sát thủ thầm lặng.
Giải Pháp: Cursor Pagination

Thay vì bảo database là "hãy bỏ qua 100,000 dòng", cursor pagination bảo database rằng "hãy bắt đầu lấy dữ liệu ngay sau thằng này".
"Cursor" (con trỏ) thực chất chỉ là một mốc tham chiếu trỏ đến một dòng cụ thể trong dữ liệu của bạn. Thường thì chúng ta xài các cột mang tính unique và tuần tự như id (auto-increment) hoặc created_at.
Ở trang đầu tiên, query nhìn rất bình thường:
SELECT * FROM articles
ORDER BY id DESC
LIMIT 20;
Bạn lấy id của bản ghi cuối cùng trong mảng kết quả trả về, và gửi nó xuống client. Cái id này chính là cursor của bạn. Khi user cuộn xuống để lấy trang tiếp theo, client gửi ngược lại cursor đó lên API. Câu query lúc này sẽ đổi thành:
SELECT * FROM articles
WHERE id < 15234 -- Cursor từ trang trước
ORDER BY id DESC
LIMIT 20;
Thấy điểm khác biệt chưa? Chữ OFFSET đã bay màu! Vì cột id đã được đánh index, database dùng B-Tree index để nhảy thẳng đến đúng vị trí của dòng có ID 15234 và đọc tiếp 20 dòng sau đó. Kể cả user đang ở trang 2 hay trang 10,000, tốc độ query gần như không đổi (hằng số O(1)).
Thiết Kế API Cho "Ngầu"
Khi build API hiện đại, một best practice là đừng expose trần trụi cái cột database ra ngoài làm cursor. Thay vào đó, anh em nên encode nó (thường là dùng Base64).
Ví dụ: thay vì gọi ?last_id=15234, API của bạn nên thiết kế để nhận ?cursor=eyJpZCI6MTUyMzR9 (chính là chuỗi JSON {"id":15234} được mã hóa).
Cách làm này đem lại sự linh hoạt cực lớn. Về sau, lỡ bạn muốn đổi kiểu phân trang từ ID sang timestamp, bạn có thể thoải mái sửa bên dưới backend mà không làm gãy các app client bản cũ. Nó cũng chống việc user ngứa tay thử tự tăng giảm số ID để dò quét dữ liệu của hệ thống.
Cạm Bẫy Khi Sắp Xếp: Bài Toán Tie-Breaker
Tất nhiên, chả có giải pháp nào là hoàn hảo. Cú lừa lớn nhất của Cursor Pagination nằm ở lúc bạn muốn sort theo một cột KHÔNG unique.
Lấy ví dụ, bạn muốn sort danh sách bài viết theo lượt xem (views):
SELECT * FROM articles
WHERE views < 5000
ORDER BY views DESC
LIMIT 20;
Điều gì xảy ra nếu có tận 50 bài viết cùng có đúng 5,000 view? Database sẽ không thể đảm bảo một thứ tự nhất quán. Hậu quả là có bài sẽ bị bỏ qua (lọt hố), trong khi có bài lại bị xuất hiện đúp 2 lần ở 2 trang khác nhau.
Cách giải quyết là bạn luôn phải đính kèm một cột unique để làm "kẻ phân xử" (tie-breaker). Cặp đôi giữa cột dùng để sort và cột unique sẽ tạo thành một cursor phức hợp.
SELECT * FROM articles
WHERE (views, id) < (5000, 15234)
ORDER BY views DESC, id DESC
LIMIT 20;
Lúc này, con trỏ API của bạn sẽ encode cả hai giá trị: {"views": 5000, "id": 15234}. Giờ thì list dữ liệu sẽ luôn được sắp xếp hoàn hảo dù cho có bao nhiêu bài bị trùng view đi chăng nữa.
Đánh Đổi
Được cái này thì phải mất cái kia. Cursor Pagination giải quyết gọn ghẽ bài toán hiệu năng, nhưng nó tước đi của bạn khả năng nhảy đến một trang bất kỳ.
Với OFFSET, bạn dễ dàng làm cái UI phân trang cổ điển kiểu [1] [2] ... [8] [9]. Còn với Cursors, bạn không thể đùng một cái nhảy từ trang 1 sang trang 9, vì bạn làm gì biết cursor của trang 9 là số mấy cho đến khi bạn query xong trang 8.
Nhưng thực tế mà nói, hãy tự hỏi: có bao nhiêu user thực sự rảnh tới mức bấm vào nút "Trang 45"? Với các UI/UX hiện đại—nhất là infinite scroll (cuộn vô tận) trên mobile hoặc nút "Load More"—người ta hiếm khi cần phải nhảy trang kiểu đó nữa.
Lời Kết
Mình không khuyên anh em over-engineer ngay từ ngày đầu tiên. Nếu bạn đang code một cái admin dashboard nội bộ, bảng có lèo tèo vài nghìn dòng, thì cứ nhắm mắt xài OFFSET cho nhẹ đầu. Nó đơn giản và hỗ trợ Table UI tốt.
Nhưng nếu bạn đang xây dựng một tính năng news feed, một API public ra ngoài, hay dự báo dữ liệu sẽ phình to trong nay mai, thì hãy vứt OFFSET đi. Chuyển qua dùng Cursor Pagination sớm sẽ cứu con database của bạn khỏi cảnh sập nguồn khi scale lên sau này đấy.
viết bởi
Nguyên Tech
Phản hồi
Đang tải bình luận…