Hiệu Năng Phân Trang: OFFSET vs Keyset Cursor Pagination
Phân tích nguyên nhân suy giảm hiệu năng của OFFSET ở trang sâu, cơ chế B-tree seek của Keyset pagination và kỹ thuật dùng Slice loại bỏ COUNT.
TL;DR: OFFSET N LIMIT 20 có độ phức tạp $O(N)$ vì database phải quét tuần tự và bỏ qua $N$ dòng trước khi trả về kết quả; trang thứ 1.000 sẽ chậm hơn trang đầu tiên tới hàng nghìn lần. Keyset (Cursor-based) pagination tận dụng B-tree index seek để nhảy thẳng tới vị trí dữ liệu với độ phức tạp $O(\log N)$ không đổi bất kể độ sâu. Kiểu trả về Slice<T> giúp loại bỏ câu lệnh COUNT(*) tiết kiệm 50% thời gian nhưng vẫn chịu chi phí của OFFSET scan.
Vì sao phân trang OFFSET bị chậm tuyến tính ở các trang sâu?
Hãy quan sát câu lệnh SQL phân trang truyền thống:
SELECT * FROM activities
ORDER BY created_at DESC
LIMIT 20 OFFSET 20000;
Cơ chế thực thi bên dưới của PostgreSQL / MySQL:
- Duyệt qua chỉ mục
created_attừ đầu. - Đếm và loại bỏ đúng 20.000 dòng dữ liệu đầu tiên.
- Đọc 20 dòng kế tiếp (từ dòng 20.001 đến 20.020).
- Trả kết quả về cho ứng dụng.
Database phải tiêu tốn tài nguyên I/O để chạm vào 20.020 bản ghi chỉ để lấy ra đúng 20 bản ghi! Kể cả khi cột created_at có index, database vẫn phải duyệt qua 20.000 mục chỉ mục. Độ phức tạp là $O(N)$ tỉ lệ thuận với giá trị OFFSET.

Keyset Cursor Pagination giải quyết bài toán hiệu năng ra sao?
Thay vì yêu cầu database "bỏ qua 20.000 dòng", Keyset Pagination đưa ra điều kiện: "Lấy 20 bản ghi tiếp theo nằm sau bản ghi cuối cùng của trang trước".
Cú pháp Composite Cursor
@Query("""
SELECT a FROM Activity a
WHERE a.createdAt < :cursorCreatedAt
OR (a.createdAt = :cursorCreatedAt AND a.id < :cursorId)
ORDER BY a.createdAt DESC, a.id DESC
""")
List<Activity> findNextPage(
@Param("cursorCreatedAt") Instant cursorCreatedAt,
@Param("cursorId") Long cursorId,
Pageable limit
);
Câu lệnh SQL phát sinh tương ứng:
SELECT * FROM activities
WHERE created_at < '2026-05-01 10:00:00'
OR (created_at = '2026-05-01 10:00:00' AND id < 5000)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Database thực hiện thao tác B-tree Index Seek để nhảy trực tiếp tới vị trí con trỏ (cursorCreatedAt, cursorId) trong thời gian $O(\log N)$, sau đó đọc đúng 20 dòng tiếp theo.
Để câu truy vấn đạt tốc độ tối đa, bắt buộc phải tạo composite index khớp chính xác với mệnh đề ORDER BY và WHERE:
CREATE INDEX idx_activities_seek ON activities(created_at DESC, id DESC);
Kiểu trả về Slice loại bỏ câu lệnh COUNT(*) như thế nào?
Khi sử dụng Page<T>, hệ thống luôn phát thêm một câu lệnh SELECT COUNT(*) tốn kém. Nếu giao diện chỉ cần nút "Xem thêm" hoặc Infinite Scroll trên thiết bị di động, Slice<T> là lựa chọn thay thế hoàn hảo.
// Chi phat sinh duy nhat 1 cau lenh SQL: LIMIT 21 OFFSET N
Slice<Activity> findByCategory(String category, Pageable pageable);
Hibernate thực hiện cơ chế tối ưu bằng cách truy vấn size + 1 dòng (ví dụ lấy 21 dòng cho trang kích thước 20):
- Nếu database trả về đủ 21 dòng: Phương thức
hasNext()trả vềtrue, dòng thứ 21 bị gọt bỏ khỏi danh sách trả về. - Nếu database trả về $\le 20$ dòng: Phương thức
hasNext()trả vềfalse.

