Keyset-Pagination in MariaDB - Nach der letzten Zeile statt nach einem Offset fortsetzen
Eine Liste kann beim Lesen stabil wirken, obwohl sie sich unter den Bedienelementen der Seitennavigation weiterbewegt. Angenommen, die erste Seite zeigt die 20 neuesten Artikel. Bevor ein Leser die zweite Seite öffnet, wird ein weiterer Artikel veröffentlicht. Wenn die zweite Seite „20 Zeilen überspringen“ bedeutet, verschiebt der neue Eintrag diese Grenze. Eine bereits gelesene Zeile kann erneut erscheinen, obwohl weder der Leser noch die Datenbank einen offensichtlichen Fehler gemacht haben.
Dieses Problem soll Keyset-Pagination lösen. Anstatt MariaDB aufzufordern, eine bestimmte Anzahl von Zeilen zu verwerfen, sagt die Anwendung: „Fahre nach der letzten bereits empfangenen sortierten Zeile fort.“ Die Idee ist klein, doch eine korrekte Umsetzung erfordert eine deterministische Reihenfolge, einen passenden Vergleich, einen geeigneten Index und realistische Erwartungen an Änderungen der Daten.
Dieser Artikel entwickelt ein Muster für die nächste Seite einer kleinen PHP-Anwendung mit MariaDB. Vorausgesetzt wird eine Liste vom neuesten zum ältesten Eintrag mit den nicht-nullbaren Spalten published_at und id. Der Artikel behauptet weder, dass Keyset-Pagination immer vorzuziehen ist, noch dass sie mehrere Anfragen in einen unveränderlichen Datenbank-Snapshot verwandelt.
Ein Offset ist eine Anzahl, keine Position
Eine geläufige Abfrage für die vierte Seite sieht so aus:
SELECT id, title, published_at
FROM articles
WHERE status = 'published'
ORDER BY published_at DESC, id DESC
LIMIT 20 OFFSET 60;
Die MariaDB-Referenz zu LIMIT definiert das Verhalten direkt: Werden ein Offset und eine Zeilenanzahl angegeben, werden zunächst die durch den Offset bestimmten Zeilen ignoriert und anschließend die folgenden Zeilen zurückgegeben. Dadurch lassen sich nummerierte Seiten leicht ausdrücken. Eine weit hinten liegende Seite beschreibt jedoch weiterhin alle vorangehenden Zeilen als zu überspringende Arbeit. Die MariaDB-Hinweise zur Pagination warnen deshalb vor dem Aufwand großer Offsets.
Ein separates Korrektheitsproblem entsteht, wenn einzelne Anfragen eine veränderliche Liste sehen. Angenommen, die erste Seite liefert die IDs 5, 4, 3. Nun wird die neue Zeile mit ID 6 vorne eingefügt. Die nächste Anfrage verwendet OFFSET 3, sodass die aktuellen ersten drei Zeilen jetzt 6, 5, 4 sind. Die zweite Seite beginnt mit ID 3, die der Leser bereits gesehen hat. Eine Löschung vor der Grenze kann eine Zeile in die andere Richtung verschieben und dazu führen, dass sie ausgelassen wird.
Das bedeutet nicht, dass Offset-Pagination defekt ist. Der Offset beschreibt vielmehr eine Position im Ergebnis zum Zeitpunkt der jeweiligen Abfrage. Er enthält keine Erinnerung daran, mit welcher Zeile die vorherige Antwort endete.
Zuerst eine vollständige Ordnung herstellen
Keyset-Pagination benötigt eine eindeutige Antwort auf die Frage: „Was kommt nach dieser Zeile?“ Eine Sortierung nur nach published_at reicht nicht aus, wenn zwei Artikel denselben Zeitstempel haben können. Laut der MariaDB-Dokumentation zu ORDER BY lösen spätere Sortierausdrücke Gleichstände in früheren Ausdrücken auf. Das Anhängen eines eindeutigen Primärschlüssels erzeugt eine deterministische Reihenfolge:
ORDER BY published_at DESC, id DESC
Das Paar (published_at, id) ist nun die Position einer Zeile in dieser Liste. Endet die erste Seite beim Zeitstempel 2026-10-17 10:00:00 und der ID 3, muss die nächste Seite Zeilen mit einem früheren Zeitstempel sowie Zeilen mit demselben Zeitstempel und einer kleineren ID auswählen:
SELECT id, title, published_at
FROM articles
WHERE status = 'published'
AND (
published_at < :before_time_lt
OR (published_at = :before_time_eq AND id < :before_id)
)
ORDER BY published_at DESC, id DESC
LIMIT 21;
Sowohl die Vergleichszeichen als auch ihre Reihenfolge folgen der absteigenden Sortierung. Für eine aufsteigende Liste müssen sie in die andere Richtung zeigen. Eine absteigende ORDER BY-Klausel mit einem aufsteigenden Fortsetzungsvergleich zu kombinieren, verringert nicht nur die Leistung, sondern fordert die falschen Zeilen an.
Das Limit beträgt 21, wenn die Seite 20 Einträge anzeigt. Die Anwendung stellt die ersten 20 dar und verwendet die zusätzliche Zeile nur, um zu entscheiden, ob ein Link zur nächsten Seite nötig ist. Dadurch entfällt für diese Entscheidung eine separate Zählabfrage. Die Gesamtzahl der Seiten wird damit jedoch nicht ermittelt.
Eine kompakte PDO-Implementierung
Das folgende Beispiel verwendet transparente Query-Parameter statt eines undurchsichtigen Cursors, damit die Grenze sichtbar bleibt. Es setzt voraus, dass published_at ein MariaDB-DATETIME mit Sekundengenauigkeit, id eine positive Ganzzahl und $pdo bereits für Exceptions konfiguriert ist. Authentifizierung, Autorisierung, Ausgabe-Escaping und Verbindungsaufbau gehören zur umgebenden Anwendung.
<?php
declare(strict_types=1);
$pageSize = 20;
$beforeTime = $_GET['before_time'] ?? null;
$beforeIdInput = $_GET['before_id'] ?? null;
if (($beforeTime === null) !== ($beforeIdInput === null)) {
http_response_code(400);
exit('Both cursor fields are required.');
}
$parameters = [];
$cursorWhere = '';
if ($beforeTime !== null) {
$time = DateTimeImmutable::createFromFormat(
'!Y-m-d H:i:s',
(string) $beforeTime
);
$beforeId = filter_var(
$beforeIdInput,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]
);
if ($time === false
|| $time->format('Y-m-d H:i:s') !== $beforeTime
|| $beforeId === false) {
http_response_code(400);
exit('Invalid cursor.');
}
$cursorWhere = <<<'SQL'
AND (
published_at < :before_time_lt
OR (published_at = :before_time_eq AND id < :before_id)
)
SQL;
$parameters = [
'before_time_lt' => $beforeTime,
'before_time_eq' => $beforeTime,
'before_id' => $beforeId,
];
}
$sql = "
SELECT id, title, published_at
FROM articles
WHERE status = 'published'
{$cursorWhere}
ORDER BY published_at DESC, id DESC
LIMIT " . ($pageSize + 1);
$statement = $pdo->prepare($sql);
$statement->execute($parameters);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
$hasNextPage = count($rows) > $pageSize;
if ($hasNextPage) {
array_pop($rows);
}
$nextUrl = null;
if ($hasNextPage && $rows !== []) {
$last = $rows[array_key_last($rows)];
$nextUrl = '/articles?' . http_build_query([
'before_time' => $last['published_at'],
'before_id' => $last['id'],
]);
}
Das dynamische SQL wählt hier zwischen zwei festen Abfrageformen: erste Seite oder Folgeseite. Benutzerwerte bleiben gebundene Parameter. Das numerische LIMIT wird aus einer serverseitigen Konstante erzeugt und nicht aus der Anfrage übernommen.
Eine API kann Zeitstempel und ID stattdessen in einer einzigen Cursor-Zeichenfolge kodieren. Eine Kodierung kann URLs übersichtlicher machen und Platz für weitere Sortierfelder lassen. Base64 ist jedoch keine Verschlüsselung, und Undurchsichtigkeit bietet keine Integrität. Der Server muss weiterhin jeden Wert dekodieren, validieren und binden. Falls ein veränderter Cursor Datensätze eines anderen Mandanten offenlegen oder einen Filter umgehen könnte, müssen Autorisierung und Abfragebereich dies verhindern. Ein signierter Cursor kann Manipulationen erkennen, diese Kontrollen aber nicht ersetzen.
Der Index muss der Abfrage folgen, nicht dem Wort „Pagination“
Für die Beispielabfrage ist der folgende Index ein plausibler Kandidat:
CREATE INDEX idx_articles_status_published_id
ON articles (status, published_at, id);
Der mit Gleichheit gefilterte status steht zuerst, gefolgt von den Spalten der sortierten Grenze. Dies entspricht der in den MariaDB-Hinweisen zur Pagination beschriebenen Form. Es bleibt dennoch ein Kandidat und keine universelle Vorgabe. Selektivität, weitere Filter, ausgewählte Spalten, Tabellengröße, Storage Engine, Schreibrate und andere Abfragen beeinflussen gemeinsam, welche Indexmenge sinnvoll ist.
Der MariaDB-Indexleitfaden empfiehlt, Indizes an WHERE und ORDER BY auszurichten, Pläne mit EXPLAIN zu prüfen und zu berücksichtigen, dass zusätzliche Indizes Speicher belegen und Schreibvorgänge verteuern. Beide Abfrageformen sollten untersucht werden:
EXPLAIN
SELECT id, title, published_at
FROM articles
WHERE status = 'published'
ORDER BY published_at DESC, id DESC
LIMIT 21;
EXPLAIN
SELECT id, title, published_at
FROM articles
WHERE status = 'published'
AND (
published_at < '2026-10-17 10:00:00'
OR (published_at = '2026-10-17 10:00:00' AND id < 3)
)
ORDER BY published_at DESC, id DESC
LIMIT 21;
Ein Plan ist ein Beleg für ein bestimmtes Schema, eine bestimmte Datenverteilung und Serverversion; er ist kein Benchmark. Frühe und weit hinten liegende Grenzen sollten mit produktionsnahen Daten getestet werden, bevor eine Verbesserung behauptet wird. Die Implementierungshinweise von GitLab betonen denselben praktischen Punkt: Sowohl die erste Abfrage als auch die Cursor-Abfrage müssen geprüft werden, denn ein gutes Ergebnis auf der ersten Seite beweist nicht, dass die Fortsetzung gut indiziert ist.
Was geschieht, wenn sich die Liste ändert?
Eine neue Zeile, die vor der gespeicherten Grenze eingefügt wird, verschiebt diese Grenze nicht. Daher lässt sie eine bereits angezeigte Zeile auf der nächsten Seite nicht erneut erscheinen. Das ist der zentrale Vorteil gegenüber dem Zählen ab dem jeweils aktuellen Anfang der Liste.
Keyset-Pagination verspricht nicht, dass jede Lesesitzung eine unveränderliche historische Liste sieht. Wird eine noch nicht gesehene Zeile gelöscht, kann sie später nicht erscheinen. Wird ein Sortierwert geändert, kann die Zeile die gespeicherte Grenze überqueren und möglicherweise ausgelassen oder anders angetroffen werden. Getrennte HTTP-Anfragen laufen normalerweise als getrennte Datenbanklesevorgänge; ein Cursor speichert eine Sortierposition, keinen Transaktions-Snapshot.
Auch Filter gehören zur Bedeutung dieser Position. Ein für status=published&author=7 erzeugter Cursor sollte nicht unbemerkt eine andere Abfrage fortsetzen. Eine Anwendung kann Filter ausdrücklich in der URL belassen, normalisierten Filterzustand in einen signierten Cursor aufnehmen oder einen Cursor bei unpassendem Kontext ablehnen. Unabhängig vom Entwurf müssen alle Filter weiterhin serverseitig durchgesetzt werden.
Nullable Sortierspalten benötigen eine ausdrückliche Regel für die Position von null, während veränderliche oder nicht eindeutige Felder sorgfältige Tie-Breaker erfordern. Das Beispiel mit zwei Spalten vermeidet diese Schwierigkeiten, indem es einen nicht-nullbaren Zeitstempel und eine eindeutige, nicht-nullbare ID voraussetzt.
Die Kompromisse gehören zum Entwurf
Keyset-Pagination unterstützt von Natur aus „Weiter“ und, mit umgekehrten Vergleichen und umgekehrter Sortierung, „Zurück“. Sie beantwortet nicht ohne Weiteres „direkt zu Seite 417“, weil Seitenzahlen durch Anzahlen und nicht durch bekannte Zeilenschlüssel definiert werden. Eine genaue Anzeige wie „Einträge 61–80 von 38.412“ kann außerdem eine separate Zählabfrage erfordern, deren Kosten und Aktualität eigens bewertet werden müssen.
Diese Nachteile sind in manchen Oberflächen relevant. Eine Verwaltungstabelle mit einigen hundert überwiegend stabilen Datensätzen kann mit nummerierten Offset-Seiten verständlicher sein. Ein öffentlicher Aktivitäts-Feed, ein Artikelarchiv oder eine kontinuierlich wachsende API-Sammlung kann besser zur cursorbasierten Navigation passen. Markus Winands Erläuterung der Seek-Methode betont sowohl die geringere Verwerfarbeit als auch die Notwendigkeit, das Keyset durch alle Anwendungsschichten zu tragen. Die Engineering-Dokumentation von GitLab behandelt das Fehlen von Seitenzahlen ausdrücklich als Einschränkung.
Nicht jeder Offset muss ersetzt werden, nur weil Keyset-Pagination existiert. Entscheidend ist, ob die Oberfläche beliebige Sprünge zu nummerierten Seiten benötigt oder eine zuverlässige Fortsetzung durch eine große, veränderliche und sortierte Menge.
Checkliste für die Prüfung
- Die Abfrage besitzt eine ausdrückliche, deterministische Reihenfolge, die mit einem eindeutigen Tie-Breaker endet.
- Der Cursor enthält jeden Wert, der zur Rekonstruktion dieser Sortiergrenze nötig ist.
- Der Fortsetzungsvergleich verwendet dieselben Spalten und Richtungen wie
ORDER BY. - Cursor-Werte werden geparst, validiert und als Daten gebunden.
- Autorisierung und Filter werden bei jeder Anfrage erneut angewendet.
- Die Anwendung lädt nur dann eine zusätzliche Zeile, wenn sie ein Signal für die nächste Seite benötigt.
- Indizes werden für die vollständige Abfrageform gewählt und sowohl auf der ersten als auch auf späteren Seiten geprüft.
- Tests decken gleiche Zeitstempel, Einfügungen vor der Grenze, Löschungen, ungültige Cursor und die letzte unvollständige Seite ab.
- Die Oberfläche verspricht keine Seitenzahlen- oder Snapshot-Semantik, die die Implementierung nicht bieten kann.
Fazit
Offset-Pagination fordert MariaDB auf, ein aktuell sortiertes Ergebnis zu finden und eine Anzahl von Zeilen am Anfang zu verwerfen. Keyset-Pagination merkt sich stattdessen eine Zeilengrenze. Für eine MariaDB-Liste vom neuesten zum ältesten Eintrag kann diese Grenze das Paar (published_at, id) sein. Der Primärschlüssel löst gleiche Zeitstempel auf, und ein passender Vergleich wählt die nachfolgenden Zeilen aus.
Das Ergebnis ist keine magische Konsistenz. Zeilen können weiterhin bearbeitet oder gelöscht werden, Cursor müssen weiterhin validiert werden, und Indizes benötigen weiterhin Belege aus dem tatsächlichen Schema und den tatsächlichen Plänen. Außerdem werden beliebige Seitensprünge gegen gerichtete Navigation eingetauscht. Wenn dieser Kompromiss zur Oberfläche passt, gibt Keyset-Pagination der Anwendung eine ehrlichere Anweisung als „überspringe so viele Zeilen, wie früher vor dieser Seite lagen“: Fahre nach der letzten Zeile fort, die der Leser tatsächlich erhalten hat.
References
- MariaDB. “Pagination Optimization.” Accessed October 17, 2026.
- MariaDB. “LIMIT.” Accessed October 17, 2026.
- MariaDB. “ORDER BY.” Accessed October 17, 2026.
- MariaDB. “Getting Started with Indexes Guide.” Accessed October 17, 2026.
- Winand, Markus. “We need tool support for keyset pagination.” August 6, 2014; updated September 8, 2023.
- GitLab contributors. “Keyset pagination.” GitLab Docs. Accessed October 17, 2026.
