Abfrageoptimierung:
Mengenbasiertes Denken — und warum SQL keine Schleifensprache ist
SQL ist eine deklarative Sprache. Du beschreibst, was du willst — nicht wie SQL Server es holen soll. Der Query Optimizer entscheidet, welche Indizes er nutzt, in welcher Reihenfolge er Tabellen verbindet, ob er parallelisiert. Das ist kein Fehler im System. Das ist das ganze Konzept.
Das Problem entsteht, wenn Entwickler mit prozeduralem Denken an SQL herangehen. Wenn der erste Reflex bei "ich muss Zeile für Zeile verarbeiten" der Griff zum CURSOR ist. Wenn NOT IN wie die intuitive Lösung für "alle, die nicht in dieser anderen Tabelle stehen" klingt. Wenn DISTINCT das Symptom einer kaputten Abfrage überdeckt statt es zu beheben. Wenn GROUP BY mit HAVING gefiltert wird, obwohl WHERE die Arbeit billiger erledigen könnte.
Dieses Kapitel geht durch die häufigsten Anti-Patterns — mit konkreten Beispielen, messbaren Unterschieden und Alternativen, die der Optimizer lieben wird. Bei Trendforge haben wir ein Dutzend dieser Muster in produktiven Abfragen gefunden (Kapitel 34 zeigt die schmerzhaften Details). Bei Sparfuchs war fn_GetSteuersatz das Paradebeispiel: eine skalare Funktion, 360.000 Mal pro Query aufgerufen, weil niemand an Mengen gedacht hat. Du musst nicht warten, bis es in Produktion brennt.
Querverweise: Die Grundlagen zu Ausführungsplänen kommen aus Kapitel 15, Index-Strategie aus Kapitel 17, SARGability-Killer aus Kapitel 21, Indexe aus Entwicklersicht aus Kapitel 22. Was du mit dem Ergebnis deiner optimierten Abfragen machst — Kapitel 24. Und Batching statt chatty round-trips: Kapitel 25.
Mengenbasiertes Denken — das Fundament
SQL Server ist für Mengenoperationen gebaut. Ein einzelnes UPDATE kann eine Million Zeilen in einem einzigen, parallelisierbaren Vorgang aktualisieren. Ein Cursor macht aus dieser einen Operation eine Million einzelne Operationen — jede mit eigenem Kontextwechsel, eigenen Sperren, eigenem Plan-Lookup.
Wenn du dich beim Schreiben einer WHILE-Schleife in SQL ertappst, frag dich: Kann das ein JOIN? Kann das ein UPDATE mit CASE? Fast immer: ja.
-- Cursor-Version: Aufträge nach Kundenkategorie rabattieren
-- Laufzeit bei 100.000 Zeilen: ~45 Sekunden
DECLARE @AuftragsID INT;
DECLARE @KundenKat VARCHAR(20);
DECLARE @Rabatt DECIMAL(5,2);
DECLARE cur CURSOR FOR
SELECT AuftragsID, KundenKategorie
FROM dbo.Auftrag
WHERE Status = 'NEU';
OPEN cur;
FETCH NEXT FROM cur INTO @AuftragsID, @KundenKat;
WHILE @@FETCH_STATUS = 0
BEGIN
-- Pro Zeile: eigener Kontextwechsel, eigene Sperre, eigener Plan-Lookup
SET @Rabatt = CASE @KundenKat WHEN 'PREMIUM' THEN 0.15 ELSE 0.05 END;
UPDATE dbo.Auftrag
SET Rabatt = @Rabatt
WHERE AuftragsID = @AuftragsID;
FETCH NEXT FROM cur INTO @AuftragsID, @KundenKat;
END
CLOSE cur; DEALLOCATE cur;
-- Set-basierte Version: identisches Ergebnis, ~0,4 Sekunden (Faktor 112x)
-- SQL Server kann Parallelismus nutzen, einen optimierten Plan wählen,
-- und alle Zeilen in einer einzigen Lock-Operation sperren.
UPDATE a
SET Rabatt = CASE a.KundenKategorie
WHEN 'PREMIUM' THEN 0.15
ELSE 0.05
END
FROM dbo.Auftrag AS a
WHERE a.Status = 'NEU';
|
Warnung: Cursor sind manchmal unvermeidbar |
|---|
|
Cursor haben ihren Platz: Wenn du wirklich zeilenweise unterschiedliche Aktionen benötigst, die sich nicht mengenbasiert ausdrücken lassen — z.B. dynamisch generierte DDL-Statements, die für jede Datenbank anders sind. FAST_FORWARD CURSOR ist dabei die performanteste Option. Aber: Prüfe immer zuerst, ob sich das mengenbasiert lösen lässt. In 90 % der Fälle lässt es sich. |
|
Praxisbeispiel: Sparfuchs — fn_GetSteuersatz als Cursor-Äquivalent |
|---|
|
fn_GetSteuersatz war eine skalare T-SQL-Funktion, die je nach Datum und Warengruppe den aktuell gültigen Steuersatz aus einer Lookup-Tabelle las. Sie wurde in der SELECT-Liste einer Hauptabfrage aufgerufen — einmal pro Rechnungszeile. Bei einer Abfrage über den Monatsabschluss: 360.000 Aufrufe. Jeder Aufruf: ein separater Index-Seek, ein separater Funktionsaufruf-Overhead, kein Parallelismus möglich (skalare T-SQL-UDFs sperren Parallelismus aus). Laufzeit: 8 Minuten. Nach dem Umbau auf einen JOIN gegen die Lookup-Tabelle: 4 Sekunden. Kapitel 26 beschreibt das vollständig. |
SELECT * — das universelle Anti-Pattern
SELECT * ist das Schweizer Messer des eiligen Entwicklers: Es tut immer irgendwie, aber es tut zu viel. SQL Server muss alle Spalten lesen und übertragen — auch die zehn VARCHAR(MAX)-Spalten, die dein Code nie auswertet. Das tötet Covering Indexes (Kapitel 22): Ein Index der alle benötigten Spalten enthält kann die Basistabelle vollständig umgehen — aber nur, wenn du auch nur diese Spalten abfragst.
Bei JOINs wird SELECT * besonders tricky: Wenn zwei Tabellen beide eine Spalte namens "ID" haben, erhältst du sie doppelt. Wenn jemand zur Tabelle eine neue Spalte hinzufügt, fließt sie plötzlich durch alle Queries die darauf zugreifen — oft unbemerkt, manchmal mit gravierenden Folgen für Netzwerktraffic oder Applikationslogik.
Bei Trendforge hatten 23 von 31 Stored Procedures ein SELECT * irgendwo. Jede dieser Queries las schätzungsweise drei- bis achtmal mehr Daten als benötigt. Das ist nicht ineffizient — das ist Ressourcenverschwendung als Architekturentscheidung.
-- Falsch: Alle Spalten lesen, davon werden vielleicht 3 gebraucht
SELECT * FROM dbo.Kunde WHERE PLZ = '10115';
-- Richtig: Explizit nur die wirklich benötigten Spalten angeben
SELECT KundenID, Firma, Ansprechpartner
FROM dbo.Kunde
WHERE PLZ = '10115';
-- Bonus: Ein Covering Index auf (PLZ) INCLUDE (KundenID, Firma, Ansprechpartner)
-- macht diese Abfrage zu einem reinen Index Seek — keine Basistabelle nötig.
Subqueries, JOINs und CTEs — wann was?
Correlated Subqueries sind der versteckte Cursor-Modus. Eine korrelierte Unterabfrage in der SELECT-Liste wird für jede Zeile der äußeren Abfrage einmal ausgeführt — das ist O(n×m), also quadratisches Wachstum. Nicht-korrelierte Unterabfragen (kein Bezug auf die äußere Abfrage) werden einmal ausgeführt und sind damit unkritisch.
-- Korrelierte Subquery: Für JEDEN Kunden ein separater SELECT → langsam
SELECT
k.KundenID,
k.Firma,
-- Diese Unterabfrage wird einmal pro Kundenzeile ausgeführt:
(SELECT MAX(BestellDatum)
FROM dbo.Auftrag
WHERE KundenID = k.KundenID) AS LetzteBestellung
FROM dbo.Kunde AS k;
-- Effizienter Ansatz 1: JOIN auf aggregiertes Zwischenergebnis
SELECT
k.KundenID,
k.Firma,
a.LetzteBestellung
FROM dbo.Kunde AS k
LEFT JOIN (
SELECT KundenID, MAX(BestellDatum) AS LetzteBestellung
FROM dbo.Auftrag
GROUP BY KundenID
) AS a ON a.KundenID = k.KundenID;
-- Effizienter Ansatz 2: Window Function (oft noch besser lesbar)
SELECT
k.KundenID,
k.Firma,
MAX(a.BestellDatum) OVER (PARTITION BY a.KundenID) AS LetzteBestellung
FROM dbo.Kunde AS k
LEFT JOIN dbo.Auftrag AS a ON a.KundenID = k.KundenID;
CTEs (WITH …) sind kein Performance-Feature — sie sind ein Lesbarkeits-Feature. Eine CTE wird nicht materialisiert. Sie wird für jeden Verweis in der nachfolgenden Abfrage neu ausgeführt. Wenn du eine CTE zweimal referenzierst, läuft sie zweimal. Das ist bei billigen CTEs irrelevant, bei teuren ein echter Kostenfaktor.
-- CTE wird zweimal ausgewertet — Optimizer materialisiert sie nicht
;WITH KundenUmsatz AS (
SELECT KundenID, SUM(Betrag) AS Gesamt
FROM dbo.Auftrag
GROUP BY KundenID -- teure Aggregation
)
SELECT a.KundenID, a.Gesamt AS EigenerUmsatz,
b.Gesamt AS VorherigerUmsatz
FROM KundenUmsatz AS a
JOIN KundenUmsatz AS b ON b.KundenID = a.KundenID - 1;
-- KundenUmsatz wird ZWEIMAL vollständig berechnet!
-- Besser: Temp Table, wenn Ergebnis mehrfach gebraucht wird
SELECT KundenID, SUM(Betrag) AS Gesamt
INTO #KundenUmsatz
FROM dbo.Auftrag
GROUP BY KundenID;
SELECT a.KundenID, a.Gesamt, b.Gesamt
FROM #KundenUmsatz AS a
JOIN #KundenUmsatz AS b ON b.KundenID = a.KundenID - 1;
-- Einmal berechnet, beliebig oft genutzt.
EXISTS, IN und die NULL-Falle
NOT IN mit NULL-Werten ist einer der gemeinsten Bugs in SQL: Die Abfrage gibt keine Fehler zurück, sie gibt einfach keine Ergebnisse zurück. Und niemand merkt es sofort.
Die Logik dahinter: "ID NOT IN (1, 2, NULL)" übersetzt SQL Server zu "ID != 1 AND ID != 2 AND ID != NULL". Da kein Wert jemals gleich NULL ist (NULL = NULL ergibt UNKNOWN, nicht TRUE), ist die letzte Bedingung immer UNKNOWN — und damit liefert die gesamte WHERE-Klausel kein einziges Ergebnis.
-- NOT IN: Tückisch, wenn die Unterabfrage NULL-Werte enthält
-- Wenn auch nur eine Zeile in dbo.Auftrag.KundenID NULL ist,
-- gibt diese Abfrage KEINE Ergebnisse zurück — kein Fehler, nur Stille.
SELECT KundenID, Firma
FROM dbo.Kunde
WHERE KundenID NOT IN (
SELECT KundenID FROM dbo.Auftrag -- nullable Spalte!
);
-- Sicher und oft schneller: NOT EXISTS ist NULL-sicher
-- SQL Server stoppt bei der ersten gefundenen Übereinstimmung.
SELECT k.KundenID, k.Firma
FROM dbo.Kunde AS k
WHERE NOT EXISTS (
SELECT 1
FROM dbo.Auftrag AS a
WHERE a.KundenID = k.KundenID
);
-- Alternative: LEFT JOIN ... IS NULL (gleiche Semantik, manchmal besserer Plan)
SELECT k.KundenID, k.Firma
FROM dbo.Kunde AS k
LEFT JOIN dbo.Auftrag AS a ON a.KundenID = k.KundenID
WHERE a.KundenID IS NULL;
-- Hinweis: EXISTS vs. IN bei nicht-nullable Spalten —
-- der moderne Optimizer (ab SQL 2008+) behandelt beide oft identisch.
-- Bei nullable Spalten: EXISTS ist immer die sichere Wahl.
|
Warnung: NOT IN mit NULL — der lautlose Datenverlust |
|---|
|
Das Tückische: Die Abfrage gibt keinen Fehler. Sie gibt einfach 0 Zeilen zurück. Wenn niemand die Ergebnismenge auf Plausibilität prüft, fliegt das wochenlang unbemerkt. Prüfregeln: Wenn die Spalte in der Unterabfrage nullable ist, ist NOT IN grundsätzlich verboten. NOT EXISTS ist die korrekte, NULL-sichere Alternative — und oft auch performanter, weil der Optimizer bei der ersten Übereinstimmung abbrechen kann. |
Aggregationen effizient schreiben: HAVING vs. WHERE
GROUP BY auf alle Zeilen anzuwenden und dann mit HAVING zu filtern ist teurer als vor der Aggregation mit WHERE zu filtern. WHERE eliminiert Zeilen, bevor sie gruppiert werden — HAVING eliminiert Gruppen, nachdem sie berechnet wurden. Wenn der Filter nicht von einem Aggregat abhängt, gehört er in die WHERE-Klausel.
-- Teuer: HAVING filtert NACH der Aggregation über alle Zeilen
SELECT
YEAR(BestellDatum) AS Bestelljahr,
SUM(Betrag) AS Umsatz
FROM dbo.Auftrag
GROUP BY YEAR(BestellDatum)
HAVING YEAR(BestellDatum) = 2024; -- erst alle Jahre berechnen, dann filtern
-- Effizienter: WHERE filtert BEVOR aggregiert wird
SELECT
YEAR(BestellDatum) AS Bestelljahr,
SUM(Betrag) AS Umsatz
FROM dbo.Auftrag
WHERE BestellDatum >= '2024-01-01' -- Nur 2024er Zeilen einlesen
AND BestellDatum < '2025-01-01' -- SARGable! (vgl. Kapitel 21)
GROUP BY YEAR(BestellDatum);
-- HAVING ist korrekt, wenn du auf ein Aggregat filterst:
SELECT KundenID, COUNT(*) AS AnzahlBestellungen
FROM dbo.Auftrag
GROUP BY KundenID
HAVING COUNT(*) > 10; -- kein Äquivalent mit WHERE möglich
Window Functions (ROW_NUMBER, RANK, SUM OVER) ersetzen häufig umständliche Self-Joins für Rankings und laufende Summen. SQL Server berechnet sie in einem einzigen Tabellendurchlauf — effizienter als ein JOIN der die Tabelle zweimal lesen muss.
Pagination richtig machen
OFFSET / FETCH NEXT ist seit SQL Server 2012 der standardkonforme Weg für Paging. Das Problem: Bei großen Offsets liest SQL Server alle Zeilen bis zum Offset — auch, wenn du sie nicht brauchst. OFFSET 10000 FETCH NEXT 20 ROWS bedeutet: 10.020 Zeilen lesen, 10.000 wegwerfen. Das kostet messbar.
-- Standard-Paging: Sauber, aber teuer bei großen Seiten
SELECT KundenID, Firma, PLZ
FROM dbo.Kunde
ORDER BY KundenID
OFFSET 10000 ROWS
FETCH NEXT 20 ROWS ONLY;
-- SQL Server liest 10.020 Zeilen und wirft 10.000 weg.
-- Keyset Pagination: Effizienter bei großen Tabellen
-- Bedingung: Es gibt einen eindeutigen, indizierten Sortierschlüssel.
DECLARE @LastKundenID INT = 10000; -- letzter ID-Wert der vorherigen Seite
SELECT TOP (20) KundenID, Firma, PLZ
FROM dbo.Kunde
WHERE KundenID > @LastKundenID -- Index Seek statt Offset-Scan
ORDER BY KundenID;
-- Laufzeit ist konstant unabhängig von der Seitentiefe.
-- Einschränkung: Kein Sprung auf beliebige Seite möglich.
|
Hinweis: Keyset vs. Offset — Kompromiss |
|---|
|
Keyset Pagination ist performanter bei tiefen Seiten, hat aber Einschränkungen: Kein Sprung auf beliebige Seitennummer (Seite 500 direkt ansteuern), schwieriger bei mehrspaltiger Sortierung, funktioniert nicht, wenn der Nutzer während des Blätterns neue Zeilen einfügt. Für "Infinite Scroll"-Szenarien (Social Feeds, Logs) ist Keyset fast immer die richtige Wahl. Für "gehe zu Seite 47" bleibt OFFSET/FETCH — aber mit gutem Index auf die ORDER BY-Spalten. Mehr zu Result-Set-Design in Kapitel 24. |
Temporäre Tabellen, CTEs und Table Variables: Wann was?
|
Konstrukt |
Statistiken? |
Indexierbar? |
Scope |
Empfehlung |
|---|---|---|---|---|
|
CTE (WITH …) |
Nein — inline |
Nein |
Nur nächstes Statement |
Lesbarkeit, Rekursion, einmalige Referenz |
|
#TempTable |
Ja — eigene Statistiken |
Ja (CREATE INDEX) |
Session |
Große Zwischenergebnisse, mehrfach genutzt |
|
@TableVariable |
Nein (1 Zeile geschätzt) |
Nur PK / UNIQUE |
Batch |
< 100 Zeilen, kurze Lebensdauer |
CTE vs. Temp Table vs. Table Variable — Entscheidungshilfe
|
Hinweis: Table Variable Kardinalitätsfalle |
|---|
|
Wenn du eine @TableVariable mit 50.000 Zeilen befüllst und dann joinst, schätzt SQL Server 1 Zeile im Ausführungsplan — weil Table Variables keine Statistiken haben. Das Ergebnis: oft ein völlig falscher Join-Algorithmus (Nested Loop statt Hash Join). Ab SQL Server 2019 hilft Deferred Compilation dieses Problem zu mildern — aber es löst es nicht vollständig. Faustregel: Mehr als 1.000 Zeilen → #TempTable. Table Variables verursachen außerdem TempDB-Last, genau wie Temp Tables (Kapitel 13). |
UPDATE und DELETE optimieren
Große DELETE-Operationen in einer einzigen Transaktion fluten das Transaction Log, sperren die Tabelle für lange Zeit und können bei einem Rollback ewig dauern. Die Lösung: Batches.
-- Großes DELETE in einem Batch: Transaction Log-Flut, lange Sperren
DELETE FROM dbo.Protokoll
WHERE ErstelltAm < DATEADD(YEAR, -2, GETDATE());
-- Bei 10 Millionen Zeilen: Eine Transaktion, enormes Log-Wachstum.
-- Batch-Delete: Kleine Transaktionen, Log-freundlich
DECLARE @BatchZaehler INT = 1;
WHILE @BatchZaehler > 0
BEGIN
DELETE TOP (1000) -- maximal 1.000 Zeilen pro Transaktion
FROM dbo.Protokoll
WHERE ErstelltAm < DATEADD(YEAR, -2, GETDATE());
SET @BatchZaehler = @@ROWCOUNT;
-- Kurze Pause bei Produktionssystemen verhindert Log-Überlauf:
-- WAITFOR DELAY '00:00:00.100';
END
-- UPDATE mit OUTPUT: Änderung und Rückgabe in einem Schritt
-- Spart ein zusätzliches SELECT nach dem UPDATE
UPDATE dbo.Auftrag
SET Status = 'VERARBEITET'
OUTPUT INSERTED.AuftragsID, INSERTED.KundenID, INSERTED.Betrag
WHERE Status = 'NEU'
AND BestellDatum < DATEADD(DAY, -1, GETDATE());
-- Gibt alle aktualisierten Zeilen direkt zurück — kein zweites SELECT nötig.
Für umfangreiche Batch-Verarbeitungslogik — was zu tun ist, wenn du Millionen Zeilen in mehreren Schritten verarbeitest — deckt Kapitel 25 die vollständige Batch-vs.-Chatty-Strategie ab.
Diagnose: Abfrage-Anti-Patterns erkennen und beheben
|
Hinweis: Symptome |
|---|
|
— Stored Procedures mit DECLARE CURSOR oder WHILE @@FETCH_STATUS = 0 im Quellcode. |
|
— SELECT * in Prozeduren, Views oder Ad-hoc-Abfragen auf großen Tabellen. |
|
— Korrelierte Subqueries in der SELECT-Liste (erkennbar im Ausführungsplan als "Index Seek" innerhalb einer Nested-Loops-Schleife mit sehr vielen Ausführungen). |
|
— Abfragelaufzeit steigt nicht-linear mit der Datenmenge — bei 10.000 Zeilen 1 Sekunde, bei 100.000 Zeilen 2 Minuten: klassisches O(n²)-Muster. |
|
— DISTINCT in Abfragen die Joins zwischen mehreren Tabellen haben — fast immer ein Hinweis auf einen fehlerhaften JOIN. |
|
Hinweis: So misst du das |
|---|
|
sys.dm_exec_query_stats für Top-CPU-Queries identifizieren: |
|
|
|
SELECT TOP 20 |
|
qs.total_worker_time / qs.execution_count AS avg_cpu_us, |
|
qs.execution_count, |
|
SUBSTRING(st.text, (qs.statement_start_offset/2)+1, 200) AS query_text |
|
FROM sys.dm_exec_query_stats AS qs |
|
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st |
|
ORDER BY avg_cpu_us DESC; |
|
|
|
Dann: Ausführungsplan der Spitzenreiter prüfen. Im Plan erkennbar: |
|
— Nested Loops mit hoher "Number of Executions" im inneren Operator → korrelierte Subquery oder CURSOR-Äquivalent. |
|
— Sort-Operator mit hohen Kosten → fehlender Index oder falsche ORDER BY-Reihenfolge. |
|
— Thick Arrows (viele Zeilen) die zu schmalen Pfeilen kollabieren → Filterung zu spät im Plan. |
|
SET STATISTICS IO ON liefert "logical reads" — die Primärkennzahl für Abfrageeffizienz. |
|
Tipp: Typische Fehlinterpretationen |
|---|
|
"CTE ist schneller als Subquery" — Nein. CTEs sind syntaktischer Zucker, keine Materialisierung. Lesbarkeit: ja. Performance-Vorteil gegenüber äquivalenter Subquery: nein. |
|
"EXISTS ist immer schneller als IN" — Nicht mehr pauschal richtig. Moderne SQL Server Optimizer (ab 2008) transformieren viele IN-Subqueries intern zu EXISTS-Äquivalenten. Der Unterschied liegt heute hauptsächlich bei NULL-Handling. |
|
"OFFSET / FETCH ist Standard, also optimal" — Standard, ja. Optimal bei großen Offsets, nein. Bei Seite 500 einer 10.000-Zeilen-Tabelle liest SQL Server alle 5.000 Zeilen bis dorthin. |
|
"Temp Tables sind immer besser als CTEs" — Nur, wenn du das Zwischenergebnis mehrfach brauchst oder es groß ist. Eine CTE die einmal referenziert wird ist meistens genauso gut und lesbarer. |
|
Tipp: Erste Gegenmaßnahmen |
|---|
|
1. Cursors identifizieren: Suche in sp_helptext / sys.sql_modules nach "DECLARE CURSOR". Durch set-basierte UPDATE/DELETE/INSERT ersetzen — Faktor 10–100× Speedup möglich. |
|
2. NOT IN durch NOT EXISTS ersetzen — überall, sofort, ohne Ausnahme, wenn die Spalte nullable ist. |
|
3. DISTINCT hinterfragen: Wäre das Ergebnis ohne DISTINCT korrekt? Wenn nicht — den JOIN korrigieren. |
|
4. CTEs die mehrfach referenziert werden: In #TempTable umschreiben. |
|
5. Correlated Subqueries in der SELECT-Liste: Durch LEFT JOIN auf Unterabfrage oder Window Function ersetzen. |
|
6. HAVING ohne Aggregatbezug: In WHERE-Klausel verschieben. |
Zusammenfassung
SQL ist eine Mengensprache. Das ist keine philosophische Aussage — es ist eine praktische Konsequenz: Der Query Optimizer ist gut darin, Mengenoperationen zu optimieren. Er ist schlecht darin, Zeile-für-Zeile-Logik zu beschleunigen, weil er dabei kaum Spielraum hat.
Sparfuchs (Kapitel 33) zeigt mit fn_GetSteuersatz live, was passiert, wenn eine skalare Funktion 360.000 Mal pro Query aufgerufen wird. Trendforge (Kapitel 34) liefert die breitere Anti-Pattern-Parade: SELECT * in 23 von 31 Prozeduren, N+1 im ORM-Industrieformat, fehlende Indizes auf Join-Spalten. Beide Fälle wären mit den Techniken dieses Kapitels vermeidbar gewesen.
Kapitel 24 dreht den Blick auf den anderen Endpunkt: Was tust du mit dem Ergebnis? Zu viele Spalten, zu viele Zeilen, falsches Paging — auch das ist ein Performance-Problem, das beim Client beginnt und beim Server endet.

Abb. 1: Cursor vs. mengenbasierte Verarbeitung
Kapitel 24
