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.

Fallstudie Trendforge Digital GmbH: – 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 ]

Fallstudie Trendforge Digital GmbH:

Ferrari mit Anhängerkupplung: Wenn die Applikation den Server ausbremst

Es gibt Analysen, bei denen du nach zehn Minuten weißt, dass die Serverinfrastruktur tadellos ist. Und dann fragst du dich, warum du eigentlich hier bist. Bei Trendforge Digital GmbH war das genau so — bis du einen Blick in die Top-CPU-Queries wirfst und verstehst, dass der Server gar nicht das Problem ist. Das Problem sitzt in drei Anwendungen, die mit SQL Server reden wie ein Kleinkind mit einem Hochleistungsrechner: laut, ineffizient und ohne jede Rücksicht auf das, was im Hintergrund passiert.

Die Serverinstanz heißt WEBSQL-PROD01. SQL Server 2022 Enterprise, Windows Server 2022, 32 vCPUs über vier NUMA-Knoten, 256 GB RAM, NVMe RAID-10 auf allen Volumes — dediziert für Data, Log und TempDB. Die Konfiguration ist musterhaft: MAXDOP 8, Cost Threshold 50, max server memory 220 GB mit ausreichend Headroom für das OS, RCSI auf allen Produktionsdatenbanken aktiv, Query Store aktiv, Instant File Initialization aktiviert, Kompatibilitätslevel 160 (aktuell). Aus DBA-Sicht gibt es buchstäblich nichts zu beanstanden.

Trotzdem: 78 % CPU-Dauerlast bei gerade einmal 340 gleichzeitigen Nutzern. Der Server ist für 3.000 Nutzer ausgelegt. Die Hardware wird auf einem Elftel ihrer theoretischen Kapazität in die Knie gezwungen — nicht durch zu viele Nutzer, sondern durch drei Anwendungen, die gemeinsam mehr Datenbankabfragen erzeugen als ein ordentlich implementiertes System bei zehnfacher Nutzerlast bräuchte.

 

Praxisbeispiel: WEBSQL-PROD01 — Systemprofil

Hardware: 32 vCPUs (4 NUMA-Knoten × 8 Kerne), 256 GB ECC-RAM

Storage: NVMe RAID-10 Data (D:), NVMe dediziert Log (E:), NVMe dediziert TempDB (F:)

SQL Server 2022 Enterprise, Windows Server 2022, VMware vSphere 8

Konfiguration: max server memory 220 GB, MAXDOP 8, Cost Threshold 50

RCSI: aktiv auf allen Produktionsdatenbanken

TempDB: 32 Dateien (= Anzahl vCPUs), eigenes NVMe-Volume

Backup: täglich voll, Log alle 15 Minuten, NAS + Offsite — RPO < 15 Minuten

Anwendungen: ShopCore (E-Commerce), ContentHub (CMS), DataLens (Analytics)

Messzeitraum: 10:00–12:00 Uhr (Hauptlastfenster), 340 gleichzeitige Nutzer

 

Das Paradox: Ein perfekter Server mit kaputten Gästen

Wenn Collect-SqlPerf.ps1 (Kapitel 31) auf WEBSQL-PROD01 läuft, sieht der erste Teil der Ausgabe ermutigend aus: Page Life Expectancy im Schnitt 9.847 Sekunden — der Buffer Pool ist kerngesund. Storage-Latenzen: Read avg 0,12 ms, Write avg 0,18 ms auf allen Volumes. Memory Grants Pending: 0, Lazy Writes fast null. Das ist kein Server mit einem Performance-Problem. Das ist ein Server, der von seinen Anwendungen schlecht behandelt wird.

Das eigentliche Signal findet sich bei den Batch Requests: 2.847 pro Sekunde im Durchschnitt, Peak 14.891 Batches/sec. Für 340 gleichzeitige Nutzer. Das bedeutet: Jeder Nutzer erzeugt im Schnitt mehr als 8 Datenbankabfragen pro Sekunde. Ein gut implementiertes Web-Frontend braucht für einen typischen Seitenaufruf 2 bis 5 Datenbankabfragen — nicht 200 bis 400. Die Ursache für diese Flut: drei Anwendungen mit unterschiedlichen Teamkulturen, aber einer gemeinsamen Eigenschaft — niemand hat die SQL-Abfragen je auf Performance geprüft.

 

Anwendung

Datenbank

Größe

Technologie

Hauptproblem

ShopCore

ShopDB

184 GB

ASP.NET / Entity Framework 6

N+1-Abfragemuster, NOLOCK-Missbrauch, SELECT *

ContentHub

ContentDB

67 GB

PHP / Doctrine ORM

Kartesisches Produkt, OFFSET-Paging-Antipattern

DataLens

AnalyticsDB

1.840 GB

Python / SQLAlchemy

Keine Indizes auf 214-Mio.-Zeilen-Tabelle

Tab. 34.1: Die drei Anwendungen auf WEBSQL-PROD01 und ihre Hauptprobleme

 

Die Wait Statistics bestätigen das Bild unmissverständlich. SOS_SCHEDULER_YIELD mit 124.847 kumulierten Wartesekunden in 120 Messminuten — das Kennzeichen von CPU-intensiven Operationen ohne natürliche Wait-Punkte. Full Scans, kartesische Produkte, Sortiervorgänge ohne Indexunterstützung. CXPACKET mit 38.412 Sekunden: große Queries werden parallelisiert und ziehen alle verfügbaren Kerne hoch. ASYNC_NETWORK_IO mit 31.247 Sekunden: die Clients können die Ergebnismengen gar nicht schnell genug verarbeiten. Das Muster ist in jedem Wait Type dasselbe: Die Wurzelursache liegt im Anwendungscode, nicht in der Infrastruktur.

 

Wait Type

Kumulierte Wartezeit

Bedeutung

Ursache

SOS_SCHEDULER_YIELD

124.847 s

Worker gibt CPU ab (zu langer Run)

Kartesisches Produkt + Full Scans über 100 Mio. Zeilen

PAGEIOLATCH_SH

47.284 s

Seiten-Lese-Latch (aus Cache)

Full Scans EventLog — selbst aus Cache teuer

CXPACKET

38.412 s

Parallele Thread-Synchronisation

Große kartesische Queries werden parallelisiert

ASYNC_NETWORK_IO

31.247 s

Client verarbeitet Ergebnis zu langsam

Unbegrenzte Resultsets: ShopCore lädt 50.000 Zeilen auf einmal

RESOURCE_SEMAPHORE

8.412 s

Warten auf Memory Grant

Riesige Sort-Operationen ohne Index

LCK_M_S

1.847 s

Shared Lock trotz RCSI

NOLOCK-Hints erzwingen Objekt-Locks

WRITELOG

284 s

Log-Flush-Wait

Normal bei dieser Transaktionslast — DBA: ok

Tab. 34.2: Wait Statistics WEBSQL-PROD01 — alle signifikanten Waits zeigen auf Anwendungscode

 

Was der Server sieht: Die Perfmon-Zeitreihe