Cảnh báo: Slice<T> chỉ loại bỏ chi phí của COUNT(*). Nếu người dùng cuộn tới trang quá sâu (OFFSET 500000), câu lệnh data query của Slice vẫn bị chậm như bình thường.
Bảng so sánh 3 chiến lược phân trang dữ liệu
| Tiêu chí | Page (OFFSET) | Slice (OFFSET) | Keyset (Cursor) |
|---|---|---|---|
| Số câu SQL thực thi | 2 (SELECT + COUNT) | 1 (SELECT size + 1) | 1 (SELECT seek qua index) |
| Chi phí trang sâu | $O(N)$ (chậm dần) | $O(N)$ (chậm dần) | $O(\log N)$ (cực nhanh, ổn định) |
| Biết tổng số trang | ✅ Có (totalPages) | ❌ Không | ❌ Không |
| Nhảy cóc trang ngẫu nhiên | ✅ Có (?page=50) | ✅ Có | ❌ Không (chỉ đi tới/lui) |
| Độ ổn định khi có insert | Dễ bị lặp hoặc mất dòng | Dễ bị lặp hoặc mất dòng | ✅ Tuyệt đối ổn định |
| Phù hợp nhất | Màn hình Admin Table nhỏ | Mobile Infinite Scroll | Bảng lớn hàng triệu dòng, Activity Feed |
Những cạm bẫy phổ biến cần tránh là gì?
- Cho phép OFFSET sâu không giới hạn: Không chặn tham số
page, khiến crawler web quét tới?page=50000làm sập database connection pool. - Cursor không duy nhất (thiếu Tie-Breaker): Chỉ dùng
createdAtlàm cursor mà không kèmid. Khi có nhiều dòng cùng timestamp, các bản ghi sẽ bị bỏ sót âm thầm. - Thiếu Composite Index cho Keyset: Viết câu lệnh seek nhưng không tạo chỉ mục trên cặp
(createdAt, id), dẫn tới việc database phải full-table scan.
Tự kiểm tra kiến thức
- Q1Tại sao câu lệnh 'SELECT * FROM activities ORDER BY created_at DESC LIMIT 20 OFFSET 20000' lại chạy chậm trên cơ sở dữ liệu quan hệ?
- Q2Keyset (Cursor-based) pagination đạt được độ phức tạp O(log N) ở mọi trang sâu nhờ vào cơ chế nào?
- Q3Tại sao trong Keyset pagination bắt buộc phải sử dụng composite cursor gồm cả (createdAt, id) thay vì chỉ dùng createdAt?
- Q4Hạn chế lớn nhất về mặt trải nghiệm người dùng (UX) của Keyset Cursor Pagination là gì?
- Q5Kiểu trả về Slice<T> trong Spring Data JPA giúp tiết kiệm tài nguyên database bằng cách nào?
Bài tiếp theo: Tổng Kết Module 05: Repository & Queries
Bài này có giúp bạn hiểu bản chất không?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Pageable và Sort: Phân Trang và Sắp Xếp Dữ Liệu Chuẩn Mực
Bài tiếpTổng Kết Module 05: Repository và Kỹ Thuật Truy Vấn
Lộ trình Junior Java Backend Developer
Chặng 16/21 · Spring Data JPA — bài 13/20 của chặng
Ôn phỏng vấn
Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.
Thảo luận & góp ý
Hỏi đáp về bài này