Command Palette

Search for a command to run...

[Spring Boot Basics] Phân trang và sắp xếp trong Spring Boot: Pageable, Sort và trả kết quả phân trang qua API

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.

Một xấp page kết quả với các row đã sort và thanh chuyển page đang ở page 1, dưới tiêu đề Phân trang và sắp xếp

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, h2postgresql. 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@ManyToOne Category lazy và một Set<Tag>, nằm trong các bảng products, categories, tagsproduct_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 đó:

src/main/java/com/example/demo/product/Product.java
    @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:

src/main/java/com/example/demo/product/CatalogSeeder.java
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:

src/main/resources/application.properties
spring.application.name=demo
spring.jpa.open-in-view=false
logging.level.org.hibernate.SQL=DEBUG

Profile postgres trỏ tới container PostgreSQL 18:

src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:55429/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=create
Bash
docker run -d --name sb-a29-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 55429:5432 postgres:18
Bash
./gradlew -q bootJar

Cá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:

Bash
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=TRACE

Mỗ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:

src/main/java/com/example/demo/product/ProductRepository.java
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();
}
src/main/java/com/example/demo/product/ProductResponse.java
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) {
}
src/main/java/com/example/demo/product/ProductMapper.java
    public ProductResponse toResponse(Product product) {
        return new ProductResponse(product.getId(), product.getName(), product.getSku(), product.getPrice(),
                product.getStock(), product.getRating(), product.getCategory().getName());
    }
src/main/java/com/example/demo/product/ProductService.java
    @Transactional(readOnly = true)
    public List<Product> findAll() {
        return repository.findAll();
    }
src/main/java/com/example/demo/product/ProductController.java
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:

seed-50k.sql
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;
Bash
docker exec -i sb-a29-pg psql -U shop -d shop < seed-50k.sql

Sau đó ứ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:

Bash
curl -s -o /dev/null -w '%{http_code} %{size_download} %{time_total}\n' http://localhost:8129/api/products
Text
200 5651608 0.098135
200 5651608 0.095069
200 5651608 0.081495
200 5651608 0.078082
200 5651608 0.068285

Mỗi request gửi một câu SQL, biến cả 50,000 row thành entity rồi thành JSON:

Text
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

Lầ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:

Java
productRepository.findAll(Sort.by("price").descending());
productRepository.findAll(Sort.by("rating").descending().and(Sort.by("price")));
Text
>>> 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.price

Cá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:

Text
    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]
Text
    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

Java
productRepository.findAll(Sort.by(Sort.Order.asc("name").ignoreCase()));
Text
>>> 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 mouseLow-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.OrdernullsFirst()nullsLast(). SQL chúng sinh ra khác nhau trên hai database, và giá trị mặc định cũng vậy:

Sort.OrderH2: order byRating NULL trên H2PostgreSQL: order byRating NULL trên PostgreSQL
desc("rating")p1_0.rating desccuốip1_0.rating descđầu
desc("rating").nullsLast()p1_0.rating desccuốip1_0.rating desc nulls lastcuối
desc("rating").nullsFirst()p1_0.rating desc nulls firstđầup1_0.rating descđầu
asc("rating")p1_0.ratingđầup1_0.ratingcuối
asc("rating").nullsLast()p1_0.rating asc nulls lastcuốip1_0.ratingcuố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:

Java
productRepository.findAll(Sort.by(Product::getPrice));
productRepository.findAll(Sort.by(Sort.Direction.DESC, Product::getPrice));
Text
>>> 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 desc

Cá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@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:

Text
>>> 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 @3e58a80e

TypedSort 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:

Java
PageRequest request = PageRequest.of(0, 5, Sort.by("price"));
Text
>>> 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:

src/main/java/com/example/demo/product/ProductRepository.java
    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 findBy 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:

Text
>>> 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=PageImpl

Cùng page đó dưới dạng Slice:

Text
>>> 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=SliceImpl

Và page 1 dưới dạng List:

Text
>>> 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=2
  • Page chạ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.
  • Slice gửi đúng row query đó với limit 6 và không có count. Row thứ sáu chỉ để trả lời hasNext(); slice chứa năm product và không có tổng số.
  • List bind 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.

Ba cột cho cùng PageRequest.of(0, 5, Sort.by price) trên category Keyboards: Page chạy row query với limit 5 cùng một count query và biết có 7 element trên 2 page; Slice bind limit 6, đọc row thứ sáu chỉ để đặt hasNext và không chạy count; List bind limit 5, không chạy count và trả 5 row không kèm metadata về page

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 PageSlice; 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:

Text
>>> 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=PageImpl

