Từ bài 26, GET /api/products trả về mọi product có trong database. Với vài chục product được seed thì không ai để ý; với 50,000 row trên PostgreSQL, cùng endpoint đó gửi 5,651,608 byte JSON cho mỗi request. Một endpoint danh sách cần ba thứ từ phía gọi: lấy bao nhiêu item, lấy những item nào và theo thứ tự nào. Spring Data JPA mô tả chúng bằng hai type: Sort cho thứ tự, và Pageable gồm số page, page size và một Sort.
Bài này chạy cả hai qua repository trước, rồi qua HTTP. Bài đọc SQL phía sau từng return type của repository, count query phía sau Page, row thừa phía sau Slice và các trường hợp Spring Data bỏ count; xem @Query, native query và JOIN FETCH làm gì với Pageable trong Hibernate 7.4; dựng một controller nhận Pageable với giá trị mặc định, giới hạn size và page đếm từ 1; xem JSON mà một Page biến thành và shape series này trả về thay thế; cuối cùng là vì sao một page nằm sâu trong bảng lớn tốn hơn page đầu tiên.
![]()
Các ví dụ dùng Spring Boot 4.1.1 và Java 21, trên một project Initializr có các dependency web, validation, data-jpa, h2 và postgresql. Log SQL và HTTP response lấy từ H2, trừ khi block ghi PostgreSQL; PostgreSQL 18 chạy trong Docker được dùng cho phần so sánh dialect, các phép đo trên 50,000 row và EXPLAIN. Ứng dụng chạy ở port 8129 thay vì 8080 mặc định.
Catalogue và cách lấy các output
Các entity giữ nguyên từ bài 28: Product có @ManyToOne Category lazy và một Set<Tag>, nằm trong các bảng products, categories, tags và product_tags. Product được thêm một cột nullable, vì sort đặt ra một câu hỏi về NULL mà chỉ cột nullable mới trả lời được. rating là điểm review trung bình, còn null nghĩa là chưa ai review product đó:
@Column(nullable = false)
private int stock;
private Integer rating;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
@ManyToMany
@JoinTable(name = "product_tags",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id"))
private Set<Tag> tags = new HashSet<>();
// constructor and the other getters as in article 28
public Integer getRating() {
return rating;
}
public void setRating(Integer rating) {
this.rating = rating;
} Seeder lưu 23 product thuộc bốn category: bảy keyboard, sáu mouse, bốn monitor và sáu accessory. Tám product chưa có rating, ba product giá 19.90, hai product giá 45.50, và một tên, iPad stand, bắt đầu bằng chữ thường:
package com.example.demo.product;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
@Order(1)
class CatalogSeeder implements CommandLineRunner {
private final CategoryRepository categories;
private final TagRepository tags;
private final ProductRepository products;
CatalogSeeder(CategoryRepository categories, TagRepository tags, ProductRepository products) {
this.categories = categories;
this.tags = tags;
this.products = products;
}
@Override
public void run(String... args) {
if (products.count() > 0) {
return;
}
Category keyboards = categories.save(new Category("Keyboards"));
Category mice = categories.save(new Category("Mice"));
Category monitors = categories.save(new Category("Monitors"));
Category accessories = categories.save(new Category("Accessories"));
Tag mechanical = tags.save(new Tag("mechanical"));
Tag wireless = tags.save(new Tag("wireless"));
Tag rgb = tags.save(new Tag("rgb"));
Tag bestseller = tags.save(new Tag("bestseller"));
Tag usbC = tags.save(new Tag("usb-c"));
products.saveAll(List.of(
product("Mechanical keyboard", "KB-01", "89.90", 25, 5, keyboards, mechanical, rgb, bestseller),
product("Compact keyboard", "KB-02", "59.00", 12, 4, keyboards, mechanical),
product("Wireless keyboard", "KB-03", "45.50", 0, null, keyboards, wireless),
product("Ergonomic keyboard", "KB-04", "129.00", 7, 4, keyboards),
product("Gaming keyboard", "KB-05", "99.00", 9, null, keyboards, mechanical, rgb),
product("Low-profile keyboard", "KB-06", "74.00", 14, 3, keyboards, wireless),
product("Numeric keypad", "KB-07", "19.90", 30, null, keyboards),
product("Wireless mouse", "MS-01", "24.50", 3, 4, mice, wireless, bestseller),
product("Gaming mouse", "MS-02", "49.90", 18, 5, mice, rgb),
product("Vertical mouse", "MS-03", "39.00", 0, null, mice),
product("Trackball mouse", "MS-04", "54.00", 6, 3, mice, wireless),
product("Travel mouse", "MS-05", "19.90", 22, null, mice, wireless, usbC),
product("Silent mouse", "MS-06", "22.00", 40, 4, mice),
product("27-inch monitor", "MN-01", "279.00", 6, 4, monitors, usbC),
product("32-inch 4K monitor", "MN-02", "449.00", 2, 5, monitors, usbC, bestseller),
product("Portable monitor", "MN-03", "189.00", 9, null, monitors, usbC),
product("Ultrawide monitor", "MN-04", "399.00", 4, 4, monitors),
product("USB-C hub", "AC-01", "39.00", 40, 5, accessories, usbC, bestseller),
product("iPad stand", "AC-02", "34.90", 15, null, accessories),
product("Mouse pad XL", "AC-03", "19.90", 60, 4, accessories, rgb),
product("Webcam 1080p", "AC-04", "64.00", 14, 3, accessories, usbC),
product("Cable organiser", "AC-05", "12.50", 33, null, accessories),
product("Desk lamp", "AC-06", "45.50", 11, 4, accessories)));
}
private static Product product(String name, String sku, String price, int stock, Integer rating,
Category category, Tag... tags) {
Product product = new Product(name, sku, new BigDecimal(price), stock, category);
product.setRating(rating);
product.getTags().addAll(List.of(tags));
return product;
}
}open-in-view vẫn tắt như bài 26 để lại, và SQL của Hibernate được ghi ra log:
spring.application.name=demo
spring.jpa.open-in-view=false
logging.level.org.hibernate.SQL=DEBUGProfile postgres trỏ tới container PostgreSQL 18:
spring.datasource.url=jdbc:postgresql://localhost:55429/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=createdocker run -d --name sb-a29-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 55429:5432 postgres:18./gradlew -q bootJarCác lời gọi repository chạy trong một CommandLineRunner nằm sau profile lab, không có web server và có bật log bind parameter, để thấy offset và limit mà Hibernate bind:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=lab --spring.main.web-application-type=none --logging.level.org.hibernate.orm.jdbc.bind=TRACEMỗi block output bắt đầu bằng lời gọi sau >>>. Tiếp theo là các dòng log của Hibernate, đã cắt chỉ còn message, rồi đến kết quả: content liệt kê từng product dạng id:SKU price, và dòng sau in những gì Page hoặc Slice báo cáo. Cùng runner đó với --spring.profiles.active=lab,postgres cho ra các output PostgreSQL.
Vì sao findAll() không phân trang không chịu được dữ liệu lớn
Response có tên category, nên repository fetch category bằng entity graph, giống cách bài 28 làm với order line:
package com.example.demo.product;
import java.util.List;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
@Override
@EntityGraph(attributePaths = "category")
List<Product> findAll();
}package com.example.demo.product;
import java.math.BigDecimal;
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock, Integer rating,
String category) {
} public ProductResponse toResponse(Product product) {
return new ProductResponse(product.getId(), product.getName(), product.getSku(), product.getPrice(),
product.getStock(), product.getRating(), product.getCategory().getName());
} @Transactional(readOnly = true)
public List<Product> findAll() {
return repository.findAll();
}package com.example.demo.product;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
private final ProductMapper mapper;
public ProductController(ProductService service, ProductMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@GetMapping
public List<ProductResponse> findAll() {
return service.findAll().stream()
.map(mapper::toResponse)
.toList();
}
}Để endpoint có một bảng đáng phân trang, container PostgreSQL được nạp 50,000 product sinh bằng SQL, sau khi Hibernate đã tạo bảng và seeder đã tạo bốn category:
select setseed(0.29);
truncate product_tags, products restart identity;
insert into products (name, sku, price, stock, rating, category_id)
select 'Product ' || g,
'SKU-' || lpad(g::text, 5, '0'),
round((10 + random() * 490)::numeric, 2),
(random() * 100)::int,
case when g % 4 = 0 then null else 1 + g % 5 end,
1 + g % 4
from generate_series(1, 50000) as g;
analyze products;docker exec -i sb-a29-pg psql -U shop -d shop < seed-50k.sqlSau đó ứng dụng chạy với --spring.profiles.active=postgres --spring.jpa.hibernate.ddl-auto=none. curl gọi endpoint ba lần để khởi động và năm lần để đo:
curl -s -o /dev/null -w '%{http_code} %{size_download} %{time_total}\n' http://localhost:8129/api/products200 5651608 0.098135
200 5651608 0.095069
200 5651608 0.081495
200 5651608 0.078082
200 5651608 0.068285Mỗi request gửi một câu SQL, biến cả 50,000 row thành entity rồi thành JSON:
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_idLần chạy nhanh nhất mất 68 ms với database nằm cùng máy, còn request đầu tiên sau khi khởi động mất 372 ms. Các con số chỉ mang tính tham khảo. Điều quan trọng là cả kích thước lẫn thời gian đều tăng theo bảng, và không có parameter nào cho client xin ít hơn. Endpoint có phân trang ở cuối bài trả page 0 của cùng bảng này bằng 2,219 byte trong 9.7 ms.
Sắp xếp kết quả query với Sort
Sort.by, descending và sort theo nhiều property
Mọi JpaRepository đều có findAll(Sort). Một Sort gọi tên property của entity và hướng sort cho từng property:
productRepository.findAll(Sort.by("price").descending());
productRepository.findAll(Sort.by("rating").descending().and(Sort.by("price")));>>> findAll(Sort.by("price").descending()) toString=price: DESC
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 order by p1_0.price desc
>>> findAll(Sort.by("rating").descending().and(Sort.by("price"))) toString=rating: DESC,price: ASC
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 order by p1_0.rating desc,p1_0.priceCác tên này là tên field của Product, không phải tên cột: Spring Data đối chiếu chúng với entity, còn Hibernate viết ra tên cột. and nối thêm một khóa sort, nên query thứ hai sort theo rating, rồi trong cùng rating thì theo price. Sort.by cũng nhận các object Sort.Order, là nơi chứa các tùy chọn riêng cho từng property ở những phần tiếp theo.
Các giá trị bằng nhau không có thứ tự cố định
order by p1_0.price không nói gì về hai product cùng giá, nên database có thể trả chúng theo thứ tự nào cũng được. Hai lần chạy lab trên H2 gửi cùng một page query, page 1 của các product giá tối đa 50.00 sort theo price trong phần về @Query, và nhận về hai page khác nhau:
content [8:MS-01 24.50, 19:AC-02 34.90, 10:MS-03 39.00, 18:AC-01 39.00, 23:AC-06 45.50] content [8:MS-01 24.50, 19:AC-02 34.90, 18:AC-01 39.00, 10:MS-03 39.00, 3:KB-03 45.50]KB-03 và AC-06 cùng giá 45.50, và mỗi lần chạy đặt một product khác nhau vào page 1. Một client đọc page 1 ở request này và page 2 ở request khác có thể nhận cùng một product hai lần và không bao giờ thấy product kia. PostgreSQL cho thấy điều này ngay trong một lần chạy: sort theo rating giảm dần, bốn product được 5 điểm về theo thứ tự KB-01, MS-02, MN-02, AC-01, còn khi thêm nullsLast() thì thành AC-01, MN-02, MS-02, KB-01. Mọi sort dùng cho pagination nên kết thúc bằng một property duy nhất, thường là id: Sort.by("price").and(Sort.by("id")).
Sắp xếp không phân biệt hoa thường với ignoreCase
productRepository.findAll(Sort.by(Sort.Order.asc("name").ignoreCase()));>>> findAll(Sort.by(Sort.Order.asc("name").ignoreCase())) toString=name: ASC, ignoring case
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 order by lower(p1_0.name)Cùng Sort.Order đó đi qua một derived query có Pageable thì sinh ra order by upper(p1_0.name); hàm nào cũng so sánh tên ở cùng một kiểu chữ. Nó có đổi được gì hay không lại phụ thuộc database. Với Sort.by("name") thông thường, H2 đặt iPad stand cuối cùng, sau Wireless mouse: chữ hoa đứng trước chữ thường. PostgreSQL 18.6, với database shop dùng collation en_US.utf8, đặt nó ở vị trí thứ chín, giữa Gaming mouse và Low-profile keyboard, dù có hay không có ignoreCase. Trên H2, ignoreCase đưa nó về đúng vị trí thứ chín đó.
Giá trị NULL: nullsFirst và nullsLast trên H2 và PostgreSQL
Sort.Order có nullsFirst() và nullsLast(). SQL chúng sinh ra khác nhau trên hai database, và giá trị mặc định cũng vậy:
Sort.Order | H2: order by | Rating NULL trên H2 | PostgreSQL: order by | Rating NULL trên PostgreSQL |
|---|---|---|---|---|
desc("rating") | p1_0.rating desc | cuối | p1_0.rating desc | đầu |
desc("rating").nullsLast() | p1_0.rating desc | cuối | p1_0.rating desc nulls last | cuối |
desc("rating").nullsFirst() | p1_0.rating desc nulls first | đầu | p1_0.rating desc | đầu |
asc("rating") | p1_0.rating | đầu | p1_0.rating | cuối |
asc("rating").nullsLast() | p1_0.rating asc nulls last | cuối | p1_0.rating | cuối |
Spring Data lần nào cũng chuyển yêu cầu về NULL xuống. Hibernate chỉ viết nulls first hoặc nulls last khi vị trí được yêu cầu khác với cách database tự làm: H2 xếp NULL nhỏ hơn mọi giá trị, còn PostgreSQL xếp lớn hơn. Vì thế nullsLast() trên sort giảm dần không để lại dấu vết gì trong SQL của H2, và nếu thiếu nó thì các product chưa có rating đứng đầu trên PostgreSQL nhưng đứng cuối trên H2. Qua @Query JPQL và qua derived query có Pageable, desc("rating").nullsLast() cũng tới PostgreSQL dưới dạng order by p1_0.rating desc nulls last fetch first ? rows only. Với cột sort nullable, hãy nói rõ vị trí của NULL; test chạy trên H2 không phát hiện được chỗ thiếu.
Tham chiếu property an toàn về type
Tên property viết trong string chỉ được kiểm tra khi query chạy. Spring Data 4.1 nhận cả method reference:
productRepository.findAll(Sort.by(Product::getPrice));
productRepository.findAll(Sort.by(Sort.Direction.DESC, Product::getPrice));>>> findAll(Sort.by(Product::getPrice)) toString=price: ASC
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 order by p1_0.price
>>> findAll(Sort.by(Sort.Direction.DESC, Product::getPrice)) toString=price: DESC
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 order by p1_0.price descCác overload này nhận TypedPropertyPath từ org.springframework.data.core, và Sort.Order.asc, Sort.Order.desc có overload tương ứng. Cách cũ, Sort.sort(Product.class).by(Product::getPrice), vẫn compile được, kèm ghi chú của javac rằng class dùng API deprecated: Sort.TypedSort là @Deprecated(since = "4.1") trong spring-data-commons 4.1.1. Sort.sort(Product.class).by(Product::getName) chạy được và sort theo p1_0.name. Với getter trả về BigDecimal, nó lỗi trước khi có query nào được gửi:
>>> findAll(Sort.sort(Product.class).by(Product::getPrice).descending())
!!! org.springframework.aop.framework.AopConfigException: Could not generate CGLIB subclass of class java.math.BigDecimal: Common causes of this problem include using a final class or a non-visible class
!!! caused by org.springframework.cglib.core.ReflectUtils$1: ClassLoader mismatch for [java.math.BigDecimal]: JVM should be started with --add-opens=java.base/java.lang=ALL-UNNAMED for ClassLoader.defineClass to be accessible on org.springframework.boot.loader.launch.LaunchedClassLoader; consider co-locating the affected class in that target ClassLoader instead.
!!! caused by java.lang.IllegalAccessException: module java.base does not open java.math to unnamed module @3e58a80eTypedSort ghi lại lời gọi getter trên một CGLIB proxy của return type, và trên Java 21 module system không mở java.math cho một subclass của BigDecimal. Hãy dùng Sort.by(Product::getPrice).
Pageable và PageRequest trong Spring Data JPA
PageRequest.of(page, size, sort) đếm page từ 0
Pageable là interface mà repository method nhận, còn PageRequest là implementation bạn tự tạo. Page đầu tiên là page 0:
PageRequest request = PageRequest.of(0, 5, Sort.by("price"));>>> PageRequest.of(0, 5, Sort.by("price")) toString=Page request [number: 0, size 5, sort: price: ASC] offset=0 next=Page request [number: 1, size 5, sort: price: ASC] previousOrFirst=Page request [number: 0, size 5, sort: price: ASC]Offset bằng page × size. Các lần chạy bên dưới bind offset 5 cho page 1 với size 5 và offset 15 cho page 3, còn page 2499 với size 20 trên PostgreSQL bind 49980.
Page, Slice và List từ cùng một derived query
Pageable có thể là parameter cuối cùng của một derived query, và return type quyết định Spring Data chạy những gì. Ba method có cùng điều kiện, đi qua relationship từ bài 28:
Page<Product> findByCategoryName(String name, Pageable pageable);
Slice<Product> findSliceByCategoryName(String name, Pageable pageable);
List<Product> findListByCategoryName(String name, Pageable pageable); Phần chữ giữa find và By chỉ để phân biệt ba method. Page 0 của các keyboard, năm product một page, sort theo price, dưới dạng Page:
>>> findByCategoryName("Keyboards", PageRequest.of(0, 5, Sort.by("price")))
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? order by p1_0.price fetch first ? rows only
binding parameter (1:VARCHAR) <- [Keyboards]
binding parameter (2:INTEGER) <- [5]
select count(p1_0.id) from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=?
binding parameter (1:VARCHAR) <- [Keyboards]
content [7:KB-07 19.90, 3:KB-03 45.50, 2:KB-02 59.00, 6:KB-06 74.00, 1:KB-01 89.90]
getNumber=0 getSize=5 getNumberOfElements=5 getTotalElements=7 getTotalPages=2 hasNext=true hasPrevious=false isFirst=true isLast=false class=PageImplCùng page đó dưới dạng Slice:
>>> findSliceByCategoryName("Keyboards", PageRequest.of(0, 5, Sort.by("price")))
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? order by p1_0.price fetch first ? rows only
binding parameter (1:VARCHAR) <- [Keyboards]
binding parameter (2:INTEGER) <- [6]
content [7:KB-07 19.90, 3:KB-03 45.50, 2:KB-02 59.00, 6:KB-06 74.00, 1:KB-01 89.90]
getNumber=0 getSize=5 getNumberOfElements=5 hasNext=true isLast=false class=SliceImplVà page 1 dưới dạng List:
>>> findListByCategoryName("Keyboards", PageRequest.of(1, 5, Sort.by("price")))
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? order by p1_0.price offset ? rows fetch first ? rows only
binding parameter (1:VARCHAR) <- [Keyboards]
binding parameter (2:INTEGER) <- [5]
binding parameter (3:INTEGER) <- [5]
content [5:KB-05 99.00, 4:KB-04 129.00] size=2Pagechạy row query với limit 5, rồi một count query có cùng join vàwhere, và biết có 7 keyboard trên 2 page.Slicegửi đúng row query đó với limit 6 và không có count. Row thứ sáu chỉ để trả lờihasNext(); slice chứa năm product và không có tổng số.Listbind offset và limit, ngoài ra không có gì. Nơi gọi nhận các row và không có thông tin page nào.