Ein Blick auf die CPU-Kennzahlen aus dem Messzeitraum macht das Ausmaß deutlich. Durchschnittlich 78,4 % Prozessorauslastung auf Systemebene — mit Spitzen bis 99,8 %. Der sqlservr-Prozess belegt im Schnitt die Kapazität von 57 der 32 verfügbaren Kerne (Parallelismus-Threads multipliziert über alle aktiven Sessions). Das klingt nach Überlastung, aber es ist nicht die Anzahl der Nutzer, die das erzeugt. Es ist die Qualität des Codes.

 

Indikator

Min

Avg

p95

Max

Einordnung

% Processor Time (System)

12 %

78,4 %

94,1 %

99,8 %

Dauerhaft hoch trotz geringer Nutzerzahl

Batch Requests/sec

124

2.847

8.412

14.891

N+1-Muster erzeugt Abfrageflut

Full Scans/sec

12

184

847

2.341

Fehlende Indizes auf großen Tabellen

SQL Compilations/sec

0,1

0,4

1,2

8,4

Niedrig — Plan-Caching funktioniert gut

Workfiles Created/sec

0

28,4

187

1.204

Erhöht — kartesisches Produkt erzwingt Spills

Page Life Expectancy (s)

3.412

9.847

14.203

18.411

Exzellent — Buffer Pool stabil

Memory Grants Pending

0

0

0

4

Kaum Warteliste — 256 GB helfen

Tab. 34.3: Perfmon-Zeitreihe WEBSQL-PROD01, 10:00–12:00 Uhr

 

Memory und Storage sind die ruhigen Teile dieser Geschichte. Der PLE von 9.847 Sekunden zeigt, dass die Arbeitsdaten aller drei Anwendungen weitgehend im Buffer Pool verbleiben — selbst die ineffizienten Full Scans auf der 214-Millionen-Zeilen-Tabelle EventLog lesen größtenteils aus dem Cache, nicht von Disk. Das verhindert das Schlimmste, löst das CPU-Problem aber nicht. Der Buffer Pool spart IO, aber nicht CPU. 100 Millionen Zeilen aus dem Cache zu sortieren kostet denselben Rechenaufwand wie von Disk — und das erklärt, warum SOS_SCHEDULER_YIELD dominiert, obwohl die Storage-Latenzen bei 0,12 ms liegen. Die Erinnerung aus Kapitel 9: Signal Waits vs. Resource Waits. Hier dominieren Signal Waits — der Scheduler ist überlastet, nicht das Storage-Subsystem.

Die Hall of Shame: Top-CPU-Queries im Überblick

sys.dm_exec_query_stats (in Kombination mit dem Query Store) liefert die acht teuersten Queries des Messzeitraums. Das Muster ist sofort erkennbar: Entweder astronomisch hohe CPU pro Ausführung (kartesisches Produkt, Full Scan auf 214 Mio. Zeilen) oder astronomisch hohe Ausführungszahl bei moderater Einzeldauer (N+1). Beide Muster vernichten CPU — auf völlig unterschiedliche Weisen.

 

#

Anwendung

CPU-ms gesamt

Ausführungen

Avg CPU ms

Avg Reads

Problem

1

ContentHub

84.124.847

1.204

69.871

102.847.123

Kartesisches Produkt: Content × Tags ohne JOIN

2

DataLens

47.284.123

2.847

16.607

214.000.000

Full Scan EventLog (214 Mio. Zeilen) — kein Index

3

ShopCore

12.847.234

284.120

45

312.847

N+1: 284.120 Einzel-SELECTs für Produktpreise

4

ShopCore

8.412.847

284.120

30

187.234

N+1: 284.120 Einzel-SELECTs für Lagerstand

5

ContentHub

4.812.347

84.203

57

48.312.847

OFFSET-Paging Seite >1.000: 24 Mio. Zeilen verworfen

6

ShopCore

3.247.891

14.207

228

8.412.847

Unbegr. Resultset: SELECT * Orders ohne TOP

7

DataLens

2.847.123

412

6.910

28.412.847

Implicit Conversion: VARCHAR-Spalte mit INT

8

ContentHub

1.847.234

120.847

15

1.247.891

Fehlender Index auf ContentItems.PublishedDate

Tab. 34.4: Top-8-CPU-Queries WEBSQL-PROD01 — die "Hall of Shame"

 

Befund 1: N+1 im industriellen Maßstab — ShopCore

Das N+1-Problem ist das bekannteste ORM-Antipattern und trotzdem das häufigste, das in der Praxis auftaucht. Bei ShopCore hat Entity Framework Lazy Loading nie jemand abgeschaltet. Das Ergebnis: Für jeden Aufruf der Produktlistenseite werden 97 einzelne SELECT-Statements abgesetzt. Eine Seite mit 24 Produkten erzeugt 24 Datenbankabfragen für Preise, 24 für Lagerstand, 24 für Bewertungsschnitt, 24 für Bild-URLs — plus die initiale Produktliste. Queries 3 und 4 in der Top-CPU-Liste zeigen jeweils 284.120 Ausführungen in drei Stunden.

sys.dm_exec_query_stats zeigt für diese Hilfsprozeduren eine execution_count von 284.120 im dreiständigen Messzeitraum. Durchschnittliche Dauer pro Query: 0,04 Sekunden — klingt harmlos. Aber: 284.120 Ausführungen × 0,04 Sekunden = 11.365 Sekunden CPU allein für diese eine Abfrageklasse. In drei Stunden Messzeit. Bei 340 Nutzern. Die Einzeldauer ist irrelevant — der Gesamtaufwand ist das, was den Prozessor beschäftigt.

-- Was ShopCore tatsächlich macht (Entity Framework Lazy Loading):
-- Abfrage 1 (die "1"): Produktliste laden
SELECT TOP 24 ProductId, Name, CategoryId
  FROM Products
 WHERE CategoryId = 47
 ORDER BY SortOrder;

 

-- Abfragen 2–25 (die "N"): Preis für jedes Produkt einzeln
SELECT Price, SalePrice FROM ProductPrices WHERE ProductId = 1001;
SELECT Price, SalePrice FROM ProductPrices WHERE ProductId = 1002;
-- ... 22 weitere identische Abfragen ...

 

-- Abfragen 26–49: Lagerstand, Abfragen 50–73: Bewertungsschnitt,
-- Abfragen 74–97: Bild-URL. Gesamt: 97 Roundtrips für eine einzige Seite.

 

-- Was stattdessen eine einzige Query erledigt:
SELECT p.ProductId,
       p.Name,
       pp.Price,
       pp.SalePrice,
       s.Quantity,
       AVG(r.Rating)   AS AvgRating,
       pi.ImageUrl
  FROM Products p
  JOIN ProductPrices  pp  ON pp.ProductId  = p.ProductId
  JOIN Stock           s  ON s.ProductId   = p.ProductId AND s.WarehouseId = 1
  LEFT JOIN ProductRatings r   ON r.ProductId  = p.ProductId
  LEFT JOIN ProductImages  pi  ON pi.ProductId = p.ProductId AND pi.IsPrimary = 1
 WHERE p.CategoryId = 47
 GROUP BY p.ProductId, p.Name, pp.Price, pp.SalePrice, s.Quantity, pi.ImageUrl
 ORDER BY p.SortOrder
 OFFSET 0 ROWS FETCH NEXT 24 ROWS ONLY;

 

-- Ergebnis: 1 Abfrage statt 97. Laufzeit: 4 ms statt 840 ms.
-- In Entity Framework Core: .Include(p => p.Prices).Include(p => p.Stock)
-- oder dedizierte Repository-Methode mit explizitem JOIN.

 

Metrik

N+1 (aktuell)

JOIN (Soll)

Verbesserung

Abfragen pro Seitenaufruf

97

1

−99 %

Roundtrips zum Datenbankserver

97

1

−99 %

Gesamtlaufzeit Seitenaufruf

840 ms

4 ms

−99,5 %

CPU-Last (ShopCore-Anteil)

12.847 s/h

~130 s/h

−99 %

Datenbankverbindungen gleichzeitig

97

1

−99 %

Tab. 34.5: N+1 vs. JOIN — die Zahlen sprechen für sich

 

Entwickleraufwand: 2–3 Tage für alle betroffenen Listenansichten in ShopCore. Die N+1-Fixes auf die übrigen Listenansichten auszuweiten (weitere 16 Seiten im System) erfordert weitere 1–2 Wochen — bringt aber nochmals geschätzte 5 % CPU-Ersparnis. Technisch trivial. Die Schwierigkeit ist nicht der Code, sondern das Bewusstsein: Lazy Loading auf Listenansichten ist keine Kleinigkeit, die man später optimiert. Es ist das teuerste Muster in der gesamten Analyse. Das Gegenteil von einer Kleinigkeit.

Befund 2: Ein Tippfehler kostet 54 % der CPU — ContentHub

Query Nummer 1 in der Top-CPU-Liste ist spektakulär: 84.124.847 CPU-Millisekunden in 120 Messminuten. Das entspricht 23,4 CPU-Stunden — für eine einzige Query, die 1.204-mal ausgeführt wurde. Durchschnittliche CPU pro Ausführung: 69.871 ms. Durchschnittliche Logical Reads: 102.847.123. Die Ursache: In der Tag-Suchfunktion von ContentHub wurde bei einem Refactoring vor acht Monaten eine JOIN-Bedingung versehentlich entfernt. Was als INNER JOIN geplant war, wurde zu einem CROSS JOIN. Das Ergebnis: 84.000 ContentItems × 1.200 Tags = 100.800.000 Zeilenkombinationen pro Suchanfrage. Davon sind nach der WHERE-Filterung etwa 2.400 Zeilen relevant. SQL Server muss alle 100.800.000 erzeugen, sortieren und filtern — für 2.400 Treffer.

-- ContentHub Tag-Suche (fehlerhafter aktueller Code):
-- Die JOIN-Bedingung fehlt seit dem Refactoring vor 8 Monaten.
SELECT c.ContentId, c.Title, c.PublishedDate, t.TagName
  FROM ContentItems c
  CROSS JOIN Tags t       -- <-- Das Problem! War früher: JOIN ContentTags ct ON ...
 WHERE t.TagName LIKE @SearchTerm
 ORDER BY c.PublishedDate DESC;
-- 84.000 × 1.200 = 100.800.000 Zeilen werden erzeugt, dann gefiltert.
-- Avg. Laufzeit: 8,4 Minuten. CPU: 69.871 ms.

 

-- Korrektur (3 Zeilen Änderung):
SELECT c.ContentId, c.Title, c.PublishedDate, t.TagName
  FROM ContentItems c
  JOIN ContentTags ct  ON ct.ContentId = c.ContentId  -- Fehlende Verbindung
  JOIN Tags t          ON t.TagId      = ct.TagId      -- Korrekter Tag-Join
 WHERE t.TagName LIKE @SearchTerm
 ORDER BY c.PublishedDate DESC;
-- Ergebnis: Direkter Index-Seek auf ContentTags + Tags.
-- Avg. Laufzeit: < 50 ms. CPU-Einsparung: ~54 % des Gesamtsystems.

Warum blieb das acht Monate unbemerkt? Die Tag-Suche war durch automatisierte Tests abgedeckt — aber die Tests prüften nur, ob Ergebnisse zurückkommen, nicht ob die Query performant ist. Auf der Testdatenbank mit 500 ContentItems und 50 Tags entsteht ein kartesisches Produkt von 25.000 Zeilen — lästig, aber noch tolerierbar. Auf der Produktionsdatenbank mit 84.000 × 1.200 Zeilen wird daraus ein Systemproblem der Kategorie "DBA ruft mitten in der Nacht an". Drei Zeilen Code-Änderung. Acht Monate unbemerkt. 54 % CPU-Einsparung nach dem Fix. Das ist kein Einzelfall — das ist das häufigste Muster in Analyse-Projekten dieser Art.

 

Warnung: Kartesisches Produkt: Der stille Killer

Ein CROSS JOIN zwischen zwei großen Tabellen ist der schnellste Weg, einen gut konfigurierten Server in die Knie zu zwingen.

Der Fehler ist oft nicht absichtlich: Eine fehlendes ON nach einem Refactoring, eine vergessene WHERE-Bedingung bei einer dynamischen Query, oder ein ORM, das bei bestimmten Include-Kombinationen kein JOIN, sondern ein Kreuzprodukt generiert.

Erkennungszeichen im Ausführungsplan: Nested Loops mit sehr hoher Row-Schätzung, Spills in TempDB, extrem hohe Logical Reads. Query Store zeigt avg_cpu_time in Millionen ms bei vergleichsweise wenigen Ausführungen.

Test-Datenbanken mit realistischer Datenmenge sind keine Kür, sondern Pflicht. 25.000 Zeilen kartesisches Produkt sind "akzeptabel langsam". 100 Millionen Zeilen sind ein Produktionsausfall.

 

Befund 3: 214 Millionen Zeilen, kein einziger Index — DataLens

Die AnalyticsDB enthält eine einzige zentrale Tabelle: EventLog mit 214.847.312 Zeilen. Alle acht Analyse-Reports von DataLens laufen gegen diese Tabelle. Die Tabelle hat exakt einen Index: den Primärschlüssel auf EventId (IDENTITY). Alle acht Report-Queries filtern nach EventDate, UserId oder EventType — keine dieser Spalten ist indiziert. Jede Report-Ausführung ist ein vollständiger Table Scan. Avg. 12 bis 22 Minuten pro Report, je nach Filterkombination. Query Nummer 2 in der Top-CPU-Liste: 47.284.123 CPU-ms, 2.847 Ausführungen, durchschnittlich 214.000.000 Logical Reads — eine exakte Full Scan-Signatur.

-- Tabellenstruktur EventLog (vereinfacht):
CREATE TABLE dbo.EventLog (
    EventId    BIGINT IDENTITY PRIMARY KEY,  -- Einziger Index
    EventDate  DATETIME2,                    -- Kein Index!
    UserId     INT,                          -- Kein Index!
    EventType  NVARCHAR(50),                 -- Kein Index!
    SessionId  UNIQUEIDENTIFIER,             -- Kein Index!
    PageUrl    NVARCHAR(2000),
    Payload    NVARCHAR(MAX)                 -- Manchmal 200 KB groß!
);
-- Aktuell: 214.847.312 Zeilen, wächst um ca. 1,2 Mio. Zeilen/Tag.

 