Không có count query, vậy mà getTotalElements() vẫn là 7. Page 3, đã vượt quá cuối:

Text
>>> 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=PageImpl

Năm lời gọi trên H2 và những gì mỗi lời gọi đã chạy:

Lời gọiSố row trả vềCount querygetTotalElements()getTotalPages()hasNext()
findByCategoryName("Keyboards", PageRequest.of(0, 5, …))572true
findByCategoryName("Keyboards", PageRequest.of(1, 5, …))2không72false
findByCategoryName("Monitors", PageRequest.of(0, 5, …))4không41false
findByCategoryName("Keyboards", PageRequest.of(1, 7, …))071false
findByCategoryName("Keyboards", PageRequest.of(3, 5, …))072false

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

src/main/java/com/example/demo/product/ProductRepository.java
    @Query("select p from Product p where p.price <= :maxPrice") 
    Page<Product> findUpToPrice(BigDecimal maxPrice, Pageable pageable); 
Text
>>> 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=PageImpl

Spring 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ó:

src/main/java/com/example/demo/product/ProductRepository.java
    @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); 
Text
>>> 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=PageImpl

Khô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:

src/main/java/com/example/demo/product/ProductRepository.java
    @Query("select p from Product p left join fetch p.tags") 
    Page<Product> findAllWithTags(Pageable pageable); 
Text
>>> 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=PageImpl

Nhiề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:

src/main/java/com/example/demo/product/ProductRepository.java
    @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);
Text
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=PageImpl

Row 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 đủ:

src/main/java/com/example/demo/product/ProductRepository.java
    @Override
    @EntityGraph(attributePaths = "category")
    List<Product> findAll();
 
    @Override
    @EntityGraph(attributePaths = "category") 
    Page<Product> findAll(Pageable pageable); 
src/main/java/com/example/demo/product/ProductService.java
    @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:

src/main/java/com/example/demo/product/ProductController.java
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:

Text
   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)
Bash
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:

Text
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_0

Parameter 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

Spring Boot serialize Page sang JSON như thế nào?

Một page ba product là đủ để thấy toàn bộ body:

Bash
curl -i 'http://localhost:8129/api/products?page=1&size=3&sort=price,desc&sort=name,asc'
Text
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:

Text
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 pageablesort riêng, size, thêm một sort nữa, totalElementstotalPages. 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

src/main/resources/application.properties
spring.data.web.pageable.serialization-mode=via-dto 

Cùng request đó, controller không đổi dòng nào:

Text
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:

src/main/java/com/example/demo/common/PageResponse.java
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());
    }
}
src/main/java/com/example/demo/product/ProductController.java
    @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)); 
    }
Text
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ình PagedModel ở 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; object page của PagedModel chỉ có size, number, totalElements và totalPages.
  • Phẳng và nhỏ. 410 byte, so với 406 byte của PagedModel và 648 byte của PageImpl đượ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:

Text
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:

Text
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:

src/main/java/com/example/demo/product/ProductController.java
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:

src/main/resources/application.properties
spring.data.web.pageable.max-page-size=100 

Khi đó, ?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:

RequestStatuspage trong responsesize trong response
?size=500020002000
?size=0200020
?size=-5200020
?page=-1200020
?page=abc&size=xyz200020

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

src/main/resources/application.properties
spring.data.web.pageable.one-indexed-parameters=true 
Bash
curl -s 'http://localhost:8129/api/products?page=1&size=3'
JSON
{"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:

Text
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 only

Phầ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ó:

Bash
curl -i 'http://localhost:8129/api/products?sort=foo'
Text
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"}
Text
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 cause

Resolver 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:

src/main/java/com/example/demo/common/GlobalExceptionHandler.java
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()); 
    } 
Text
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"):

QuerySort property không tồn tại gây ra gì
findAll(Pageable), derived queryPropertyReferenceException: No property 'foo' found for type 'Product', không có SQL
@Query JPQLInvalidDataAccessApiUsageException với cause UnknownPathException: Could not resolve attribute 'foo' of 'com.example.demo.product.Product', không có SQL
@Query nativeorder 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:

Text
 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:

Text
 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 WindowScrollPosition:

src/main/java/com/example/demo/product/ProductRepository.java
    Window<Product> findFirst20ByOrderByIdAsc(ScrollPosition position); 
Java
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:

Text
>>> 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=false

Vẫ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ó:

Text
 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 productSố row index scan đã đọcShared bufferThời gian thực thi, tốt nhất trong 7 lần
