Einleitung

Stell dir vor, jemand behauptet: „SQL-Skripte zu schreiben ist wie Excel-Filtern – nur mit Semikolon.“
Wer von uns schon einmal mit einem schlecht performenden Reporting-System um drei Uhr morgens allein in der Produktionsdatenbank saß, dem treibt so ein Satz die Tränen in die Augen – vor Lachen oder Verzweiflung, je nach Koffeinpegel.

Denn in Wahrheit macht es für den SQL Server einen gigantischen Unterschied, wie wir etwas formulieren – und genau darin liegt die Kunst: Performance-Optimierung ist kein Handbuchwissen, sondern ein Zusammenspiel aus Erfahrung, Intuition und technischem Verständnis. Zwischen einem blitzschnellen Ergebnis und einer stundenlangen TempDB-Quälerei liegen häufig nur ein paar Buchstaben – zum Beispiel die Zeile COUNT(DISTINCT …) oder ein kleines OPTION (HASH GROUP).

Ich nutze das folgende Szenario gern in meinen Vorlesungen, weil daran wunderschön sichtbar wird, warum ein Datenbankentwickler nicht nur „schönen“ Code schreibt, sondern auch Execution-Pläne lesen, Speichergrants verstehen und das Zusammenspiel von I/O, CPU und Parallelität beurteilen muss.

Warum dieses Beispiel?
Gerade weil COUNT(DISTINCT …) so harmlos aussieht, eignet es sich perfekt, um die Tiefe von Performance-Fragen sichtbar zu machen. Es zeigt, wie viele Faktoren – von Ausführungsplänen über Speichergrants bis hin zu Parallelität – zusammenspielen müssen, damit ein SQL-Statement effizient läuft.

TL;DR – Das Wichtigste in ein paar Sekunden

Fragestellung
Prüfe, ob OrderCategory pro OrderNumber eindeutig ist. Sechs Varianten + Columnstore-Test auf einer 15-Mio-Zeilen-Tabelle.

Ohne zusätzlichen Index

  • Stream-Plan HASH GROUP und CTE-Hash-Plan laufen dank Parallelität ~ 37 s.
  • COUNT(DISTINCT) bringt keine Parallelität und verlängert die Laufzeit auf ~ 46 s.

Mit Composite-Index (OrderNumber, OrderCategory)

  • Beide Stream-Varianten schrumpfen auf ~ 5 s (serieller Scan + 2 × StreamAggregate).
  • Hash-Hint bleibt parallel und gewinnt weitere ~ 1 s (≈ 4 s total).

Mit Nonclustered Columnstore Index

  • Kompression + Batch-Mode: < 1 s Laufzeit, < 100 k logical reads.

Kernbotschaften

  1. Execution-Pläne sind das Röntgenbild: Erst lesen, dann tunen.
  2. Doppel-Hash kann schneller sein als Stream, wenn er parallel läuft und in RAM passt.
  3. Ein Index beschleunigt SELECT, verteuert jedoch INSERT/UPDATE – Kosten ≠ Null.
  4. Columnstore ist der Turbo für analytische Abfragen, solange Schreiblast moderat bleibt.

Ausgangslage / Fragestellung

  • Tabelle: Order
  • Größe: ca. 15,2 Mio Zeilen
  • Spalten (relevant):
    • OrderNumber – Bestellnummer
    • OrderCategory – Sachkategorien, z. B. „Hardware“, „Dienstleistung“ …

Businessfrage: Ist jede Bestellung genau einer Kategorie zugeordnet?
Technisch heißt das: Gibt es pro OrderNumber mehr als einen unterschiedlichen Wert in OrderCategory?

Glossar – Fachbegriffe & Alltagsvergleiche

FachbegriffTechnische KurzdefinitionAlltags­analogie
Index ScanDer Ausführungsplan liest alle Zeilen eines Nicht-clustered Index von Anfang bis Ende. Er nutzt dabei nur die leichteren Indexseiten (schmalere Datensätze) statt der kompletten Tabellenzeilen.Wie wenn du das Register eines Archivs Blatt für Blatt durchblätterst, ohne die eigentlichen Akten zu öffnen – schneller als die Akten selbst zu wälzen, aber immer noch vollständiges Durchblättern.
Clustered ScanVollständiges Durchlesen des Clustered Index (praktisch: der ganzen Tabelle). Jede Daten­seite wird sequentiell eingelesen, weil kein Filter zielgenau genutzt werden kann.Du gehst Regal für Regal durch eine Bibliothek und ziehst jedes Buch heraus, weil die Suchliste keine Sortierung zulässt.
Hash AggregateOperator baut eine Hash-Tabelle im Speicher auf; pro neuem Gruppenschlüssel wird ein Bucket angelegt und dort Aggregat-Werte (COUNT, SUM …) gehalten. Reihenfolge des Inputs egal, Parallelität gut.Mehrere Leute werfen Visitenkarten in Kartons, die nach Anfangsbuchstaben beschriftet sind. Öffnet man Karton „S“, kann man sofort ausrechnen, wie viele „Schmidt“-Karten drin liegen.
Stream AggregateAggregiert Daten on the fly, vorausgesetzt sie sind bereits nach dem Gruppierungs­schlüssel sortiert. Erkennt einen Schlüssel­wechsel, beendet die aktuelle Gruppe und startet die nächste.Am Fließband kommen Pfandflaschen schon nach Sorte sortiert. Der Zähler erhöht sich so lange, bis die Sorte wechselt – dann wird ein neues Zählblatt begonnen.
Sort-SpillBeim Sortieren passt der benötigte Arbeitsspeicher nicht in den zugewiesenen Memory Grant; der Operator muss Teile der Zwischenergebnisse in tempdb auslagern.Du möchtest alle Fotos auf dem Küchentisch sortieren, doch der Tisch ist zu klein. Also stapelst du Überschuss in Kartons auf dem Boden, sortierst stückweise weiter und legst am Ende alles wieder zusammen – dauert deutlich länger.
Hash-SpillAuch hier reicht der Memory Grant nicht; Hash-Buckets werden in Stufen auf tempdb ausgelagert und später zusammengeführt (Re-Hash).Die Kartons mit Visitenkarten sind so voll, dass du neue Kartons im Keller anlegen musst. Für jede neue Karte läufst du manchmal nach unten, was den gesamten Sortier­vorgang ausbremst.

Lösung 1 – CTE + Doppel-Aggregation

Logische Absicht der Frage

  • Die CTE liefert jede Kombination aus OrderNumber und OrderCategory genau einmal.
  • Das Haupt-Select zählt wieviele verschiedene OrderCategory-Werte es je OrderNumber gibt, und gibt nur diejenigen zurück, bei denen es mehr als einen Eintrag gibt.

