Pagination và Sorting: Page, Slice, keyset

4 câu hỏi~22 phútTừ khóa: Pageable, PageRequest, Sort, Page, Slice, count query, OFFSET, keyset pagination, cursor, fetch join

Tiếp đi: phân trang và sắp xếp.

Bài trước ta viết được query lọc Product. Giờ xử lý chuyện số lượng: kết quả có thể là cả triệu dòng.

1. Bài toán

Database shop-app có một triệu Product. Nếu gọi:

repository.findAll();

Ứng dụng có thể load lượng dữ liệu rất lớn vào memory.

2. Vấn đề

3. Giải pháp: Pageable

Pageable pageable = PageRequest.of(0, 20, Sort.by("price").descending());
Page<Product> page = repository.findAll(pageable);
SELECT * FROM products ORDER BY price DESC LIMIT 20 OFFSET 0;

Ba kiểu trả về:

KiểuÝ nghĩa
Page<T>Có thông tin tổng số phần tử/trang (thêm count query)
Slice<T>Chỉ biết có trang tiếp theo hay không
List<T>Trả danh sách, không kèm metadata phân trang

4. Code từng bước

Bước 1: Dependency: spring-boot-starter-data-jpa, spring-boot-starter-web (đã có).

Bước 2: Config giới hạn page size, chống client xin size=1000000

spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 100

Bước 3: Code Java

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Page: có count query, có thể tự viết countQuery nhẹ hơn
    @Query(value = "SELECT p FROM Product p WHERE p.stock > 0",
           countQuery = "SELECT count(p.id) FROM Product p WHERE p.stock > 0")
    Page<Product> findInStock(Pageable pageable);

    // Slice: lấy size + 1 dòng để biết hasNext, không COUNT
    Slice<Product> findByCategory(String category, Pageable pageable);

    // Keyset: sau id cuối cùng client đã thấy
    List<Product> findByIdGreaterThanOrderByIdAsc(Long lastId, Limit limit);
}

@RestController
@RequestMapping("/products")
class ProductController {
    private final ProductRepository repo;
    ProductController(ProductRepository repo) { this.repo = repo; }

    @GetMapping
    Page<ProductSummary> list(@PageableDefault(size = 20, sort = "id") Pageable pageable) {
        return repo.findInStock(pageable).map(ProductSummary::from);
    }

    @GetMapping("/feed")
    List<ProductSummary> feed(@RequestParam(defaultValue = "0") Long after) {
        return repo.findByIdGreaterThanOrderByIdAsc(after, Limit.of(20))
                   .stream().map(ProductSummary::from).toList();
    }
}

(Limit có từ Spring Data 3.2; bản cũ dùng findTop20ByIdGreaterThanOrderByIdAsc. Spring Data cũng có ScrollPosition/Window cho keyset.)

Bước 4: Test bằng curl

curl "http://localhost:8080/products?page=0&size=20&sort=price,desc"
# {"content":[...], "totalElements":1000000, "totalPages":50000, ...}

curl "http://localhost:8080/products/feed?after=900000"
# 20 product có id > 900000

5. Cách hoạt động

OFFSET pagination (trang sâu):
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 900000;

  index id ─► đọc 900000 dòng ─► vứt bỏ ─► lấy 20 dòng
              (chậm dần theo số trang)

Keyset pagination:
SELECT * FROM products WHERE id > 900000 ORDER BY id LIMIT 20;

  index id ─► seek thẳng tới 900001 ─► lấy 20 dòng
              (gần như hằng số)

Keyset phù hợp khi có khóa sắp xếp ổn định và index thích hợp. Nếu sort theo nhiều cột, cursor phải chứa đủ thông tin để xác định vị trí chính xác:

-- sort theo created_at DESC, id DESC (id để phá hòa)
SELECT * FROM orders
WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- index: (created_at DESC, id DESC)

Cảnh báo fetch join + pagination:

@Query("SELECT o FROM Order o JOIN FETCH o.items")
Page<Order> findAllWithItems(Pageable p);   // ĐỪNG
WARN HHH90003004: firstResult/maxResults specified with
collection fetch; applying in memory

JOIN với collection nhân số dòng (1 order × 5 items = 5 dòng), nên LIMIT ở SQL sẽ cắt sai. Hibernate bỏ LIMIT, load toàn bộ rồi cắt trong memory. Cách sửa: phân trang id trước, rồi fetch theo id.

@Query(value = "SELECT o.id FROM Order o",
       countQuery = "SELECT count(o) FROM Order o")
Page<Long> findPageIds(Pageable p);

@Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findWithItems(@Param("ids") List<Long> ids);

Hoặc giữ query phân trang đơn giản và đặt hibernate.default_batch_fetch_size để items được load theo lô IN (...). Fetch join @ManyToOne (không nhân dòng) thì phân trang vẫn an toàn.

