Pagination và Sorting: Page, Slice, keyset
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 đề
- Load hết → OutOfMemoryError, response vài trăm MB.
- Phân trang bằng OFFSET thì trang càng sâu càng chậm.
Pagechạy thêmCOUNT(*)mỗi lần, trên bảng lớn rất tốn.- Fetch join collection + phân trang → Hibernate phân trang trong memory.
3. Giải pháp: Pageable
Pageable pageable = PageRequest.of(0, 20, Sort.by("price").descending());
Page<Product> page = repository.findAll(pageable);
- Page index = 0, tức trang đầu tiên.
- Page size = 20.
- Sắp xếp price giảm dần.
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
| Page | Slice | Keyset/Cursor | |
|---|---|---|---|
| Count query | Có | Không | Không |
| Nhảy tới trang N | Được | Được (OFFSET) | Không |
| Trang sâu | Chậm | Chậm | Nhanh |
| Dữ liệu chèn thêm khi lướt | Có thể trùng/sót | Có thể trùng/sót | Ổn định |
| Hợp với | Bả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
- Luôn có
ORDER BYổn định (thêm id để phá hòa) khi phân trang. - Page cho UI cần tổng số; viết
countQuerygọn, bỏ join/fetch không cần. - Slice cho mobile "load more".
- Keyset cho bảng lớn, feed, job đọc toàn bảng.
- Không fetch join collection trong query có Pageable.
- Không cho client sort theo field tùy ý chưa có index.
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
- Page = data + COUNT; Slice = data + 1 dòng; List = data.
- OFFSET sâu = đọc rồi vứt; keyset = seek theo index.
- Cursor nhiều cột phải có id phá hòa.
- Fetch join collection + phân trang = phân trang trong RAM.
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.