-- Empfohlene Indizes (nach Impact priorisiert):
-- Prio 1: EventDate — alle 5 Zeit-Reports profitieren
CREATE INDEX IX_EventLog_EventDate
    ON dbo.EventLog (EventDate)
    INCLUDE (UserId, EventType, SessionId)
    WITH (ONLINE = ON, MAXDOP = 8, DATA_COMPRESSION = ROW);
-- Hinweis: Bei 214 Mio. Zeilen ca. 2–4 Stunden Buildzeit — ONLINE einplanen!

 

-- Prio 2: Composite für User-Journey-Report
CREATE INDEX IX_EventLog_User_Date
    ON dbo.EventLog (UserId, EventDate)
    INCLUDE (EventType, SessionId)
    WITH (ONLINE = ON, MAXDOP = 8, DATA_COMPRESSION = ROW);

 

-- DATA_COMPRESSION = ROW spart bei einer 214-Mio.-Zeilen-Tabelle
-- erheblich Speicher und verbessert den IO-Durchsatz beim Index-Aufbau.

 

Report

Filtert nach

Laufzeit Ist

Laufzeit Soll

Fehlender Index

Tagesaktive Nutzer

EventDate (heute)

14,2 Min

< 1 s

IX_EventLog_EventDate

Nutzer-Journey

UserId, EventDate

18,7 Min

< 2 s

IX_EventLog_User_Date

EventType-Auswertung

EventType

11,4 Min

< 1 s

IX_EventLog_EventType

Session-Analyse

SessionId

22,1 Min

< 3 s

IX_EventLog_SessionId

Stunden-Heatmap

EventDate (Stunde)

16,8 Min

< 2 s

wie EventDate-Index

Tab. 34.6: DataLens Report-Queries vor und nach Index-Anlage

 

Zusätzlich erschwerend: Die Spalte Payload enthält JSON-Nutzdaten mit stark variierender Größe — von 20 Bytes bis 200 KB pro Zeile. SQL Server lagert große NVARCHAR(MAX)-Werte auf separate LOB-Pages aus, was bei einem Full Scan nicht nur die 214 Mio. Datenseiten liest, sondern für jede Zeile mit großem Payload auch die zugehörigen LOB-Pages nachlädt. Und: Solange NVARCHAR(MAX) in der Tabelle ist, lässt sich kein Columnstore-Index anlegen — der für analytische Workloads dieser Art ideal wäre. Die mittelfristige Empfehlung: Payload-Spalte in eine separate Tabelle EventPayload auslagern, dann Columnstore auf EventLog aufbauen. Reports würden damit in Sekunden statt Minuten fertig sein.

Befund 4: WITH(NOLOCK) überall — und dabei völlig nutzlos

Ein früherer Entwickler hatte bei ShopCore auf jeden SELECT-Befehl WITH(NOLOCK) gesetzt. Die Idee dahinter ist nachvollziehbar: Blocking-Probleme vermeiden, Abfragen beschleunigen. Das Ergebnis ist zweifach problematisch. Erstens führt WITH(NOLOCK) zu Dirty Reads — Abfragen lesen Daten, die von einer anderen Transaktion geändert, aber noch nicht committed wurden. Im Checkout-Prozess von ShopCore bedeutet das konkret: Kunden sehen Lagerbestände, die gerade in einer offenen Transaktion auf 0 gesetzt werden. Wenn die schreibende Transaktion danach committed, wurde Ware als verfügbar angezeigt, die es nicht ist. Wenn sie zurückrollt, hat der Nutzer Phantom-Daten gesehen, die nie existierten.

-- ShopCore: typischer Code mit NOLOCK (so steht es in jeder Repository-Methode):
SELECT p.ProductId, p.Name, s.Quantity
  FROM Products   p WITH(NOLOCK)
  JOIN Stock       s WITH(NOLOCK) ON s.ProductId  = p.ProductId
  JOIN Categories  c WITH(NOLOCK) ON c.CategoryId = p.CategoryId
 WHERE p.IsActive = 1;

 

-- Das Ironische: Auf diesem Server ist RCSI bereits aktiv.
-- RCSI löst das Blocking-Problem sauber und ohne Dirty Reads.
-- WITH(NOLOCK) ist damit nicht nur schädlich, sondern vollständig überflüssig.

 

-- Zweites Problem: NOLOCK auf Joins kann bei Page Splits
-- Zeilen doppelt oder gar nicht lesen — unabhängig von Transaktionen.

 

-- Der korrekte Code (kein Hint nötig — RCSI übernimmt):
SELECT p.ProductId, p.Name, s.Quantity
  FROM Products   p
  JOIN Stock       s ON s.ProductId  = p.ProductId
  JOIN Categories  c ON c.CategoryId = p.CategoryId
 WHERE p.IsActive = 1;
-- Identische Performance, keine Dirty Reads, kein Blocking.
-- Wer RCSI aktiviert hat, braucht WITH(NOLOCK) nicht mehr. Punkt.

Die Wait Statistics zeigen LCK_M_S mit 1.847 kumulierten Wartesekunden, obwohl RCSI aktiv ist. Das ist kein Widerspruch — WITH(NOLOCK) als Table-Hint erzwingt bei bestimmten Abfragen Objekt-Level Locks statt zeilenebenenbasierter Versionierung, was auf RCSI-Systemen sogar mehr Blocking erzeugen kann als kein Hint. Mit anderen Worten: Der Hint, der Blocking verhindern soll, erzeugt auf diesem System Blocking, das ohne den Hint nicht entstehen würde. Entwickleraufwand: 1 Tag grep-Suche über alle Repository-Klassen. Priorität: hoch — das ist kein Performance-Problem, das ist ein Datenintegritätsproblem. RCSI und warum das der richtige Weg ist, haben wir ausführlich in Kapitel 29 behandelt.

Befund 5: Das OFFSET-Paging-Antipattern — ContentHub

ContentHub verwendet für die CMS-Listenansicht die Standard-SQL-Pagination mit OFFSET/FETCH. Das Problem damit: SQL Server muss für OFFSET n Zeilen zuerst alle n Zeilen lesen und verwerfen, um die erste relevante Zeile zu finden. Seite 1 ist schnell. Seite 4.000 braucht 47 Sekunden, weil SQL Server 95.976 Zeilen einlesen und wegwerfen muss, um 24 Ergebniszeilen zu liefern. Query Nummer 5 in der Top-CPU-Liste: 4.812.347 CPU-ms für 84.203 Ausführungen, durchschnittlich 48.312.847 Logical Reads pro Ausführung. Das ist das Paging-Antipattern im vollen Betrieb — und es betrifft die reguläre Nutzung des CMS-Backends, nicht Edge Cases.

 

Seite

OFFSET-Wert

Verworfene Zeilen

Laufzeit

Nutzerakzeptanz

1

0

0

12 ms

Sehr gut

50

1.176

1.176

280 ms

Akzeptabel

500

11.976

11.976

2.847 ms

