Pageable & Sort — phân trang, sắp xếp, validate, projection DTO
Pageable wrap LIMIT/OFFSET SQL, Spring tự sinh 2 câu query (data + COUNT), kết quả trả về trong Page chứa content và totalElements. Bài này đào sâu cơ chế bên dưới: vì sao Page cần COUNT, vì sao phải cap size tránh DoS, sort multi-field, và projection DTO kết hợp Pageable.
TL;DR: Pageable là object gói page, size, sort — Spring Data Web tự bind từ query string ?page=0&size=20&sort=createdAt,desc. Repository trả Page<T> chứa content + totalElements bằng cách chạy 2 SQL: data query (LIMIT/OFFSET) + count query (COUNT(*)). Page có COUNT vì UI cần biết "tổng bao nhiêu trang"; nếu UI chỉ cần "còn trang tiếp không" thì dùng Slice để bỏ COUNT query. Cap size bắt buộc — request ?size=999999 không giới hạn sẽ trả hàng triệu row, OOM server. Sort multi-field và projection DTO là hai kỹ thuật quan trọng để tối ưu list endpoint production.
Bài Specification & auditing đã cover dynamic filter với Specification. Bài này ghép Pageable vào repository để list endpoint có đầy đủ filter + sort + page.
1. Pageable hoạt động ra sao
Pageable (interface org.springframework.data.domain.Pageable) là contract ba chiều: trang nào (page), bao nhiêu item (size), theo thứ tự nào (Sort). Spring Data Web cung cấp PageableHandlerMethodArgumentResolver — tự bind query string vào Pageable mà không cần code thủ công.
public interface ProductRepository extends JpaRepository<Product, Long> {
Page<Product> findByCategory(String category, Pageable pageable);
}
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductRepository repo;
@GetMapping
public Page<ProductDto> list(
@RequestParam(required = false) String category,
Pageable pageable
) {
Page<Product> page = category == null
? repo.findAll(pageable)
: repo.findByCategory(category, pageable);
return page.map(ProductDto::from);
}
}
Request: GET /products?category=ELECTRONICS&page=0&size=20&sort=createdAt,desc
Spring bind:
page=0— chỉ số trang, 0-basedsize=20— số item mỗi trangsort=createdAt,desc— field + chiều sắp xếp
PageableHandlerMethodArgumentResolver được đăng ký tự động khi bật Spring Data Web (mặc định trong Spring Boot). Không cần config thêm.
2. SQL Spring sinh — tại sao Page cần 2 query
Khi repository return Page<T>, Hibernate/Spring Data sinh hai câu SQL cho mỗi request:
-- Query 1: lấy data trang hiện tại
SELECT p.* FROM products p
WHERE p.category = 'ELECTRONICS'
ORDER BY p.created_at DESC
LIMIT 20 OFFSET 0;
-- Query 2: đếm tổng để tính totalPages
SELECT COUNT(*) FROM products p
WHERE p.category = 'ELECTRONICS';
Kết quả được đóng gói trong Page<T>:
// Page interface (rút gọn)
public interface Page<T> extends Slice<T> {
long getTotalElements(); // tổng row khớp filter
int getTotalPages(); // = ceil(totalElements / size)
boolean isFirst();
boolean isLast();
boolean hasNext();
List<T> getContent();
int getNumber(); // trang hiện tại (0-based)
int getSize(); // size đã request
}
JSON response mà client nhận:
{
"content": [ { "id": 1, "name": "Laptop" }, "..." ],
"totalElements": 240,
"totalPages": 12,
"number": 0,
"size": 20,
"first": true,
"last": false,
"numberOfElements": 20,
"empty": false
}
Cơ chế bên dưới — vì sao phải có 2 query:
flowchart TB REQ["GET /products?page=0&size=20&sort=createdAt,desc"] BIND["PageableHandlerMethodArgumentResolver<br/>bind Pageable(page=0, size=20, Sort.by(createdAt).DESC)"] REPO["findByCategory(category, pageable)"] Q1["SQL 1 -- data query<br/>SELECT * ... ORDER BY created_at DESC LIMIT 20 OFFSET 0"] Q2["SQL 2 -- count query<br/>SELECT COUNT(*) FROM products WHERE category = ?"] ASSEMBLE["PageImpl assembly<br/>content: 20 rows<br/>totalElements: 240<br/>totalPages: 12"] RESP["JSON response<br/>content + totalElements + totalPages + ..."] REQ --> BIND --> REPO REPO --> Q1 REPO --> Q2 Q1 --> ASSEMBLE Q2 --> ASSEMBLE ASSEMBLE --> RESP
Lý do thiết kế: UI kiểu admin table cần hiển thị "Trang 1 / 12" hoặc thanh phân trang với số trang cụ thể. Để render điều đó, client phải biết totalElements hoặc totalPages. COUNT query là cách duy nhất có được con số đó từ DB. Nếu UI chỉ cần "Load more" (infinite scroll), bỏ COUNT bằng cách dùng Slice — xem Pagination performance để so sánh đầy đủ.
3. Sort multi-field
Sort nhiều trường đồng thời để kết quả ổn định — chỉ sort một trường hay bị tie (nhiều row bằng nhau), page boundary không nhất quán.
// Sort một trường
Sort s1 = Sort.by("createdAt").descending();
// Sort nhiều trường: priority giảm dần, name tăng dần
Sort s2 = Sort.by(
Sort.Order.desc("priority"),
Sort.Order.asc("name")
);
// Tạo Pageable với sort tường minh
Pageable pageable = PageRequest.of(0, 20, s2);
// URL tương đương:
// ?sort=priority,desc&sort=name,asc
// Spring bind nhiều tham số sort=... thành Sort multi-field
Spring sinh SQL:
SELECT p.* FROM products p
ORDER BY p.priority DESC, p.name ASC
LIMIT 20 OFFSET 0;
Sort ổn định cần tie-breaker: nếu sort theo priority mà nhiều row cùng priority, thứ tự giữa chúng không xác định qua các request. Luôn thêm field unique (thường id hoặc createdAt) làm tie-breaker cuối:
Sort stableSort = Sort.by(
Sort.Order.desc("priority"),
Sort.Order.asc("name"),
Sort.Order.asc("id") // tie-breaker: id unique dam bao thu tu nhat quan
);
3.1 Whitelist sort field — bảo mật bắt buộc
Nếu sort field đến từ client không được kiểm soát, attacker có thể gửi ?sort=hashedPassword,desc để khai thác thứ tự hash (information leak), hoặc ?sort=some_unindexed_blob_column để tạo full-table sort — tương đương slow query injection, gây DoS.
// Tập trường được phép sort — define trong service/controller
private static final Set<String> ALLOWED_SORT_FIELDS =
Set.of("createdAt", "name", "price", "priority", "updatedAt");
@GetMapping
public Page<ProductDto> list(Pageable pageable) {
// Validate truoc khi goi DB
pageable.getSort().forEach(order -> {
if (!ALLOWED_SORT_FIELDS.contains(order.getProperty())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Sort field not allowed: " + order.getProperty()
);
}
});
return repo.findAll(pageable).map(ProductDto::from);
}
Hoặc type-safe hơn với enum:
public enum ProductSortField {
CREATED_AT("createdAt"),
NAME("name"),
PRICE("price");
private final String field;
ProductSortField(String f) { this.field = f; }
public String getField() { return field; }
}
Client chỉ được gửi enum value — Spring convert tự động, giá trị không hợp lệ trả 400 trước khi vào controller.
4. Validate Pageable — cap size tránh DoS
Một request ?size=1000000 buộc DB trả hàng triệu row và làm server OOM — attacker không cần tool gì ngoài đổi query string. Không bao giờ expose Pageable ra public API mà thiếu max-page-size hoặc @Max validation.
Có hai lớp bảo vệ:
Lớp 1 — global config (application.yml):
spring:
data:
web:
pageable:
default-page-size: 20 # size mặc định khi client không gửi
max-page-size: 100 # hard cap — Spring tự trim, không throw
one-indexed-parameters: false # 0-based (mặc định)
max-page-size: 100 khiến PageableHandlerMethodArgumentResolver im lặng trim về 100 bất kể client gửi bao nhiêu. Client nhận response với size: 100 thay vì size đã request — không throw exception, không báo lỗi. Hành vi này thuận tiện nhưng dễ confuse client vì pagination math của họ sai.
Lớp 2 — per-endpoint explicit validation (khuyến nghị cho API public):
@GetMapping
public Page<ProductDto> list(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(defaultValue = "createdAt,desc") String sort
) {
// @Max(100) throw ConstraintViolationException -> 400 Bad Request
// Client biet tuong minh la size vuot gioi han
Sort parsedSort = parseSortParam(sort);
Pageable pageable = PageRequest.of(page, size, parsedSort);
return repo.findAll(pageable).map(ProductDto::from);
}
@Max(100) kết hợp @Validated trên class trả 400 với message rõ ràng. Client biết tường minh là request không hợp lệ — tốt hơn silent trim cho public API.
Vì sao cần cả hai lớp? Config max-page-size là safety net toàn app — dù endpoint nào vô tình không validate cũng bị bảo vệ. Per-endpoint @Max là tường minh — contract rõ ràng cho client và CI test được.
flowchart TB REQ["?size=999999"] YAML["spring.data.web.pageable.max-page-size=100<br/>PageableResolver trim size to 100 (silent)"] CTRL["@Max(100) per endpoint<br/>ConstraintViolation -> 400 Bad Request (explicit)"] SAFE["DoS ngan chan<br/>Server khong bi load trieu row"] REQ --> YAML REQ --> CTRL YAML --> SAFE CTRL --> SAFE
5. Projection DTO kết hợp Pageable
Load full entity rồi map DTO là pattern phổ biến nhưng lãng phí khi entity có nhiều column — DB trả 20 column, ứng dụng chỉ dùng 4. Page<T> hỗ trợ projection DTO trực tiếp qua @Query SELECT new.
Full entity (không tối ưu):
// Load toat Project entity -> 15+ column, bao gom large CLOB
Page<Project> page = repo.findByStatus(status, pageable);
return page.map(ProjectDto::from); // map in-memory sau khi load
Projection DTO trực tiếp:
// DTO chi chon 4 field can thiet
public record ProjectSummary(Long id, String name, ProjectStatus status, long taskCount) {}
@Query(value = """
SELECT new com.example.dto.ProjectSummary(
p.id, p.name, p.status, COUNT(t.id)
)
FROM Project p LEFT JOIN p.tasks t
WHERE p.status = :status
GROUP BY p.id, p.name, p.status
""",
countQuery = """
SELECT COUNT(p) FROM Project p WHERE p.status = :status
""")
Page<ProjectSummary> findSummariesByStatus(
@Param("status") ProjectStatus status,
Pageable pageable
);
countQuery là bắt buộc khi @Query có JOIN hoặc GROUP BY: Spring Data mặc định sẽ tự sinh count query bằng cách wrap toàn bộ query — bao gồm JOIN và GROUP BY không cần thiết cho count, gây chậm đáng kể trên table lớn. countQuery custom chỉ đếm trên bảng gốc, không JOIN.
-- Count query mặc định (Spring Data tự sinh) - BAD:
SELECT COUNT(p.id, p.name, p.status, COUNT(t.id))
FROM Project p LEFT JOIN tasks t ON t.project_id = p.id
WHERE p.status = ?
GROUP BY p.id, p.name, p.status;
-- JOIN tasks cho count -- vo ich, cham
-- Count query custom - GOOD:
SELECT COUNT(p) FROM projects p WHERE p.status = ?;
-- Khong JOIN, khong GROUP BY -- nhanh
Kết hợp Specification + projection + Pageable cho filter endpoint đầy đủ:
@Service
@Transactional(readOnly = true)
public class ProjectService {
private final ProjectRepository repo;
public Page<ProjectSummary> search(ProjectFilter filter, Pageable pageable) {
Specification<Project> spec = ProjectSpecs.matching(filter);
// findAll(Specification, Pageable) tu dong sinh WHERE + ORDER BY + LIMIT/OFFSET
return repo.findAll(spec, pageable)
.map(ProjectSummary::from);
}
}
@GetMapping("/projects")
public Page<ProjectSummary> list(
@RequestParam(required = false) ProjectStatus status,
@RequestParam(required = false) String keyword,
@PageableDefault(size = 20)
@SortDefault(sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
return service.search(new ProjectFilter(status, keyword), pageable);
}
@PageableDefault đặt giá trị mặc định khi client không gửi size. @SortDefault đặt sort mặc định — đảm bảo luôn có stable sort dù client không gửi sort param.
6. Pitfall cụ thể
Pitfall 1 — Không có sort default, page boundary không nhất quán:
// SAI — khong co sort -> DB tra theo "any order"
Page<Product> page = repo.findAll(PageRequest.of(0, 20));
// page 0 lan 1 va lan 2 co the khac nhau neu DB optimizer chon khac
// DUNG — luon sort theo field co index
Page<Product> page = repo.findAll(
PageRequest.of(0, 20, Sort.by("createdAt").descending())
);
Pitfall 2 — Quên countQuery khi dùng @Query với JOIN:
// SAI — Spring tự sinh count wrap toàn query
@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);
// -> count query se JOIN tasks -- cham
// DUNG — custom countQuery
@Query(value = "...", countQuery = "SELECT COUNT(p) FROM Project p WHERE p.status = :status")
Page<ProjectSummary> findByStatus(...);
Pitfall 3 — findAll() không có Pageable trên table lớn:
// SAI — load toan bo entity, co the hang trieu row
List<Product> all = repo.findAll();
// DUNG — luon dung Pageable cho list endpoint
Page<Product> page = repo.findAll(PageRequest.of(0, 20));
7. Liên hệ các bài khác
- Specification & auditing:
Specificationlà filter động cho WHERE clause — kết hợpspec + pageabletrongfindAll(Specification, Pageable)là pattern chuẩn cho list endpoint có filter. Bài này chỉ cover phầnPageable; Specification là phần bổ sung. - Pagination performance:
Pagevới OFFSET chậm tuyến tính khi page number tăng — OFFSET 20000 yêu cầu DB scan 20000 row trước khi trả 20. Bài đó so sánh OFFSET vs keyset cursor pagination (O(log N) hằng số) và khi nào dùngSlicethayPageđể bỏ COUNT.
Tóm tắt
Pageablegóipage,size,sort— Spring Data Web auto-bind từ?page=&size=&sort=.Page<T>chạy 2 SQL: data query (LIMIT/OFFSET) + count query (COUNT(*)). COUNT cần thiết để UI biết tổng trang.Slice<T>bỏ COUNT — dùng cho infinite scroll khi không cầntotalElements.- Sort multi-field qua
Sort.by(Order.desc("priority"), Order.asc("name")). Luôn có tie-breaker cuối. - Whitelist sort field: sort theo field nhạy cảm hoặc không có index gây security leak và slow query.
- Cap
size: globalmax-page-size: 100trim silent; per-endpoint@Max(100)trả 400 explicit. - Projection DTO +
countQuerycustom: chỉ SELECT column cần, count không JOIN — giảm 50-80% data transfer.
Tự kiểm tra
Q1Tại sao Page<T> phải chạy 2 câu SQL (data + COUNT) thay vì 1? Khi nào có thể bỏ COUNT?▸
Page<T> phải chạy 2 câu SQL (data + COUNT) thay vì 1? Khi nào có thể bỏ COUNT?Page<T> cần COUNT vì interface của nó expose getTotalElements() và getTotalPages() — hai giá trị này đòi hỏi biết tổng số row khớp filter trong DB, không chỉ page hiện tại. Không có cách nào tính tổng trang mà không đếm toàn bộ row trước.
UI kiểu admin table hiển thị "Trang 1 / 12" cần totalPages. Nếu không có COUNT, Spring không thể cung cấp con số đó.
COUNT có thể bỏ khi dùng Slice<T> thay Page<T>. Slice chỉ expose hasNext() — Spring fetch size + 1 row, nếu row thứ size+1 tồn tại thì hasNext = true, không cần COUNT. Phù hợp cho infinite scroll hoặc "Load more" UI khi client không cần biết tổng.
Q2Endpoint list product có `@Query` với `LEFT JOIN tasks`. Không khai báo countQuery. Hậu quả gì? Fix thế nào?▸
countQuery. Hậu quả gì? Fix thế nào?Spring Data tự sinh count query bằng cách wrap toàn bộ JPQL query — bao gồm LEFT JOIN tasks không cần thiết cho việc đếm. Câu count trở thành:
SELECT COUNT(p) FROM Project p LEFT JOIN p.tasks t WHERE ...JOIN này lãng phí: count chỉ cần đếm Project row khớp filter, không cần JOIN tasks. Trên table lớn (100k+ task), query này chậm hơn đáng kể so với count đơn giản.
Fix: khai báo countQuery tường minh không có JOIN:
@Query(
value = "SELECT new ...ProjectSummary(...) FROM Project p LEFT JOIN p.tasks t WHERE ...",
countQuery = "SELECT COUNT(p) FROM Project p WHERE p.status = :status"
)Count query custom chỉ quét bảng projects với điều kiện filter — không JOIN — nhanh hơn nhiều lần cho table có nhiều task.
Q3Cấu hình `spring.data.web.pageable.max-page-size: 100`. Client gửi `?size=5000`. Response trả gì? Khác gì so với dùng `@Max(100)` trên parameter?▸
Với global config: PageableHandlerMethodArgumentResolver im lặng trim size về 100. Response trả data 100 item với "size": 100. Không có exception, không có error message. Client không biết size đã bị cắt — pagination math của client (nếu họ tự tính) sẽ sai.
Với @Max(100) per-endpoint: ConstraintViolationException được ném ngay khi Spring validate parameter, trả về 400 Bad Request với message rõ ràng ("size must be less than or equal to 100"). Client biết tường minh request không hợp lệ và cần gửi lại với size hợp lệ.
Khuyến nghị: dùng cả hai lớp. Global config là safety net cho toàn app (kể cả endpoint vô tình không validate). Per-endpoint @Max là API contract tường minh — test được, document được, client hiểu đúng hành vi.
Q4Endpoint nhận `?sort=password,desc`. Risk là gì? Implement whitelist để chặn.▸
Hai risk chính:
1. Information leak: sort theo field nhạy cảm (password, ssn, salary) không trả giá trị đó ra response, nhưng thứ tự kết quả phản ánh thứ tự lexicographic của hash/value. Attacker quan sát thứ tự + timing có thể suy luận thông tin về phân phối giá trị field.
2. Slow query injection (DoS): sort theo column không có index hoặc column tính toán nặng (JSONB, TEXT dài) buộc DB full-table sort — query chậm hàng trăm lần, tương đương tấn công từ chối dịch vụ.
Implement whitelist:
private static final Set<String> ALLOWED = Set.of(
"createdAt", "name", "price", "priority"
);
@GetMapping
public Page<ProductDto> list(Pageable pageable) {
pageable.getSort().forEach(order -> {
if (!ALLOWED.contains(order.getProperty())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Sort field not allowed: " + order.getProperty()
);
}
});
return repo.findAll(pageable).map(ProductDto::from);
}Reject ngay tại controller trước khi query DB — không để sort field lạ lọt vào SQL.
Q5Sort endpoint theo `priority` không có tie-breaker. Data có 200 project cùng priority = HIGH. Vấn đề gì xảy ra khi phân trang? Fix?▸
Vấn đề: khi nhiều row có cùng giá trị sort field (priority = HIGH), thứ tự giữa chúng là không xác định — DB optimizer tự do sắp xếp tuỳ theo execution plan. Kết quả: cùng một item có thể xuất hiện ở cả page 0 lẫn page 1 (duplicate), hoặc bị bỏ qua hoàn toàn (missing). Page boundary trở nên không nhất quán giữa các request, đặc biệt nếu có concurrent insert/update.
Fix — thêm tie-breaker unique:
Sort stableSort = Sort.by(
Sort.Order.desc("priority"),
Sort.Order.asc("id") // id unique: dam bao thu tu xac dinh tuyet doi
);
Pageable pageable = PageRequest.of(page, 20, stableSort);Với id là tie-breaker, toàn bộ 200 project có priority = HIGH được sắp xếp deterministic theo id. Page boundary ổn định qua các request. Field tie-breaker lý tưởng là id (unique, indexed) hoặc createdAt + id nếu cần giữ thứ tự thời gian.
Bài tiếp theo: OFFSET vs keyset pagination performance
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên