Result-Set-Design:
Hol nur was du brauchst — und schick nicht die ganze Datenbank durch die Leitung
Es gibt eine bestimmte Art von Performance-Problem, die sich erst im Netzwerk-Monitor zeigt: SQL Server verarbeitet die Abfrage in 12 ms — und dann schickt er 47 MB durch die Leitung zum Client. Alle 3 Sekunden. Von 200 gleichzeitigen Sessions. Das ist kein SQL Server Problem. Das ist ein Result-Set-Design-Problem.
Result-Set-Design bedeutet: Was gibst du zurück? Welche Spalten, wie viele Zeilen, in welchem Format, wann und wie viel davon? Diese Entscheidungen treffen Entwickler oft implizit — SELECT *, DataTable.Fill(), "wir zeigen dem Nutzer sowieso nur 20 Zeilen" — und zahlen den Preis später in Megabytes, Latenz und Memory Grants.
Bei Trendforge Digital haben wir genau dieses Muster in der Produktion gefunden: eine Abfrage auf die Bestelltabelle holt 50.000 Zeilen, übergibt sie an C#, das dann im Client filtert und aggregiert. SQL Server braucht 18 ms für die Abfrage. Das Netzwerk braucht 2,4 Sekunden für den Transfer. Kapitel 34 beschreibt den vollständigen Befund — hier schauen wir uns die Mechanismen dahinter an.
Projektion: Hol nur die Spalten, die du wirklich brauchst
SELECT * ist das erste und häufigste Anti-Pattern im Result-Set-Design — und gleichzeitig das, das am meisten normalisiert wurde. "Ist doch einfacher", "wir brauchen vielleicht mal alle Spalten", "das macht kein Problem". Macht es doch. Drei davon, die unabhängig voneinander schmerzen. Wir haben SELECT * bereits in Kapitel 23 als Abfrageoptimierungs-Anti-Pattern behandelt — hier betrachten wir die spezifischen Auswirkungen auf den Daten-Transfer und den Server-Speicher.
Problem 1: Netzwerk-Overhead
Bei einer Tabelle mit 47 Spalten holst du im Zweifelsfall 47 Spalten durchs Netzwerk, obwohl die UI nur 5 davon anzeigt. Das klingt harmlos — wird es aber nicht, wenn eine der Spalten NVARCHAR(MAX) für Beschreibungstexte ist, oder eine VARBINARY(MAX) für PDF-Anhänge. Bei Trendforge: eine Produkttabelle, 47 Spalten, davon zwei NVARCHAR(MAX)-Felder (Beschreibung + Freitext). Durchschnittliche Zeilengröße: 4,7 KB. Bei 500 angezeigten Produkten sind das 2,35 MB pro Request — obwohl die Artikelliste nur Name, Preis und Kategorie benötigt.
Problem 2: Buffer Pool Pollution
SQL Server liest Seiten in den Buffer Pool (Kapitel 11). Wenn deine Abfrage alle 47 Spalten abruft, werden alle Seiten dieser Tabelle in den Cache geladen — auch die mit den NVARCHAR(MAX)-Daten, die du gar nicht brauchst. Targeted Queries auf wenige Spalten können schmale Covering Indizes nutzen, die nur die benötigten Felder enthalten — und der Buffer Pool wird mit relevanten Daten gefüllt, nicht mit Ballast.
Problem 3: Memory Grant Explosion
Hier wird es richtig teuer. Der Query Optimizer schätzt den Memory Grant für Sort- und Hash-Operationen auf Basis der projizierten Spaltenbreite. Breite Zeilen bedeuten große Grants. Bei Trendforge: SELECT * auf der Bestelltabelle (23 Spalten, durchschnittlich 380 Bytes pro Zeile) führt bei einem Sort auf 200.000 Zeilen zu einem geschätzten Memory Grant von 2,3 GB — für eine einfache sortierte Liste. Mit einer Projektionsliste auf die tatsächlich benötigten 6 Spalten (42 Bytes pro Zeile) sinkt der Grant auf 260 MB. Faktor 8,8 — für denselben logischen Inhalt. Das ganze Thema Memory Grants und was passiert, wenn sie überlaufen steht in Kapitel 12.
|
Warnung: SELECT * in Stored Procedures |
|---|
|
SELECT * in Stored Procedures ist besonders tückisch: Wenn die Tabelle später eine neue Spalte bekommt, ändert sich das Result Set der Stored Procedure — ohne, dass der Code angefasst wurde. Clients die auf feste Spaltenindizes bauen (DataReader.GetValue(5)) brechen dann auf mysteriöse Weise. Das ist kein theoretisches Problem — das passiert bei Schemamigrationen regelmäßig. |
|
Regel: Explizite Spaltenliste in jeder SELECT-Anweisung. Immer. |
Paging: Nicht alle auf einmal, bitte
Paging ist das offensichtlichste Result-Set-Design-Muster: Zeige dem Nutzer 20 Zeilen statt 50.000. Klingt trivial. Ist es nicht — denn wie du das implementierst, entscheidet darüber ob deine Abfrage O(log n) oder O(n) ist.
OFFSET/FETCH NEXT: Standard, aber mit Tücken
Seit SQL Server 2012 ist OFFSET/FETCH NEXT die offizielle Paging-Syntax. Korrekt, standardkonform, weit verbreitet. Und bei großen Offsets ein stilles Performance-Problem.
-- Standard-Paging mit OFFSET/FETCH (SQL Server 2012+)
-- Seite 1: kein Problem — liest Zeilen 1-20
SELECT BestellNr, KundenID, Betrag, Datum
FROM dbo.Bestellung
ORDER BY Datum DESC
OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;
-- Seite 501: liest intern 10.020 Zeilen, wirft 10.000 davon weg
-- Das ist O(n) — bei 1 Million Zeilen und Seite 10.000 wird das schmerzhaft
SELECT BestellNr, KundenID, Betrag, Datum
FROM dbo.Bestellung
ORDER BY Datum DESC
OFFSET 10000 ROWS FETCH NEXT 20 ROWS ONLY;
Das versteckte Problem: SQL Server muss intern die ersten 10.020 Zeilen lesen und sortieren, um die Zeilen 10.001–10.020 zu liefern. Bei einem Index auf Datum läuft er den Index rückwärts — aber er muss trotzdem 10.000 Einträge überspringen. Bei großen Tabellen und tiefen Seiten (Nutzer klickt auf "Seite 500") kann das schnell 200–400 ms kosten, obwohl der Nutzer nur 20 Zeilen sieht.
Keyset Pagination: Der skalierbare Weg
Keyset Pagination — auch Seek Method genannt — löst das Problem durch einen anderen Ansatz: statt dem Offset übergibst du den letzten bekannten Schlüsselwert der vorherigen Seite. SQL Server kann dann direkt an der richtigen Stelle im Index einsteigen — kein Überspringen, keine versteckte O(n)-Arbeit. Das Ergebnis ist immer O(log n), egal auf welcher Seite du bist.
-- Keyset Pagination: immer O(log n) — egal ob Seite 1 oder Seite 10.000
-- Erste Seite: kein vorheriger Schlüssel bekannt
SELECT TOP (20) BestellNr, KundenID, Betrag, Datum
FROM dbo.Bestellung
ORDER BY Datum DESC, BestellNr DESC; -- BestellNr als Tiebreaker für Eindeutigkeit
-- Folgeseiten: letzten Datum- und BestellNr-Wert der Vorseite übergeben
-- @LetztesDatum und @LetzteBestellNr kommen vom Client (aus dem letzten Response)
SELECT TOP (20) BestellNr, KundenID, Betrag, Datum
FROM dbo.Bestellung
WHERE Datum < @LetztesDatum
OR (Datum = @LetztesDatum AND BestellNr < @LetzteBestellNr)
ORDER BY Datum DESC, BestellNr DESC;
-- SQL Server springt direkt per Index Seek auf die erste passende Zeile
-- Kein Scan, kein Überspringen von 10.000 Zeilen — direkt die Top 20
|
Tipp: Wann welches Paging? |
|---|
|
OFFSET/FETCH ist in Ordnung für: kleine Tabellen (< 50.000 Zeilen), erste 10–20 Seiten, Fälle wo wahlfreier Seitenzugriff ("springe zu Seite 348") erforderlich ist. |
|
Keyset Pagination ist Pflicht für: große Tabellen, tiefe Seiten, Infinite Scroll, APIs mit hohem Durchsatz. Der Nachteil: kein wahlfreier Seitenzugriff — du kannst nur vorwärts blättern (oder mit umgekehrtem Sortierkriterium rückwärts). |
|
Methode |
Komplexität |
Wahlfreier Zugriff |
Empfehlung |
|---|---|---|---|
|
OFFSET/FETCH |
O(n) bei tiefem Offset |
Ja — Seite beliebig |
Kleine Tabellen, frühe Seiten |
|
Keyset Pagination |
O(log n) immer |
Nein — nur sequenziell |
Große Tabellen, tiefe Seiten, APIs |
Paging-Methoden im Vergleich
Top-N Queries: Wenn der Optimizer mitdenkt
TOP ist mehr als Syntaxzucker für "gib mir die ersten N Zeilen". Der Query Optimizer behandelt TOP-Queries anders — er kann einen Row Goal setzen, der die gesamte Planstrategie verändert.
Row Goal: Der Optimizer denkt in Wahrscheinlichkeiten
Wenn SQL Server sieht, dass du TOP (20) abfragst, kalkuliert er: Wie viele Zeilen muss ich lesen, um wahrscheinlich 20 zu finden? Das Ergebnis ist ein Row Goal — ein interner Schätzwert, auf den der Optimizer optimiert. Mit Row Goal wählt er unter Umständen Nested Loop statt Hash Join, weil Nested Loop die ersten Treffer sehr schnell liefert (gut für TOP 20) — auch, wenn er bei der Gesamtmenge langsamer wäre.
Das kann schiefgehen: Wenn die Selektivität des WHERE-Prädikats schlecht geschätzt wird (veraltete Statistiken, Parameter Sniffing — Kapitel 16 und 18), wählt der Optimizer einen auf "schnelle erste 20 Zeilen" optimierten Plan, der dann aber Millionen Zeilen lesen muss, weil fast alle das WHERE-Kriterium erfüllen. Row Goal-Probleme sehen im Ausführungsplan oft aus wie dramatische Unterschätzungen: Estimated 1 Row, Actual 500.000 Rows.
-- TOP mit ORDER BY: SQL Server nutzt Early Termination, wenn Index die Sortierung liefert
SELECT TOP (10) KundenName, Umsatz
FROM dbo.Kunde
ORDER BY Umsatz DESC;
-- Mit Index auf Umsatz: Index Scan rückwärts, stoppt nach 10 Zeilen — sehr effizient
-- Ohne Index: Full Table Scan + Sort der gesamten Tabelle — teuer
-- TOP WITH TIES: gibt auch Zeilen mit dem gleichen Wert wie der letzte Platz zurück
-- Nützlich für Rankings wo Gleichstand möglich ist
SELECT TOP (3) WITH TIES KundenName, Umsatz
FROM dbo.Kunde
ORDER BY Umsatz DESC;
-- Gibt ggf. mehr als 3 Zeilen zurück, wenn Platz 3 mehrfach belegt ist
|
Hinweis: TOP ohne ORDER BY ist non-deterministic |
|---|
|
SELECT TOP (1) * FROM dbo.Tabelle ohne ORDER BY liefert irgendeine Zeile — welche, hängt vom Ausführungsplan ab, der sich ändern kann. Heute liefert es die neuste Zeile, morgen nach einem Index-Rebuild eine andere. Nur mit explizitem ORDER BY ist das Ergebnis reproduzierbar. |
Streaming vs. Buffering: Was der Client damit macht
SQL Server liefert das Result Set — was dann auf Clientseite passiert, entscheidet über den Memory-Bedarf der Applikation. Hier gibt es zwei grundlegende Strategien, und die falsche Wahl bei großen Result Sets endet mit OutOfMemoryExceptions in Produktion.
SqlDataReader: Streaming
Der SqlDataReader in .NET ist ein Forward-Only, Read-Only Cursor auf das Result Set. Er holt die Daten nicht auf einmal — er liest sie in Chunks vom Server, während du mit Read() durch die Zeilen iterierst. Bei 500.000 Zeilen hält der Applikationsserver zu keinem Zeitpunkt mehr als einen kleinen Puffer im RAM. Für große Result Sets die richtige Wahl.
DataTable und DataSet: Buffering
DataTable.Load() und SqlDataAdapter.Fill() machen das Gegenteil: Sie materialisieren das gesamte Result Set im Arbeitsspeicher, bevor die erste Zeile verarbeitet werden kann. Bei 500.000 Zeilen à 380 Bytes sind das 190 MB im Heap des Applikationsservers — pro Request. Skaliert nicht. Entity Framework macht standardmäßig dasselbe: die LINQ-Query wird vollständig materialisiert (ToList(), ToArray()), nicht gestreamt.
Mehr dazu in Kapitel 30 (ORM & Applikationsdesign): EF Core bietet AsAsyncEnumerable() für echtes Streaming — wird aber selten genutzt, weil der Default (ToListAsync()) für die meisten Entwickler "einfach funktioniert" — bis die Tabelle 2 Millionen Zeilen hat.
Aggregation gehört auf den Server — nicht in den Client
Das klassische Client-Side-Filtering Anti-Pattern sieht so aus: Die Applikation holt alle Bestellungen des letzten Quartals (50.000 Zeilen), iteriert in einer foreach-Schleife darüber und berechnet die Summe. SQL Server liefert das in 18 ms. Der Transfer der 50.000 Zeilen dauert 2,4 Sekunden. Die foreach-Schleife braucht nochmal 80 ms. Das SQL-Äquivalent mit SUM() braucht insgesamt unter 5 ms — inklusive Netzwerk, weil nur eine einzelne Zeile zurückkommt.
-- Anti-Pattern: Client-Side-Aggregation
-- Alle Zeilen übertragen, Aggregation in der Applikation
-- Ergebnis: 50.000 Zeilen, 47 MB Netzwerk-Traffic, foreach-Schleife in C#
-- SELECT BestellNr, Betrag FROM dbo.Bestellung WHERE Quartal = 1
-- Richtig: Aggregation auf dem Server
-- Eine Zeile zurück, < 5 ms end-to-end
SELECT
COUNT(*) AS AnzahlBestellungen,
SUM(Betrag) AS Gesamtumsatz,
AVG(Betrag) AS DurchschnittsBestellung,
MIN(Betrag) AS MinBestellung,
MAX(Betrag) AS MaxBestellung
FROM dbo.Bestellung
WHERE YEAR(Bestelldatum) = @Jahr
AND DATEPART(QUARTER, Bestelldatum) = @Quartal;
-- SQL Server aggregiert auf dem Index, liefert eine einzige Zeile zurück
-- Netzwerk-Traffic: ein paar Hundert Bytes statt 47 MB
Trendforge: Die Applikation holte täglich um 02:00 Uhr alle Bestellungen der letzten 30 Tage (ca. 150.000 Zeilen, 71 MB), um einen Report zu berechnen. Dieser Nightly Job lief 8 Minuten — davon 7 Minuten und 42 Sekunden Netzwerk-Transfer. Mit serverseitiger Aggregation und GROUP BY: 12 Sekunden für den gesamten Report-Aufbau.
SET NOCOUNT und Multiple Result Sets
Kleine Maßnahme, messbarer Effekt: SET NOCOUNT ON verhindert, dass SQL Server nach jedem INSERT, UPDATE, DELETE eine "X rows affected" Nachricht an den Client schickt. Das sind zusätzliche Netzwerk-Roundtrips — bei einer Stored Procedure die in einer Schleife 1.000 Updates macht, sind das 1.000 zusätzliche Nachrichten, die der Client verarbeiten muss.
-- Immer SET NOCOUNT ON am Anfang jeder Stored Procedure
-- Verhindert die "X rows affected" Nachrichten nach DML-Anweisungen
CREATE OR ALTER PROCEDURE dbo.BestellungVerarbeiten
@BestellNr INT
AS
SET NOCOUNT ON; -- Keine "1 row affected" Nachrichten durch die Leitung schicken
-- Für einzelne Rückgabewerte: OUTPUT-Parameter statt Result Set
-- Spart einen Roundtrip und macht die Intention klar
CREATE OR ALTER PROCEDURE dbo.NaechsteBestellNrHolen
@NeueNr INT OUTPUT
AS
SET NOCOUNT ON;
SELECT @NeueNr = NEXT VALUE FOR dbo.BestellNrSequenz;
-- Client liest @NeueNr aus dem OUTPUT-Parameter — kein Result Set nötig
|
Tipp: Multiple Result Sets: sinnvoll, aber mit Bedacht |
|---|
|
Eine Stored Procedure kann mehrere SELECT-Anweisungen enthalten und damit mehrere Result Sets zurückgeben — praktisch für Dashboard-Abfragen die Kopf- und Detaildaten in einem Roundtrip holen. Die Kehrseite: der Caller muss wissen in welcher Reihenfolge die Result Sets kommen. Das ist implizit und zerbrechlich — bei jeder Änderung der Prozedur muss auch der Caller angepasst werden. Für stark frequentierte APIs: lieber zwei Aufrufe mit klaren Verträgen als einen Aufruf mit versteckter Reihenfolge. |
FOR JSON und FOR XML: Serialisierung auf dem Server
SQL Server kann Result Sets direkt als JSON oder XML serialisieren — eine bequeme Möglichkeit, um Roundtrips zu reduzieren oder hierarchische Datenstrukturen in einer Abfrage aufzubauen.
-- FOR JSON PATH: explizite Kontrolle über die JSON-Struktur
-- Praktisch für REST-APIs die direkt JSON zurückgeben sollen
SELECT
k.KundenNr AS [kunde.nr],
k.Name AS [kunde.name],
b.BestellNr AS [bestellung.nr],
b.Betrag AS [bestellung.betrag]
FROM dbo.Kunde k
JOIN dbo.Bestellung b ON b.KundenID = k.KundenID
WHERE k.KundenNr = @KundenNr
FOR JSON PATH, ROOT('kunde');
-- Gibt einen einzelnen NVARCHAR(MAX)-String zurück
-- Direkt als HTTP-Response nutzbar — kein Serialisierungsschritt nötig
Der Vorteil: ein Roundtrip, kein Serialisierungsoverhead auf dem Applikationsserver. Der Nachteil: CPU-Last auf SQL Server — der Optimizer kann FOR JSON nicht parallelisieren, und bei komplexen Abfragen mit vielen Spalten wird die JSON-Serialisierung zum Bottleneck. Faustformel: Wenn die Applikation sowieso einen JSON-Serializer hat (was in .NET/Node.js/Python immer der Fall ist), ist FOR JSON nur dann sinnvoll, wenn die Reduktion von Roundtrips den CPU-Overhead aufwiegt — also bei vielen kleinen Abfragen, nicht bei wenigen großen.
|
Serialisierungsort |
CPU-Last |
Netzwerk |
Flexibilität |
Empfehlung |
|---|---|---|---|---|
|
SQL Server (FOR JSON) |
SQL Server |
Gering (1 String) |
Mittel |
Viele kleine Requests, simple Strukturen |
|
Applikationsserver |
App-Server |
Höher (Rohdaten) |
Hoch |
Komplexe Strukturen, hohe Query-Last |
FOR JSON auf Server vs. Serialisierung im Applikationsserver
Diagnose: Zu große Result Sets erkennen und beheben
|
Warnung: Symptome |
|---|
|
Netzwerk-Traffic zu SQL Server ist hoch, obwohl die Abfragen selbst schnell sind (< 50 ms). Im Netzwerk-Monitor: große Paketmengen von SQL Server zum Applikationsserver. |
|
Memory Grants für scheinbar einfache SELECT-Abfragen liegen im GB-Bereich — Hinweis auf breite Projektion und Sort-Operationen. |
|
Client-Applikation ist langsam, obwohl SQL Server-Queries im Profiler schnell aussehen. Der Engpass liegt im Transfer, nicht in der Verarbeitung. |
|
Trendforge-Symptom: 2,3 GB Memory Grant für eine einfache Bestellungsabfrage. Ursache: SELECT * auf eine 23-spaltige Tabelle mit NVARCHAR(MAX)-Feldern. |
|
Applikationsserver-Speicher wächst stetig bei Last — Zeichen für ungepufferte Materialisierung großer Result Sets (DataTable.Fill auf 100.000 Zeilen). |
|
Hinweis: So misst du das |
|---|
|
— Queries mit hohem IO pro Ausführung identifizieren |
|
— Hohe logische Reads oft Zeichen für zu breite Projektionen oder fehlende Filter |
|
SELECT TOP (20) |
|
SUBSTRING(qt.text, (qs.statement_start_offset/2)+1, |
|
((CASE qs.statement_end_offset |
|
WHEN -1 THEN DATALENGTH(qt.text) |
|
ELSE qs.statement_end_offset |
|
END – qs.statement_start_offset)/2)+1) AS query_text, |
|
qs.execution_count, |
|
qs.total_logical_reads / qs.execution_count AS avg_logical_reads, |
|
qs.total_elapsed_time / qs.execution_count / 1000 AS avg_ms, |
|
qs.total_rows / qs.execution_count AS avg_rows_returned |
|
FROM sys.dm_exec_query_stats qs |
|
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt |
|
ORDER BY avg_logical_reads DESC; |
|
— avg_rows_returned > 10.000 bei avg_ms < 100: Result Set zu groß, nicht die Query |
|
— Kandidaten für Paging, Projektion oder serverseitige Aggregation |
|
Hintergrund: Typische Fehlinterpretationen |
|---|
|
"Paging mit OFFSET ist Standard und performant." — Stimmt für die ersten Seiten. Bei OFFSET 50.000 liest SQL Server intern 50.020 Zeilen und wirft 50.000 davon weg. Das ist kein Bug, das ist by design — und der Grund warum Keyset Pagination für tiefe Seiten Pflicht ist. |
|
"Aggregation im Client ist flexibler." — Flexibler, ja. Aber sie kostet Netzwerk (alle Rohdaten übertragen) und Client-Memory (alle Zeilen im Heap). SUM(Betrag) auf dem Server kostet einen Index Scan und liefert eine Zeile. SUM in C# kostet denselben Index Scan plus 50.000 Zeilen durch die Leitung. |
|
"Result Sets sind nur ein Applikationsproblem." — Falsch. Breite Projektionen beeinflussen Memory Grants (Kapitel 12) und Buffer Pool Nutzung (Kapitel 11) direkt auf SQL Server. Das ist kein reines Netzwerk- oder Applikationsproblem. |
|
"FOR JSON ist immer besser als separate Serialisierung." — FOR JSON reduziert Roundtrips, verlagert aber CPU-Last auf SQL Server — den teuersten und am schwersten skalierbaren Teil des Stacks. Bei hoher Query-Last lieber auf dem Applikationsserver serialisieren. |
|
Tipp: Erste Gegenmaßnahmen |
|---|
|
1. SELECT * durch explizite Spaltenliste ersetzen — in allen Stored Procedures, Views und ORM-Queries. Bei EF Core: explizite .Select()-Projektion statt ToList() auf dem vollständigen Entity. |
|
2. OFFSET/FETCH durch Keyset Pagination ersetzen wo tiefe Seiten möglich sind (> Seite 50, Tabellen > 100.000 Zeilen). |
|
3. SET NOCOUNT ON in alle Stored Procedures einfügen — sofort, ohne Analyse, kein Risiko. |
|
4. Client-seitige Aggregation auf den Server verlagern: jede foreach-Schleife die SUM/COUNT/MIN/MAX berechnet, ist ein Kandidat für eine GROUP BY Abfrage. |
|
5. SqlDataReader statt DataTable.Fill() für große Result Sets — oder EF Core AsAsyncEnumerable() statt ToListAsync(). |
Zusammenfassung
Result-Set-Design ist die stille Performance-Dimension, die in vielen Projekten komplett ignoriert wird — bis der Netzwerk-Traffic explodiert oder der Applikationsserver mit OutOfMemoryException aufgibt. Die Kernregel ist simpel: Hol nur was du brauchst, aggregiere auf dem Server, und, wenn du pagst, dann mit einer Methode die skaliert.
SELECT * ist das Anti-Pattern mit den meisten Nebenwirkungen: Netzwerk-Overhead, Buffer Pool Pollution und überdimensionierte Memory Grants — drei verschiedene Kostenstellen für eine einzige schlechte Entscheidung. OFFSET/FETCH ist praktisch, hat aber bei tiefen Seiten eine versteckte O(n)-Komplexität — Keyset Pagination löst das eleganter. Aggregation im Client ist das teuerste Netzwerk-Muster überhaupt: alle Rohdaten transferieren, um dann eine einzige Zahl zu berechnen.
Die gute Nachricht: Diese Muster sind leicht zu finden (sys.dm_exec_query_stats, avg_rows_returned) und meistens schnell zu beheben. Kein Schema-Change, kein großes Refactoring — oft reicht das Hinzufügen einer Spaltenliste oder das Ersetzen eines OFFSET durch eine WHERE-Bedingung.
Im nächsten Kapitel (25) geht es um Batch-Verarbeitung vs. Chatty Apps — das andere Ende des Spektrums. Wenn Result-Set-Design darüber entscheidet was du pro Abfrage zurückholst, entscheidet Batch-Design darüber wie viele Abfragen du überhaupt machst. Spoiler: N+1-Queries auf einer Tabelle mit 500 Produkten sind das Result-Set-Design-Problem auf maximaler Lautstärke.

Abb. 1: Paging-Strategien im Vergleich
Kapitel 25