Sehr langsam

2.000

47.976

47.976

18.412 ms

Timeout

4.000+

95.976+

95.976+

47.000+ ms

Absturz / Timeout

Tab. 34.7: OFFSET-Paging — Laufzeitentwicklung mit steigender Seitenzahl

 

-- ContentHub: aktueller Code (OFFSET-Paging — wird mit Seitenzahl exponentiell langsamer):
SELECT ContentId, Title, PublishedDate, Author, Status
  FROM ContentItems
 WHERE Status = 'Published'
 ORDER BY PublishedDate DESC
 OFFSET (@Page - 1) * 24 ROWS FETCH NEXT 24 ROWS ONLY;
-- Problem: SQL Server liest und verwirft (@Page-1)*24 Zeilen vor dem Ergebnis.
-- Bei Seite 4.000 = 95.976 verworfene Zeilen für 24 Treffer.

 

-- Keyset-Pagination (korrekte Methode — konstante Laufzeit für jede Seite):
-- Parameter: @LastPublishedDate und @LastContentId aus der vorherigen Seite
SELECT TOP 24 ContentId, Title, PublishedDate, Author, Status
  FROM ContentItems
 WHERE Status = 'Published'
   AND (    PublishedDate  < @LastPublishedDate
         OR (PublishedDate = @LastPublishedDate AND ContentId < @LastContentId)
       )
 ORDER BY PublishedDate DESC, ContentId DESC;
-- Laufzeit: konstant 8–15 ms für jede Seite, egal ob Seite 1 oder Seite 10.000.
-- Voraussetzung: Index auf (PublishedDate DESC, ContentId DESC).
-- Einschränkung: Kein freies Springen auf beliebige Seiten (z.B. "Seite 47").

Keyset-Pagination hat eine bekannte Einschränkung: Das freie Springen auf beliebige Seiten ist nicht möglich. Die Navigation ist immer relativ zur letzten gesehenen Zeile. Für den normalen Anwendungsfall — Vor- und Zurückblättern — ist das kein Problem. Wenn ein Admin-Interface freies Seitenspringen braucht, muss das Feature-Design angepasst werden. Für den Standardfall der meisten Web-Anwendungen ist Keyset-Pagination die einzig skalierbare Lösung. Konstante Performance ist keine Option, sie ist eine Anforderung.

Befund 6: Implicit Conversion — der unsichtbare Index-Killer

In DataLens (Python / SQLAlchemy) wird eine VARCHAR-Spalte mit einem INT-Parameter verglichen. SQL Server führt daraufhin eine implizite Typkonvertierung durch — und kann den vorhandenen Index nicht nutzen. Das ist Query Nummer 7 in der Top-CPU-Liste: 2.847.123 CPU-ms, 412 Ausführungen, avg. Logical Reads 28.412.847. Ein Index auf UserId existiert. Er wird nur nicht verwendet, weil der Parametertyp im Python-Code nicht zum Spaltentyp passt. Das macht Implicit Conversions so heimtückisch: keine Fehlermeldung, korrekte Ergebnisse, nur dramatisch höhere CPU. Das einzige sichtbare Zeichen: ein Warning im Ausführungsplan.

-- DataLens (Python): UserId als Integer übergeben, Spalte ist NVARCHAR(50)
-- Spaltentyp in EventLog: UserId NVARCHAR(50)
-- Parametertyp aus Python: @UserId INT
WHERE UserId = @UserId
-- SQL Server Ausführungsplan zeigt Warning:
-- "Type conversion in expression may affect CardinalityEstimate"
-- CONVERT_IMPLICIT(nvarchar(50), [UserId], 0) = @UserId
-- Index IX_EventLog_User_Date wird nicht genutzt — Full Scan statt Seek.

 

-- Alle Queries mit Implicit Conversion im Plan Cache finden:
SELECT qs.execution_count,
       qs.total_logical_reads,
       qs.total_worker_time / 1000  AS total_cpu_ms,
       CAST(qp.query_plan AS NVARCHAR(MAX)) AS plan_xml
  FROM sys.dm_exec_query_stats qs
 CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
 WHERE CAST(qp.query_plan AS NVARCHAR(MAX)) LIKE '%ConvertIssue%'
 ORDER BY qs.total_logical_reads DESC;

 

-- Lösung Option A: Parameter-Typ im Python-Code anpassen
-- cursor.execute("... WHERE UserId = ?", str(user_id))  # Als String

 

-- Lösung Option B: Spaltentyp zu INT ändern (die bessere Lösung)
ALTER TABLE EventLog ALTER COLUMN UserId INT;
-- Dann stimmen Typ und Parameter überein: kein Implicit Conversion mehr.

In Kapitel 21 (SARGability) haben wir erklärt, warum Typkonvertierungen auf der Spalten-Seite einer WHERE-Bedingung Index-Seeks verhindern. Implicit Conversion ist ein Spezialfall davon: SQL Server konvertiert implizit die Spalte, statt den Parameter — und das macht den Index unbrauchbar. Nach der Korrektur des Parametertyps sinkt avg. Logical Reads von 28 Mio. auf wenige Hundert.

Befund 7: SELECT * ohne Limit — ShopCore Admin-Dashboard

Query Nummer 6 in der Top-CPU-Liste lädt aus dem ShopCore Admin-Dashboard alle Bestellungen für einen ausgewählten Datumsbereich — ohne TOP, ohne FETCH-Limit, mit SELECT *. Bei einem Datumsbereich von mehreren Monaten sind das 1.847.234 Zeilen, übertragen über mehrere Minuten via ASYNC_NETWORK_IO. Interessanterweise ist das nicht einmal böse Absicht: Der ursprüngliche Entwickler wollte dem Admin ermöglichen, alle Bestellungen zu sehen. Das Limit von "realistisch machbaren Bestellungen" lag ursprünglich bei einigen Tausend. Drei Jahre Wachstum später hat ShopCore fast zwei Millionen Bestellungen — und der Code hat das nie erfahren.

-- ShopCore Admin-Dashboard (aktueller Code — alle Bestellungen ohne Limit):
SELECT *
  FROM Orders
 WHERE OrderDate >= @StartDate
   AND OrderDate <= @EndDate;
-- Bei @StartDate = '2024-01-01': 1.847.234 Zeilen, SELECT * auf 38-Spalten-Tabelle,
-- Übertragung dauert Minuten, ASYNC_NETWORK_IO-Wartezeit explodiert.

 

-- Korrektur: Paginierung + nur benötigte Spalten
SELECT OrderId, CustomerId, OrderDate, TotalAmount, Status
  FROM Orders
 WHERE OrderDate >= @StartDate
   AND OrderDate <= @EndDate
 ORDER BY OrderDate DESC
 OFFSET @Page * 100 ROWS FETCH NEXT 100 ROWS ONLY;
-- 100 Zeilen pro Seite. Für Export großer Mengen: asynchroner Hintergrundjob,
-- nicht synchrone Query im HTTP-Request-Zyklus.

Befund 8: Optionale Filter ohne Schutzlimit — ContentHub