6. So sánh

PageSliceKeyset/Cursor
Count queryCóKhôngKhông
Nhảy tới trang NĐượcĐược (OFFSET)Không
Trang sâuChậmChậmNhanh
Dữ liệu chèn thêm khi lướtCó thể trùng/sótCó thể trùng/sótỔn định
Hợp vớiBảng admin có "trang 1..N""Tải thêm"Infinite scroll, API export, bảng lớn

7. Khi nào nên dùng / không nên dùng

Có phải Page luôn chạy hai câu SELECT?

Gần đúng, chỉ cần chỉnh một điểm: Spring Data đủ thông minh để bỏ count khi suy ra được tổng. Ví dụ trang đầu mà kết quả ít hơn page size, hoặc trang cuối (offset + số dòng nhận được = total). Còn lại thì có câu COUNT. Trên bảng hàng chục triệu dòng với filter phức tạp, câu count có thể chậm hơn cả câu lấy dữ liệu. Khi đó cân nhắc Slice, count ước lượng, hoặc cache tổng số.

Keyset thì sort theo price được không? price trùng nhau nhiều mà.

Được, miễn cursor là (price, id) chứ không chỉ price. Price trùng nên cần id để phá hòa:

WHERE (p.price > :lastPrice) OR (p.price = :lastPrice AND p.id > :lastId)
ORDER BY p.price, p.id
-- index (price, id)

Client nhận cursor dạng mã hóa (ví dụ Base64 của {"price":199,"id":5012}) và gửi lại ở request sau.

Phỏng vấn hỏi pagination trong JPA thì trả lời sao?

Q1. What is the difference between Page and Slice?

🇻🇳 Tiếng Việt:

Page có tổng số phần tử và tổng số trang nên thường cần thêm một count query. Slice chỉ cho biết có trang tiếp theo hay không, bằng cách lấy dư một dòng, nên tránh được count query. Em dùng Page khi UI cần hiện tổng số trang, Slice cho kiểu "tải thêm".

🇬🇧 English:

Page provides total element and page counts, which usually needs an extra count query. Slice only tells whether more results are available, by fetching one extra row, so it avoids the count query. I use Page when the UI shows total pages and Slice for "load more" style screens.

Q2. Vì sao OFFSET chậm ở trang sâu, keyset pagination giải quyết thế nào?

🇻🇳 Tiếng Việt:

Với OFFSET lớn, database vẫn phải duyệt rồi bỏ qua tất cả các dòng phía trước, nên trang càng sâu càng chậm. Keyset dùng điều kiện WHERE id > :lastId ORDER BY id LIMIT n, database seek thẳng trên index nên tốc độ gần như không đổi, và ổn định khi có dữ liệu mới chèn vào. Đổi lại không nhảy được tới trang bất kỳ, và cần khóa sắp xếp duy nhất có index.

🇬🇧 English:

With a large OFFSET the database still has to walk and discard all previous rows, so deep pages get slower. Keyset pagination uses WHERE id > :lastId ORDER BY id LIMIT n, so the database seeks directly on the index with near-constant cost, and results stay stable when new rows are inserted. The trade-off is no random page jumps, and it needs a unique, indexed sort key.

Q3. Fetch join collection kèm Pageable có vấn đề gì?

🇻🇳 Tiếng Việt:

Join với collection nhân số dòng nên Hibernate không thể áp LIMIT ở SQL. Nó cảnh báo HHH90003004 và load toàn bộ kết quả rồi phân trang trong memory, rất nguy hiểm với bảng lớn. Em phân trang id trước rồi fetch join theo danh sách id, hoặc dùng batch fetching.

🇬🇧 English:

Joining a collection multiplies rows, so Hibernate cannot apply LIMIT in SQL. It logs HHH90003004 and loads the full result, then paginates in memory, which is dangerous on large tables. I paginate the ids first and then fetch-join by that id list, or rely on batch fetching.

Cách nhớ ngắn gọn


Tự kiểm tra

PageRequest.of(2, 20) sinh OFFSET bao nhiêu?

40 (page index bắt đầu từ 0).

Slice biết có trang sau bằng cách nào?

Lấy size + 1 dòng; nếu nhận được dòng thứ size + 1 thì hasNext = true.

Vì sao cursor theo price phải kèm id?

Price có thể trùng; thiếu id để phá hòa thì trùng hoặc sót dòng giữa các trang.

Cảnh báo HHH90003004 nghĩa là gì?

Có collection fetch kèm phân trang, Hibernate phân trang trong memory sau khi load hết.

Làm sao giảm chi phí count của Page?

Viết countQuery gọn không join/fetch, hoặc chuyển sang Slice/keyset, hoặc cache/ước lượng tổng.