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:
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
