Eine paginierte Antwort kann zum Zeitpunkt ihrer Erstellung korrekt sein und dennoch zu einem inkonsistenten Durchlauf führen. Wenn eine Anwendung eine Seite abruft, sich der Datenbestand ändert und sie anschließend die nächste Seite anfordert, kann sie Elemente doppelt erhalten oder andere übersehen. Das ist ein häufiges Problem bei Aktivitätslisten, Bestellungen und Datensätzen, die kontinuierlich anwachsen.
Die Cursor-Paginierung in einer PHP-API hilft, diese Verschiebungen zu kontrollieren, garantiert für sich genommen aber keine eingefrorene Datensicht. Entscheidend ist festzulegen, was es bedeutet, die Liste zu durchlaufen, welche Änderungen währenddessen auftreten können und welchen Vertrag der Client benötigt.
Warum sich die Ergebnisse zwischen Seiten ändern

Angenommen, eine Abfrage sortiert Datensätze nach Datum absteigend. Der Client erhält die ersten 20. Bevor er die nächsten anfordert, werden drei aktuelle Datensätze eingefügt. Verwendet die zweite Anfrage OFFSET 20, beginnt sie an Position 21 des aktuellen Datenbestands und nicht an der Position 21, die beim ersten Aufruf galt. Einige Elemente der ersten Seite können erneut erscheinen.
Auch Auslassungen sind möglich. Wird ein Datensatz vor dem Offset gelöscht, rücken die folgenden Elemente um eine Position nach vorn, und eine Zeile, die der Client erwartet, kann dadurch zurückbleiben. Auch die Sortierung ist nicht zwingend stabil, wenn mehrere Zeilen dasselbe Datum haben: Ohne ein zusätzliches Kriterium muss die Datenbank solche Gleichstände nicht immer in derselben Reihenfolge zurückgeben.
Es empfiehlt sich, zwei Ziele zu unterscheiden: Sprünge durch Positionsänderungen zu vermeiden und einen exakten Snapshot des gesamten Datenbestands bereitzustellen. Eine klar definierte Cursor-Paginierung hilft beim ersten Ziel. Das zweite erfordert eine ausdrückliche Konsistenzstrategie, die aufwendiger sein und von der Datenbank abhängen kann.
Offset oder Cursor: Wähle passend zum Lesemuster
Die Offset-Paginierung, üblicherweise mit LIMIT und OFFSET ausgedrückt, ist einfach und ermöglicht den direkten Sprung zu einer bekannten Seite. Sie kann für kleine oder relativ statische Datenbestände, Oberflächen mit häufigen Seitenwechseln und Fälle geeignet sein, in denen Inkonsistenzen während der Navigation akzeptabel sind. Bei großen Datenbeständen können hohe Offsets dazu führen, dass die Datenbank viele Zeilen durchlaufen oder verwerfen muss. Die tatsächlichen Kosten hängen von der Datenbank-Engine, den Indizes und der Abfrage ab.
Bei der Cursor-Paginierung wird eine Referenz auf den Punkt zurückgegeben, ab dem fortgesetzt werden soll, zum Beispiel der letzte Sortierwert und sein eindeutiger Schlüssel. Die nächste Abfrage sucht Datensätze nach oder vor diesem Punkt, statt eine bestimmte Anzahl von Zeilen zu überspringen. Das eignet sich für sequenzielle Durchläufe, Feeds und Listen, in die häufig neue Datensätze eingefügt werden. Dafür gibt es keine natürliche Möglichkeit, zu einer beliebigen Seite zu springen: Der Client muss Seiten durchlaufen oder eine andere Strategie verwenden.
Die Wahl muss nicht für die gesamte API einheitlich sein. Für eine administrative Abfrage mit nummerierten Seiten kann Offset angeboten werden, für einen Aktivitätsstrom dagegen ein Cursor. Die Schnittstelle sollte widerspiegeln, was der Server garantieren kann, statt mit einem einzigen Mechanismus sowohl zufällige Navigation als auch absolute Stabilität zu versprechen.
Lege eine totale Ordnung fest, bevor du den Cursor erstellst
Ein Cursor identifiziert nur dann eine Position, wenn die Sortierung deterministisch ist. Eine Sortierung ausschließlich nach created_at reicht nicht aus, wenn zwei Datensätze dasselbe Datum haben. Füge eine eindeutige und unveränderliche Spalte als weiteres Sortierkriterium hinzu, zum Beispiel id:
ORDER BY created_at DESC, id DESCSo erhält jede Zeile eine klar bestimmte Position innerhalb der Sortierung. Der Fortsetzungscursor muss beide Werte enthalten. Bei derselben absteigenden Sortierrichtung sucht die nächste Abfrage nach Paaren, die kleiner als das zuletzt gelieferte Paar sind:
WHERE created_at < :cursor_date
OR (created_at = :cursor_date AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :page_sizeBei aufsteigender Sortierung werden die Vergleiche umgekehrt. Bei mehreren Kriterien müssen die Bedingungen der vollständigen lexikografischen Ordnung entsprechen: Zuerst wird das erste Feld verglichen; bei Gleichstand folgt das nächste. Werden unterschiedliche Richtungen kombiniert – beispielsweise Datum absteigend und Kennung aufsteigend –, muss jeder Vergleich der Richtung seiner Spalte entsprechen. Es reicht nicht, alle Operatoren gleichzeitig umzukehren.
Auch für die Sortierwerte sind stabile Regeln erforderlich. Kann eine Spalte den Wert NULL enthalten, lege fest, wie diese Werte sortiert werden, und bilde diese Unterscheidung in der Fortsetzungsbedingung ab. Vorzuziehen sind Kriterien, die sich während des Durchlaufs nicht ändern: Wird ein Datum geändert, das die Position bestimmt, kann eine Zeile von einer Seite des Cursors auf die andere wechseln.
Gestalte den Cursor undurchsichtig, validiert und an die Abfrage gebunden
Ein Cursor kann die Sortierwerte serialisieren und sie beispielsweise mit Base64URL kodieren. „Undurchsichtig“ bedeutet, dass der Verbraucher ihn weder interpretieren noch erstellen muss – nicht, dass Base64 ihn schützt. Könnte eine Änderung seiner Werte den Umfang der Abfrage beeinflussen, müssen Format und Inhalt validiert und mit einem HMAC signiert oder durch einen gleichwertigen Integritätsmechanismus geschützt werden. Füge keine Geheimnisse oder unnötigen personenbezogenen Daten ein.
Validiere Typen, erwartete Felder, Formatversion und Größenlimits, bevor du die Datenbank abfragst. Verwende SQL-Parameter für die Werte. Spaltennamen und Sortierrichtungen dürfen nicht direkt aus dem Cursor oder der Anfrage übernommen werden: Sie müssen aus einer serverseitigen Zulassungsliste stammen.
Ein Cursor aus Datum und Kennung darf nicht versehentlich mit anderen Filtern wiederverwendet werden, wenn dadurch eine irreführende Fortsetzung entsteht. Du kannst eine kanonische Darstellung der relevanten Filter, der Sortierrichtung und gegebenenfalls der Seitengröße zusammen mit dem Fortsetzungspunkt aufnehmen und signieren. Stimmen sie nicht mit der aktuellen Anfrage überein, antworte mit einem klaren Fehler, statt stillschweigend eine andere Abfrage fortzusetzen. Zentralisiere in PHP Kodierung, Validierung und Signatur, damit Regeln nicht in mehreren Controllern dupliziert werden.
Lege fest, welche Konsistenz bei gleichzeitigen Änderungen geboten wird
Bei einem Durchlauf über aktuelle Daten fragt jede Seite den zu diesem Zeitpunkt verfügbaren Zustand ab. Ein Cursor auf Basis einer unveränderlichen Sortierung verhindert viele Verschiebungen, die durch Einfügungen vor dem erreichten Punkt entstehen. Er erstellt jedoch keinen Snapshot: Nach dem Cursor können neue Zeilen erscheinen, noch nicht besuchte Zeilen können gelöscht werden oder die geltenden Berechtigungen und Filter können sich ändern. Dokumentiere dieses Verhalten, damit der Client es nicht mit einem abgeschlossenen Export verwechselt.
Benötigt das Produkt für alle Seiten einen abgegrenzten Datenbestand, kann zu Beginn des Durchlaufs ein Grenzwert festgelegt werden, etwa ein Datum oder eine maximale Kennung, der jeder Abfrage hinzugefügt wird. Damit werden Einfügungen nach diesem Grenzwert ausgeschlossen, sofern das gewählte Kriterium dies ermöglicht. Gelöschte Zeilen bleiben dadurch jedoch nicht erhalten, und ein perfekter Snapshot gegenüber Änderungen ist ebenfalls nicht garantiert. Eine weitere Möglichkeit ist ein transaktionaler Snapshot. Eine Transaktion über mehrere Anfragen hinweg offen zu halten, hat jedoch meist Auswirkungen auf Betrieb und Ressourcen und sollte daher nicht ohne Weiteres als Standardlösung angenommen werden.
Der Vertrag kann klare Grenzen festlegen: unterstützte Sortierung, unverändert beizubehaltende Filter, gegebenenfalls Ablaufzeit, Verhalten bei ungültigem Cursor und die Frage, ob gleichzeitige Änderungen den Datenbestand verändern können. Versprich nicht, dass es keine Duplikate oder Auslassungen geben kann, wenn die Strategie dies nicht garantiert.
Teste Grenzfälle und dokumentiere den Vertrag

Die Tests sollten den vollständigen Durchlauf prüfen, nicht nur die Form einer Antwort. Erstelle Zeilen mit wiederholten Sortierwerten und prüfe, ob mehrere aneinandergehängte Seiten die erwartete Sortierung ohne Duplikate ergeben. Berücksichtige Fälle, in denen die Seitengröße eine Gruppe gleicher Werte aufteilt, und überprüfe sowohl aufsteigende als auch absteigende Sortierung.
- Füge zwischen zwei Anfragen Datensätze vor und nach dem Cursor ein und prüfe, ob das vereinbarte Verhalten eintritt.
- Lösche eine noch ausstehende Zeile und ändere eine Sortierspalte, sofern das Modell dies erlaubt; dokumentiere die Folgen.
- Ändere einen Filter, die Sortierung oder die Durchlaufrichtung und prüfe, ob ein inkompatibler Cursor abgewiesen wird.
- Sende fehlerhaft formatierte, manipulierte oder übermäßig große Cursor sowie Cursor mit Werten falscher Typen.
- Überprüfe die Grenzen der Seitengröße und den Fall ohne Ergebnisse, einschließlich des Fehlens einer Folgeseite.
Erfasse für die Diagnose Metriken zur Abfragedauer, Seitengröße und zu Validierungsfehlern, ohne sensible Cursor oder personenbezogene Daten auszugeben. Treten Wiederholungen auf, überprüfe zuerst die totale Ordnung und die Fortsetzungsbedingung. Geht es um die Kosten der Abfragen, untersuche den Ausführungsplan und die Indizes auf den Sortierfeldern und den Filterbedingungen.
Eine stabile Paginierung hängt nicht davon ab, eine Zeichenfolge in Base64 zu verbergen, sondern von einer deterministischen Sortierung, einem konsistenten Vergleich, kontrollierten Filtern und klaren Erwartungen an gleichzeitige Änderungen. Mit diesen Entscheidungen werden Offset und Cursor zu Werkzeugen, die sich passend zum tatsächlichen Durchlauf des Clients auswählen lassen.