In der ContentHub-Codebasis wurden drei gespeicherte Prozeduren identifiziert, die unter bestimmten Bedingungen keine WHERE-Klausel anwenden und damit vollständige Tabellenscans auslösen. Das klassische Muster: optionale Filter mit OR-Logik. Wenn alle Parameter NULL sind, greift kein Filter — SQL Server liefert alle 4,8 Millionen Zeilen zurück. Das passiert bei jedem Klick auf "Alle anzeigen" im CMS-Backend.

-- ContentHub: sp_GetContentByFilter
-- Wenn alle Parameter NULL: kein Filter → Full Scan über 4,8 Mio. Zeilen
SELECT * FROM ContentItems
 WHERE (@CategoryId IS NULL OR CategoryId = @CategoryId)
   AND (@AuthorId   IS NULL OR AuthorId   = @AuthorId)
   AND (@Status     IS NULL OR Status     = @Status);

 

-- Lösung: TOP/FETCH hinzufügen — "Alle anzeigen" zeigt maximal 100 Ergebnisse
SELECT TOP 100 ContentId, Title, PublishedDate, Author, Status
  FROM ContentItems
 WHERE (@CategoryId IS NULL OR CategoryId = @CategoryId)
   AND (@AuthorId   IS NULL OR AuthorId   = @AuthorId)
   AND (@Status     IS NULL OR Status     = @Status)
 ORDER BY PublishedDate DESC;
-- Für vollständige Exports: asynchroner Hintergrundjob, nicht synchrone Query.

Der Maßnahmenplan: Wer macht was — und warum fast alles beim Entwickler liegt

Das Besondere an dieser Fallstudie ist die Verteilung der Verantwortung. Bei Sparfuchs & Partner (Kapitel 33) war der DBA für den größten Teil der Maßnahmen zuständig: Konfiguration korrigieren, Wartungspläne aufsetzen, Speicher bereinigen. Bei Trendforge liegt die Situation fundamental anders. Der DBA kann Indizes anlegen — aber er kann das N+1-Muster nicht im ORM-Code beheben. Er kann die kartesische-Produkt-Query nicht reparieren. Er kann WITH(NOLOCK) nicht aus dem Quellcode entfernen. Ohne die Entwicklerteams ändert sich hier grundlegend nichts. Das ist die unbequeme Wahrheit dieser Fallstudie: Der DBA ist machtlos ohne Entwickler-Kooperation.

 

Befund

Maßnahme

Owner

Aufwand

CPU-Effekt

Priorität

Kartesisches Produkt ContentHub

JOIN-Bedingung ergänzen (3 Zeilen)

Entwickler

0,5 Tage

−54 %

SOFORT

EventLog ohne Index (EventDate)

CREATE INDEX ONLINE

DBA

4–8 Std.

−22 %

SOFORT

NOLOCK-Missbrauch ShopCore

Alle WITH(NOLOCK)-Hints entfernen

Entwickler

1 Tag

0 %*

SOFORT

EventLog ohne Index (UserId)

CREATE INDEX ONLINE

DBA

2–4 Std.

−8 %

SOFORT

N+1 ShopCore Produktliste

Eager Loading / expliziter JOIN

Entwickler

2–3 Tage

−12 %

KURZFRISTIG

Unbegrenzte Resultsets

Paginierung + Spaltenauswahl

Entwickler

1 Tag

−3 %

KURZFRISTIG

OFFSET-Paging ContentHub

Keyset-Pagination implementieren

Entwickler

2 Tage

−4 %

KURZFRISTIG

Implicit Conversion DataLens

Spaltentyp INT, Parameter anpassen

Entw. + DBA

0,5 Tage

−2 %

KURZFRISTIG

EventLog-Partitionierung

Monatspartitionierung nach EventDate

Arch. + DBA

1–2 Wochen

Wartbarkeit

MITTELFRISTIG

Payload auslagern + Columnstore

EventPayload-Tabelle, dann Columnstore

Arch. + Entw.

1 Woche

10× Analytics

MITTELFRISTIG

Tab. 34.8: Maßnahmenplan Trendforge — * NOLOCK-Entfernung ist Integritätsproblem, kein CPU-Thema

 

Die Quick Wins sind außergewöhnlich stark: Die ersten beiden Maßnahmen — kartesisches Produkt in ContentHub beheben und EventDate-Index anlegen — reduzieren die CPU-Last um geschätzte 76 %. Die erste Maßnahme kostet einen halben Entwicklertag und 3 Zeilen Code. Die zweite kostet einen DBA-Arbeitstag für das Index-Building. Beide sind ohne Downtime und ohne Risiko durchführbar. Das ist kein Ergebnis eines aufwendigen Tuning-Projekts — das ist Beheben eines Tippfehlers und Anlegen des offensichtlichsten Index der Welt.

Vorher/Nachher: Was die Maßnahmen tatsächlich bringen

 

Metrik

Vor Optimierung

Nach Quick Wins (Wo. 1–2)

Nach allen Maßnahmen

CPU-Auslastung avg

78,4 %

~22 %

~8 %

Batch Requests/sec avg

2.847

~2.100

~420

ContentHub Tag-Suche

8,4 Min

< 50 ms

< 50 ms

ShopCore Produktliste

840 ms

840 ms

< 80 ms

DataLens Tagesaktive-Nutzer-Report

14,2 Min

< 1 s

< 5 s

DataLens User-Journey-Report

18,7 Min

< 2 s

< 5 s

Unterstützbare Nutzer bei 80 % CPU

~340

~1.200

~3.000+

SOS_SCHEDULER_YIELD kum. pro Stunde

124.847 s

~28.000 s

~4.000 s

Tab. 34.9: Erwarteter Gesamteffekt nach Umsetzung aller Maßnahmen

 

Das ursprüngliche Hardware-Sizing-Ziel — 3.000 gleichzeitige Nutzer bei akzeptabler Last — wird nach vollständiger Umsetzung aller Maßnahmen tatsächlich erreichbar. Nicht durch Hardware-Upgrade. Nicht durch SQL-Server-Konfigurationsänderung. Durch besseren Anwendungscode. Der Server war von Anfang an richtig dimensioniert. Er wurde nie ausgelastet, weil die Anwendungen seine Kapazitäten mit ineffizienten Abfragen verschwendeten. Das ist der Kernbefund dieser Fallstudie.

Diagnose-Kästen: Applikationsbedingte Performance-Probleme erkennen

 

Warnung: Symptome: Wenn der Server nicht schuld ist

CPU-Auslastung > 70 % bei gleichzeitig moderater Nutzerzahl (< 20 % der theoretischen Kapazität)

Batch Requests/sec weit höher als erwartet (> 5 Batches/sec pro aktivem Nutzer)

Top-CPU-Queries: entweder sehr viele Ausführungen mit geringen Reads (N+1) ODER sehr wenige Ausführungen mit astronomischen Reads (kartesisches Produkt / Full Scan)

ASYNC_NETWORK_IO als relevanter Wait Type: Client-seitige Datenverarbeitung als Engpass

SOS_SCHEDULER_YIELD dominiert: CPU-intensive Operationen ohne natürliche Pause

Query Store zeigt avg_cpu_time in Millionen ms für eigentlich einfache Queries

