Spring REST API & Data JPA
34/46
Bài 34 / 46~12 phútRepository & QueriesLộ trình · chặng 16/21Miễn phí… lượt xem

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:

SQL
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:

  1. Duyệt qua chỉ mục created_at từ đầu.
  2. Đếm và loại bỏ đúng 20.000 dòng dữ liệu đầu tiên.
  3. Đọc 20 dòng kế tiếp (từ dòng 20.001 đến 20.020).
  4. 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.

Hai cột đối chiếu đo bằng số row phải chạm: cột OFFSET quét index từ đầu, bỏ qua đúng 20.000 row rồi đọc 20 row nên đã chạm 20.020 row; cột keyset dùng điều kiện cặp created_at và id nhỏ hơn cursor để B-tree seek thẳng tới vị trí rồi đọc 20 row, chạm đúng 20 row ở mọi trang

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

Java
@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:

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

Index hỗ trợ Keyset

Để 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.

Java
// 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.

Hai cột đối chiếu Page và Slice rồi gộp về một kết luận chung: Page chạy câu lấy dữ liệu LIMIT 20 OFFSET N cộng câu COUNT nên có totalElements, Slice chỉ chạy một câu LIMIT 21 OFFSET N và dùng row thứ 21 để biết hasNext, nhưng cả hai vẫn giữ nguyên OFFSET N

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 thi2 (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ó insertDễ bị lặp hoặc mất dòngDễ bị lặp hoặc mất dòng✅ Tuyệt đối ổn định
Phù hợp nhấtMàn hình Admin Table nhỏMobile Infinite ScrollBả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ì?

  1. 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=50000 làm sập database connection pool.
  2. Cursor không duy nhất (thiếu Tie-Breaker): Chỉ dùng createdAt làm cursor mà không kèm id. Khi có nhiều dòng cùng timestamp, các bản ghi sẽ bị bỏ sót âm thầm.
  3. 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

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tạ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ệ?
  2. Q2
    Keyset (Cursor-based) pagination đạt được độ phức tạp O(log N) ở mọi trang sâu nhờ vào cơ chế nào?
  3. Q3
    Tạ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?
  4. Q4
    Hạ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ì?
  5. Q5
    Kiể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.

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

Bài tiếp theo

Tổng Kết Module 05: Repository và Kỹ Thuật Truy Vấn