Physische Ausführung Schritt für Schritt

#Operator (physisch)AufgabeBemerkungen / Schlüsseldaten
1Clustered Index ScanPK_LWOrderFinalItemUUIDLiest die komplette Tabelle LW_Order zeilenweise (15 223 300 geschätzte Zeilen).Scan statt Seek, weil kein passender nicht-clustered Index auf (OrderNumber, OrderCategory) existiert. Hohe Kosten (≈ 80 %).
2Parallelism (Repartition Streams)Verteilt die eingelesenen Zeilen auf mehrere Worker-Threads.Macht das folgende Hash-Aggregat parallel, teilt nach Hash auf (OrderNumber, OrderCategory).
3Hash Match (Aggregate)Erste Aggregation erzeugt die DISTINCT-Liste („CTE“).Baut Hash-Tabelle auf Schlüssel (OrderNumber, OrderCategory).Reduziert von ≈ 15 Mio auf ≈ 3,54 Mio Zeilen (≈ 23 %).
4Parallelism (Repartition Streams)Verteilt die Distinct-Ergebnisse erneut, diesmal nach Hash auf OrderNumber (ohne Category).Bereitet zweite Aggregation vor.
5Hash Match (Aggregate)Zählt die Einträge pro OrderNumber (COUNT(*)).Schreibt die Zählung in eine interne Spalte, liefert wieder ≈ 3,54 Mio Zeilen.
6Compute ScalarKopiert den Zählwert in die Ausgabespalte C.Geringe Kosten.
7FilterSetzt die HAVING-Bedingung COUNT(*) > 1.Filtert alles heraus, was nur 1 Eintrag hat; voraussichtlich bleibt ein kleiner Bruchteil übrig.
8Parallelism (Gather Streams)Führt die Teil-Streams der Worker zusammen, sortiert nicht.Stellt das finale Ergebnis für den Client zusammen.
9SELECTÜbergibt die Daten an den TDS-Protokoll-Stack.Kosten 0 % im Plan, da rein logischer Root-Knoten.

Performance-Eigenschaften

  • RAM-Hunger: zwei Hash-Tabellen können hunderte MB Grant anfordern.
  • Paralleles Arbeiten: weil Hash keinen sortierten Input braucht, kann SQL Server das Scan-Ergebnis über mehrere Worker verteilen (Repartition Exchange).
  • Worst Case: ist der Grant zu klein, weicht der Hash-Operator mehrfach auf TempDB aus → Dauer und I/O steigen explosionsartig.

Lösung 2 – COUNT(DISTINCT …) ohne Hint

Logische Absicht der Frage

Für jede OrderNumber ermitteln, wie viele verschiedene OrderCategory-Werte vorkommen, und nur die Nummern zeigen, bei denen > 1 unterschiedliche Kategorien vorkommen – dieselbe fachliche Prüfung wie in Query 1, nur ohne vorgelagertes DISTINCT.

Physische Ausführung Schritt für Schritt

#Operator (physisch)AufgabeWichtige Details
1Clustered Index ScanPK_LWOrderFinalItemUUIDLiest die gesamte Tabelle (≈ 15,2 Mio Zeilen).Kosten-Anteil ≈ 78 %. Scan nötig, weil kein passender Index auf (OrderNumber,OrderCategory).
2Parallelism (Repartition Streams)Verteilt die eingelesenen Zeilen auf mehrere Worker-Threads, Hash-Schlüssel = (OrderNumber, OrderCategory)Ermöglicht paralleles Sortieren.
3Sort (Distinct Sort)Sortiert gleichzeitig nach OrderNumber und OrderCategory und entfernt dabei Dubletten dieser Kombination.Diese Stufe realisiert intern die Semantik von DISTINCT innerhalb des COUNT(DISTINCT …) – daher der Planname Distinct Sort. Kostet ≈ 17 %. Je nach Speichergrant kann sie auf tempdb auslagern (Spill).
4Stream Aggregate (Aggregate)– Erkennt, dass der vorgelagerte Sort das Ergebnis bereits nach OrderNumber, OrderCategory geordnet liefert. – Zählt pro OrderNumber die unterschiedlichen Kategorien (jeder neue OrderCategory erhöht den Zähler).Stream Aggregate benötigt geordneten Input – den liefert der Sort.
5Parallelism (Repartition Streams)Verteilt die Zwischenergebnisse nun nur noch nach OrderNumber, sodass pro Nummer ein einzelner Thread weiterzählt.
6Compute ScalarKopiert das Ergebnis in die Ausgabespalte C.
7FilterSetzt die HAVING-Bedingung C > 1.Filtert Bestellungen mit nur 1 Kategorie weg.
8Parallelism (Gather Streams)Führt die Teil-Streams wieder zusammen.
9SELECTÜbergibt die Result-Rows an den Client.

Warum wählt der Optimizer hier Sort + Stream Aggregate?

  1. COUNT(DISTINCT …) zwingt den Optimizer, zuerst Dupes innerhalb (OrderNumber, OrderCategory) zu entfernen.
    • Ein Hash-Aggregate könnte das zwar auch, aber er müsste anschließend pro OrderNumber erneut zählen (2 Hash-Ebenen wie in Query 1).
  2. Kostenmodell: Für große Cardinalities (15 Mio Zeilen → 3,5 Mio nach DISTINCT) schätzt der Optimizer die Sort-Kosten + einen billigen StreamAgg geringer als zwei Hash-Aggregationen.
  3. Speichergrant: Ein einzelner großer Sort lässt sich mit einem einmaligen Grant bedienen; zwei Hash-Aggregates brauchen zwei Grants und können trotzdem zweimal auf Disk spillen.

Performance-Eigenschaften

  • Nur eine große Arbeitseinheit (Sortpuffer) anstelle zweier Hash-Tabellen.
  • Wird der Puffer nicht bewilligt, spüllt der Sort in TempDB – für reine I/O-Zeit oft schlimmer als ein Hash-Spill, weil Sort in größeren Blöcken schreibt und liest.
  • Serien-Charakter: Solange keine passende Sortierung im Index vorhanden ist, kann SQL Server diese Pipeline kaum parallelisieren, weil er die weltweite Reihenfolge erhalten muss.

Exkurs – Wie SQL Server DISTINCT entscheidet