Page Life Expectancy hoch, Storage-Latenzen niedrig — aber CPU trotzdem unter Last

 

 

Tipp: So misst du das: N+1 und Applikationsprobleme mit Query Store identifizieren

— Top-20 nach Ausführungszahl × avg. Logical Reads (N+1-Kandidaten oben):

SELECT TOP 20

qt.query_sql_text,

SUM(rs.count_executions) AS Executions,

AVG(rs.avg_logical_io_reads) AS AvgReads,

SUM(rs.count_executions) * AVG(rs.avg_logical_io_reads) AS TotalReadImpact,

AVG(rs.avg_cpu_time) AS AvgCPU_ms

FROM sys.query_store_query_text qt

JOIN sys.query_store_query q ON q.query_text_id = qt.query_text_id

JOIN sys.query_store_plan p ON p.query_id = q.query_id

JOIN sys.query_store_runtime_stats rs ON rs.plan_id = p.plan_id

GROUP BY qt.query_sql_text

ORDER BY TotalReadImpact DESC;

— N+1-Muster: hohe Executions, niedrige AvgReads, viele ähnliche Query-Texte.

— Kartesisches Produkt: wenige Executions, astronomische AvgReads.

— Beide Muster sind sofort erkennbar, wenn man die Spalten zusammen betrachtet.

 

 

Hintergrund: Typische Fehlinterpretationen

"Der Server ist schuld — wir brauchen mehr Hardware": Der häufigste Reflex. Wenn CPU-Auslastung und Nutzerzahl nicht proportional sind, liegt das Problem in der Code-Qualität, nicht in der Hardware. Ein größerer Server zeigt dasselbe Muster — nur bei doppelter Investition.

"Das ORM ist schuld": Nein. ORM ist ein Werkzeug. Entity Framework, Hibernate, SQLAlchemy — alle können effiziente Abfragen generieren, wenn der Entwickler sie korrekt verwendet. Lazy Loading auf Listenansichten, fehlende Projektionen, unkontrolliertes ToList() — das sind Entwickler-Entscheidungen, keine Framework-Fehler.

"Kleine Queries sind harmlos": Eine Query, die 0,04 ms dauert, ist erst dann harmlos, wenn sie selten ausgeführt wird. 284.120 Ausführungen in 3 Stunden ergeben 11.365 CPU-Sekunden. Die Einzeldauer ist irrelevant — der Gesamtaufwand zählt.

"Performance-Tests decken das ab": Funktionale Tests prüfen Korrektheit, nicht Performance. Eine Query mit kartesischem Produkt liefert korrekte Ergebnisse und besteht jeden Unit Test — auf einer Testdatenbank mit 500 Zeilen. Auf Produktion mit 84.000 Zeilen bricht der Server zusammen.

 

 

Tipp: Erste Gegenmaßnahmen: Was der DBA sofort tun kann

1. Missing Indexes anlegen: sys.dm_db_missing_index_group_stats nach Impact sortieren, Top-10 anlegen. DBA-Aufgabe, keine Entwickler-Koordination nötig.

2. optimize for ad hoc workloads aktivieren (falls nicht aktiv): reduziert Plan-Cache-Pollution durch ORM-generierte Einmal-Queries sofort.

3. N+1-Kandidaten identifizieren und dokumentieren: Query Store nach execution_count > 1.000 und avg_logical_reads < 500 filtern. Mit Query-Text und Execution Count dem Entwicklerteam übergeben.

4. Kartesische Produkte suchen: sys.dm_exec_query_stats nach total_logical_reads DESC — Queries mit > 10 Mio. Reads und < 100 Ausführungen sind Kandidaten für strukturelle Fehler.

5. RESOURCE_SEMAPHORE überwachen: wenn Memory Grants Pending > 0 erscheinen, gibt es unkontrolliert große Queries. Resource Governor als temporäre Begrenzung erwägen (Kapitel 12).

 

Das eigentliche Problem: Kultur, nicht Code

Der DBA bei Trendforge hatte alle diese Probleme erkannt und dokumentiert. Nicht einmal, sondern in regelmäßigen Abständen über 18 Monate. Die Antwort des Entwicklerteams war jedes Mal dieselbe: "Wir haben keine Zeit für Performance-Optimierung. Wir haben Features zu liefern." Das ist keine böse Absicht — das ist eine weit verbreitete Priorisierungsentscheidung in Unternehmen, die Features als Output messen und Performance als Kostenfaktor sehen. Das Ergebnis: 18 Monate Berichte, keine Umsetzung, dann ein Produktionsausfall.

Der Wendepunkt kam durch genau diesen Produktionsausfall: RESOURCE_SEMAPHORE-Stau durch das kartesische Produkt in ContentHub während eines Marketing-Peaks. Alle Queries warteten auf Memory Grants, die Anwendungen wurden unresponsiv, der Server war für 47 Minuten nicht erreichbar. Erst danach war die Priorität gesetzt. Erst danach hatte der DBA einen Raum im nächsten Sprint. Erst danach wurden Performance-Anforderungen in die Definition of Done aufgenommen. Das sollte nicht so laufen müssen.

 

Tipp: Performance als Teil der Definition of Done

Was Trendforge nach der Analyse eingeführt hat:

1. SQL Code Review: Jede neue Datenbankabfrage wird im Code Review auf N+1, SELECT *, fehlende Parameterisierung und kartesische Produkte geprüft. 2 Minuten pro Query.

2. Query-Performance-Tests in CI/CD: Tests, die bei avg. Laufzeit > 500 ms auf der Testdatenbank fehlschlagen.

3. DBA im Sprint-Review: einmal pro Sprint präsentiert der DBA die Top-10 neu aufgetauchten teuren Queries aus dem Query Store. Frühzeitige Erkennung vor dem Produktionsausfall.

4. Definition of Done erweitern: "Feature ist fertig" bedeutet auch: keine neuen N+1-Muster, keine Plan-Warnings im Ausführungsplan, kein kartesisches Produkt in neuen Queries.

 

Dieses Thema kehrt in Teil V immer wieder zurück — nicht, weil es besonders kompliziert wäre, sondern, weil es so häufig ignoriert wird. Die technischen Lösungen für alle acht Befunde bei Trendforge sind bekannt, dokumentiert und trivial umsetzbar. Das Hindernis ist organisatorisch, nicht technisch. Als DBA in so einer Situation zu sein ist frustrierend. Der effektivste Hebel ist nicht die nächste schlaue DMV-Query — es ist der Produktionsausfall, der alle anderen Prioritäten schlagartig neu sortiert. Schöner wäre es, wenn es dafür nicht erst brennen müsste.

Die Befunde im Kontext des Buchs

Alle acht Befunde aus dieser Fallstudie sind in den vorangegangenen Kapiteln ausführlich behandelt. Das N+1-Muster und seine Auswirkungen: Kapitel 30 (ORM & Applikationsdesign) und Kapitel 25 (Batch-Verarbeitung). Das kartesische Produkt und wie man es im Ausführungsplan erkennt: Kapitel 15 (Query Performance & Ausführungspläne). Implicit Conversion und SARGability: Kapitel 21. OFFSET-Paging und Result-Set-Design: Kapitel 24. Fehlende Indizes und Missing Index DMVs: Kapitel 17. RCSI und warum WITH(NOLOCK) damit überflüssig wird: Kapitel 29. Plan Cache-Analyse und Query Store für die Identifikation: Kapitel 18 und 19. Diese Fallstudie ist gewissermaßen der Praxistest für alles, was Teil IV gelehrt hat.

