Keine Werbung mögen? Gehen Werbefrei Heute

Cursor-basiert gegen Offset-Verzeichnis – Warum wird bei page=2 automatisch Datensätze gelöscht

Aktualisiert am

Offset-Verzögerung hat einen reproduzierbaren Fehler: Wenn neue Zeilen zwischen zwei Seitenanfragen eingefügt werden, werden diese automatisch übersprungen. Hier ist das SQL, das diesen Fehler belegt, und wann Cursor-Verzögerung stattfinden sollte.

Cursor-basierte vs Offset-Verfolgung – Warum Seite=2 schweigend Einträge überspringt 1
ANZEIGE Entfernen?

Sie bauen eine Feed-Struktur. Seite 1 wird geladen, der Benutzer scrollt nach unten und Sie laden Seite 2. Doch einige Einträge, die beim Laden von Seite 1 existierten, erscheinen nicht auf Seite 2. Es gibt kein Fehler, kein Warnung – sie verschwinden einfach.

Dies ist das Problem der Offset-Verfolgung und es ist kein Race-Bedingung. Es ist deterministisch. Jedes System, das LIMIT x OFFSET y eine lebende, schreibintensive Tabelle verwendet, trifft es letztlich.

Das SQL, das es verursacht

Offset-Verfolgung übersetzt sich direkt auf:

-- Page 1: rows 1–10
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 0;

-- Page 2: rows 11–20
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;

Die Datenbank hat keine Erinnerung daran, was auf Seite 1 existierte, wenn Sie Seite 2 anfordern. Jedes Mal beginnt sie von Position 0 aus, abhängig von den vorhandenen Zeilen zum Zeitpunkt der Anfrage.

Die Szenario, das es bricht

Beginnen Sie mit 20 Beiträgen, IDs 1–20, neuere zuerst. Der Benutzer lädt Seite 1 – erhält IDs 20–11. Zwischen dieser Anfrage und der nächsten werden drei neue Beiträge eingefügt: IDs 21, 22, 23.

Jetzt scrollt der Benutzer und fordert Seite 2 ab:

SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
-- Returns: IDs 13–4, not 10–1

Beiträge 10, 9 und 8 – die der Benutzer noch nicht gesehen hat – wurden einfach über das Offset-Fenster hinausgeschoben. Drei neue Einfügungen verursachten genau drei verpasste Einträge. Die Mathematik ist immer genau.

Der Benutzer sieht nichts falsch. Ihre Protokolle zeigen nichts falsch. Die Einträge existieren in der Datenbank. Sie sind einfach für diese Sitzung unsichtbar.

Cursor-Verfolgung behebt das

Statt Positionen zu zählen, verankert die nächste Seite an dem letzten Eintrag, der tatsächlich zurückgegeben wurde. Die Abfrage wird zu „gebe mir die nächsten 10 Zeilen nach diesem spezifischen Eintrag“ statt „gebe mir Zeilen 11–20“.

-- Page 1: no cursor, start from top
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 10;
-- Last row returned: created_at = '2024-01-10 12:00:00', id = 11

-- Page 2: anchor to that exact row
SELECT * FROM posts
WHERE (created_at < '2024-01-10 12:00:00')
   OR (created_at = '2024-01-10 12:00:00' AND id < 11)
ORDER BY created_at DESC, id DESC
LIMIT 10;

Neue Einfügungen beeinflussen dies nicht. Sie landen an der Spitze der Sortierreihenfolge, vor Ihrer Cursor-Position. Alles ab dem Cursor bleibt stabil, unabhängig von gleichzeitigen Schreibvorgängen.

Was ein Cursor tatsächlich ist

Ein Cursor ist die kodierte Position des letzten zurückgegebenen Eintrags. In der Praxis ist es normalerweise eines:

  • Ein kodierter Zeitstempel + ID-Paar — die häufigste Methode. Serialisieren Sie {"created_at": "...", "id": 11} als Base64-String und geben Sie es als next_cursor.
  • einen UUID oder einen opaque Row-ID — einige Systeme verwenden die Primärschlüssel direkt, wenn sie zeitgeordnet sind (ULIDs, UUID v7). Ein UUID-Generator ist nützlich, wenn Sie zwischen verschiedenen UUID-Formaten für Ihr Schema entscheiden – v4 ist zufällig und sortiert nicht chronologisch, v7 ist zeitbasiert und sortiert.
  • Ein signierter Token — die Herangehensweise von Stripe, verhindert, dass Clients Cursor-Werte erzeugen, die zu beliebigen Positionen springen.

Wenn Offset-Verfolgung noch in Ordnung ist

Nicht jeder Anwendungsfall benötigt Cursor. Offset-Verfolgung ist in Ordnung, wenn:

  • Die Datensammlung statisch oder nur am Ende hinzugefügt wird. Archivseiten, Dokumentationsseiten, Exportwarten. Wenn nichts am Anfang der Sortierreihenfolge eingefügt wird, bleibt die Offset-Verfolgung stabil.
  • Admin-Dashboard mit expliziten Seitennummern. Benutzer erwarten, auf „Seite 5“ zu springen und diese spezifische Schnittstelle zu sehen. Cursor können dies nicht tun – es gibt keine Konzept von „Seite N“, wenn Ihr Anker ein Eintrag ist, nicht eine Anzahl.
  • Kleine Datensätze unter Ihrer Kontrolle. Paginieren Sie 200 Einträge in einem internen Tool, bei dem neue Daten täglich in Batch-Jobs eingefügt werden? Die Komplexität von Cursor ist nicht wert.
  • Einfügungen erfolgen nur am Ende der Sortierreihenfolge. Wenn Ihre Paginierung aufsteigend nach ID erfolgt und neue Einträge immer höhere IDs erhalten, sind frühere Seiten nicht von neuen Einfügungen betroffen.

Die entscheidende Frage: können neue Zeilen zwischen Anfragen an die aktuelle Cursor-Position eingefügt werden? bevor Wenn ja, wird Offset-Verfolgung schweigend Einträge überspringen. Wenn nein, ist es in Ordnung.

Vergleich

Offset-VerfolgungCursor-Verfolgung
SQLLIMIT x OFFSET yWHERE (created_at, id) < (cursor) LIMIT x
Konsistent bei gleichzeitigen EinfügungenNein – schweigend Einträge überspringtJa – die Position ist stabil
Springen zu einer beliebigen SeiteJa (OFFSET = page * size)Nein – nur vorwärts durchlaufen
Gesamte SeitenanzahlEinfach (COUNT(*) / size)Nicht möglich ohne vollständige Scan
Leistung bei großen OffsetsVerliert – OFFSET 100000 scans 100k Zeilen, um sie zu verwerfenStabil – verwendet immer einen Indexbereichs-Scan
Client-KomplexitätNiedrig – nur eine SeitennummerHöher – muss Cursor-Token speichern und weitergeben

Wie GitHub und Stripe es behandeln

Beide GitHub und Stripe verwenden Cursor-basierte Verfolgung in ihren APIs – und dafür gute Gründe, die spezifisch auf jedes System zutreffen.

GitHub

GitHubs REST-API verwendet immer noch page und per_page auf den meisten Endpunkten, aber die GraphQL-API verwendet eine richtige Cursor-Verfolgung mit after und before:

query {
  repository(owner: "vercel", name: "next.js") {
    issues(first: 10, after: "Y3Vyc29yOnYyOpHOAABGPQ==") {
      pageInfo {
        endCursor
        hasNextPage
      }
      nodes {
        title
        number
      }
    }
  }
}

Dieser Cursor (Y3Vyc29yOnYyOpHOAABGPQ==) ist base64-kodiert – decodieren Sie ihn, erhalten Sie eine interne Zeilenreferenz, nicht eine Seitennummer. Auf einem Repository wie next.js können zwischen Ihrer Seite 1 und Seite 2 Anfragen Dutzende von Issues generieren. Mit Offset-Verfolgung würden Sie einige dieser Issues schweigend überspringen.

Stripe

Stripe's List-APIs verwenden starting_after und ending_before — Sie geben die ID des letzten Objekts, das Sie erhalten haben:

# First page
curl https://api.stripe.com/v1/charges   -u sk_test_xxx:   -d limit=10