Page 0, offset 0 rows fetch first 20 rows only20110.033 ms
Page 2499, offset 49980 rows fetch first 20 rows only50,0006627.785 ms
Keyset, where p1_0.id>49980 … fetch first 21 rows only2060.022 ms
select count(p1_0.id) from products p1_050,000, sequential scan5161.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:

Text
>>> 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=WindowImpl

Cá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 typeSQL mỗi lần gọiCount queryNơi gọi biết được gìDùng khi
Page<T>Row query có offset và limit, rồi một countCó, bỏ qua khi các row đã cho ra tổngContent, tổng số element, tổng số page, hasNextThanh chuyển page có đánh số, "23 kết quả"
Slice<T>Row query với limit size + 1KhôngContent, hasNext"Tải thêm" và infinite scroll, bảng mà đếm tốn kém
List<T> kèm PageableRow query có offset và limitKhôngChỉ contentXử lý theo lô nội bộ, nơi gọi tự biết khi nào dừng
Window<T> kèm ScrollPositionRow query có where theo keyset và limit size + 1KhôngContent, hasNext, vị trí để đi tiếpCuộ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 contentpage 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

SortPageable 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.

Bài viết liên quan

[Spring Boot Basics] Unit test trong Spring Boot: JUnit 6, AssertJ và Mockito cho tầng Service

Unit test cho tầng service của ứng dụng Spring Boot 4.1.1 với JUnit, AssertJ và Mockito: unit test thay thế những gì, test task của Gradle và report, mỗi test method một instance mới được chứng minh bằng identity, @Nested và tên hiển thị của parameterized test trong JUnit 6, bẫy isEqualTo với BigDecimal và soft assertion cùng thông báo lỗi, @Mock với constructor injection so với @InjectMocks truyền null, stub, verify và ArgumentCaptor, UnnecessaryStubbingException và PotentialStubbingProblem dưới strict stubs, một Clock cố định, và nạp Mockito dưới dạng -javaagent để bỏ cảnh báo self-attaching.

[Spring Boot Basics] Công cụ tăng năng suất trong Spring Boot: DevTools, Lombok và Actuator cơ bản

Spring Boot DevTools, Lombok và Actuator trên Spring Boot 4.1.1: vì sao developmentOnly giữ DevTools ngoài bootJar, base classloader và restart classloader với lần restart đo được 0.185 s so với khởi động lạnh 1.488 s, kích hoạt restart bằng ./gradlew -t classes, vì sao build resource bằng Gradle vẫn làm ứng dụng restart, các giá trị property mặc định DevTools áp dụng và LiveReload bị deprecate từ 4.1.0; Lombok sinh ra gì theo javap, @Value và @Builder so với Java record với Jackson 3 và @Jacksonized, các bẫy của @Data trên entity (StackOverflowError, HashSet làm mất entity, LazyInitializationException, @Builder không có no-args constructor) và tập annotation an toàn; Actuator /actuator, /actuator/health với show-details và 503 DOWN, expose /actuator/info với thông tin build, git, java và os, vì sao include=* nguy hiểm, và bảo vệ Actuator bên cạnh chain securityMatcher("/api/**").

[Spring Boot Basics] Transaction trong Spring Boot: @Transactional là gì, đặt ở đâu và khi nào rollback

@Transactional trong Spring Boot 4.1.1 với PostgreSQL: dữ liệu bị ghi dở dang khi đặt order không có transaction, log DEBUG của JpaTransactionManager cho begin, commit và rollback, proxy CGLIB phía sau bean được inject, đặt ở method của service hay ở repository, ở class hay ở method, quy tắc rollback với exception unchecked và checked, rollbackFor, noRollbackFor và exception đã catch, readOnly làm gì với dirty checking và với thao tác ghi trên PostgreSQL, self-invocation, method private và protected, UnexpectedRollbackException từ bẫy rollback-only, jakarta.transaction.Transactional và TransactionTemplate.

[Spring Boot Basics] Kiến trúc phân lớp trong Spring Boot: Controller – Service – Repository và tổ chức package theo layer hay feature

Kiến trúc phân lớp trong Spring Boot 4.1.1, refactor trên một catalogue API: controller làm tất cả mọi việc và lỗi crash lúc khởi động khi tái sử dụng nó, controller, service và repository mỗi layer chịu trách nhiệm gì, map DTO ở layer nào, refactor từng bước và dùng curl chứng minh hành vi không đổi, service nên là interface hay class cụ thể kiểm tra bằng Mockito, năm anti-pattern khi phân layer, và so sánh tổ chức package theo layer với theo feature trên cùng một thay đổi, kèm bean package-private vẫn được inject và lỗi compile giữ các feature tách biệt.