Für die Analyse-Methodik — wie man systematisch von Wait Statistics zu den Anwendungsproblemen kommt — empfehle ich nochmals Kapitel 31. Das Phasenmodell der Performance-Analyse zeigt, wie man verhindert, bei den Symptomen (hohe CPU) stehenzubleiben, ohne die Ursache (schlechter Anwendungscode) zu identifizieren. Bei Trendforge hätte eine strukturierte Analyse nach diesem Modell den Befund in weniger als einer Stunde geliefert — statt 18 Monaten ungenutzter DBA-Berichte. Collect-SqlPerf.ps1 ist dabei der erste Schritt: Die strukturierte Datenerhebung, die in Minuten das vollständige Bild liefert und den Blick auf das Wesentliche lenkt.

Zusammenfassung: Der Ferrari fährt endlich ohne Anhänger

Die Fallstudie Trendforge Digital GmbH ist in vielerlei Hinsicht das Gegenteil von Sparfuchs & Partner. Bei Sparfuchs war alles falsch: Hardware zu schwach, Konfiguration katastrophal, Wartung inexistent, Datenbankdesign fahrlässig. Kein DBA-Werkzeug, das nicht rot leuchtete. Bei Trendforge ist alles richtig — außer dem Code. Das macht diese Fallstudie in der Praxis tatsächlich schwieriger: Der offensichtliche Angeklagte fehlt. Kein misconfigurierter Server, kein fehlender Wartungsplan, kein veralteter Kompatibilitätslevel. Der Täter sitzt in den Quelltexten der drei Anwendungen — und er spricht eine andere Sprache als der DBA.

Die Musterwerk GmbH aus Kapitel 32 repräsentiert den Normalfall: solide Infrastruktur, ein paar Konfigurationsprobleme, klassische gemischte Ursachen. Sparfuchs aus Kapitel 33 repräsentiert den Horror-Fall, der in der Praxis seltener ist als man denkt — aber eindrücklich demonstriert, was passiert, wenn niemand je hinschaut. Trendforge repräsentiert vielleicht den häufigsten Fall der drei: ein gut gebauter Wagen mit schlechten Fahrern. Die Lösung ist kein neues Auto. Die Lösung ist Fahrschule.

Die zentralen Lektionen dieser Fallstudie zusammengefasst:

  • Hardware-Upgrades lösen keine Code-Probleme. Ein größerer Server bei denselben Abfragemustern zeigt dieselben Symptome — nur bei höherem Budget.
  • N+1 ist das häufigste und am einfachsten vermeidbare Performance-Antipattern in ORM-basierten Anwendungen. Eager Loading ist keine Optimierung, sondern der korrekte Standardzustand für Listenansichten.
  • Ein kartesisches Produkt durch eine fehlende JOIN-Bedingung kann mehr als die Hälfte der gesamten Server-CPU verbrauchen. Drei Zeilen Code. Acht Monate unbemerkt. 54 % CPU-Einsparung nach dem Fix.
  • WITH(NOLOCK) ist kein Performance-Feature. Es ist ein Weg, Dirty Reads zu produzieren. Auf RCSI-fähigen Systemen ist es außerdem überflüssig. Entfernen, nicht behalten.
  • SQL-Kompetenz im Entwicklerteam ist keine optionale Zusatzqualifikation. Jeder Entwickler, der Datenbankabfragen schreibt, sollte die Grundlagen von Ausführungsplänen und N+1-Erkennung kennen.
  • Performance ist eine funktionale Anforderung. "Wir haben keine Zeit" ist keine gültige Antwort — es ist eine Entscheidung, die Schulden aufbaut, die sich mit 78 % CPU-Last und Produktionsausfällen verzinsen.
  • Der DBA als Partner, nicht als Feuerwehr. Regelmäßige Query-Store-Reviews im Team verhindern den Eskalationsweg über den Produktionsausfall.
  • Nach vollständiger Umsetzung aller Maßnahmen wird WEBSQL-PROD01 bei 80 % CPU-Last die ursprünglich geplanten 3.000 gleichzeitigen Nutzer unterstützen — ohne eine einzige Hardware-Änderung, ohne eine einzige SQL-Server-Konfigurationsänderung, ohne einen Euro zusätzliche Infrastrukturkosten. Nur besserer Anwendungscode. So einfach. So selten.

    Ausblick: Die Anhänge als Nachschlagewerk — und was in Band 2 wartet

    Damit schließt der inhaltliche Teil dieses Buchs. Was folgt, sind die Anhänge — kein eigenständiges Lehrwerk, sondern ein schnelles Nachschlagewerk für den Alltag. Anhang A enthält das Glossar aller Fachbegriffe, die im Buch verwendet wurden. Anhang B ist die Wait Type-Referenz: alle relevanten Wait Types mit Ursache, Einordnung und Kapitelverweisen — für den Moment, wenn du im Monitoring auf einen unbekannten Wait Type stößt und keine Zeit hast, Kapitel 9 nochmals zu lesen. Anhang C ist die sp_configure-Referenz: Standardwerte, Empfehlungen, und die explizite Verbotsliste der Einstellungen, die du niemals aktivieren solltest — mit Begründung, nicht nur als Dogma.

    Anhang D ist die Diagnose-Checkliste: ein Entscheidungsbaum für den Moment, wenn dein Telefon klingelt und jemand sagt "der SQL Server ist langsam". Sieben Fragen, die dich von den Symptomen zur wahrscheinlichen Ursache führen — basierend auf dem Phasenmodell aus Kapitel 31 und den Mustern aus allen drei Fallstudien. Anhang E ist der DMV-Spickzettel: die 20 wichtigsten Dynamic Management Views mit Beispielabfragen und Interpretationshinweisen für den direkten Einsatz. Anhang F beschreibt Collect-SqlPerf.ps1 — das Script, das in Kapitel 31 als Basis für strukturierte Performance-Analysen vorgestellt wurde, mit Bezugsquelle und Einsatzhinweisen.

    Anhang G gibt einen Vorgeschmack auf Band 2 — SQL Server Betrieb, Security & Plattformen. Wer nach Band 1 weiß, wie SQL Server unter der Haube funktioniert, wer Performance-Probleme systematisch diagnostiziert und wer die drei Fallstudien als Orientierungspunkte verinnerlicht hat — der ist bereit für die nächste Ebene: Hochverfügbarkeit, Security, Betrieb in der Cloud und die Frage, was sich ändert, wenn SQL Server nicht mehr auf bare metal läuft, sondern in einem Azure SQL Managed Instance-Cluster. Aber das ist, wie man so schön sagt, eine andere Geschichte.

     

     

    Abb. 1: N+1-Problem bei Trendforge Digital

     

    Abb. 2: Wait Statistics: Trendforge Digital GmbH