Trên PostgreSQL 18.6 các câu SQL giống hệt, kể cả offset ? rows fetch first ? rows only: Hibernate 7.4 viết mệnh đề OFFSET … FETCH theo chuẩn SQL cho cả hai database. Derived query bỏ offset ở page 0, như trong block Page và Slice; còn findAll(Pageable) thì gửi offset ? rows với giá trị 0 được bind.
Page cho biết gì, và khi nào Spring Data bỏ qua count query
Page 1 của các keyboard, chứa hai product cuối:
>>> findByCategoryName("Keyboards", PageRequest.of(1, 5, Sort.by("price")))
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? order by p1_0.price offset ? rows fetch first ? rows only
binding parameter (1:VARCHAR) <- [Keyboards]
binding parameter (2:INTEGER) <- [5]
binding parameter (3:INTEGER) <- [5]
content [5:KB-05 99.00, 4:KB-04 129.00]
getNumber=1 getSize=5 getNumberOfElements=2 getTotalElements=7 getTotalPages=2 hasNext=false hasPrevious=true isFirst=false isLast=true class=PageImplKhông có count query, vậy mà getTotalElements() vẫn là 7. Page 3, đã vượt quá cuối:
>>> findByCategoryName("Keyboards", PageRequest.of(3, 5, Sort.by("price")))
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? order by p1_0.price offset ? rows fetch first ? rows only
binding parameter (1:VARCHAR) <- [Keyboards]
binding parameter (2:INTEGER) <- [15]
binding parameter (3:INTEGER) <- [5]
select count(p1_0.id) from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=?
binding parameter (1:VARCHAR) <- [Keyboards]
content []
getNumber=3 getSize=5 getNumberOfElements=0 getTotalElements=7 getTotalPages=2 hasNext=false hasPrevious=true isFirst=false isLast=true class=PageImplNăm lời gọi trên H2 và những gì mỗi lời gọi đã chạy:
| Lời gọi | Số row trả về | Count query | getTotalElements() | getTotalPages() | hasNext() |
|---|---|---|---|---|---|
findByCategoryName("Keyboards", PageRequest.of(0, 5, …)) | 5 | có | 7 | 2 | true |
findByCategoryName("Keyboards", PageRequest.of(1, 5, …)) | 2 | không | 7 | 2 | false |
findByCategoryName("Monitors", PageRequest.of(0, 5, …)) | 4 | không | 4 | 1 | false |
findByCategoryName("Keyboards", PageRequest.of(1, 7, …)) | 0 | có | 7 | 1 | false |
findByCategoryName("Keyboards", PageRequest.of(3, 5, …)) | 0 | có | 7 | 2 | false |
Spring Data bỏ count đúng vào lúc bản thân các row đã cho ra tổng số. Page đầu có ít row hơn size thì chứa tất cả, nên tổng bằng số row: 4 monitor. Một page sau đó có ít nhất một row nhưng ít hơn size là page cuối, nên tổng bằng offset cộng số row đó: 5 + 2 = 7. Một page đầy có thể còn row phía sau, còn page rỗng không cho biết phía trước có bao nhiêu row, nên cả hai đều chạy count. Page 3 báo getNumber() là 3 bên cạnh getTotalPages() là 2: page vượt quá cuối là page rỗng, không phải lỗi.
@Query và native query với Pageable
JPQL: count query do Spring Data tự suy ra
@Query("select p from Product p where p.price <= :maxPrice")
Page<Product> findUpToPrice(BigDecimal maxPrice, Pageable pageable); >>> findUpToPrice(50, PageRequest.of(1, 5, Sort.by("price"))) [JPQL]
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 where p1_0.price<=? order by p1_0.price offset ? rows fetch first ? rows only
binding parameter (1:NUMERIC) <- [50]
binding parameter (2:INTEGER) <- [5]
binding parameter (3:INTEGER) <- [5]
select count(p1_0.id) from products p1_0 where p1_0.price<=?
binding parameter (1:NUMERIC) <- [50]
content [8:MS-01 24.50, 19:AC-02 34.90, 18:AC-01 39.00, 10:MS-03 39.00, 3:KB-03 45.50]
getNumber=1 getSize=5 getNumberOfElements=5 getTotalElements=12 getTotalPages=3 hasNext=true hasPrevious=true isFirst=false isLast=false class=PageImplSpring Data nối sort vào chính câu JPQL; lỗi khi sort theo property không tồn tại ở phần sau in ra đúng query nó đã dựng, select p from Product p where p.price <= :maxPrice order by p.foo asc. Nó cũng suy ra count từ cùng mệnh đề where, nên câu JPQL không cần count query riêng. Sort method này theo category.name thì row query có thêm join categories c1_0 on c1_0.id=p1_0.category_id, còn count query giữ nguyên.
Native query có và không có countQuery
Cùng query đó viết bằng SQL, một lần không có count query và một lần có:
@Query(value = "select * from products where price <= :maxPrice", nativeQuery = true)
Page<Product> findUpToPriceNative(BigDecimal maxPrice, Pageable pageable);
@Query(value = "select * from products where price <= :maxPrice",
countQuery = "select count(*) from products where price <= :maxPrice",
nativeQuery = true)
Page<Product> findUpToPriceNativeCounted(BigDecimal maxPrice, Pageable pageable); >>> findUpToPriceNative(50, PageRequest.of(1, 5, Sort.by("price"))) [native, no countQuery]
select * from products where price <= ? order by price asc offset ? rows fetch next ? rows only
binding parameter (1:NUMERIC) <- [50]
select count(1) from products where price <= ?
binding parameter (1:NUMERIC) <- [50]
content [8:MS-01 24.50, 19:AC-02 34.90, 10:MS-03 39.00, 18:AC-01 39.00, 3:KB-03 45.50]
getNumber=1 getSize=5 getNumberOfElements=5 getTotalElements=12 getTotalPages=3 hasNext=true hasPrevious=true isFirst=false isLast=false class=PageImpl
>>> findUpToPriceNativeCounted(50, PageRequest.of(1, 5, Sort.by("price"))) [native, countQuery]
select * from products where price <= ? order by price asc offset ? rows fetch next ? rows only
binding parameter (1:NUMERIC) <- [50]
select count(*) from products where price <= ?
binding parameter (1:NUMERIC) <- [50]
content [8:MS-01 24.50, 19:AC-02 34.90, 10:MS-03 39.00, 18:AC-01 39.00, 3:KB-03 45.50]
getNumber=1 getSize=5 getNumberOfElements=5 getTotalElements=12 getTotalPages=3 hasNext=true hasPrevious=true isFirst=false isLast=false class=PageImplKhông có countQuery thì cũng không có gì lỗi và không có warning nào trong log: Spring Data 4.1.1 tự viết lại câu native SQL thành select count(1) from products where price <= ?. Nó cũng nối order by price asc vào chuỗi SQL, dùng tên sort property làm tên cột. Có countQuery thì count chạy đúng như đã viết. Mệnh đề page của native query là offset ? rows fetch next ? rows only, next ở chỗ JPQL có first, và PostgreSQL nhận đúng chuỗi đó. Count suy ra từ select * from products where … thì dễ đúng; với native SQL có join, group by hay subquery, hãy viết countQuery và đọc count một lần trong log.
Phân trang với JOIN FETCH trên một collection
Hibernate 7.4 gửi gì cho một fetch join có phân trang
Bài 28 fetch order line bằng left join fetch để tránh N+1 và để lại một câu hỏi cho bài này: chuyện gì xảy ra khi query đó nhận thêm Pageable. Danh sách product kèm tag:
@Query("select p from Product p left join fetch p.tags")
Page<Product> findAllWithTags(Pageable pageable); >>> findAllWithTags(PageRequest.of(1, 5, Sort.by("id"))) [left join fetch p.tags]
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock,t1_0.product_id,t1_1.id,t1_1.name from (select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 order by p1_0.id offset ? rows fetch first ? rows only) p1_0(id,category_id,name,price,rating,sku,stock) left join product_tags t1_0 on p1_0.id=t1_0.product_id left join tags t1_1 on t1_1.id=t1_0.tag_id order by p1_0.id
binding parameter (1:INTEGER) <- [5]
binding parameter (2:INTEGER) <- [5]
select count(p1_0.id) from products p1_0 left join product_tags t1_0 on p1_0.id=t1_0.product_id
content [6:KB-06 74.00 tags=[wireless], 7:KB-07 19.90 tags=[], 8:MS-01 24.50 tags=[bestseller, wireless], 9:MS-02 49.90 tags=[rgb], 10:MS-03 39.00 tags=[]]
getNumber=1 getSize=5 getNumberOfElements=5 getTotalElements=30 getTotalPages=6 hasNext=true hasPrevious=true isFirst=false isLast=false class=PageImplNhiều tutorial cảnh báo rằng query kiểu này load mọi row rồi mới cắt page trong bộ nhớ. Hibernate ORM 7.4.5 vẫn còn warning đó trong các message của QueryLogging, HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory, nhưng không lần chạy nào của bài này log ra nó, trên H2 cũng như PostgreSQL. Hibernate đưa offset và limit vào một derived table chọn ra năm product, rồi mới join tag vào năm product đó. Đặt spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true cũng không đổi gì: cùng câu SQL đó chạy và lời gọi thành công. Với join fetch (inner join), derived table có thêm where exists(select 1 from product_tags t1_0 where p1_0.id=t1_0.product_id), nên product không có tag bị loại trước khi cắt limit chứ không phải sau.
Count query đếm các row sau khi join
Các row thì đúng, còn metadata thì sai. Count query mà Spring Data suy ra giữ nguyên join, select count(p1_0.id) from products p1_0 left join product_tags t1_0 on p1_0.id=t1_0.product_id, và báo 30 element trên 6 page cho 23 product: mỗi product được đếm một lần cho mỗi tag, và một lần nếu không có tag nào. Client tin vào totalPages sẽ xin page 5 và nhận về page rỗng. PostgreSQL cũng trả về đúng con số 30 đó.
Một count query viết tường minh sửa được lỗi này:
@Query("select p from Product p left join fetch p.tags")
@Query(value = "select p from Product p left join fetch p.tags",
countQuery = "select count(p) from Product p")
Page<Product> findAllWithTags(Pageable pageable);select count(p1_0.id) from products p1_0
content [6:KB-06 74.00 tags=[wireless], 7:KB-07 19.90 tags=[], 8:MS-01 24.50 tags=[bestseller, wireless], 9:MS-02 49.90 tags=[rgb], 10:MS-03 39.00 tags=[]]
getNumber=1 getSize=5 getNumberOfElements=5 getTotalElements=23 getTotalPages=5 hasNext=true hasPrevious=true isFirst=false isLast=false class=PageImplRow query vẫn là derived table như trước. Entity graph trên một derived query cũng cho count đúng: @EntityGraph(attributePaths = "tags") Page<Product> findGraphByStockGreaterThan(int stock, Pageable pageable) gửi row query cùng dạng và đếm bằng select count(p1_0.id) from products p1_0 where p1_0.stock>?, ra 21 product còn hàng.
Cách viết lại này có giới hạn. Fetch join có điều kiện lọc trên chính collection được fetch, select p from Product p left join fetch p.tags t where t.name = :tag kèm Pageable, đặt where t1_1.name=? vào bên trong derived table, nơi alias đó không tồn tại. H2 lỗi Column "T1_1.NAME" not found, còn PostgreSQL lỗi ERROR: missing FROM-clause entry for table "t1_1".
REST endpoint nhận Pageable
Pageable làm parameter của controller
Repository fetch category cho một page product giống hệt cách làm với danh sách đầy đủ:
@Override
@EntityGraph(attributePaths = "category")
List<Product> findAll();
@Override
@EntityGraph(attributePaths = "category")
Page<Product> findAll(Pageable pageable); @Transactional(readOnly = true)
public Page<Product> findPage(Pageable pageable) {
return repository.findAll(pageable);
} Controller nhận một Pageable và map page bằng Page.map, giữ nguyên thông tin page và chỉ thay content:
package com.example.demo.product;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
private final ProductMapper mapper;
public ProductController(ProductService service, ProductMapper mapper) {
this.service = service;
this.mapper = mapper;
}
@GetMapping
public List<ProductResponse> findAll() {
return service.findAll().stream()
.map(mapper::toResponse)
.toList();
public Page<ProductResponse> findAll(Pageable pageable) {
return service.findPage(pageable).map(mapper::toResponse);
}
}Không cần cấu hình gì thêm. spring-boot-starter-data-jpa kéo theo spring-boot-data-commons, trong đó DataWebAutoConfiguration đăng ký argument resolver dựng Pageable từ request. Trích từ condition report của một lần chạy với --debug:
DataWebAutoConfiguration matched:
- @ConditionalOnClass found required classes 'org.springframework.data.web.PageableHandlerMethodArgumentResolver', 'org.springframework.web.servlet.config.annotation.WebMvcConfigurer' (OnClassCondition)
- found 'session' scope (OnWebApplicationCondition)
- @ConditionalOnMissingBean (types: org.springframework.data.web.PageableHandlerMethodArgumentResolver; SearchStrategy: all) did not find any beans (OnBeanCondition)curl -i 'http://localhost:8129/api/products?page=0&size=20&sort=price,desc&sort=name,asc'Hai câu SQL mà request đó gửi:
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id order by p1_0.price desc,p1_0.name offset ? rows fetch first ? rows only
select count(p1_0.id) from products p1_0Parameter sort có dạng property,direction, và mỗi lần lặp lại thêm một khóa sort tiếp theo. Entity graph chỉ thêm join vào row query; count không cần đến category. Việc lọc danh sách theo các request parameter tùy chọn, bằng Specifications, nằm trong khóa Advanced.
![Sáu bước của GET /api/products?page=2&size=20&sort=price,desc trên PostgreSQL 18.6 với 50,000 product: các query parameter, PageableHandlerMethodArgumentResolver với @PageableDefault(size = 20, sort = "id"), Pageable Page request [number: 2, size 20, sort: price: DESC] với offset 40, repository.findAll(pageable) kèm entity graph category, row query có order by p1_0.price desc offset ? rows fetch first ? rows only được bind 40 và 20 rồi đến count query, và JSON PageResponse với page 2, size 20, totalElements 50000, totalPages 2500 và hasNext true](/images/blog/sb-pageable-request-to-sql.vi.webp)
Spring Boot serialize Page sang JSON như thế nào?
Một page ba product là đủ để thấy toàn bộ body:
curl -i 'http://localhost:8129/api/products?page=1&size=3&sort=price,desc&sort=name,asc'HTTP/1.1 200
Content-Type: application/json
Content-Length: 648
Date: Sun, 13 Sep 2026 10:36:15 GMT
{"content":[{"id":16,"name":"Portable monitor","sku":"MN-03","price":189.00,"stock":9,"rating":null,"category":"Monitors"},{"id":4,"name":"Ergonomic keyboard","sku":"KB-04","price":129.00,"stock":7,"rating":4,"category":"Keyboards"},{"id":5,"name":"Gaming keyboard","sku":"KB-05","price":99.00,"stock":9,"rating":null,"category":"Keyboards"}],"empty":false,"first":false,"last":false,"number":1,"numberOfElements":3,"pageable":{"offset":3,"pageNumber":1,"pageSize":3,"paged":true,"sort":{"empty":false,"sorted":true,"unsorted":false},"unpaged":false},"size":3,"sort":{"empty":false,"sorted":true,"unsorted":false},"totalElements":23,"totalPages":8}Lần đầu tiên ứng dụng serialize một Page, log ghi ra một warning, chỉ một lần:
2026-09-13T17:36:15.957+07:00 WARN 43597 --- [demo] [nio-8129-exec-1] ration$PageModule$WarningLoggingModifier : Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure!
For a stable JSON structure, please use Spring Data's PagedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO))
or Spring HATEOAS and Spring Data's PagedResourcesAssembler as documented in https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables.Logger là SpringDataJackson3Configuration$PageModule$WarningLoggingModifier, bị log pattern của Boot cắt ngắn. Khi serialization-mode để mặc định là direct, Jackson ghi các getter của PageImpl theo thứ tự alphabet, như Jackson 3 làm với class (bài 18): content, rồi empty, first, last, number, numberOfElements, một object pageable có sort riêng, size, thêm một sort nữa, totalElements và totalPages. Ba flag mô tả sort hai lần mà không nêu tên property nào. Tổng cộng 648 byte cho ba product, và warning nói đúng: shape này là bất cứ thứ gì PageImpl để lộ ra.
serialization-mode=via-dto và PagedModel
spring.data.web.pageable.serialization-mode=via-dto spring:
data:
web:
pageable:
serialization-mode: via-dtoCùng request đó, controller không đổi dòng nào:
HTTP/1.1 200
Content-Type: application/json
Content-Length: 406
Date: Sun, 13 Sep 2026 10:36:19 GMT
{"content":[{"id":16,"name":"Portable monitor","sku":"MN-03","price":189.00,"stock":9,"rating":null,"category":"Monitors"},{"id":4,"name":"Ergonomic keyboard","sku":"KB-04","price":129.00,"stock":7,"rating":4,"category":"Keyboards"},{"id":5,"name":"Gaming keyboard","sku":"KB-05","price":99.00,"stock":9,"rating":null,"category":"Keyboards"}],"page":{"size":3,"number":1,"totalElements":23,"totalPages":8}}Với via-dto, Spring Data chuyển mỗi Page thành PagedModel trước khi Jackson nhìn thấy nó: phần content và một object page gồm bốn con số, và log không còn warning. Muốn có shape này cho riêng một endpoint thì trả về new PagedModel<>(page); PagedModel có public constructor nhận một Page.
Response mà series này trả về: PageResponse
Series này trả về một record của riêng mình:
package com.example.demo.common;
import java.util.List;
import org.springframework.data.domain.Page;
public record PageResponse<T>(List<T> content, int page, int size, long totalElements, int totalPages,
boolean hasNext) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(page.getContent(), page.getNumber(), page.getSize(),
page.getTotalElements(), page.getTotalPages(), page.hasNext());
}
} @GetMapping
public Page<ProductResponse> findAll(Pageable pageable) {
return service.findPage(pageable).map(mapper::toResponse);
public PageResponse<ProductResponse> findAll(Pageable pageable) {
return PageResponse.from(service.findPage(pageable).map(mapper::toResponse));
}HTTP/1.1 200
Content-Type: application/json
Content-Length: 410
Date: Sun, 13 Sep 2026 10:40:35 GMT
{"content":[{"id":16,"name":"Portable monitor","sku":"MN-03","price":189.00,"stock":9,"rating":null,"category":"Monitors"},{"id":4,"name":"Ergonomic keyboard","sku":"KB-04","price":129.00,"stock":7,"rating":4,"category":"Keyboards"},{"id":5,"name":"Gaming keyboard","sku":"KB-05","price":99.00,"stock":9,"rating":null,"category":"Keyboards"}],"page":1,"size":3,"totalElements":23,"totalPages":8,"hasNext":true}Vì sao chọn record thay vì PagedModel:
- Contract nằm trong ứng dụng. Bài 18 và bài 28 giữ entity phía sau response record vì lý do này, và metadata của page cũng vậy. JSON không phụ thuộc vào một property mà ai đó có thể đổi lại thành
direct, cũng không phụ thuộc vào cách Spring Data định hìnhPagedModelở các phiên bản sau. - Có đủ những gì thanh chuyển page cần.
hasNextđiều khiển nút "next" mà không phải tính toán; objectpagecủaPagedModelchỉ có size, number, totalElements và totalPages. - Phẳng và nhỏ. 410 byte, so với 406 byte của
PagedModelvà 648 byte củaPageImplđược serialize nguyên trạng.
PagedModel vẫn là lựa chọn hợp lý cho API mà client đã quen với shape của Spring Data. Link hypermedia tới page đầu, page trước và page sau đi kèm Spring HATEOAS và PagedResourcesAssembler, nằm trong khóa Advanced.
Các request parameter page, size và sort
Giá trị mặc định với @PageableDefault
Không có parameter nào, controller ở trên nhận các giá trị mặc định toàn cục: page 0, spring.data.web.pageable.default-page-size là 20, và không sort. Row query hoàn toàn không có ORDER BY, tức là thứ tự của mọi page do database tự quyết:
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id offset ? rows fetch first ? rows only@PageableDefault đặt giá trị mặc định cho một endpoint. Lần thử đầu chỉ có sort, @PageableDefault(sort = "id"), sort theo id nhưng trả về 10 product chứ không phải 20:
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id order by p1_0.id offset ? rows fetch first ? rows only
binding parameter (1:INTEGER) <- [0]
binding parameter (2:INTEGER) <- [10]Attribute size của annotation mặc định là 10, và nó thay giá trị toàn cục 20 ngay khi annotation xuất hiện. Vì vậy hãy ghi cả hai:
import com.example.demo.common.PageResponse;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
// ...
@GetMapping
public PageResponse<ProductResponse> findAll(Pageable pageable) {
public PageResponse<ProductResponse> findAll(@PageableDefault(size = 20, sort = "id") Pageable pageable) {
return PageResponse.from(service.findPage(pageable).map(mapper::toResponse));
}Bây giờ request không có parameter sort theo p1_0.id và bind limit 20 trên PostgreSQL. Parameter sort thay sort mặc định chứ không cộng thêm vào: ?size=2&sort=price,desc gửi order by p1_0.price desc, không có p1_0.id phía sau, nên vấn đề giá trị bằng nhau ở phần về Sort quay lại với mọi sort của client trên cột có giá trị lặp.
max-page-size và các giá trị không hợp lệ
?size=5000 không lỗi. Resolver chặn nó ở spring.data.web.pageable.max-page-size, mặc định là 2000: limit được bind là 2000, và trên PostgreSQL response chứa 2000 product với "size":2000. Với API public, đó là rất nhiều row cho một request, và giới hạn này chỉ là một property:
spring.data.web.pageable.max-page-size=100 spring:
data:
web:
pageable:
max-page-size: 100Khi đó, ?size=5000 trả về "size":100. Những giá trị resolver không dùng được sẽ quay về mặc định thay vì báo lỗi. Với controller cuối cùng trên PostgreSQL và giới hạn mặc định:
| Request | Status | page trong response | size trong response |
|---|---|---|---|
?size=5000 | 200 | 0 | 2000 |
?size=0 | 200 | 0 | 20 |
?size=-5 | 200 | 0 | 20 |
?page=-1 | 200 | 0 | 20 |
?page=abc&size=xyz | 200 | 0 | 20 |
Không trường hợp nào là 400, dù bài 15 quy định parameter không hợp lệ trả về 400. Client gõ nhầm page sẽ lặng lẽ nhận page đầu tiên. Muốn từ chối các giá trị đó thì cần kiểm tra trong controller; series này chấp nhận cơ chế quay về mặc định cho các parameter phân trang.
one-indexed-parameters
spring.data.web.pageable.one-indexed-parameters=true spring:
data:
web:
pageable:
one-indexed-parameters: truecurl -s 'http://localhost:8129/api/products?page=1&size=3'{"content":[{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":25,"rating":5,"category":"Keyboards"},{"id":2,"name":"Compact keyboard","sku":"KB-02","price":59.00,"stock":12,"rating":4,"category":"Keyboards"},{"id":3,"name":"Wireless keyboard","sku":"KB-03","price":45.50,"stock":0,"rating":null,"category":"Keyboards"}],"page":0,"size":3,"totalElements":23,"totalPages":8,"hasNext":true}Request xin page 1, query bind offset 0, còn response ghi "page":0. ?page=2 bind offset 3 và trả về "page":1, còn ?page=0 được xử lý như ?page=1. Setting này đổi cách đọc parameter, không đổi Page.getNumber(), là giá trị mà PageResponse.from copy sang. API chuyển sang page đếm từ 1 phải cộng thêm 1 ở cả response. Series này giữ mặc định đếm từ 0, khớp với PageRequest và các con số trong JSON.
Sắp xếp theo property lồng nhau
Parameter sort có thể đi theo relationship. ?size=2&sort=category.name&sort=price dùng lại join mà entity graph đã thêm:
select p1_0.id,p1_0.category_id,c1_0.id,c1_0.name,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id order by c1_0.name,p1_0.price offset ? rows fetch first ? rows onlyPhần tử thứ ba trong parameter yêu cầu sort không phân biệt hoa thường: ?sort=name,asc,ignorecase gửi order by lower(p1_0.name). Không có gì giới hạn các path mà client được dùng, kể cả collection: ?sort=tags.name thêm left join product_tags t1_0 on p1_0.id=t1_0.product_id left join tags t1_1 on t1_1.id=t1_0.tag_id vào row query và sort theo t1_1.name.
Sort property không tồn tại: từ 500 thành ProblemDetail 400
?sort=foo, khi chưa có handler nào cho nó:
curl -i 'http://localhost:8129/api/products?sort=foo'HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:36:16 GMT
Connection: close
{"timestamp":"2026-09-13T10:36:16.068Z","status":500,"error":"Internal Server Error","path":"/api/products"}2026-09-13T17:36:16.063+07:00 ERROR 43597 --- [demo] [nio-8129-exec-4] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.data.core.PropertyReferenceException: No property 'foo' found for type 'Product'] with root causeResolver chấp nhận foo. PropertyReferenceException đến từ repository, lúc Spring Data đối chiếu sort với Product để dựng ORDER BY, và chưa có câu SQL nào được gửi. Gõ sai tên field sort là lỗi của client, nên advice từ Chương 3 map nó thành 400:
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.core.PropertyReferenceException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
// ...
@ExceptionHandler(PropertyReferenceException.class)
public ProblemDetail unknownSortProperty(PropertyReferenceException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"Unknown sort property: " + e.getPropertyName());
} HTTP/1.1 400
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:40:36 GMT
Connection: close
{"detail":"Unknown sort property: foo","instance":"/api/products","status":400,"title":"Bad Request"}?sort=price,foo cũng nhận 400 như vậy, vì resolver đọc foo là property thứ hai chứ không phải hướng sort. Detail chỉ nêu giá trị của parameter, không nêu tên entity class như message của exception. Handler này khớp với findAll(Pageable) của endpoint; các kiểu query khác lỗi theo cách khác với cùng Sort.by("foo"):
| Query | Sort property không tồn tại gây ra gì |
|---|---|
findAll(Pageable), derived query | PropertyReferenceException: No property 'foo' found for type 'Product', không có SQL |
@Query JPQL | InvalidDataAccessApiUsageException với cause UnknownPathException: Could not resolve attribute 'foo' of 'com.example.demo.product.Product', không có SQL |
@Query native | order by foo asc được gửi tới database: Column "FOO" not found trên H2, ERROR: column "foo" does not exist trên PostgreSQL |
Parameter sort không phải là đường để chèn SQL vào các query này. Tên phải khớp với một property của Product, và bất cứ thứ gì không phải path property thuần đều bị từ chối trước khi SQL được dựng. ?sort=name; drop table products lỗi với Sort expression 'name; drop table products: ASC' must only contain property references or aliases used in the select clause; If you really want to use something other than that for sorting, please use JpaSort.unsafe(…), và price; drop table products truyền vào native query cũng lỗi với cùng message. Exception đó là InvalidDataAccessApiUsageException, advice không map nó, nên request này vẫn trả về 500. JpaSort.unsafe("length(p.name)") là lối thoát cho biểu thức và sinh ra order by character_length(p1_0.name); đừng bao giờ dựng nó từ dữ liệu của request.
Page sâu: chi phí của OFFSET và keyset scrolling với Window
Query dùng OFFSET không nhảy thẳng tới một row được: database đọc các row bị bỏ qua rồi vứt đi. EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) trên PostgreSQL 18.6 cho row query của GET /api/products?size=20 đúng như Hibernate log ra, với giá trị bind được viết thẳng vào. Page 0:
Limit (cost=0.44..1.67 rows=20 width=577) (actual rows=20.00 loops=1)
Buffers: shared hit=11
-> Nested Loop (cost=0.44..3070.82 rows=50000 width=577) (actual rows=20.00 loops=1)
Buffers: shared hit=11
-> Index Scan using products_pkey on products p1_0 (cost=0.29..1825.29 rows=50000 width=53) (actual rows=20.00 loops=1)Page 2499, offset 49980 rows fetch first 20 rows only:
Limit (cost=3069.59..3070.82 rows=20 width=577) (actual rows=20.00 loops=1)
Buffers: shared hit=662
-> Nested Loop (cost=0.44..3070.82 rows=50000 width=577) (actual rows=50000.00 loops=1)
Buffers: shared hit=662
-> Index Scan using products_pkey on products p1_0 (cost=0.29..1825.29 rows=50000 width=53) (actual rows=50000.00 loops=1)Keyset scrolling thì xin các row nằm sau khóa cuối cùng. Spring Data diễn đạt nó bằng Window và ScrollPosition:
Window<Product> findFirst20ByOrderByIdAsc(ScrollPosition position); Window<Product> first = productRepository.findFirst20ByOrderByIdAsc(ScrollPosition.keyset());
ScrollPosition next = first.positionAt(first.size() - 1);
Window<Product> deep = productRepository.findFirst20ByOrderByIdAsc(ScrollPosition.forward(Map.of("id", 49980L)));Trên PostgreSQL:
>>> findFirst20ByOrderByIdAsc(ScrollPosition.keyset())
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 order by p1_0.id fetch first ? rows only
binding parameter (1:INTEGER) <- [21]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] hasNext=true pos=KeysetScrollPosition [FORWARD, {id=20}]
>>> findFirst20ByOrderByIdAsc(KeysetScrollPosition [FORWARD, {id=49980}])
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id>? order by p1_0.id fetch first ? rows only
binding parameter (1:BIGINT) <- [49980]
binding parameter (2:INTEGER) <- [21]
[49981, 49982, 49983, 49984, 49985, 49986, 49987, 49988, 49989, 49990, 49991, 49992, 49993, 49994, 49995, 49996, 49997, 49998, 49999, 50000] hasNext=falseVẫn là 20 product của page 2499, nhưng lấy qua where p1_0.id>?, và cũng như Slice, đọc thêm một row cho hasNext. Plan của nó:
Limit (cost=0.29..8.62 rows=19 width=53) (actual rows=20.00 loops=1)
Buffers: shared hit=6
-> Index Scan using products_pkey on products p1_0 (cost=0.29..8.62 rows=19 width=53) (actual rows=20.00 loops=1)
Index Cond: (id > 49980)| Query, PostgreSQL 18.6, 50,000 product | Số row index scan đã đọc | Shared buffer | Thời gian thực thi, tốt nhất trong 7 lần |
|---|---|---|---|
Page 0, offset 0 rows fetch first 20 rows only | 20 | 11 | 0.033 ms |
Page 2499, offset 49980 rows fetch first 20 rows only | 50,000 | 662 | 7.785 ms |
Keyset, where p1_0.id>49980 … fetch first 21 rows only | 20 | 6 | 0.022 ms |
select count(p1_0.id) from products p1_0 | 50,000, sequential scan | 516 | 1.761 ms |
TIMING OFF bỏ các lần đọc đồng hồ cho từng node; với ANALYZE thông thường, query page 2499 báo 11.170 ms. Qua HTTP, với count và JSON ở mỗi request, page 0 mất 9.7 ms và page 2499 mất 12.0 ms, tốt nhất trong năm lần. Thời gian chỉ mang tính tham khảo; số row đã đọc thì không. Công việc của OFFSET tăng theo số page, còn keyset query đọc đúng 20 row dù bắt đầu ở đâu.
Sort trên cột có giá trị lặp cần id trong khóa, và Spring Data tự thêm nó. Window<Product> findFirst5ByOrderByPriceAsc(ScrollPosition position) trên H2 gửi order by p1_0.price,p1_0.id cho window đầu tiên, và cho window tiếp theo:
>>> findFirst5ByOrderByPriceAsc(KeysetScrollPosition [FORWARD, {id=13, price=22.00}])
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.rating,p1_0.sku,p1_0.stock from products p1_0 where p1_0.price>? or p1_0.price=? and p1_0.id>? order by p1_0.price,p1_0.id fetch first ? rows only
binding parameter (1:NUMERIC) <- [22.00]
binding parameter (2:NUMERIC) <- [22.00]
binding parameter (3:BIGINT) <- [13]
binding parameter (4:INTEGER) <- [6]
[8:MS-01 24.50, 19:AC-02 34.90, 10:MS-03 39.00, 18:AC-01 39.00, 3:KB-03 45.50] size=5 hasNext=true isLast=false class=WindowImplCái giá phải trả: window không có tổng số, không có số page và không nhảy được tới page 2,500, chỉ có vị trí sau row cuối cùng, thứ mà API giao cho client làm cursor. ScrollPosition.offset() trả về cùng type Window nhưng bên dưới là OFFSET.
So sánh Page, Slice, List và Window
| Return type | SQL mỗi lần gọi | Count query | Nơi gọi biết được gì | Dùng khi |
|---|---|---|---|---|
Page<T> | Row query có offset và limit, rồi một count | Có, bỏ qua khi các row đã cho ra tổng | Content, tổng số element, tổng số page, hasNext | Thanh chuyển page có đánh số, "23 kết quả" |
Slice<T> | Row query với limit size + 1 | Không | Content, hasNext | "Tải thêm" và infinite scroll, bảng mà đếm tốn kém |
List<T> kèm Pageable | Row query có offset và limit | Không | Chỉ content | Xử lý theo lô nội bộ, nơi gọi tự biết khi nào dừng |
Window<T> kèm ScrollPosition | Row query có where theo keyset và limit size + 1 | Không | Content, hasNext, vị trí để đi tiếp | Cuộn sâu, feed, export trên bảng lớn |
FAQ
Số page trong Spring Data JPA có đếm từ 0 không?
Có. PageRequest.of(0, 5) là page đầu tiên, và Page.getNumber() đếm từ 0. spring.data.web.pageable.one-indexed-parameters=true chỉ đổi cách đọc request parameter page: ?page=1 bind offset 0, còn getNumber() vẫn trả về 0.
Vì sao Page chạy hai câu SQL?
Page báo tổng số element và tổng số page, và chỉ count query mới cho được hai con số đó. findByCategoryName chạy row query rồi đến select count(p1_0.id) … where c1_0.name=?. Spring Data bỏ count khi page đầu có ít row hơn size, hoặc khi một page sau đó chỉ đầy một phần. Hãy trả về Slice khi client chỉ cần biết còn page tiếp theo hay không.
Giới hạn page size tối đa trong Spring Boot thế nào?
Đặt spring.data.web.pageable.max-page-size. Mặc định là 2000, và size lớn hơn bị chặn chứ không bị từ chối: ?size=5000 trả về 2000 product, còn với max-page-size=100 thì trả về 100.
Vì sao totalElements sai khi phân trang một query JOIN FETCH?
Count query mà Spring Data suy ra từ select p from Product p left join fetch p.tags giữ nguyên join, nên mỗi product được đếm một lần cho mỗi tag: 30 cho 23 product trong Spring Data JPA 4.1.1. Hãy cho @Query một countQuery = "select count(p) from Product p" tường minh, hoặc dùng @EntityGraph trên derived query, vì count của nó không có join. Bản thân Hibernate 7.4.5 vẫn phân trang các row đúng trong SQL và không log warning nào về xử lý trong bộ nhớ.
Vì sao Spring Boot log "Serializing PageImpl instances as-is is not supported"?
Vì controller trả về một Page, và với spring.data.web.pageable.serialization-mode=direct, giá trị mặc định, Jackson ghi mọi getter mà PageImpl có. Đặt property này thành via-dto để có shape content và page của PagedModel, trả về new PagedModel<>(page), hoặc trả về một response record như PageResponse.
Parameter sort trong request có bị dùng để SQL injection không?
Không, qua cách Spring Data xử lý Sort trong các query này. Property phải khớp với entity, nếu không sẽ có PropertyReferenceException, và một giá trị như name; drop table products bị từ chối với "Sort expression … must only contain property references" trước khi SQL được dựng, kể cả với native query. Chỉ JpaSort.unsafe cho biểu thức đi qua, nên đừng bao giờ đưa dữ liệu request vào đó.
Sort.Order.nullsLast() có hoạt động với Spring Data JPA không?
Có, nhưng SQL phụ thuộc database. Trên PostgreSQL, Sort.Order.desc("rating").nullsLast() thành order by p1_0.rating desc nulls last; trên H2 nó chỉ là desc, vì H2 vốn đã đặt NULL cuối cùng khi sort giảm dần. Không có nó, PostgreSQL đặt các product chưa có rating lên đầu.
Kết luận
Sort và Pageable trở thành ORDER BY, OFFSET và FETCH, còn return type quyết định những gì chạy thêm: một count query cho Page, một row thừa cho Slice, không gì cả cho List. Spring Data bỏ count khi các row đã cho ra tổng, tự suy ra count cho JPQL và cho native SQL đơn giản, nhưng suy ra sai cho JOIN FETCH trên collection có phân trang, nơi một countQuery tường minh sửa được; bản thân Hibernate 7.4 vẫn phân trang query đó trong SQL. Trong ORDER BY, hãy thêm id làm khóa cuối, vì các giá trị giá bằng nhau đã đặt những product khác nhau vào cùng một page ở hai lần chạy, và hãy nêu rõ vị trí của NULL, vì H2 và PostgreSQL không thống nhất về nó.
Qua HTTP, parameter Pageable hoạt động mà không cần cấu hình. @PageableDefault cần ghi rõ size, max-page-size chặn lượng dữ liệu client có thể xin, giá trị không hợp lệ quay về mặc định, và sort property không tồn tại trở thành 400 qua advice. Trả nguyên một Page cho ra JSON không ổn định kèm một warning; via-dto cho ra PagedModel, còn series này trả về PageResponse của riêng mình. Với page sâu, OFFSET đọc mọi row nó bỏ qua, 50,000 row cho page 2499, trong khi keyset Window chỉ đọc 20.
Bài tiếp theo nói về transaction cơ bản: @Transactional là gì, đặt ở đâu, và khi nào transaction rollback.