Wissen

Praxis-Artikel und Buchkapitel zu SQL-Performance, Sicherheit und Hochverfügbarkeit – alle frei verfügbar.

Beratung

Festpreis-Analyse mit Bericht und Handlungsempfehlung – oder strategische Begleitung bei Architektur, Migration und Hochverfügbarkeit.

Fachbücher

Die fünfbändige Reihe „Ulis SQL-Bibliothek“ – Band 1 verfügbar. Leseprobe herunterladen!

Tools

UB.SimSQL: SQL-Server-Lastsimulator mit regelbasierten Konfigurationsempfehlungen. Lokal, ohne Cloud, ohne Abo.

Schulungen

Online-Workshops zu Performance, Sicherheit und Entwicklung – kompakt, hands-on, ohne MOC-Folienschlacht.

Abfrageoptimierung: – SQL Server Performance

von

Dieser Artikel ist ein Kapitel aus:
SQL Server Performance & Troubleshooting
Praxisleitfaden, ca. 600 Seiten

[ Hier bei Amazon bestellen ]
[ Mehr zum Buch ]

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.

  • Cursor durch set-basierte UPDATE/DELETE/INSERT ersetzen — Faktor 10 bis 100 schneller bei großen Mengen.
  • SELECT * durch explizite Spaltenlisten ersetzen — Covering Indexes werden erst dadurch nutzbar (Kapitel 22).
  • NOT IN ist bei nullable Spalten ein Bug, nicht ein Stil-Problem — NOT EXISTS ist die sichere Alternative.
  • Korrelierte Subqueries in der SELECT-Liste verhalten sich wie ein Cursor — durch JOIN oder Window Function ersetzen.
  • CTEs werden für jeden Verweis neu ausgeführt — bei teuren Ergebnissen die mehrfach genutzt werden: Temp Table.
  • HAVING nur für Aggregat-Filter nutzen — alles andere gehört in WHERE.
  • Batch-Deletes statt einer großen DELETE-Transaktion schonen das Transaction Log und reduzieren Sperren.
  •  

    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