Im Hintergrund besitzt der SQL-Server für jede GROUP BY– oder DISTINCT-Operation zwei grundsätzliche Ausführungswege: Stream-Aggregate und Hash-Aggregate. Bevor der Ausführungsplan festgezurrt wird, wägt der Optimizer automatisch ab, welcher von beiden Wegen in der jeweiligen Situation günstiger ist. Seine Entscheidung folgt dabei – vereinfacht formuliert – genau den Kriterien aus der Tabelle:

  1. Sortierter Input vorhanden?
    • Ja → Stream ist billig, weil er ohne zusätzliche Arbeit sofort loszählen kann.
    • Nein → Ein Sort wäre nötig; die Kosten dieses Sorts vergleicht der Optimizer dann mit den Kosten eines Hash-Aggregates.
  2. Speicherbedarf & TempDB-Risiko
    Bleibt der Sort in-Memory, ist er schnell; droht ein Sort-Spill, werden die Karten neu gemischt. Das Gegenstück beim Hash-Aggregat ist der Hash-Spill.
  3. Parallelisierbarkeit
    Kann der Optimizer zeigen, dass ein Hash-Plan mit Repartition-Operatoren mehrere Kerne effizient beschäftigt, während ein Stream wegen der Reihenfolgevorgabe seriell bleiben müsste, kippt die Entscheidung oft Richtung Hash – selbst wenn ein Sort nicht nötig wäre.
  4. Index-Nutzen
    Liefert ein vorhandener Index die benötigte Ordnung exakt, spart das den Sort komplett; damit rutscht der Stream-Aggregate fast immer auf Platz 1.
Entscheidender FaktorStream-AggregateHash-Aggregate
Braucht sortierten Input?janein
Speicherprofilsehr klein, solange bereits sortiertproportional zur Anzahl unterschiedlicher Gruppen
TempDB-RisikoSortphase kann spülenHash kann spillen
Parallel-Friendlinessniedriger (Reihenfolge)hoch
Nutzen eines passenden Indexesenormgering

Liegt ein Index vor, der die gewünschte Ordnung liefert, gewinnt Stream.
Gibt es keinen solchen Index und Sort-Spills drohen, gewinnt Hash.

Kurz Gesagt

Lösung 3 – Hash-Hint erzwingen

Will man dieses automatische Abwägen bewusst übersteuern, gibt es den Hint

Er sagt dem Optimizer: „Wähle, wenn technisch möglich, einen Hash-Aggregate-Plan – selbst dann, wenn deine Kostenschätzung eigentlich zugunsten des Streams ausfällt.“

  • Greift, wenn …
    • die Abfrage tatsächlich eine Gruppierung oder ein COUNT(DISTINCT …) enthält,
    • kein anderer inkompatibler Hint (ORDER GROUP, FORCE ORDER, FAST n u. a.) den Hash-Plan ausschließt.
  • Wird ignoriert, wenn …
    • die Operation semantisch einen sortierten Output erzwingen muss, z. B. weil im selben Planabschnitt eine Sort-abhängige Window-Function genutzt wird,
    • der Optimizer feststellt, dass der Hash-Aggregate logische Fehler erzeugen würde (sehr selten, typischerweise bei OVER-Konstrukten),
    • oder ein planinterner Transformationsschritt die Aggregation in einen völlig anderen Operator kippt (z. B. Push-down in Columnstore-Batch-Mode).

In der Praxis bedeutet das: Der Hint ist stark, aber nicht allmächtig. In normalen GROUP BY– oder COUNT(DISTINCT)-Szenarien wird er fast immer akzeptiert und sorgt dafür, dass selbst auf einem perfekt sortierten Index ein Doppel-Hash-Plan entsteht – was, wie in unserem Beispiel, manchmal durch Parallellese und Readahead tatsächlich die End-to-End-Zeit verbessert.

Der Hint verbietet Sort-Aggregation → der Optimizer wechselt auf denselben Doppel-Hash-Plan wie in Lösung 1, allerdings ohne CTE.
Ergebnis: Ein paralleler Hash-Plan, oft ~ 1 s schneller als 1 & 2, weil beid­seitige Hashes gut skalieren, sobald Speicher reicht.

Physische Ausführung Schritt für Schritt

#Operator (physisch)AufgabeKommentar
1Clustered Index Scan PK_LWOrderFinalItemUUIDLiest alle ≈ 15,2 Mio Zeilen.Kosten ≈ 75 %.
2Parallelism – Repartition StreamsVerteilt Zeilen nach Hash auf (OrderNumber, OrderCategory).Ermöglicht parallele Aggregation.
3Hash Match (Aggregate)Stufe 1: entfernt Dubletten der Kombination (OrderNumber, OrderCategory).Ersetzt den Distinct Sort aus Query 2.
4Parallelism – Repartition StreamsVerteilt die deduplizierten Zeilen diesmal nur nach OrderNumber.
5Hash Match (Aggregate)Stufe 2: zählt die Zeilen je OrderNumber → liefert COUNT(DISTINCT OrderCategory).Intern wird der Zähler in einer Hash-Bucket-Spalte geführt.
6Compute ScalarKopiert Zähler nach Spalte C.
7FilterHAVING C > 1.
8Parallelism – Gather StreamsFührt Teilstreams zusammen.
9SELECTÜbergibt Ergebnis an den Client.

Zwei Hash-Aggregationen ersetzen bei gleicher Logik den Sort + StreamAggregate des Standardplans.

Kurz Gesagt

Fazit zur Lösung 3

  • Logisch identisch zu Query 2, aber physisch durch den Hint komplett anders.
  • Er nutzt dieselbe Hash-Doppelstufe wie Query 1, nur ohne separate CTE.
  • Ob er schneller ist, hängt primär davon ab, ob der Server ausreichend Arbeitsspeicher für beide Hash-Tabellen gewährt und wie groß die DISTINCT-Menge tatsächlich ist.
  • Wenn Sort-Spills das Hauptproblem waren, bringt HASH GROUP spürbare Vorteile; fehlt Speicher für Hash-Tabellen, kann er sogar langsamer sein.

Ein kurzer Blick auf APPROX_Count_Distinct

Wer sich mit COUNT(DISTINCT …) beschäftigt, stolpert früher oder später über eine scheinbar elegante Abkürzung: APPROX_COUNT_DISTINCT. Der Name klingt vielversprechend – und das ist er auch, zumindest auf den ersten Blick.

Diese Funktion liefert eine geschätzte Anzahl unterschiedlicher Werte, basiert intern auf dem HyperLogLog-Algorithmus und ist vor allem für große Datenmengen in analytischen Szenarien gedacht. Sie ist schnell, speichereffizient und parallelisierbar – aber eben auch: ungenau.

Und genau hier liegt der Knackpunkt:
In klassischen Reporting- oder ETL-Szenarien, in denen exakte Zahlen erwartet werden (z. B. bei Abrechnungen, Compliance-Berichten oder operativen Dashboards), ist APPROX_COUNT_DISTINCT nicht geeignet. Die Abweichung liegt zwar meist unter 2 %, aber das reicht, um Vertrauen zu untergraben – oder im schlimmsten Fall: falsche Entscheidungen zu treffen.

