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

Pageable và Sort: Phân Trang và Sắp Xếp Dữ Liệu Chuẩn Mực

Làm chủ phân trang Pageable, Sort multi-field, cơ chế sinh 2 câu SQL trong Page, kỹ thuật chặn DoS kích thước trang và projection DTO.

TL;DR: Pageable đại diện cho yêu cầu phân trang (page, size, Sort) được Spring Data Web tự động bind từ URL query params. Với return type Page<T>, Spring Data phát hai câu SQL: một câu lấy dữ liệu phân đoạn (LIMIT/OFFSET) và một câu đếm tổng số dòng (COUNT(*)). Cần thiết lập giới hạn cứng max-page-size để ngăn chặn tấn công DoS làm sập bộ nhớ máy chủ.

Đối tượng Pageable hoạt động trong Spring MVC như thế nào?

Spring Data Web cung cấp bộ chuyển đổi PageableHandlerMethodArgumentResolver tự động ánh xạ các tham số từ query string HTTP vào đối tượng Pageable:

Java
@RestController
@RequestMapping("/products")
@RequiredArgsConstructor
public class ProductController {
    private final ProductRepository repo;

    @GetMapping
    public Page<ProductDto> list(
        @PageableDefault(size = 20)
        @SortDefault(sort = "createdAt", direction = Sort.Direction.DESC)
        Pageable pageable
    ) {
        return repo.findAll(pageable).map(ProductDto::from);
    }
}

Khi client gửi request: GET /products?page=0&size=20&sort=createdAt,desc&sort=id,asc:

  • page=0: Chỉ số trang bắt đầu từ 0.
  • size=20: Số lượng bản ghi tối đa trên một trang.
  • sort: Tiêu chí sắp xếp nhiều tầng (multi-field sort).

Tại sao Page lại phát sinh 2 câu lệnh SQL riêng biệt?

Khi phương thức trả về Page<T>, Spring Data cần đầy đủ thông tin để phục vụ giao diện hiển thị thanh phân trang (ví dụ: "Trang 1 / 15"):

SQL
-- SQL 1: Lay du lieu trang hien tai
SELECT p.* FROM products p
WHERE p.category = 'ELECTRONICS'
ORDER BY p.created_at DESC
LIMIT 20 OFFSET 0;

-- SQL 2: Dem tong so ban ghi de tinh totalPages
SELECT COUNT(*) FROM products p
WHERE p.category = 'ELECTRONICS';

Một request sau khi bind thành Pageable rẽ thành hai câu SQL: câu lấy dữ liệu với ORDER BY và LIMIT OFFSET nuôi phần content, câu đếm nuôi đúng hai field totalElements và totalPages, rồi PageImpl gói cả hai thành JSON

Kết quả trả về cho client ở dạng JSON chuẩn:

JSON
{
  "content": [ ... ],
  "totalElements": 300,
  "totalPages": 15,
  "number": 0,
  "size": 20,
  "first": true,
  "last": false
}

Lưu ý: Nếu giao diện người dùng chỉ là danh sách cuộn vô tận (Infinite Scroll) hoặc nút "Xem thêm" mà không cần biết tổng số trang, bạn nên chuyển sang kiểu trả về Slice<T> để loại bỏ hoàn toàn câu lệnh COUNT(*).

Sắp xếp nhiều trường và cạm bẫy Tie-Breaker là gì?

Khi sắp xếp theo một trường có nhiều giá trị trùng lặp (ví dụ: priority = HIGH), cơ sở dữ liệu không đảm bảo tính xác định (non-deterministic) của thứ tự giữa các bản ghi cùng mức độ ưu tiên.

Hậu quả: Một bản ghi có thể xuất hiện ở cuối trang 1 và lại bị lặp lại ở đầu trang 2 khi người dùng chuyển trang!

200 project cùng priority HIGH sắp bằng ORDER BY priority không tie-breaker: request page 0 lúc 10:00:03 xếp P12 ở cuối trang, request page 1 lúc 10:00:05 gặp plan khác đẩy P12 sang đầu trang 1, nên P12 trả về hai lần còn một project khác không xuất hiện lần nào

Khắc phục bằng Tie-Breaker duy nhất

Luôn luôn bổ sung một trường duy nhất (thường là trường khóa chính id) vào vị trí cuối cùng của mệnh đề Sort:

