SQL Server: schnelle Pagination auch auf späten Seiten
Zuverlässige SQL Server-Pagination mit eindeutiger Sortierung, passendem Index und klaren Regeln für Cursor und gleichzeitige Datenänderungen.
Ein Kunde öffnet seine Bestellhistorie. Die erste Seite erscheint sofort, Seite 8.000 braucht mehrere Sekunden. Die Anwendung liefert weiterhin nur 25 Zeilen. Mehr Anwendungsserver lösen das eigentliche Problem häufig nicht: OFFSET muss die vorherigen Einträge überspringen, bevor SQL Server den gewünschten Ausschnitt zurückgibt.
Ein geordneter Index kann eine vollständige Sortierung vermeiden. Die übersprungenen Einträge verursachen trotzdem Arbeit. Vergleichen Sie deshalb die logischen Lesezugriffe einer frühen und einer späten Seite bei identischen Filtern. Wachsen sie mit dem Offset, sollte der Seitenvertrag geändert werden. Ist bereits Seite eins langsam, liegt möglicherweise ein anderes Problem vor, etwa eine Sortierung ohne geeigneten Index.
Eine eindeutige Grenze festlegen
Bei der Keyset-Pagination enthält der nächste Aufruf die letzten Sortierwerte der vorherigen Antwort. Eine absteigend sortierte Bestellhistorie sucht ältere Zeitstempel sowie kleinere IDs bei gleichem Zeitstempel. Diese zweite Bedingung verhindert Datenverluste. Zeitstempel sind gewöhnlich nicht eindeutig; wer alle gleichen Zeitstempel ausschließt, überspringt möglicherweise Bestellungen.
Das Beispiel verwendet ausschließlich eine temporäre Tabelle. Die erste Abfrage liefert 105 und 104, die zweite 103 und 102. Damit lässt sich die Grenzbedingung prüfen, bevor dieselbe Logik auf Millionen Datensätze angewendet wird.
CREATE TABLE #Orders
(
OrderId bigint NOT NULL PRIMARY KEY,
CreatedAt datetime2(3) NOT NULL,
Amount decimal(12,2) NOT NULL
);
INSERT #Orders VALUES
(105,'2022-01-10T09:00:00',25),
(104,'2022-01-10T09:00:00',40),
(103,'2022-01-09T12:00:00',15),
(102,'2022-01-08T08:00:00',80),
(101,'2022-01-07T08:00:00',30);
CREATE INDEX IX_Orders_Page
ON #Orders(CreatedAt DESC, OrderId DESC)
INCLUDE(Amount);
SELECT TOP (2) OrderId, CreatedAt, Amount
FROM #Orders
ORDER BY CreatedAt DESC, OrderId DESC;
DECLARE @LastTime datetime2(3) = '2022-01-10T09:00:00';
DECLARE @LastId bigint = 104;
SELECT TOP (2) OrderId, CreatedAt, Amount
FROM #Orders
WHERE CreatedAt < @LastTime
OR (CreatedAt = @LastTime AND OrderId < @LastId)
ORDER BY CreatedAt DESC, OrderId DESC;
DROP TABLE #Orders;
Der Index enthält Sortierspalten als Schlüssel und Amount als eingeschlossene Ausgabespalte. In einer mandantenfähigen Anwendung bietet sich häufig TenantId, CreatedAt, OrderId als Schlüsselfolge an, sofern TenantId auf genau einen Wert eingeschränkt wird. Eine globale Übersicht aller Mandanten benötigt ein anderes Zugriffsmuster. Ein Index passt nicht automatisch zu beiden Anforderungen.
Den Cursor als Bestandteil der Schnittstelle behandeln
Übertragen Sie Zeitstempel und ID mit unveränderter Genauigkeit. Eine Rundung von datetime2 durch ein Datumsobjekt im Client verschiebt die Grenze. Binden Sie Mandant, Filter, Sortierrichtung und Formatversion an den Cursor. Ein Cursor für unbezahlte Bestellungen darf nicht unbemerkt auf alle Bestellungen angewendet werden. Eine Signatur schützt gegen Änderungen; sie ersetzt keine Berechtigungsprüfung.
Für die erste Seite eignet sich eine eigene Abfrage ohne Grenzparameter. Eine gemeinsame Bedingung nach dem Muster „Cursor ist NULL oder ...“ kann einen ungünstigen wiederverwendbaren Plan erzeugen. Vergleichen Sie tatsächliche Ausführungspläne. Auch das OR der lexikografischen Bedingung kann teilweise als Restfilter erscheinen. Entscheidend sind gelesene Zeilen und logische Zugriffe, nicht allein das Symbol eines Index Seek.
Lesen Sie eine zusätzliche Zeile, um weitere Ergebnisse zu erkennen, und geben Sie nur die vereinbarte Anzahl zurück. Der nächste Cursor muss aus der letzten tatsächlich zurückgegebenen Zeile entstehen. Wird dafür die Zusatzzeile verwendet, überspringt eine strikte Kleiner-als-Bedingung genau diesen Datensatz. Ein vollständiges COUNT bei jedem Aufruf kann teurer sein als die Seite selbst; eine Historienansicht benötigt diese Zahl häufig nicht.
Änderungen zwischen zwei Aufrufen berücksichtigen
Neue Bestellungen oberhalb der Grenze verschieben die nächste Seite normalerweise nicht. Änderungen an bestehenden Sortierwerten können Zeilen dennoch über die Grenze bewegen und doppelte oder fehlende Ergebnisse verursachen. Verwenden Sie möglichst unveränderliche Sortierspalten. Für einen konsistenten Auditexport reichen voneinander unabhängige Lesezugriffe nicht aus. Dafür kommen eine zeitlich begrenzte Snapshot-Transaktion, eine materialisierte Ergebnismenge oder eine feste Exportgrenze infrage.
Testen Sie gleiche Zeitstempel, gelöschte Grenzzeilen, eine leere letzte Seite und Mandanten mit stark unterschiedlichem Datenvolumen. Eine gelöschte Grenzzeile ist unproblematisch, wenn der Cursor ihre ehemaligen Werte enthält. Die Abfrage muss diese Zeile nicht erneut finden. Für die Rückwärtsnavigation werden Vergleich und Sortierung umgedreht; anschließend wird die begrenzte Ergebnismenge wieder in Anzeigeordnung gebracht.
Die Methode eignet sich besonders für fortlaufendes Blättern. Beliebige Sprünge zu Seitennummern können weiterhin OFFSET oder vorberechnete Marken erfordern. Entscheiden Sie anhand der tatsächlichen Bedienung und prüfen Sie die Kosten später Seiten mit repräsentativen Daten. Vollständigkeit, Reihenfolge und stabile Lesezugriffe bilden zusammen das Qualitätskriterium.
Technische Referenzen: Microsoft Learn: ORDER BY · Microsoft Learn: Pagination.