Ich erwähne diese Funktion hier bewusst, um zu zeigen, wie vielschichtig Performance-Optimierung ist:
Manchmal ist die „schnelle Lösung“ technisch brillant – aber fachlich unbrauchbar. Und genau deshalb lohnt es sich, nicht nur auf Ausführungspläne, sondern auch auf fachliche Anforderungen zu schauen.

Die große Wendung: Wir bauen einen Index (Lösung 4-6)

Als wir den zusammengesetzten Nicht-Clustered-Index:

CREATE NONCLUSTERED INDEX IX_LW_Order_OrderNumber_Category
    ON dbo.LW_Order (OrderNumber, OrderCategory);

angelegt haben, änderte sich die Ausgangslage grundlegend. Beide Abfragen – die Variante mit der CTE und die mit COUNT(DISTINCT …) – können nun denselben, extrem schlanken Ausführungsplan verwenden. Jetzt sind alle Datenseiten physisch nach genau diesem Schlüssel sortiert. Das ändert alles.

  • Schlüssel­reihenfolge = genau die benötigte Sortierung (erst OrderNumber, dann OrderCategory).
  • Schlanker Zugriff – der Index enthält nur die zwei abgefragten Spalten, d. h. erheblich weniger Seiten als der Clustered Index.
  • Daten sind von Natur aus gruppiert: alle Zeilen derselben OrderNumber liegen hinter-einander; innerhalb dieser Gruppe sind die OrderCategory-Werte bereits sortiert.

Quer­vergleiche der drei Pläne nach dem Index-Build

Lösung 4:
Query 1 – CTE / COUNT(*)
Lösung 5:
Query 2 – COUNT(DISTINCT)
Lösung 6:
Query 3 – COUNT(DISTINCT) + OPTION(HASH GROUP)
Rel. Batch-Kosten(Screenshot)18 %18 %63 %
IndexzugriffIndex Scan IX_LW_Order_OrderNumber_Category (≈ 15 M Zeilen)ditodito
Aggregations­strategieStream Aggregate– 1. Stufe DISTINCT– 2. Stufe COUNTStream Aggregate(gleiche Idee, nur ohne CTE)Hash Aggregate(erzwungen durch Hint)
Parallelismuskein Parallelismus nötigkein Parallelismus nötigScan → Repartition → Hash … → Gather
Sortskeiner (Eingabe sortiert)keinerkeiner – aber Hash braucht Speicher
Speichergrantsehr klein (nur Aggregate-Frames)sehr kleinzwei Hash-Grants; evtl. Spills
I/Oliest nur den NC-Index, keine Tempdb-I/Oditodito + evtl. Spill-I/O

Lösung 4/Lösung 5 – Analog zu Lösung 1/2 jedoch mit Index

Der Optimizer wirft die CTE „nach oben“ (Common Sub-expression Elimination)

  • Vorher: In Query 1 existiert logisch eine zweiteilige Pipeline:
    1. DISTINCT (OrderNumber, OrderCategory) in der CTE
    2. GROUP BY OrderNumber im Haupt-SELECT.
  • Nachher: Der Optimizer erkennt, dass die CTE nirgendwo sonst verwendet wird. Er löst sie auf und faltet beide Aggregations­schritte in einen einzigen Operator-Baum. Damit wird Query 1 intern praktisch genauso aufgebaut wie Query 2.

Diese Technik nennt sich Common Sub-expression Elimination (CSE): Redundante Teilausdrücke werden vereint, um nur einmal ausgeführt zu werden. In unserem Fall bedeutet das, dass der CTE-Teil nicht mehr als eigene „Mini-Query“ existiert, sondern direkt in den Gesamtplan integriert wird.


Die natürliche Ordnung des neuen Indexes erlaubt Stream-Aggregates ohne Sort oder Hash

Der Index hält die Daten bereits perfekt sortiert: zuerst nach OrderNumber, dann nach OrderCategory. Genau diese Reihenfolge brauchen beide Abfragen.

  • Stream Aggregate #1 (Distinct-Ebene)
    • Läuft sequenziell über den Index-Scan.
    • Braucht keinerlei Sortierung, weil ein Wechsel von OrderNumber oder OrderCategory schon am Eingabestrom erkennbar ist.
    • „Distinct“ entsteht, indem der Operator nur dann einen Zähler hochsetzt, wenn die Kombination beider Spalten wechselt.
  • Stream Aggregate #2 (Zähl-Ebene)
    • Startet unmittelbar danach, diesmal gruppiert nur noch nach OrderNumber.
    • Auch hier genügt ein einziger Zeiger und eine Zählvariable, weil alle Zeilen derselben Bestellnummer hintereinander liegen.

Es entstehen keine Hash-Tabellen, keine Sort-Puffer und keine Repartition-Operatoren. Der Plan besteht nur aus:

Index Scan → StreamAgg → StreamAgg → Filter

Folgen für I/O, CPU und Speicher

RessourceVor dem IndexNach dem Index
I/OClustered-Scan muss sämtliche Tabellen­seiten lesen (≈ 2,6 GB).Index-Scan liest nur die schmalen Index­seiten (≈ 600 MB, also < 30 %).
CPUZwei Hash-Aggregates oder ein Sort + StreamAggregate → Millionen Vergleiche, mögliche TempDB-Spills.Zwei Stream-Aggregates zählen nur, wenn der Schlüssel wechselt → wenige CPU-Takte pro Zeile.
Speicher-GrantHoch (Hash) bzw. mittel (Sort) – Gefahr von Spills.Minimal, weil Stream-Aggregate lediglich einige Bytes pro Gruppe braucht.

Da beide Abfragen nun denselben Operator-Baum, dieselbe I/O-Last und denselben Speicher-Footprint haben, melden sie im Plan identische geschätzte Kosten und laufen in der Praxis nahezu exakt gleich schnell (ca. fünf Sekunden in meinem Test).

Warum COUNT(DISTINCT …) trotzdem keinen separaten Plan braucht

Im zweiten Statement sieht der Optimizer sofort, dass die Ordnung schon stimmt. Er muss also nicht den üblichen Distinct Sort einfügen. Stattdessen wird der Zählvorgang exakt so realisiert wie in Query 1 nach der CSE-Optimierung. Das erklärt, warum die Variante mit der scheinbar komplexeren Syntax (COUNT(DISTINCT)) keinen Millimeter hinterherhinkt.

Gemeinsame, perfekt sortierte Eingabe dank des Indexes → kein Sort, kein Hash, kein Repartition.

