Zum Inhalt springen

Effiziente Paginierung für große Datensätze entwerfen

Offset-, Cursor- und Keyset-Paginierung im Vergleich: praktische Implementierungen, Leistungsmerkmale, Trade-offs und der passende Einsatzbereich.

4 Min. Lesezeit
Vergleichsdiagramm mit Offset-, Cursor- und Keyset-Paginierungsstrategien sowie Leistungsdiagrammen im großen Maßstab

Paginierung wirkt simpel, bis deine Tabelle 10 Millionen Zeilen hat. Der Ansatz, der auf Seite 1 noch gut funktioniert, wird auf Seite 50.000 schmerzhaft langsam. Die richtige Paginierungsstrategie hängt von deinen Datencharakteristiken, Zugriffsmustern und davon ab, ob deine Nutzer wahlfreien Seitenzugriff oder unendliches Scrollen brauchen.

Offset-Paginierung: Die vertraute Standardeinstellung

Offset-Paginierung ist am intuitivsten: Überspringe N Zeilen und gib den nächsten Block zurück. Sie lässt sich direkt auf SQLs OFFSET und LIMIT abbilden.

tstypescript
// ❌ Offset pagination — simple but performance degrades
interface OffsetPaginationParams {
  page: number;
  pageSize: number;
}
 
interface PaginatedResponse<T> {
  data: T[];
  page: number;
  pageSize: number;
  totalCount: number;
  totalPages: number;
}
 
async function getOrdersOffset(
  params: OffsetPaginationParams
): Promise<PaginatedResponse<Order>> {
  const offset = (params.page - 1) * params.pageSize;
 
  // This query gets slower as offset increases
  const [data, countResult] = await Promise.all([
    db.query(
      `SELECT * FROM orders
       ORDER BY created_at DESC
       LIMIT $1 OFFSET $2`,
      [params.pageSize, offset]
    ),
    db.query("SELECT COUNT(*) FROM orders"),
  ]);
 
  return {
    data: data.rows,
    page: params.page,
    pageSize: params.pageSize,
    totalCount: parseInt(countResult.rows[0].count),
    totalPages: Math.ceil(
      parseInt(countResult.rows[0].count) / params.pageSize
    ),
  };
}
 
// Page 1: OFFSET 0 → fast (scans 20 rows)
// Page 1000: OFFSET 20000 → slow (scans 20020 rows, discards 20000)
// Page 50000: OFFSET 1000000 → very slow (scans 1000020 rows)

Die Datenbank muss alle Offset-Zeilen lesen und verwerfen, bevor sie Ergebnisse zurückgibt. Bei hohen Offsets bedeutet das, Millionen von Zeilen zu lesen, um 20 zurückzugeben.

Cursor-Paginierung: Stabil und skalierbar

Cursor-Paginierung verwendet einen Zeiger (typischerweise eine kodierte Zeilenkennung), der markiert, wo die nächste Seite beginnt. Die Performance ist konstant, egal wie tief du in den Datensatz paginierst.

tstypescript
interface CursorPaginationParams {
  cursor?: string;
  limit: number;
  direction: "forward" | "backward";
}
 
interface CursorPaginatedResponse<T> {
  data: T[];
  nextCursor: string | null;
  previousCursor: string | null;
  hasMore: boolean;
}
 
function encodeCursor(id: string, createdAt: Date): string {
  const payload = JSON.stringify({ id, createdAt: createdAt.toISOString() });
  return Buffer.from(payload).toString("base64url");
}
 
function decodeCursor(cursor: string): { id: string; createdAt: Date } {
  const payload = JSON.parse(
    Buffer.from(cursor, "base64url").toString("utf-8")
  );
  return {
    id: payload.id,
    createdAt: new Date(payload.createdAt),
  };
}
 
async function getOrdersCursor(
  params: CursorPaginationParams
): Promise<CursorPaginatedResponse<Order>> {
  const limit = params.limit + 1; // Fetch one extra to detect hasMore
  let query: string;
  let values: unknown[];
 
  if (params.cursor) {
    const { id, createdAt } = decodeCursor(params.cursor);
 
    // Keyset condition — uses index, no scanning
    query = `
      SELECT * FROM orders
      WHERE (created_at, id) < ($2, $3)
      ORDER BY created_at DESC, id DESC
      LIMIT $1`;
    values = [limit, createdAt, id];
  } else {
    query = `
      SELECT * FROM orders
      ORDER BY created_at DESC, id DESC
      LIMIT $1`;
    values = [limit];
  }
 
  const result = await db.query(query, values);
  const hasMore = result.rows.length > params.limit;
  const data = hasMore
    ? result.rows.slice(0, params.limit)
    : result.rows;
 
  const lastItem = data[data.length - 1];
  const firstItem = data[0];
 
  return {
    data,
    nextCursor: hasMore && lastItem
      ? encodeCursor(lastItem.id, lastItem.created_at)
      : null,
    previousCursor: firstItem
      ? encodeCursor(firstItem.id, firstItem.created_at)
      : null,
    hasMore,
  };
}
tstypescript
// ✅ Consistent performance at any depth
// Page 1: WHERE (created_at, id) < (now, max_id) → index seek
// Page 1000: WHERE (created_at, id) < (some_date, some_id) → same index seek
// Page 50000: same performance — always reads exactly limit+1 rows