# Next page — pass the last charge ID from the previous response
curl https://api.stripe.com/v1/charges   -u sk_test_xxx:   -d limit=10   -d starting_after=ch_1ABC123def456

Stripes Objekte haben zeitgeordnete IDs (ch_, cus_, pi_), daher funktioniert die ID selbst als Cursor. Ihre API hat nie eine basierende Offset-Verfolgung angeboten, weil Billing-Daten genau dort, wo schweigende Übersprünge zu einem ernsthaften Problem werden – ein verpasster Zahlungsverlauf ist nicht nur ein UX-Problem.

Implementierung von Cursor: ein minimaler Muster

Hier ist ein minimaler Node.js/PostgreSQL-Implementierung mit einem komplexen Zeitstempel + ID-Cursor:

function encodeCursor(row) {
  return Buffer.from(JSON.stringify({
    created_at: row.created_at,
    id: row.id
  })).toString('base64url');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
}

async function fetchPage(db, cursor = null, limit = 10) {
  let rows;

  if (!cursor) {
    rows = await db.query(
      'SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT $1',
      [limit]
    );
  } else {
    const { created_at, id } = decodeCursor(cursor);
    // PostgreSQL supports tuple comparison natively.
    // MySQL requires: WHERE created_at < $1 OR (created_at = $1 AND id < $2)
    rows = await db.query(
      `SELECT * FROM posts
       WHERE (created_at, id) < ($1, $2)
       ORDER BY created_at DESC, id DESC
       LIMIT $3`,
      [created_at, id, limit]
    );
  }

  const nextCursor = rows.length === limit
    ? encodeCursor(rows[rows.length - 1])
    : null;

  return { rows, nextCursor };
}

Eines, worauf Sie achten sollten: Wenn Sie UUIDs als Primärschlüssel anstelle von automatisch inkrementierten ganzen Zahlen verwenden, funktioniert das kompounde Sortieren nur dann, wenn Ihre UUIDs zeitgeordnet sind. UUID v4 ist zufällig – die Sortierung nach (created_at, uuid_v4) funktioniert, aber Sie verlieren die Sicherheit, dass eine sequentielle ID eine Beziehung bietet. UUID v7 oder ULIDs sind zeitgeordnet und vermeiden dieses Problem vollständig.

Die kurze Version

Offset-Verfolgung ist einfach zu implementieren und funktioniert gut, bis Ihre Daten live und schreibintensiv sind. Sobald Benutzer Einfügungen zwischen Paginierungsanfragen auslösen – Beiträge, Zahlungen, Probleme, alles – überspringt Offset schweigend Einträge. Die Anzahl der übersprungen Einträge ist genau gleich der Anzahl der neuen Einfügungen am Anfang Ihrer Sortierreihenfolge. Kein Fehler, keine Warnung.

Cursor-Verfolgung wechselt „Springen zu Seite N“ gegen Positionsstabilität. Für Benutzersichtbarkeiten und jede API, die von anderen aufgebaut wird, ist das die richtige Entscheidung. OFFSET ist für Admin-Tools und statische Exporte in Ordnung – fügen Sie keine Cursor-Komplexität hinzu, wo das Problem nicht auftreten kann.

Möchten Sie werbefrei genießen? Werde noch heute werbefrei

Erweiterungen installieren

IO-Tools zu Ihrem Lieblingsbrowser hinzufügen für sofortigen Zugriff und schnellere Suche

Zu Chrome-Erweiterung Zu Kantenerweiterung Zu Firefox-Erweiterung Zu Opera-Erweiterung

Die Anzeigetafel ist eingetroffen!

Anzeigetafel ist eine unterhaltsame Möglichkeit, Ihre Spiele zu verfolgen. Alle Daten werden in Ihrem Browser gespeichert. Weitere Funktionen folgen in Kürze!

ANZEIGE Entfernen?
ANZEIGE Entfernen?
ANZEIGE Entfernen?

Nachrichtenecke mit technischen Highlights

Beteiligen Sie sich

Helfen Sie uns, weiterhin wertvolle kostenlose Tools bereitzustellen

Kauf mir einen Kaffee
ANZEIGE Entfernen?