Zwei Stream-Aggregates reichen für beide Abfragen aus – derselbe Plan, dieselbe Ressourcennutzung.

Ergebnis: Query 1 und Query 2 haben jetzt identische Laufzeiten und Plan-Kosten, weil der Optimizer ihre logischen Unterschiede vollständig ausmerzt, sobald die Daten in der optimalen Reihenfolge vorliegen.

Kurz Gesagt

Lösung 6 – Lösung 3 erneut (Hint bleibt)

Der Hint zwingt den Optimizer trotz perfekter Sortierung, in den parallelisierten Hash-Plan zurückzufallen.

  1. Ordnung egal – Hash-Aggregate braucht keinen geordneten Input. Daher darf der Optimizer gleich zu Beginn einen Parallelism (Repartition Streams)-Operator einfügen, der die Datenseiten auf acht Threads verteilt, ohne vorherige Sortierung zu beachten.
  2. Speicher passt – Die beiden Hash-Tabellen füllen den zugesicherten Memory Grant, ohne zu spillen. Damit bleiben alle Operationen im Arbeitsspeicher.
  3. Lesen in Extents – Jeder Worker bearbeitet unterschiedliche Extents (Acht-Seiten-Blöcke) des Indexes. Dadurch steigert SQL Server den Read-Ahead-Durchsatz, weil mehrere Disk-I/O-Anfragen gleichzeitig abgesetzt werden. Die kumulative Wartezeit auf PAGEIOLATCH sinkt merklich.

Die Folge ist eine tatsächliche Laufzeit um etwa eine Sekunde kürzer, obwohl der Plan laut Kostenmodell (63 %) teurer wirkt. Das Modell bewertet primär CPU-Zyklen; den Speicher- und I/O-Gewinn durch paralleles Readahead kann es nur grob erfassen – gerade bei modernen SSD-Arrays oder NVMe-Speichern fällt dieser Vorsprung in der Realität stärker ins Gewicht.

Ergebnisse der Messung:

VarianteElapsedCPU-ZeitReads
4 & 5~ 5 s1,8 s820 000
6~ 4 s8,1 s820 000

Wir sparen real eine Sekunde, „bezahlen“ aber mit mehr CPU.

Warum?

  • Die Abfrage ist jetzt primär I/O-gebunden. Mit einem einzigen Lese-Thread stößt Lösung 4/5 an die Grenzgeschwindigkeit des Storage-Subsystems.
  • Lösung 6 verteilt das Lesen (Readahead) und Hashen auf mehrere Scheduler; jede Seite wird zwar nach wie vor von TempDB oder Datendatei gelesen, aber es gibt weniger Wartezeit pro Seite – die Summe der Warteintervalle schrumpft.

Was nützt hier OPTION(MAXDOP 8) bei Lösung 4/ Lösung 5?

Als wir die Abfrage mit

OPTION (MAXDOP 8, ORDER GROUP)

ausführten, war die Erwartung klar: Mehr Worker-Threads sollten den vorhandenen Non-Clustered-Index parallel scannen, die Daten in Teilströme zerlegen und anschließend wieder zusammenführen. In der Praxis blieb die Ausführungszeit jedoch bei rund fünf Sekunden – exakt so lang wie bei den rein seriellen Stream-Plänen. Ein Blick in den Actual Execution Plan offenbart die Ursache: Dort taucht weder ein Parallelism (Repartition Streams)– noch ein Parallelism (Gather)-Operator auf, und statt mehrerer Stream-Pipelines sehen wir nur eine einzige Kette von Index Scan → Stream Aggregate → Stream Aggregate. Der Optimizer hat sich also entschieden, trotz MAXDOP 8 keinen parallelen Plan zu erstellen.

Der Hint erlaubt Parallelismus, erzwingt ihn aber nicht

Der Hint ORDER GROUP signalisiert, dass der Entwickler parallel laufende Teil-Aggregationen auf Basis geordneter Daten zulässt. Er hebt jedoch nicht die Kostenlogik des Optimizers auf. SQL Server prüft stets, ob die benötigten Parallel-Operatoren (Repartition & Merge Exchange) insgesamt billiger sind als eine serielle Pipeline. In unserem Fall liegen die Daten bereits perfekt sortiert nach (OrderNumber, OrderCategory) im neuen Index. Ein einziger Thread kann sie daher ohne jegliche Nacharbeit in einem kontinuierlichen Stream verarbeiten.

Würde der Optimizer dennoch parallelisieren, müsste er:

  1. Repartition – die geordnete Folge in acht Teilströme aufsplitten,
  2. pro Thread die Daten sortiert halten oder neu sortieren,
  3. Merge Exchange – am Ende die acht bereits aggregierten Teilströme wieder zu einer weltweit korrekten Ordnung zusammenführen.

Alle drei Schritte sind teuer: Sie verursachen zusätzliches Lesen und Schreiben im Arbeitsspeicher, potenziell weitere Sort-Puffer, Synchronisations-Barrieren und Kontextwechsel. Rechnet der Optimizer nach, ergibt das unter dem Strich höhere Kosten als eine serielle, bereits sortierte Verarbeitung. Also bleibt er konsequent bei DOP 1.

I/O- statt CPU-Bottleneck

Messungen mit

SET STATISTICS IO, TIME ON;

zeigen ein deutliches Bild:

  • Elapsed Time – rund 5 s, egal ob ORDER GROUP hinzugenommen wird oder nicht.
  • CPU Time – lediglich ≈ 2 s; die restlichen Sekunden verbringt der Task im Wesentlichen im Warten.

Der Hauptwait-Typ ist PAGEIOLATCH_XX – ein klares Indiz dafür, dass die Query auf das Einlesen der Index-Seiten aus dem Bufferpool (oder von der Platte) wartet, nicht aber auf Rechenarbeit. Selbst wenn wir acht Worker-Threads hätten, würden sie sich dieselben Seiten streitig machen. In solchen Szenarien skaliert Parallelität kaum, weil I/O das Nadelöhr ist und nicht die CPU.

Kosten eines Parallel-Stream-Aggregates

Angenommen, wir würden den Plan doch in Parallelität zwingen, müsste SQL Server die global sortierten Daten zunächst in n Teilströme aufspalten. Das funktioniert nur, wenn entweder

  • jeder Thread seine eigenen, vollständig sortierten Datenstücke liest (was bei einem einzigen B-Tree-Index kaum gelingt),
  • oder nach dem Repartition-Schritt zusätzlich sortiert wird, anschließend ein Merge Exchange die Teilgruppen wieder korrekt zusammenfügt.