Java
Sort stableSort = Sort.by(
    Sort.Order.desc("priority"),
    Sort.Order.asc("id") // Tie-breaker dam bao tinh xac dinh tuyet doi
);
Pageable pageable = PageRequest.of(page, size, stableSort);

Làm thế nào để bảo mật tham số Sort và ngăn chặn DoS?

1. Whitelist các trường được phép Sort

Nếu không kiểm duyệt, kẻ tấn công có thể gửi ?sort=passwordHash,asc (gây rò rỉ thông tin qua thời gian phản hồi) hoặc sort trên cột văn bản không đánh index gây nghẽn database:

Java
private static final Set<String> ALLOWED_SORT = Set.of("createdAt", "name", "price", "id");

public void validateSort(Sort sort) {
    for (Sort.Order order : sort) {
        if (!ALLOWED_SORT.contains(order.getProperty())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Sort field khong hop le");
        }
    }
}

2. Giới hạn cứng kích thước trang (Cap Size)

Client có thể gửi ?size=1000000, buộc ứng dụng phải nạp một triệu dòng dữ liệu vào Heap và gây sập hệ thống (OutOfMemoryError).

Thiết lập bảo vệ hai tầng:

YAML
# application.yml: Bao ve toan cuc (Safety Net)
spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 100 # Tu dong trim ve 100 neu client vuot qua
Java
// Controller: Validation tuong minh tra loi 400 Bad Request
@GetMapping
public Page<ProductDto> list(
    @RequestParam(defaultValue = "0") @Min(0) int page,
    @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size
) { ... }

Tối ưu hóa Custom countQuery khi sử dụng @Query

Khi bạn viết @Query có phép nối bảng JOIN, Spring Data mặc định sẽ bọc nguyên câu lệnh đó để đếm, gây suy giảm hiệu năng nghiêm trọng:

Java
// CHUA TOI UU: Count query se thuc hien ca phep JOIN tasks khong can thiet
@Query("SELECT p FROM Project p LEFT JOIN p.tasks t WHERE p.status = :status GROUP BY p.id")
Page<Project> findByStatus(@Param("status") ProjectStatus status, Pageable pageable);

// TOI UU: Khai bao countQuery rieng biet chi quet tren bang projects
@Query(
    value = "SELECT p FROM Project p LEFT JOIN p.tasks t WHERE p.status = :status GROUP BY p.id",
    countQuery = "SELECT COUNT(p) FROM Project p WHERE p.status = :status"
)
Page<Project> findByStatusOptimized(@Param("status") ProjectStatus status, Pageable pageable);

Những cạm bẫy phổ biến cần tránh là gì?

  1. Quên Tie-Breaker: Gây mất hoặc lặp bản ghi giữa các trang khi dữ liệu có nhiều dòng trùng giá trị sắp xếp.
  2. Không giới hạn max-page-size: Mở ra lỗ hổng tấn công DoS tài nguyên RAM máy chủ.
  3. Quên custom countQuery khi JOIN bảng lớn: Làm cho câu truy vấn COUNT(*) chạy cực kỳ chậm chạp.
  4. Gọi repo.findAll() không truyền Pageable: Khi bảng phình to hàng trăm ngàn dòng, hệ thống lập tức sập vì OutOfMemory.

Tự kiểm tra kiến thức

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao phương thức repository trả về Page<T> lại phải thực hiện tới hai câu lệnh SQL riêng biệt?
  2. Q2
    Hiện tượng gì có thể xảy ra nếu phân trang danh sách với tiêu chí ORDER BY trên một cột có nhiều giá trị trùng lặp mà không có Tie-Breaker?
  3. Q3
    Tại sao cần phải kiểm duyệt (whitelist) danh sách các trường được phép truyền vào tham số Sort từ phía người dùng?
  4. Q4
    Khi sử dụng @Query phức tạp có liên kết JOIN nhiều bảng cùng với Pageable, tại sao nên khai báo thêm thuộc tính countQuery?
  5. Q5
    Nếu giao diện người dùng chỉ yêu cầu tính năng 'Cuộn vô tận' (Infinite Scroll) hoặc nút 'Tải thêm', kiểu trả về nào sẽ tối ưu hơn Page<T>?

Bài tiếp theo: Hiệu Năng Phân Trang: OFFSET vs Keyset Cursor Pagination

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 12/20 của chặng

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Hiệu Năng Phân Trang: OFFSET vs Keyset Cursor Pagination