A praktischer Leitfodn zur API-Paginierung
Gib olle Zeiln von ana wachsenden Tabelle in oaner einzigen Antwort zruck, und du host a Feature gbaut, des in da Entwicklung super funktioniert und in Produktion an dem Tag zamfoit, wo a Kunde 50.000 Datensätze hot. Paginierung is die Lösung, aber der naive Ansatz (LIMIT/OFFSET) hot an Korrektheitsfehler, der nur bei nebenläufigen Schreibzugriffen auftaucht - und genau des is die Bedingung, unter der deine API in Produktion steht und unter der deine Tests wahrscheinlich ned stengan.
Offset-Paginierung: einfach, und still und leise falsch
Der offensichtliche Ansatz: a Seitnnummer übergeben, an Offset ausrechnen und die Datenbank Zeiln überspringa lassn.
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40;
app.get('/posts', async (req, res) => {
const page = Number(req.query.page ?? 1);
const pageSize = 20;
const offset = (page - 1) * pageSize;
const posts = await db.query(
'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[pageSize, offset],
);
res.json(posts);
});
Des funktioniert, und es is die richtige Wahl für klane, meist statische Tabellen mit ana UI, de echte Seitnnummern braucht (1, 2, 3 …). Es hot zwoa echte Probleme, sobois die Tabelle groß is und aktiv beschrieben wird.
Problem 1: Performance wird mit dem Offset schlechter
OFFSET 100000 springt ned direkt zu Zeiln 100.000 - die Datenbank muass die ersten 100.000 passenden Zeiln trotzdem scannen und verwerfen, bevor's die nächste Seitn zruckgebn kann. Seitn 1 is schnell; Seitn 5.000 kann bei exakt der söbn Query-Form dramatisch langsamer sei.
Problem 2: Einträge verschiebn si bei nebenläufigen Schreibzugriffen
Des is der Korrektheitsfehler, ned nur a Performance-Problem. Sogn ma, a Client blättert durch Ergebnisse, sortiert nach created_at DESC, und zwischn der Anfrage für Seitn 1 und Seitn 2 wird a neichs Datensatz eingfügt. Alle Zeiln rutschen um oane Position nach unten - der letzte Eintrag von Seitn 1 wird auf Seitn 2 gschoben, und da Client sieht ihn zwoamoi. Löscht ma stattdessen a Zeiln, rutscht olles nach oben, und da Client überspringt still oane. Koans von beiden taucht als Fehler auf; da Client kriagt einfach subtil falsche Daten.
Cursor-Paginierung: behebt beides
Statt "überspring N Zeiln" sogt a Cursor "gib mir Zeiln nach dieser bestimmten". Der Cursor is typischerweise der Sortier-Key von da letztn Zeiln der vorherigen Seitn, ned a Zeilnanzoi.
SELECT * FROM posts
WHERE created_at < $1
ORDER BY created_at DESC
LIMIT 20;
app.get('/posts', async (req, res) => {
const pageSize = 20;
const cursor = req.query.cursor; // ISO timestamp of the last item seen, or undefined
const posts = await db.query(
cursor
? 'SELECT * FROM posts WHERE created_at < $1 ORDER BY created_at DESC LIMIT $2'
: 'SELECT * FROM posts ORDER BY created_at DESC LIMIT $1',
cursor ? [cursor, pageSize] : [pageSize],
);
const nextCursor = posts.length === pageSize ? posts[posts.length - 1].created_at : null;
res.json({ items: posts, nextCursor });
});
Weil die Query auf created_at < $1 filtert, statt a Zeilnanzoi zum überspringa, verschiebt a oben neu eingfügte Zeiln nix darunter - jeder folgende Cursor zeigt weiterhin auf dieselbe tatsächliche Zeiln. Und mit am Index auf created_at is die Query a schneller Range-Scan, egal wia tief du in den Ergebnissen bist - ka kumulierende Scan-Kosten wia bei OFFSET.
Der Cursor braucht an eindeutigen Tiebreaker
created_at alloa bricht, wenn zwoa Zeiln exakt den söbn Zeitstempel teiln - du kunntst oane überspringa oda duplizieren. Füg den Primary Key ois Tiebreaker dazu, und codier beides in den Cursor:
SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
function encodeCursor(row) {
return Buffer.from(`${row.created_at.toISOString()}|${row.id}`).toString('base64');
}
function decodeCursor(cursor) {
const [createdAt, id] = Buffer.from(cursor, 'base64').toString().split('|');
return { createdAt, id };
}
Den Cursor in Base64 zu codieren hot nix mit Sicherheit zum tun - es geht drum, dass der Wert aus Sicht vom Client a opaques Token is, wos dir erlaubt, später zu ändern, wos drin steht (a Feld dazu, Sortierspalte wechseln), ohne Clients zu brechen, de ihn einfach unverändert zruckgebn.
Wos Clients tatsächlich sehn
A Cursor-paginierte Antwort schaut typischerweise so aus - ka Gesamtzoi, ka Seitnnummer, nur "do is des nächste Token":
{
"items": [ { "id": 512, "title": "..." }, { "id": 511, "title": "..." } ],
"nextCursor": "MjAyNi0wNy0xN1QwOTozMDowMFp8NTEx"
}
Der Client speichert nextCursor und schickt ihn ois ?cursor=... für die nächste Seitn zruck. Für den Client is er opak - er soit ihn weder parsn noch von Hand baun, und dieser Vertrag erlaubt deim Backend, die Paginierungs-Interna später zu ändern, ohne die API zu brechen.
Wann ma weiterhin Offset-Paginierung nimmt
Cursor-Paginierung gibt die Möglichkeit auf, zu ana beliebigen Seitnnummer zu springen ("zeig mir Seitn 47"), weil's ka Möglichkeit gibt, an Cursor für a Seitn zu berechnen, die ma no ned gholt hot. Wenn deine UI wirklich nummerierte Seitn mit Direktsprüngen braucht - zum Beispiel a Admin-Tabelle mit Seitn-Auswahl -, is Offset-Paginierung weiterhin das richtige Werkzeug, vor allem bei Tabellen, de klan gnua sand, dass die Performance-Klippe praktisch nie zählt. Greif zu Cursor-Paginierung, wenn die Tabelle groß is, nebenläufig beschrieben wird und da Client nur "weiter" und "zruck" braucht, was die meisten Infinite-Scroll-Feeds und öffentlichen APIs beschreibt.
Fazit
Offset-Paginierung is einfach und in Ordnung für klane UIs mit nummerierten Seitn. Sobois a Tabelle groß is und nebenläufig beschrieben wird, wird OFFSET sowohl langsamer ois a still und leise falsch, weil si Seitn unter bewegten Daten verschiebn. Cursor-Paginierung - filtern auf "nach dieser Zeiln" statt "überspring N Zeiln", mit am eindeutigen Tiebreaker - behebt beides, zum Preis, dass beliebige Seitnsprünge wegfallen.