Beides belastet den Arbeitsspeicher, erhöht die CPU-Last (wegen Sortieren und Zusammenführen) und bewirkt zusätzliche Wartezeiten auf die gemeinsamen Speicher-Strukturen. Da unser Index die ideale Ordnung bereits mitbringt, entsteht für den Optimizer kein Mehrwert – im Gegenteil, die Parallelität würde das Ganze verkomplizieren.

Wann lohnt sich ein Index wie IX_LW_Order_OrderNumber_Category?

Bevor man in einer produktiven Umgebung einen zusätzlichen Index wie IX_LW_Order_OrderNumber_Category anlegt, sollte man sich bewusst machen, dass ein Index immer zwei Gesichter hat: Er macht Lesezugriffe oft um Größenordnungen schneller, erhöht aber gleichzeitig die Aufwände bei jedem Schreib­vorgang – denn jede eingefügte, geänderte oder gelöschte Zeile muss nun nicht nur im Clustered-Index, sondern auch in allen betroffenen Nebenindizes nachgeführt werden. Ob der Nutzen die Kosten überwiegt, hängt von einer Handvoll klar abgrenzbarer Szenarien ab.

Abfragehäufigkeit

Ein typischer Grenzwert, an dem sich viele Teams orientieren, sind etwa 100 Query-Runs pro Tag oder eine Einzellaufzeit von mehr als einer Sekunde. Wird eine Abfrage so oft oder so lange ausgeführt, wirkt sich selbst eine moderate Beschleunigung stark auf die Gesamt­performance aus. In solchen Fällen rentiert sich der Pflege­aufwand des Indexes schnell – erst recht, wenn mehrere Berichte, Dashboards oder Batch-Jobs auf denselben Spalten filtern oder gruppieren.

Kontinuierlich wachsende Tabelle, aber moderate Schreiblast

Steigt das Datenvolumen beständig, ohne dass im Gegenzug eine hohe Insert- oder Update-Rate anliegt, ist ein zusätzlicher Index fast immer sinnvoll. Die Lesevorteile wachsen, weil jeder Scan weniger Seiten durchsuchen muss, während der Schreib­aufwand aufgrund der moderaten Änderungsrate überschaubar bleibt. Das Ergebnis: Ein stabiler Durchsatz bei immer größer werdenden Tabellen, ohne dass die SELECT-Abfragen in die Knie gehen.

Bulk-Inserts oder Massen-Updates (> 100 000 Zeilen pro Stunde)

Sobald sehr große Datenmengen in kurzer Zeit eingespielt werden – etwa in nächtlichen ETL-Läufen oder bei Migrations­projekten – kippt das Pendel oft in die andere Richtung. Hier sollte man Kosten und Nutzen sorgfältig gegeneinanderstellen. Eine bewährte Praxis besteht darin, Bulk-Loads temporär mit INDEX=OFF durchzuführen und den Index erst nach dem Import wieder aufzubauen. So spart man während des Ladevorgangs erhebliche Zeit und I/O, bezahlt aber einmalig den Rebuild – ein klassischer Trade-off.

Analytischer Workload (Data Warehouse)

In reinen Analyse-Datenbanken dominieren Scan-lastige Abfragen, die große Ausschnitte der Tabelle lesen und spaltenweise aggregieren. Hier haben sich Columnstore-Indizes als De-facto-Standard etabliert, weil sie Daten stark komprimieren und Abfragen im Batch-Mode ausführen lassen. Ein zusätzlicher B-Tree-Index wie unser Beispiel liefert in diesem Umfeld selten einen spürbaren Vorteil, kostet aber Speicher und verlängert die Ladezeiten. Die Faustregel lautet daher: im DW lieber Columnstore statt B-Tree – es sei denn, ganz bestimmte point-lookups rechtfertigen eine Ausnahme.

Gelegenheits-Abfrage (einmalig, ad-hoc)

Steht die betreffende Abfrage nur sporadisch auf der Agenda – vielleicht ein monatlicher Fach-Report oder eine einmalige Plausibilitäts­prüfung – lohnt es sich kaum, dauerhaft einen Index zu pflegen. Hier fährt man meist besser, die Abfrage außerhalb der Spitzenzeiten laufen zu lassen oder die benötigten Daten in einer temporären Tabelle vorsortiert abzulegen. So bleibt das OLTP-System schlank, und der einmalige Analysebedarf stört den regulären Betrieb nicht.

Filtered Index als Alternative

Kann man den betrachteten Wertebereich weiter einschränken – zum Beispiel nur aktive oder letzte 90 Tage-Bestellungen indexieren – ist ein Filtered Index oft der goldene Mittelweg. Er bietet nahezu dieselben Lesevorteile wie ein Vollindex, verursacht aber bei Inserts und Updates nur einen Bruchteil der Arbeit, weil er eben nicht jede, sondern nur die relevanten Zeilen umfasst. In Szenarien mit strenger Datenpartitionierung oder historisierten Tabellen ist das fast immer die attraktivste Lösung.

KriteriumEmpfehlung
Abfragehäufigkeit> 100 Runs/Tag oder Einzellaufzeit > 1 s
Tabelle wächst kontinuierlich, Schreib-Last aber moderatIndex bauen
Bulk-Inserts oder Massen-Updates (> 100 000 Zeilen/Stunde)erst Kosten/Nutzen gegen­über­stellen, evtl. Batch-Insert mit INDEX=OFF
Analytischer Workload (DW)lieber Columnstore statt vieler B-Tree-Indizes
Gelegenheits-Abfrage (einmalig, Ad-hoc)Index nicht gebaut – stattdessen Abfrage nachts laufen lassen oder in temp Tabelle vorsortieren
Filtered Index möglichKlarer Gewinner, weil Insert-Kosten minimal bleiben

Ein Index beschleunigt jede SELECT-Operation, verlangsamt aber jede Änderung (Insert, Update, Delete) des betroffenen Schlüssels. Bei reinen Lese-Workloads ist er ein No-Brainer, bei OLTP sollte man genauer rechnen.

Baue einen solchen Komposit-Index, wenn …

* Abfragehäufigkeit ≥ ein paar Hundert pro Tag oder jede Ausführung > sekundenlang dauert.
* Tabelle wächst, aber nicht in allen Spalten; Haupt-Insert-Last liegt nicht auf genau diesem Indexschlüssel.
* Du brauchst keine 100 % Insert-TPS, sondern priorisierst Analyse-Workloads.
Baue keinen Index, wenn …
* Die Analyse ist einmalig / ad-hoc (Tagesgeschäft).
* Bulk-Insert-Rates sind kritisch (ETL-Loads).
* Eine Columnstore-Lösung deckt denselben Use-Case besser ab (Data-Warehouse).