Die zusammengesetzte Bedingung WHERE (created_at, id) < ($2, $3) nutzt den Index, um direkt zur richtigen Position zu springen. Es werden keine Zeilen gescannt und verworfen.

Keyset-Paginierung mit zusammengesetzter Sortierung

Wenn du nach mehreren Spalten sortierst, muss der Cursor alle Sortierwerte kodieren, um die korrekte Reihenfolge beizubehalten.

tstypescript
interface SortableColumn {
  name: string;
  direction: "asc" | "desc";
}
 
function buildKeysetQuery(
  table: string,
  sort: SortableColumn[],
  cursor: Record<string, unknown> | null,
  limit: number
): { query: string; values: unknown[] } {
  const orderClause = sort
    .map(s => `${s.name} ${s.direction.toUpperCase()}`)
    .join(", ");
 
  if (!cursor) {
    return {
      query: `SELECT * FROM ${table} ORDER BY ${orderClause} LIMIT $1`,
      values: [limit + 1],
    };
  }
 
  // Build compound comparison for cursor position
  // (a, b, c) < ($1, $2, $3) for DESC ordering
  const columns = sort.map(s => s.name);
  const placeholders = sort.map((_, i) => `$${i + 2}`);
  const comparison = sort[0].direction === "desc" ? "<" : ">";
 
  const whereClause =
    `(${columns.join(", ")}) ${comparison} (${placeholders.join(", ")})`;
 
  const values = [
    limit + 1,
    ...sort.map(s => cursor[s.name]),
  ];
 
  return {
    query: `SELECT * FROM ${table}
            WHERE ${whereClause}
            ORDER BY ${orderClause}
            LIMIT $1`,
    values,
  };
}

Die richtige Strategie wählen

Jeder Paginierungsansatz hat klare Stärken und Einschränkungen.

tstypescript
interface PaginationStrategy {
  name: string;
  performance: string;
  randomAccess: boolean;
  stableResults: boolean;
  bestFor: string[];
  avoidFor: string[];
}
 
const strategies: PaginationStrategy[] = [
  {
    name: "Offset",
    performance: "Degrades linearly with page depth",
    randomAccess: true,
    stableResults: false,
    bestFor: [
      "Small datasets (< 100K rows)",
      "Admin interfaces with page numbers",
      "Rarely accessed deep pages",
    ],
    avoidFor: [
      "Large datasets with deep pagination",
      "High-concurrency write tables",
      "Real-time feeds with frequent inserts",
    ],
  },
  {
    name: "Cursor (keyset)",
    performance: "Constant regardless of depth",
    randomAccess: false,
    stableResults: true,
    bestFor: [
      "Infinite scroll / load more UIs",
      "Large datasets with sequential access",
      "Real-time feeds and timelines",
      "APIs consumed by mobile clients",
    ],
    avoidFor: [
      "UIs requiring 'jump to page N'",
      "Sorting by non-indexed columns",
    ],
  },
];

Index-Anforderungen der Datenbank

Die Performance der Paginierung hängt vollständig von korrekten Indizes ab.

sqlsql
-- For cursor pagination: compound index matching sort order
CREATE INDEX idx_orders_cursor
  ON orders (created_at DESC, id DESC);
 
-- For filtered cursor pagination
CREATE INDEX idx_orders_user_cursor
  ON orders (user_id, created_at DESC, id DESC);
 
-- Check if your index is being used
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE (created_at, id) < ('2023-06-01', 'abc-123')
ORDER BY created_at DESC, id DESC
LIMIT 21;
 
-- Should show: Index Scan using idx_orders_cursor
-- NOT: Seq Scan or Sort
tstypescript
// Validate pagination query plans
async function validatePaginationQuery(
  query: string,
  values: unknown[]
): Promise<{ usesIndex: boolean; estimatedCost: number }> {
  const plan = await db.query(
    `EXPLAIN (FORMAT JSON) ${query}`,
    values
  );
 
  const planNode = plan.rows[0]["QUERY PLAN"][0].Plan;
 
  const usesIndex =
    planNode["Node Type"] === "Index Scan" ||
    planNode["Node Type"] === "Index Only Scan";
 
  return {
    usesIndex,
    estimatedCost: planNode["Total Cost"],
  };
}

Wichtige Erkenntnisse

Offset-Paginierung reicht für kleine Datensätze und Admin-Oberflächen, in denen Nutzer Seitenzahlen brauchen, aber die Performance sinkt linear mit der Tiefe, weil die Datenbank alle übersprungenen Zeilen lesen und verwerfen muss. Cursor-Paginierung bleibt bei beliebiger Tiefe konstant, da sie indexierte Keyset-Bedingungen nutzt, um direkt zur richtigen Position zu springen – ideal für unendliches Scrollen, mobile APIs und große Datensätze. Kodiere Cursor-Werte opak, damit Clients keine Positionen fälschen oder von der internen Struktur abhängen können. Welche Strategie du auch wählst, verifiziere mit EXPLAIN ANALYZE, dass deine Abfragen Index Scans statt Sequential Scans verwenden, und lege zusammengesetzte Indizes an, die exakt deiner Sortierung entsprechen. Der Unterschied zwischen einer gut indexierten Cursor-Abfrage und einer nicht indexierten Offset-Abfrage auf Seite 50.000 ist der Unterschied zwischen Millisekunden und Minuten.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX