Phân trang và sắp xếp với Pageable trong Spring Boot
1. Tổng quan
Một API trả về toàn bộ bảng orders có thể chạy ổn với 100 dòng. Khi bảng lên một triệu dòng, API đó làm sập ứng dụng. Spring Data giải quyết vấn đề này bằng Pageable, một đối tượng mô tả “lấy trang nào, bao nhiêu phần tử, sắp xếp theo gì”.
Đọc xong bài, bạn sẽ biết cách:
- khai báo phương thức repository có phân trang;
- chọn giữa
PagevàSlice; - nhận tham số
page,size,sorttrực tiếp từ HTTP request; - tránh vài cái bẫy về hiệu năng và bảo mật.
2. Chuẩn bị
Bạn cần Spring Data JPA, Spring Web và một database. Bài này dùng H2 cho gọn:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Entity mẫu là một đơn hàng:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerName;
private BigDecimal total;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDateTime createdAt;
protected Order() {
}
public Order(String customerName, BigDecimal total, OrderStatus status, LocalDateTime createdAt) {
this.customerName = customerName;
this.total = total;
this.status = status;
this.createdAt = createdAt;
}
// getters
}
public enum OrderStatus { NEW, PAID, SHIPPED }
Bảng được đặt tên là orders vì ORDER là từ khóa SQL. Nếu để mặc định, nhiều database sẽ báo lỗi cú pháp.
3. Phân trang ở tầng repository
3.1. Pageable và PageRequest
JpaRepository đã kế thừa PagingAndSortingRepository, nên bạn có sẵn findAll(Pageable). Để tạo một Pageable, bạn dùng PageRequest.of:
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findAll(pageable);
System.out.println("Trang: " + page.getNumber());
System.out.println("Số phần tử trang này: " + page.getNumberOfElements());
System.out.println("Tổng phần tử: " + page.getTotalElements());
System.out.println("Tổng số trang: " + page.getTotalPages());
System.out.println("Còn trang sau? " + page.hasNext());
Ý nghĩa từng tham số:
0là chỉ số trang. Spring Data đếm trang từ 0, không phải từ 1.10là số phần tử mỗi trang.Sort.by("createdAt").descending()sắp xếp đơn hàng mới nhất lên đầu.
Với 25 đơn hàng trong bảng, output là:
Trang: 0
Số phần tử trang này: 10
Tổng phần tử: 25
Tổng số trang: 3
Còn trang sau? true
Bật spring.jpa.show-sql=true và bạn sẽ thấy Hibernate chạy hai câu SQL:
select o.id, o.created_at, o.customer_name, o.status, o.total
from orders o order by o.created_at desc offset ? rows fetch first ? rows only
select count(o.id) from orders o
Câu đầu lấy dữ liệu của trang. Câu thứ hai đếm tổng số dòng để tính totalElements và totalPages. Cú pháp offset ... fetch first là của H2. Với MySQL hay PostgreSQL, bạn sẽ thấy limit ? offset ?.
3.2. Derived query có phân trang
Bạn chỉ cần thêm tham số Pageable vào cuối phương thức. Spring Data tự xử lý phần còn lại:
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
Slice<Order> findByCustomerName(String customerName, Pageable pageable);
List<Order> findTop5ByStatus(OrderStatus status, Sort sort);
}
findByStatustrả vềPage, có kèm câucount.findByCustomerNametrả vềSlice. Phần 4 sẽ nói rõ khác biệt.findTop5ByStatuschỉ cần sắp xếp mà không cần phân trang. Khi đó bạn truyềnSortthay choPageable.
3.3. Kết hợp nhiều tiêu chí sắp xếp
Sort cho phép nối nhiều cột lại với nhau:
Sort sort = Sort.by(Sort.Order.desc("total"), Sort.Order.asc("createdAt"));
Pageable pageable = PageRequest.of(1, 5, sort);
Page<Order> paidOrders = orderRepository.findByStatus(OrderStatus.PAID, pageable);
Đoạn code trên lấy trang thứ hai, gồm phần tử thứ 6 tới 10, của các đơn đã thanh toán. Đơn có tổng tiền lớn được xếp trước. Nếu hai đơn bằng tiền nhau, đơn cũ hơn đứng trước.
Lưu ý: Luôn sắp xếp theo ít nhất một cột duy nhất, ví dụ thêm
idvào cuối. Nếu chỉ sort theototalvà nhiều dòng có cùng giá trị, database được phép trả về thứ tự khác nhau giữa các lần gọi. Hậu quả là một bản ghi có thể xuất hiện ở hai trang, hoặc biến mất khỏi cả hai.
4. Page hay Slice?
Page<T> |
Slice<T> |
|
|---|---|---|
Câu count |
Có | Không |
getTotalElements(), getTotalPages() |
Có | Không |
hasNext() |
Có | Có |
| Phù hợp với | Bảng có số trang (1, 2, 3 … 50) | Infinite scroll, nút “Xem thêm” |
Slice không chạy câu count. Thay vào đó, nó lấy size + 1 dòng. Nếu nhận được dòng dư, nó biết còn trang sau:
Slice<Order> slice = orderRepository.findByCustomerName("An", PageRequest.of(0, 10));
System.out.println(slice.getContent().size() + " " + slice.hasNext());
10 true
Với bảng lớn, count(*) có thể chậm không kém câu truy vấn chính. Nếu giao diện không cần hiển thị tổng số trang, hãy dùng Slice.
5. Nhận Pageable từ HTTP request
5.1. Controller
Spring Boot tự cấu hình PageableHandlerMethodArgumentResolver. Vì vậy bạn khai báo Pageable làm tham số controller là đủ:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderRepository orderRepository;
public OrderController(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@GetMapping
public PagedModel<OrderResponse> list(
@RequestParam OrderStatus status,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
Page<OrderResponse> page = orderRepository.findByStatus(status, pageable)
.map(OrderResponse::from);
return new PagedModel<>(page);
}
}
public record OrderResponse(Long id, String customerName, BigDecimal total, LocalDateTime createdAt) {
static OrderResponse from(Order o) {
return new OrderResponse(o.getId(), o.getCustomerName(), o.getTotal(), o.getCreatedAt());
}
}
Các điểm cần chú ý:
@PageableDefaultđặt giá trị mặc định khi client không gửipage,sizehoặcsort.page.map(...)chuyển entity sang DTO mà vẫn giữ nguyên thông tin phân trang. Bạn không nên trả entity ra ngoài API.PagedModel(góiorg.springframework.data.web, có từ Spring Data 3.3 / Boot 3.3) bọcPagethành một cấu trúc JSON ổn định.
Client gọi API như sau:
curl "http://localhost:8080/orders?status=PAID&page=0&size=2&sort=total,desc&sort=id,asc"
{
"content": [
{ "id": 17, "customerName": "Bình", "total": 990000.00, "createdAt": "2026-10-01T09:15:00" },
{ "id": 4, "customerName": "An", "total": 870000.00, "createdAt": "2026-09-28T14:02:00" }
],
"page": { "size": 2, "number": 0, "totalElements": 12, "totalPages": 6 }
}
Tham số sort có dạng thuộcTính,hướng. Bạn có thể lặp nó nhiều lần để sort theo nhiều cột.
5.2. Vì sao không trả thẳng Page?
Trả về Page<OrderResponse> vẫn chạy được. Tuy nhiên JSON khi đó là kết quả serialize PageImpl, một lớp nội bộ, nên cấu trúc có thể đổi giữa các phiên bản. Từ Spring Data 3.3, bạn sẽ thấy cảnh báo trong log khi làm vậy. Có hai cách xử lý: trả PagedModel như trên, hoặc bật chế độ chuyển đổi tự động cho toàn ứng dụng:
@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class WebConfig {
}
5.3. Cấu hình toàn cục
spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.one-indexed-parameters=false
spring.data.web.pageable.page-parameter=page
spring.data.web.pageable.size-parameter=size
spring.data.web.sort.sort-parameter=sort
max-page-size là thuộc tính quan trọng nhất. Nếu client gửi size=1000000, Spring sẽ tự cắt xuống 100. Mặc định giới hạn này là 2000, quá lớn với phần lớn API.
one-indexed-parameters=true cho phép client gửi page=1 cho trang đầu tiên. Bên trong, Pageable vẫn dùng chỉ số từ 0.
6. Phân trang với @Query
Với JPQL tự viết, Spring Data vẫn tự thêm order by và limit/offset. Nó cũng cố suy ra câu count từ câu truy vấn của bạn. Khi câu truy vấn có join fetch, việc suy ra này thường sai, nên bạn cần khai báo countQuery riêng:
@Query(value = "select o from Order o join fetch o.customer c where c.city = :city",
countQuery = "select count(o) from Order o join o.customer c where c.city = :city")
Page<Order> findByCity(@Param("city") String city, Pageable pageable);
Phần này giả định Order có quan hệ @ManyToOne tới một entity Customer. Với @ManyToOne, join fetch kết hợp phân trang hoạt động bình thường.
Lưu ý: Không dùng
join fetchvới collection (@OneToMany) khi phân trang. Hibernate không thể áplimitở mức SQL cho trường hợp này. Nó sẽ tải toàn bộ dữ liệu vào bộ nhớ rồi mới cắt trang, đồng thời ghi cảnh báoHHH90003004: firstResult/maxResults specified with collection fetch; applying in memory. Cách làm đúng là phân trang trên ID trước, sau đó fetch các collection theo danh sách ID đó.
7. Kiểm thử
@DataJpaTest khởi động một database nhúng và chỉ nạp tầng JPA, nên rất hợp để test repository:
@DataJpaTest
class OrderRepositoryTest {
@Autowired
OrderRepository orderRepository;
@BeforeEach
void setUp() {
LocalDateTime base = LocalDateTime.of(2026, 10, 1, 8, 0);
for (int i = 1; i <= 25; i++) {
orderRepository.save(new Order("Khách " + i, BigDecimal.valueOf(i * 10_000L),
OrderStatus.PAID, base.plusHours(i)));
}
}
@Test
void shouldReturnSecondPageSortedByTotalDesc() {
Pageable pageable = PageRequest.of(1, 10, Sort.by("total").descending());
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, pageable);
assertThat(page.getTotalElements()).isEqualTo(25);
assertThat(page.getTotalPages()).isEqualTo(3);
assertThat(page.getContent()).hasSize(10);
assertThat(page.getContent().getFirst().getTotal())
.isEqualByComparingTo("150000");
assertThat(page.hasNext()).isTrue();
}
@Test
void lastPageShouldContainRemainingOrders() {
Page<Order> page = orderRepository.findByStatus(OrderStatus.PAID, PageRequest.of(2, 10));
assertThat(page.getContent()).hasSize(5);
assertThat(page.isLast()).isTrue();
}
}
Trong test đầu tiên, trang 1 (trang thứ hai) chứa các phần tử thứ 11 tới 20. Phần tử đầu tiên của trang là đơn lớn thứ 11, tức 25 − 10 = 15, ứng với 150.000. Test thứ hai kiểm tra trang cuối chỉ còn 5 đơn.
8. Lỗi hay gặp
Sort theo thuộc tính không tồn tại. Request ?sort=passwordHash hoặc ?sort=abc gây ra PropertyReferenceException. Nếu không xử lý, client nhận về lỗi 500. Tệ hơn, client có thể sort theo một cột nhạy cảm để dò dữ liệu. Bạn nên lọc theo whitelist:
private static final Set<String> SORTABLE = Set.of("createdAt", "total", "id");
static Pageable sanitize(Pageable pageable) {
Sort safe = Sort.by(pageable.getSort().stream()
.filter(order -> SORTABLE.contains(order.getProperty()))
.toList());
return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
safe.isSorted() ? safe : Sort.by("createdAt").descending());
}
Offset quá sâu. page=50000&size=20 buộc database đọc rồi bỏ qua một triệu dòng. Thời gian truy vấn tăng tuyến tính theo offset. Với dữ liệu lớn hoặc infinite scroll, hãy dùng keyset pagination, tức “lấy các dòng có id < lastSeenId”. Spring Data hỗ trợ cách này qua ScrollPosition và Window.
Nhầm trang bắt đầu từ 1. PageRequest.of(1, 10) là trang thứ hai. Nếu frontend đếm từ 1, hãy bật one-indexed-parameters thay vì trừ 1 rải rác khắp code.
Dữ liệu thay đổi giữa các lần gọi. Phân trang theo offset không ổn định khi có bản ghi mới được chèn vào. Người dùng có thể thấy một đơn hàng lặp lại khi chuyển trang. Keyset pagination tránh được vấn đề này.
Khi nào KHÔNG nên dùng Page: khi bảng rất lớn và giao diện không cần tổng số trang, ví dụ feed hoặc log. Câu count là chi phí thừa trong trường hợp đó, nên dùng Slice hoặc Window.
9. Kết luận
Pageable gom ba thông tin vào một đối tượng: trang, kích thước và cách sắp xếp. Spring Data biến nó thành limit/offset và order by ở mọi tầng, từ derived query, @Query cho tới tham số controller. Bạn dùng Page khi cần tổng số trang và Slice khi chỉ cần biết còn dữ liệu hay không. Khi trả JSON, hãy dùng PagedModel để cấu trúc response ổn định.
Đừng quên ba điều: giới hạn max-page-size, chỉ cho phép sort theo các thuộc tính trong whitelist, và luôn sort theo một cột duy nhất. Khi offset bắt đầu chậm, đó là lúc bạn nên chuyển sang keyset pagination.