Merke

Columnstore Index – was das ist, wie er funktioniert und warum er unser Beispiel in Lichtgeschwindigkeit beantwortet (Lösung 7)

Was ist ein Columnstore Index?

Ein Columnstore-Index speichert die Daten spaltenweise (column-oriented) statt zeilenweise (row-oriented).
Dadurch ergeben sich drei zentrale Effekte:

EffektWarum er entstehtKonkreter Nutzen
Hohe KompressionWerte in derselben Spalte ähneln sich stark (z. B. viele wiederkehrende OrderCategory-Codes). Das erlaubt Wörterbuch-, Run-Length-, Bitmap- usw. Kompression.5- bis 15-fach kleinere Daten – weniger I/O, höhere Puffer-Trefferquote.
„Batch Mode“-AusführungSQL Server liest jeweils bis zu 1024 Werte einer Spalte in einem Vektor („Batch“) und verarbeitet sie in einem einzigen CPU-Operator.Weniger Funktionsaufrufe, bessere CPU-Cache-Nutzung, oft 5-10× schneller als Zeilenmodus.
Segment-PruningJede Spalte wird in Rowgroups à 1 048 576 Zeilen abgelegt. Für jede Gruppe merkt sich der Engine den Min- und Max-Wert. Passen sie nicht zum Prädikat, wird die gesamte Gruppe übersprungen.Weniger physische Seiten müssen gelesen werden – noch einmal I/O-Ersparnis.

Es gibt zwei Spielarten:

  • Clustered Columnstore Index (CCI)
    Ersetzt den bisherigen Clustered-B-Tree. Die ganze Tabelle liegt spaltenorientiert vor – perfekt für reine Analyse-Tables und Data-Warehouse-Fakten.
  • Nonclustered Columnstore Index (NCCI)
    Zusätzlicher Index auf einer bestehenden Rowstore-Tabelle. Typisch für hybride OLTP-/Reporting-Tabellen, weil Inserts/Updates weiter in der Rowstore-Struktur landen, analytische Abfragen aber den Columnstore nutzen können.

Unser Beispiel mit ColumnStore

Vorbereitung – NCCI anlegen

Angenommen, BI_STAGE.dbo.LW_Order soll weiterhin transaktional befüllt werden (ETL-Ladevorgänge, vereinzelt Updates). Dann wählen wir einen nonclustered Columnstore Index, um Insert-Kosten im Rahmen zu halten:

CREATE NONCLUSTERED COLUMNSTORE INDEX IXCS_LW_Order
ON BI_STAGE.dbo.LW_Order
(
    OrderNumber,
    OrderCategory   -- wir dürfen alle Spalten weglassen, die für die Analyse irrelevant sind;
                    -- SQL Server speichert sie intern trotzdem, aber getrennt komprimiert
);
GO

(In SQL Server 2022 könnte man mit ORDER = (OrderNumber, OrderCategory) sogar die physische Sortierung vorgeben, ist hier aber nicht zwingend.)

Wie beeinflusst der Columnstore den Plan?

  1. Index Scan (Columnstore)
    statt Rowstore-Scan liest der Plan jetzt komprimierte Spaltensegmente.
    – I/O-Volumen sinkt typischerweise von ~800 000 auf <100 000 logische Reads.
  2. Batch Mode Hash Aggregate oder Batch Mode Stream Aggregate
    – Operator verarbeitet ganze „Batches“, nicht einzelne Zeilen.
  3. Parallele Ausführung ist Standard; SQL Server splittet Rowgroups automatisch auf die Worker auf.

Das Resultat:

PlanElapsedCPU-ZeitReads
Rowstore-Index (Stream)≈ 5 s1,8 s820 000
Rowstore-Index (Hash)≈ 4 s8,0 s820 000
Columnstore-Index≈ 0,8 s0,6 s< 100 000

Warum so viel schneller?

  1. Kompression – weniger Seiten müssen von der Festplatte oder aus dem Buffer Pool gelesen werden.
  2. Segment-Elimination – falls nur 70 % der Rowgroups den fraglichen Zeitraum abdecken, überspringt SQL Server 30 % komplett.
  3. Batch Mode – Hash- oder Stream-Aggregates fassen je 1024 Werte zusammen und nutzen Vektor-Instruktionen (SIMD) der CPU.
  4. Parallelität ohne Repartition-Hürden – weil Columnstore ohnehin segmentiert speichert, kann jeder Worker eine oder mehrere Rowgroups selbstständig lesen.

Wann Columnstore nicht passt?

  • Häufige point-lookups (z. B. SELECT … WHERE OrderNumber = 42) – da ist Rowstore-Seek unschlagbar.
  • Sehr hohe Update-Rate – jede Änderung landet erst in einem „Delta-Store“ (Rowstore-Bereich) und muss später in Rowgroups umgewandelt werden. Ab ~10 % Änderungsrate der Tabelle wird das teuer.
  • Notwendigkeit strenger transaktionaler Konsistenz mit vielen Lese-/Schreib-Locks – Batch-Mode kann länger Sperren halten; für OLTP-Workloads riskant.

Take-aways für unser Szenario

  • Analytischer Prüf-Report: Columnstore ist quasi ein Turbo-Boost: zehn Millionen Zeilen in unter einer Sekunde ohne Hint-Akrobatik.
  • Einmaliger Plausibilitäts-Check: Evtl. lohnt sich der Aufbau nicht – ein Columnstore-Index braucht selbst beim Build Zeit und Log-Platz.
  • Hybrid-Workload: NCCI plus ROWGROUP_SIZE = 1000000 (Standard) ist häufig der Sweet Spot – Inserts bleiben akzeptabel, Reports rennen.

Für breit angelegte, leselastige Prüfungen wie „Zähle die einzigartigen Kategorien pro Bestellung“ ist ein Columnstore-Index das effektivste Mittel. Er reduziert I/O, nutzt Batch-Mode-Vektorisierung und parallelisiert quasi von selbst. Entscheidend ist nur, dass die Tabelle nicht im Sekundentakt massenhaft aktualisiert wird – sonst erkauft man sich die Geschwindigkeit beim Lesen mit Verwaltungsballast beim Schreiben.

Kurz Gesagt

Performance-Vergleich

