Paginierung und Sortierung mit Pageable in Spring Boot
1. Übersicht
Eine API, die die gesamte Tabelle orders zurückgibt, kann mit 100 Zeilen gut laufen. Wenn die Tabelle auf eine Million Zeilen wächst, bricht die API die Anwendung zusammen. Spring Data löst dieses Problem mit Pageable, einem Objekt, das beschreibt „welche Seite abrufen, wie viele Elemente, nach was sortieren”.
Nach dem Lesen dieses Artikels wirst du wissen, wie du:
- Repository-Methoden mit Paginierung deklarierst;
- zwischen
PageundSlicewählst; - Parameter
page,size,sortdirekt aus dem HTTP-Request abrufst; - einige Fallen bei Performance und Sicherheit vermeidest.
2. Vorbereitung
Du benötigst Spring Data JPA, Spring Web und eine Datenbank. Dieser Artikel verwendet H2 der Einfachheit halber:
<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>
Die Beispiel-Entity ist eine Bestellung:
@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;
}
// Getter
}
public enum OrderStatus { NEW, PAID, SHIPPED }
Die Tabelle wird orders genannt, weil ORDER ein SQL-Schlüsselwort ist. Wenn du den Standardnamen verwendest, werden viele Datenbanken einen Syntaxfehler melden.
3. Paginierung auf der Repository-Schicht
3.1. Pageable und PageRequest
JpaRepository erbt bereits von PagingAndSortingRepository, daher hast du bereits findAll(Pageable) verfügbar. Um ein Pageable zu erstellen, verwendest du PageRequest.of:
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page<Order> page = orderRepository.findAll(pageable);
System.out.println("Seite: " + page.getNumber());
System.out.println("Anzahl der Elemente auf dieser Seite: " + page.getNumberOfElements());
System.out.println("Gesamtzahl der Elemente: " + page.getTotalElements());
System.out.println("Gesamtzahl der Seiten: " + page.getTotalPages());
System.out.println("Gibt es eine nächste Seite? " + page.hasNext());
Die Bedeutung der Parameter:
0ist der Seitenindex. Spring Data nummeriert Seiten ab 0, nicht ab 1.10ist die Anzahl der Elemente pro Seite.Sort.by("createdAt").descending()sortiert die Bestellungen mit den neuesten zuerst.
Mit 25 Bestellungen in der Tabelle ist die Ausgabe:
Seite: 0
Anzahl der Elemente auf dieser Seite: 10
Gesamtzahl der Elemente: 25
Gesamtzahl der Seiten: 3
Gibt es eine nächste Seite? true
Wenn du spring.jpa.show-sql=true aktivierst, wirst du sehen, dass Hibernate zwei SQL-Anweisungen ausführt:
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
Die erste Anweisung ruft die Daten der Seite ab. Die zweite zählt die Gesamtanzahl der Zeilen, um totalElements und totalPages zu berechnen. Die Syntax offset ... fetch first ist für H2. Mit MySQL oder PostgreSQL siehst du limit ? offset ?.
3.2. Derived Query mit Paginierung
Du musst nur den Parameter Pageable am Ende der Methode hinzufügen. Spring Data kümmert sich um den Rest:
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);
}
findByStatusgibtPagezurück, einschließlich einercount-Abfrage.findByCustomerNamegibtSlicezurück. Abschnitt 4 erklärt den Unterschied.findTop5ByStatusbenötigt nur Sortierung, keine Paginierung. Dann übergibst duSortstattPageable.
3.3. Kombination mehrerer Sortierkriterien
Sort erlaubt es, mehrere Spalten zu verknüpfen:
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);
Der obige Code ruft die zweite Seite ab (Elemente 6 bis 10) der bezahlten Bestellungen. Bestellungen mit höherer Gesamtsumme werden zuerst angeordnet. Wenn zwei Bestellungen denselben Betrag haben, wird die ältere zuerst angeordnet.
Hinweis: Sortiere immer nach mindestens einer eindeutigen Spalte, z. B. füge
idam Ende hinzu. Wenn du nur nachtotalsortierst und viele Zeilen denselben Wert haben, kann die Datenbank bei verschiedenen Aufrufen unterschiedliche Reihenfolgen zurückgeben. Die Folge ist, dass ein Datensatz auf zwei Seiten angezeigt werden kann oder auf beiden Seiten verschwindet.
4. Page oder Slice?
Page<T> |
Slice<T> |
|
|---|---|---|
count-Abfrage |
Ja | Nein |
getTotalElements(), getTotalPages() |
Ja | Nein |
hasNext() |
Ja | Ja |
| Geeignet für | Tabelle mit Seitennummern (1, 2, 3 … 50) | Infinite Scroll, „Mehr laden”-Button |
Slice führt keine count-Abfrage aus. Stattdessen ruft es size + 1 Zeilen ab. Wenn es eine zusätzliche Zeile erhält, weiß es, dass es eine nächste Seite gibt:
Slice<Order> slice = orderRepository.findByCustomerName("An", PageRequest.of(0, 10));
System.out.println(slice.getContent().size() + " " + slice.hasNext());
10 true
Bei großen Tabellen kann count(*) genauso langsam sein wie die Hauptabfrage. Wenn die Benutzeroberfläche nicht die Gesamtzahl der Seiten anzeigen muss, verwende Slice.
5. Pageable aus dem HTTP-Request abrufen
5.1. Controller
Spring Boot konfiguriert automatisch PageableHandlerMethodArgumentResolver. Daher reicht es aus, Pageable als Controller-Parameter zu deklarieren:
@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());
}
}
Einige wichtige Punkte:
@PageableDefaultlegt die Standardwerte fest, wenn der Clientpage,sizeodersortnicht sendet.page.map(...)wandelt die Entity in ein DTO um, behält aber die Paginierungsinformationen bei. Du solltest niemals eine Entity direkt an die API zurückgeben.PagedModel(Packageorg.springframework.data.web, verfügbar seit Spring Data 3.3 / Boot 3.3) umhülltPagein eine stabile JSON-Struktur.
Der Client ruft die API wie folgt auf:
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 }
}
Der Parameter sort hat die Form eigenschaft,richtung. Du kannst ihn mehrmals wiederholen, um nach mehreren Spalten zu sortieren.
5.2. Warum nicht einfach Page zurückgeben?
Die Rückgabe von Page<OrderResponse> funktioniert zwar. Allerdings ist das JSON das Ergebnis der Serialisierung von PageImpl, einer internen Klasse, daher kann sich die Struktur zwischen Versionen ändern. Ab Spring Data 3.3 siehst du eine Warnung im Log, wenn du dies tust. Es gibt zwei Lösungsansätze: Gib wie oben PagedModel zurück, oder aktiviere die automatische Konvertierung für die gesamte Anwendung:
@Configuration
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class WebConfig {
}
5.3. Globale Konfiguration
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 ist die wichtigste Eigenschaft. Wenn der Client size=1000000 sendet, wird Spring es auf 100 reduzieren. Der Standardwert für diese Grenze ist 2000, was für die meisten APIs zu groß ist.
one-indexed-parameters=true erlaubt dem Client, page=1 für die erste Seite zu senden. Intern verwendet Pageable weiterhin Indizes ab 0.
6. Paginierung mit @Query
Bei selbst geschriebenem JPQL fügt Spring Data trotzdem automatisch order by und limit/offset hinzu. Es versucht auch, die count-Abfrage von deiner Abfrage abzuleiten. Wenn deine Abfrage join fetch enthält, geht diese Ableitung oft schief, daher musst du countQuery separat deklarieren:
@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);
Dieser Abschnitt setzt voraus, dass Order eine @ManyToOne-Beziehung zu einer Entity Customer hat. Mit @ManyToOne funktioniert join fetch mit Paginierung normal.
Hinweis: Verwende
join fetchnicht mit Collections (@OneToMany) bei Paginierung. Hibernate kannlimitauf SQL-Ebene für diesen Fall nicht anwenden. Es lädt alle Daten in den Speicher und schneidet dann die Seite aus, während es auch die WarnungHHH90003004: firstResult/maxResults specified with collection fetch; applying in memoryausgibt. Der richtige Weg ist, zuerst nach ID zu paginieren, dann die Collections nach dieser ID-Liste zu laden.
7. Tests
@DataJpaTest startet eine eingebettete Datenbank und lädt nur die JPA-Schicht, daher ist es perfekt zum Testen des 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("Kunde " + 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();
}
}
Im ersten Test enthält Seite 1 (zweite Seite) die Elemente 11 bis 20. Das erste Element auf der Seite ist die 11. größte Bestellung, also 25 − 10 = 15, entsprechend 150.000. Der zweite Test prüft, dass die letzte Seite nur noch 5 Bestellungen hat.
8. Häufige Fehler
Sortierung nach nicht existierenden Properties. Request ?sort=passwordHash oder ?sort=abc verursacht PropertyReferenceException. Ohne Behandlung erhält der Client einen 500-Fehler. Schlimmer noch, der Client könnte nach einer sensiblen Spalte sortieren, um Daten auszuspähen. Du solltest nach einer Whitelist filtern:
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());
}
Zu tiefe Offsets. page=50000&size=20 zwingt die Datenbank, eine Million Zeilen zu lesen und zu überspringen. Die Abfragezeit steigt linear mit dem Offset. Bei großen Datenmengen oder Infinite Scroll solltest du Keyset-Paginierung verwenden, d. h. „Zeilen abrufen, bei denen id < lastSeenId“. Spring Data unterstützt dies über ScrollPosition und Window.
Verwechslung, dass Seiten bei 1 beginnen. PageRequest.of(1, 10) ist die zweite Seite. Wenn das Frontend ab 1 zählt, aktiviere one-indexed-parameters, anstatt überall im Code 1 zu subtrahieren.
Daten ändern sich zwischen Aufrufen. Paginierung mit Offset ist nicht stabil, wenn neue Datensätze eingefügt werden. Benutzer können einen Befehl dupliziert sehen, wenn sie Seiten wechseln. Keyset-Paginierung vermeidet dieses Problem.
Wann man Page NICHT verwenden sollte: Wenn die Tabelle sehr groß ist und die Benutzeroberfläche die Gesamtzahl der Seiten nicht benötigt, z. B. bei Feeds oder Logs. Die count-Abfrage ist in diesem Fall ein unnötiger Aufwand, also verwende Slice oder Window.
9. Fazit
Pageable fasst drei Informationen in einem Objekt zusammen: Seite, Größe und Sortierung. Spring Data wandelt es auf allen Ebenen in limit/offset und order by um, vom abgeleiteten Query, @Query bis hin zu Controller-Parametern. Du verwendest Page, wenn du die Gesamtzahl der Seiten benötigst, und Slice, wenn du nur wissen musst, ob es mehr Daten gibt. Verwende bei der Rückgabe von JSON PagedModel, um eine stabile Response-Struktur zu gewährleisten.
Vergiss nicht drei Dinge: Begrenzen Sie max-page-size, erlauben Sie Sortierung nur nach Properties auf der Whitelist, und sortieren Sie immer nach einer eindeutigen Spalte. Wenn Offset zu langsam wird, ist es Zeit, zur Keyset-Paginierung zu wechseln.