Keyset-Pagination – warum LIMIT ... OFFSET auf Seite 500 kriecht
OFFSET 10000 heißt für die Datenbank: zehntausend Zeilen lesen und wegwerfen. Und wenn zwischendurch jemand etwas einfügt, sieht der Nutzer beim Blättern denselben Eintrag zweimal. Beides löst dieselbe Umstellung.
So blättert fast jede Liste im Netz:
SELECT * FROM beitraege
ORDER BY erstellt_am DESC
LIMIT 20 OFFSET 9980; -- Seite 500Das liefert das richtige Ergebnis. Aber die Datenbank muss dafür 10.000 Zeilen sortieren, lesen und die ersten 9.980 wieder wegwerfen. Seite 1 ist blitzschnell, Seite 500 zäh — und je größer die Tabelle wird, desto schlimmer.
Das zweite Problem ist subtiler und ärgert Nutzer noch mehr: Fügt jemand zwischen Seite 3 und Seite 4 einen neuen Beitrag ein, verschiebt sich alles um eins. Der letzte Eintrag von Seite 3 taucht auf Seite 4 nochmal auf. Und wenn etwas gelöscht wird, überspringt man einen.
Die Alternative: sich merken, wo man war
Statt zu zählen, wie viele Zeilen man überspringen will, sagt man der Datenbank, hinter welchem Wert es weitergehen soll:
-- Erste Seite
SELECT id, titel, erstellt_am FROM beitraege
ORDER BY erstellt_am DESC, id DESC
LIMIT 20;
-- Folgeseite: die Werte der LETZTEN Zeile der Vorseite einsetzen
SELECT id, titel, erstellt_am FROM beitraege
WHERE (erstellt_am, id) < (:letztes_datum, :letzte_id)
ORDER BY erstellt_am DESC, id DESC
LIMIT 20;Diese Abfrage ist auf Seite 500 genauso schnell wie auf Seite 1: Mit einem Index auf (erstellt_am, id) springt die Datenbank direkt an die richtige Stelle und liest genau zwanzig Zeilen.
CREATE INDEX idx_beitraege_zeit ON beitraege (erstellt_am DESC, id DESC);Warum das id-Feld mit dabei ist
Wenn zwei Beiträge dieselbe Sekunde haben, ist die Reihenfolge ohne zweites Kriterium nicht festgelegt — und dann kann beim Blättern trotzdem ein Eintrag doppelt oder gar nicht erscheinen. Deshalb kommt immer ein eindeutiges Feld als letztes Sortierkriterium dazu. Meistens die ID.
Der Vergleich (a, b) < (x, y) ist dabei ein Zeilenwert-Vergleich: erst a gegen x, bei Gleichstand b gegen y. SQLite und PostgreSQL beherrschen ihn; wo er fehlt, schreibt man ihn aus:
WHERE erstellt_am < :letztes_datum
OR (erstellt_am = :letztes_datum AND id < :letzte_id)Was man dafür aufgibt
Ehrlich bleiben: Es gibt keine Seitenzahlen mehr. Man kann nicht direkt zu Seite 47 springen, weil niemand weiß, wo die anfängt. Man bekommt „weiter" und „zurück" — oder einen Knopf „mehr laden".
Für die meisten Listen ist das kein Verlust. Wer klickt schon auf Seite 47? Nutzer suchen oder filtern, statt zu blättern. Und da, wo Seitenzahlen wirklich gebraucht werden — im Admin-Bereich mit ein paar hundert Einträgen — ist OFFSET völlig in Ordnung, weil die Zahlen klein bleiben.
Meine Faustregel
- Öffentliche, endlos wachsende Listen (Feeds, Beiträge, Verlauf) → Keyset.
- Verwaltungsansichten mit überschaubarer Menge →
OFFSETist einfacher und genügt.
Ob dein Fall der eine oder andere ist, sagt dir EXPLAIN QUERY PLAN schneller als jede Faustregel: Steht dort ein vollständiger Tabellendurchlauf, weißt du Bescheid.
Du hast eine Liste, die mit wachsender Datenmenge immer träger wird? Das ist ein häufiger Grund. Melde dich.
Quellen
Du brauchst mehr als ein Snippet?
Ich entwickle Android-Apps in Kotlin und moderne Websites für Selbstständige und kleine Unternehmen — von der ersten Idee bis zum Release.
Projekt anfragen →Verwandte Snippets
UNION vs. UNION ALL – zwei Abfragen zusammenlegen, ohne Zeilen zu verlieren
UNION entfernt Duplikate. Das klingt hilfreich, kostet aber eine komplette Sortierung – und wirft dir stillschweigend echte Zeilen weg, die zufällig identisch aussehen. UNION ALL ist fast immer die richtige Wahl.
Volltextsuche in SQLite mit FTS5 – schneller und besser als LIKE '%wort%
LIKE '%suchwort%' kann keinen Index nutzen und liest bei jeder Suche die ganze Tabelle. FTS5 ist in SQLite eingebaut, findet auch Wortanfänge und sortiert die Treffer nach Relevanz – ohne Elasticsearch daneben.
WITH RECURSIVE – Kategoriebäume und Kommentar-Threads in einer einzigen Abfrage
Kategorien mit Unterkategorien, Kommentare mit Antworten, Ordner in Ordnern: Die meisten holen sich so etwas mit einer Schleife und einer Abfrage pro Ebene. Eine rekursive CTE holt den ganzen Baum in einem Rutsch – inklusive Tiefe und Pfad.