VarianteKernaussageØ Laufzeit*Haupt­ursache für das Ergebnis
Lösung 1(CTE + 2 × Hash Aggregate)„Basisplan“ ohne Hint oder Index37 sVoll­scan, zwei Hash-Tabellen, aber gute Parallelität
Lösung 2(COUNT(DISTINCT) ohne Hint)Sort + Stream-Aggregate52 sSort-Spill in TempDB, seriell → I/O + I/O
Lösung 3(COUNT(DISTINCT) + HASH GROUP)erzwungener Hash-Plan37 swie L1, Hash profitiert von Parallelität
Lösung 4(CTE-Variante mit Komposit-Index)Stream-Aggregates, seriell5 sIndex reduziert I/O → wenig Seiten, kaum CPU
Lösung 5(COUNT(DISTINCT) mit Index)identischer Plan zu L45 sgleiche Ressourcen wie L4
Lösung 6(Index + HASH GROUP)Hash-Parallelplan auf schlankem Index4 sMehrere Threads, Readahead besser genutzt
Lösung 7(Non-clustered Columnstore)Batch-Mode, segmentiert< 1 sstarke Kompression, Batch-Vektor­isierung, Segment-Pruning

In meinem Test-Szenario ergab sich folgende durchschnittliche Ausführungszeiten (10 Ausführungen – Durchschnitt)

Warum unterscheiden sich die Zeiten so stark?

  1. I/O-Menge
    Clustered-Scan (L1–3) liest die ganze Tabelle (≈ 2,6 GB).
    Komposit-Index (L4–6) reduziert das Volumen auf ein Drittel.
    Columnstore (L7) komprimiert zusätzlich und überspringt ganze Rowgroups.
  2. Arbeitsweise
    Hash: braucht keinen Sort, arbeitet parallel, verteuert aber CPU, wenn Speicher knapp wird.
    Stream: unschlagbar, wenn Datensatzfolge bereits sortiert ist; parallel schwerer.
  3. Neben­kosten
    • Sort-Spills in L2 haben TempDB belastet ⇒ +15 s.
    • L6 zahlt mehr CPU (Hash), erhält aber bessere Parallel-Readaheads ⇒ -1 s.

Argumente für die Entscheidung für Lösung 1:

Ihr ArgumentDetail-Ausführung
LesbarkeitDie CTE-Version ist sofort verständlich: „erst DISTINCT, dann zähle“. Kein Hint, keine Fremd­syntax – ideal für Kollegen, die Execution-Pläne kaum lesen.
Kein zusätzlicher IndexAufwand sparen: Das Erstellen des Komposit-Index dauert initla ca 5min. Außerdem würde der Index künftig gepflegt werden müssen (Insert, Update, Delete, Rebuild/ Reorg).
Nur 1-mal täglicher LaufWenn die Query 37 s läuft und nur einmal um 2 Uhr früh ausgeführt wird, ist der Zeitgewinn von 32 s (gegenüber L6) betriebs­wirtschaftlich unkritisch – die Wartungskosten des Index wiegen schwerer.
Null Einfluss auf OLTPOhne neuen Index keine zusätzlichen Latches, keine größere Log-Rate, kein Split-Overhead. In einem System mit vielen Inserts ist das ein schlagendes Argument.
WartungsarmKein Rebuild-Intervall, keine Statistiken auf neuem Index, keine Absprache mit DBA-Team. Die Query ist „fire-and-forget“.
Hint-FreiheitExecution-Hints sind Waffen mit Ablaufdatum: Sie können durch künftige Versionen oder Cardinality-Estimator-Updates kontraproduktiv werden. L1 verzichtet darauf.

Entscheidung:

  • Für eine einzige, nächtliche Prüfung mit klar kalkuliertem Zeitfenster ist Lösung 1 ein vollkommen legitimer Kompromiss zwischen Laufzeit (37 s), Verständlichkeit und Wartungsaufwand = 0.
  • Hätte dieselbe Abfrage ein höheres Aufruf­volumen, würde während Geschäftszeiten laufen oder läge Performance-Druck vor, müsste man den Index (L4/L5) oder gleich Columnstore (L7) erneut in die Waagschale werfen.

maW: In Ihrem Umfeld sind die 32 gesparten Sekunden den dauerhaften Pflege- und Speicher­kosten eines Indexes nicht wert. Die Wahl von Lösung 1 ist daher rational und wartungs­freundlich – solange das Einsatz­szenario nicht wächst oder sich verschiebt.

Fazit

Unsere sieben Varianten rund um COUNT(DISTINCT …) zeigen eindrucksvoll, dass schon eine Nuance in der Formulierung oder ein einzelner Hint das Innenleben des Optimizers komplett umkrempeln kann: mal sortiert der Server, mal hasht er, mal läuft alles brav seriell, mal entfaltet sich volle Parallelität. Wer diese Effekte verstehen und steuern will, kommt nicht darum herum, Execution-Pläne wie ein Laborprotokoll zu studieren – und damit sind wir bei der Frage, ob SQL-Tuning eher „Wissenschaft“ oder „Kunst“ ist.

Wissenschaftlich ist es, weil wir in sauberer Manier vorgehen:

  1. Beobachten & messen – STATISTICS IO/TIME, Wait-Stats, Extended Events.
  2. Hypothesen bilden – „Ist I/O oder CPU der Engpass? Wird der Speicher grant gesprengt?“
  3. Kontrollierte Experimente fahren – Index anlegen, Hint setzen, MaxDOP ändern, alles andere konstant lassen.
  4. Ergebnisse auswerten und reproduzieren – Vorher-/Nachher-Pläne diffen, objektive Kennzahlen vergleichen.

Doch an der Schwelle zur Produktion wird das Ganze schnell zum Handwerk – manchmal sogar zur Kunst:

  • Erfahrung verrät, welcher von fünf Hebeln den größten Hebeleffekt bringt.
  • Komplexe Wechselwirkungen (gleichzeitige Workloads, Backup-Fenster, ETL-Loads) lassen sich nicht exakt modellieren, sondern erfordern Intuition.
  • Und am Ende gilt es, Kompromisse auszuhandeln: Der Index, der einen Report in Sekunden liefert, kann einen nächtlichen Bulk-Load um Stunden verlängern.

Genau deshalb ist unser Weg zur High-Performance-Query immer zweigleisig: methodisch wie ein Wissenschaftler, pragmatisch wie ein Handwerker. Wir messen, erklären und wiederholen – und wir feilen, testen und justieren, bis Kosten-Nutzen, Ressourcen und Geschäftsziele im Lot sind.

So bleibt letztlich nur eine Kernbotschaft: Schönen T-SQL-Code schreiben kann jeder. Wirklich schnelle, robuste Abfragen liefern kann nur, wer Wissenschaft und Handwerk miteinander verbindet. Gelingt das, reduziert man zehn Millionen Zeilen auf 800 000 Reads, hält die CPU bei Laune und hat das Ergebnis trotzdem in wenigen Sekunden auf dem Schirm, ohne dabei Inserts udgl. zu verlangsamen.

Keep calm – it’s only a hash join.