Cursor- vs. Offset-basierte Strategien im Vergleich
Pagination entscheidet über Ladezeit und Konsistenz jedes Listen-Endpunkts. Dieser Artikel zeigt, warum klassische Offset-Pagination bei wachsenden Tabellen langsam wird, wie Cursor-basierte Keyset-Pagination in PHP funktioniert und wie opake Cursor-Tokens robust kodiert werden.
Inhaltsverzeichnis
- 1. Warum Pagination bei wachsenden Datenmengen zum Problem wird
- 2. Offset-basierte Pagination im Detail
- 3. Die Schwächen von OFFSET bei großen Tabellen
- 4. Cursor-basierte Pagination: das Grundprinzip
- 5. Keyset-Pagination in PHP implementieren
- 6. Opake Cursor-Tokens kodieren und dekodieren
- 7. Stabile Sortierung: eindeutige Tie-Breaker-Spalten
- 8. Pagination-Metadaten in der API-Antwort
- 9. Offset vs. Cursor im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Pagination bei wachsenden Datenmengen zum Problem wird
Jeder Listen-Endpunkt einer PHP-API braucht eine Strategie für Pagination, sobald die zugrunde liegende Tabelle über einige Tausend Zeilen hinauswächst. Ohne Pagination würde ein einzelner Request potenziell Millionen Datensätze laden, das Backend blockieren und den Client mit einer riesigen JSON-Antwort überfordern. Die Wahl der richtigen Pagination-Strategie entscheidet dabei nicht nur über die Antwortzeit, sondern auch über die Konsistenz der Ergebnisse bei parallel eingefügten oder gelöschten Datensätzen.
In der Praxis existieren zwei dominante Ansätze für Pagination in PHP-APIs: die klassische Offset-basierte Pagination mit LIMIT und OFFSET, sowie die Cursor-basierte Pagination, oft auch Keyset-Pagination genannt. Beide lösen dasselbe Grundproblem, unterscheiden sich aber erheblich in Performance-Charakteristik, Implementierungsaufwand und Verhalten bei sich ändernden Datenmengen während der Navigation durch mehrere Seiten.
Welche Pagination-Strategie die richtige ist, hängt stark vom Anwendungsfall ab. Ein Admin-Backend mit seitenweiser Navigation und Sprüngen zu beliebigen Seitenzahlen profitiert eher von Offset-Pagination, ein öffentlicher API-Feed mit ständig wachsenden Daten und Infinite-Scroll-Oberflächen profitiert fast immer von Cursor-Pagination. Dieser Artikel geht auf beide Strategien im Detail ein und zeigt, wann welche Wahl sinnvoll ist.
2. Offset-basierte Pagination im Detail
Offset-basierte Pagination ist die intuitivste Umsetzung: der Client fordert eine Seitenzahl und eine Seitengröße an, die API übersetzt das in LIMIT und OFFSET in der SQL-Abfrage. Bei Seite 3 mit 20 Einträgen pro Seite lautet die Abfrage LIMIT 20 OFFSET 40, die Datenbank überspringt die ersten 40 Zeilen und liefert die nächsten 20. Dieses Modell ist leicht verständlich, leicht zu implementieren und erlaubt dem Client, direkt zu einer beliebigen Seitenzahl zu springen.
Der entscheidende Nachteil zeigt sich erst bei wachsender Tabellengröße: die Datenbank muss bei OFFSET 100000 tatsächlich die ersten 100000 Zeilen durchlaufen, um sie zu verwerfen, bevor die eigentlich gewünschten Zeilen zurückgegeben werden. Dieser Aufwand wächst linear mit dem Offset-Wert, unabhängig von der Seitengröße, wodurch tief liegende Seiten in großen Tabellen spürbar langsamer werden als die ersten Seiten.
<?php
declare(strict_types=1);
/**
* Classic offset-based pagination against a products table.
*/
final class OffsetProductRepository
{
public function __construct(private readonly \PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function findPage(int $page, int $perPage = 20): array
{
$offset = ($page - 1) * $perPage;
$stmt = $this->pdo->prepare(
'SELECT id, name, price, created_at
FROM products
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset'
);
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}
3. Die Schwächen von OFFSET bei großen Tabellen
Neben der reinen Performance-Frage hat Offset-Pagination ein zweites, subtileres Problem: die Konsistenz der Ergebnisse während der Navigation. Wird zwischen dem Laden von Seite 1 und Seite 2 ein neuer Datensatz eingefügt, der in die Sortierreihenfolge vor die aktuelle Position rutscht, verschieben sich alle nachfolgenden Zeilen um eine Position. Der Nutzer sieht dann entweder denselben Eintrag doppelt oder überspringt einen Eintrag komplett, ein als Page-Drift bekanntes Phänomen.
Bei sehr großen Tabellen mit Millionen Zeilen kann Offset-Pagination auf tiefen Seiten Antwortzeiten im Sekundenbereich erreichen, selbst mit passenden Indizes, weil die Datenbank die übersprungenen Zeilen physisch lesen muss, um deren Anzahl zu zählen. Für öffentliche APIs mit unvorhersehbarem Nutzerverhalten, etwa Suchmaschinen-Crawler, die systematisch tief in die Ergebnisse vordringen, wird dieses Verhalten schnell zum echten Performance-Risiko für die gesamte Infrastruktur.
4. Cursor-basierte Pagination: das Grundprinzip
Cursor-basierte Pagination, auch Keyset-Pagination genannt, löst beide Probleme gleichzeitig. Statt eine Anzahl zu überspringender Zeilen anzugeben, merkt sich der Client den Wert der Sortierspalte des letzten gesehenen Datensatzes und schickt diesen Wert als Cursor mit der nächsten Anfrage. Die Datenbank kann dann direkt über den Index zur passenden Position springen, ohne vorherige Zeilen lesen zu müssen, unabhängig davon, wie tief in der Ergebnismenge sich der Cursor befindet.
Diese Verschiebung von "überspringe N Zeilen" zu "gib mir alles nach diesem Wert" macht Cursor-Pagination unabhängig von der Position in der Gesamtmenge: Seite 2 und Seite 20000 kosten die Datenbank denselben Aufwand, solange ein passender Index existiert. Der Preis dafür: der Client kann nicht mehr direkt zu einer beliebigen Seitenzahl springen, sondern nur vorwärts oder rückwärts navigieren, ausgehend vom zuletzt gesehenen Cursor.
5. Keyset-Pagination in PHP implementieren
Die Implementierung von Keyset-Pagination in PHP folgt einem festen Muster: die Sortierspalte, meist ein Zeitstempel oder eine ID, wird als WHERE-Bedingung statt als OFFSET verwendet. Bei absteigender Sortierung nach created_at lautet die Bedingung für die nächste Seite created_at < :cursor_value, bei aufsteigender Sortierung entsprechend >. Diese Bedingung nutzt denselben Index, der ohnehin für die ORDER BY-Klausel existieren sollte, wodurch die Abfrage konstant schnell bleibt.
Der Vorteil gegenüber Offset-Pagination zeigt sich hier direkt im Ausführungsplan der Datenbank: statt eines Full-Table-Scans bis zur Offset-Position nutzt die Datenbank einen Index-Seek, der unabhängig von der Position in der Gesamtmenge konstante Zeit benötigt. Für PHP-APIs mit großen, ständig wachsenden Tabellen ist das der entscheidende Performance-Gewinn gegenüber der klassischen Alternative.
<?php
declare(strict_types=1);
/**
* Keyset (cursor-based) pagination against a products table.
*/
final class CursorProductRepository
{
public function __construct(private readonly \PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function findAfter(?string $cursorCreatedAt, ?int $cursorId, int $perPage = 20): array
{
$sql = 'SELECT id, name, price, created_at
FROM products
WHERE (:no_cursor = 1)
OR (created_at < :cursor_created_at)
OR (created_at = :cursor_created_at AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit';
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':no_cursor', $cursorCreatedAt === null ? 1 : 0, \PDO::PARAM_INT);
$stmt->bindValue(':cursor_created_at', $cursorCreatedAt ?? '', \PDO::PARAM_STR);
$stmt->bindValue(':cursor_id', $cursorId ?? 0, \PDO::PARAM_INT);
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}
6. Opake Cursor-Tokens kodieren und dekodieren
Ein Cursor sollte für den Client eine opake Zeichenkette sein, nicht die rohen Sortierwerte im Klartext. Das verhindert, dass Clients sich auf die interne Struktur des Cursors verlassen oder ihn manipulieren, um Datensätze zu überspringen, für die sie keine Berechtigung haben. In PHP kodiert man den Cursor typischerweise als Base64-codiertes JSON-Objekt, das alle für die Fortsetzung nötigen Werte enthält.
Diese Opakheit erlaubt es außerdem, das interne Format des Cursors später zu ändern, etwa eine zusätzliche Sortierspalte zu ergänzen, ohne bestehende Client-Integrationen zu brechen, solange die Kodierungs- und Dekodierungslogik zentral in einer einzigen Klasse gekapselt bleibt.
<?php
declare(strict_types=1);
/**
* Encodes and decodes opaque pagination cursors.
*/
final class PaginationCursor
{
private function __construct(
public readonly ?string $createdAt,
public readonly ?int $id,
) {
}
public static function initial(): self
{
return new self(null, null);
}
public static function fromToken(?string $token): self
{
if ($token === null || $token === '') {
return self::initial();
}
$decoded = json_decode(base64_decode($token, true) ?: '', true);
if (!is_array($decoded) || !isset($decoded['created_at'], $decoded['id'])) {
throw new \InvalidArgumentException('Invalid pagination cursor.');
}
return new self((string) $decoded['created_at'], (int) $decoded['id']);
}
public static function fromRow(array $row): string
{
$payload = ['created_at' => $row['created_at'], 'id' => $row['id']];
return base64_encode(json_encode($payload, JSON_THROW_ON_ERROR));
}
}
7. Stabile Sortierung: eindeutige Tie-Breaker-Spalten
Eine Sortierung allein nach einem Zeitstempel ist für Cursor-Pagination nicht ausreichend, weil mehrere Datensätze denselben Zeitstempel haben können, insbesondere bei Massenimporten oder hochfrequenten Schreibvorgängen. Ohne eindeutigen Tie-Breaker können bei gleichem Zeitstempel Datensätze doppelt erscheinen oder übersprungen werden, dasselbe Grundproblem, das Cursor-Pagination eigentlich beheben sollte.
Die Lösung ist eine zusammengesetzte Sortierung aus dem eigentlichen Sortierkriterium und einer garantiert eindeutigen Spalte wie der Primärschlüssel-ID, wie im Beispiel oben mit ORDER BY created_at DESC, id DESC bereits umgesetzt. Der Index für diese Abfrage sollte exakt dieselbe Spaltenreihenfolge abbilden, ein zusammengesetzter Index über (created_at, id), damit die Datenbank die Bedingung ohne zusätzlichen Sortierschritt auswerten kann.
8. Pagination-Metadaten in der API-Antwort
Neben den eigentlichen Datensätzen sollte jede paginierte Antwort Metadaten enthalten, die dem Client die Navigation ermöglichen: einen next_cursor für die nächste Seite, ein has_more-Flag, das signalisiert, ob weitere Datensätze existieren, und optional einen prev_cursor für die Rückwärtsnavigation. Diese Metadaten gehören konsequent in ein eigenes Objekt, meist meta oder pagination genannt, statt in die Datenliste selbst gemischt zu werden.
{
"data": [
{ "id": 4821, "name": "Wireless Keyboard", "price": 59.90 },
{ "id": 4820, "name": "USB-C Hub", "price": 34.50 }
],
"pagination": {
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0zMCIsImlkIjo0ODIwfQ==",
"has_more": true,
"per_page": 20
}
}
9. Offset vs. Cursor im direkten Vergleich
Die folgende Tabelle fasst die wichtigsten Unterschiede zwischen beiden Pagination-Strategien zusammen.
| Aspekt | Offset-Pagination | Cursor-Pagination |
|---|---|---|
| Performance auf tiefen Seiten | Wird langsamer mit wachsendem Offset | Konstant, unabhängig von der Tiefe |
| Sprung zu beliebiger Seite | Direkt möglich | Nur vorwärts/rückwärts |
| Konsistenz bei Änderungen | Anfällig für Page-Drift | Stabil gegenüber Einfügungen |
| Implementierungsaufwand | Gering | Moderat, benötigt Cursor-Kodierung |
| Geeignet für | Admin-UIs mit Seitenzahlen | Infinite Scroll, große/wachsende Tabellen |
Ein pragmatischer Mittelweg vieler PHP-APIs: Offset-Pagination für kleinere, administrative Endpunkte behalten und für öffentliche, datenintensive Listen-Endpunkte konsequent auf Cursor-Pagination umstellen, sobald absehbar ist, dass die Tabelle über die Zeit deutlich wächst.
Mironsoft
PHP-API-Performance und Datenbank-Optimierung
Listen-Endpunkte, die auch bei Millionen Zeilen schnell bleiben?
Wir migrieren bestehende Offset-Pagination auf Keyset-Pagination, entwerfen passende zusammengesetzte Indizes und bauen robuste, opake Cursor-Tokens für eure PHP-API.
Performance-Analyse
Ausführungspläne bestehender Listen-Endpunkte prüfen
Cursor-Migration
Keyset-Pagination und Cursor-Kodierung produktionsreif umsetzen
Index-Design
Zusammengesetzte Indizes für stabile, schnelle Sortierung
10. Zusammenfassung
API-Pagination in PHP ist keine rein kosmetische Entscheidung, sondern hat direkten Einfluss auf Datenbank-Last und Antwortzeiten bei wachsenden Tabellen. Offset-basierte Pagination bleibt für kleine bis mittlere Datenmengen und Admin-Oberflächen mit direktem Seitensprung eine legitime, einfache Wahl. Cursor-basierte Keyset-Pagination löst dagegen sowohl das Performance-Problem tiefer Seiten als auch das Konsistenzproblem bei parallelen Änderungen und ist für öffentliche, datenintensive PHP-APIs fast immer die robustere Option.
Die technische Umsetzung von Cursor-Pagination in PHP braucht drei Bausteine: eine zusammengesetzte, eindeutige Sortierung mit Tie-Breaker-Spalte, eine WHERE-Bedingung, die den bestehenden Index nutzt statt Zeilen zu überspringen, und opake, kodierte Cursor-Tokens, die interne Details vor dem Client verbergen. Wer diese drei Punkte beachtet, baut Listen-Endpunkte, deren Antwortzeit unabhängig von der Tabellengröße konstant bleibt.
Cursor- vs. Offset-Pagination in PHP: Das Wichtigste auf einen Blick
Offset-Pagination
LIMIT/OFFSET, einfach umzusetzen, erlaubt Sprung zu jeder Seite, wird bei großen Tabellen auf tiefen Seiten langsam.
Cursor-Pagination
WHERE-Bedingung statt OFFSET, konstante Performance unabhängig von der Tiefe, kein direkter Seitensprung.
Tie-Breaker
Zusammengesetzte Sortierung mit eindeutiger ID verhindert doppelte oder übersprungene Datensätze bei gleichem Zeitstempel.
Cursor-Format
Base64-kodiertes JSON als opaker Token, verbirgt interne Struktur und bleibt erweiterbar.