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.

Wait Statistics: – 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 ]

Table of Contents
2
3

Wait Statistics:

Was SQL Server wirklich tut, wenn er nichts tut

Das Schweigen des Servers — und was es uns verrät

Es gibt einen Moment in jedem Performance-Gespräch, der immer wieder kommt: "Der Server ist langsam." Auf die Frage, was genau langsam ist, folgt meistens ein Schulterziehen. Auf die Frage, worauf SQL Server gerade wartet, folgt meistens Schweigen. Dabei ist genau das der Schlüssel — und SQL Server kennt die Antwort. Er schreibt sie in Echtzeit auf.

Wait Statistics sind das Diagnose-Werkzeug, das du immer zuerst konsultierst. Nicht, weil alles andere unwichtig ist, sondern, weil Wait Statistics dir sagen, in welche Richtung du schauen sollst. Ohne dieses erste Orientierungssignal läufst du blind durch ein dunkles Rechenzentrum und hoffst, dass du zufällig über den richtigen Engpass stolperst. Das ist keine Methode. Das ist Kaffeesatzleserei mit ernstem Gesicht.

Das Grundprinzip ist simpel: Jeder Worker-Thread in SQL Server wartet irgendwann auf irgendetwas. Auf eine Seite, die von der Disk gelesen werden muss. Auf einen Lock, den ein anderer Thread hält. Auf einen freien CPU-Scheduler. Oder auf einen Client, der endlich seine Daten verarbeitet. SQL Server zählt diese Wartezeiten mit — für jeden Wait Type separat, kumulativ seit dem letzten Neustart. Das Ergebnis ist eine Landkarte des Leidens. Oder, wenn alles gut läuft, eine Landkarte der relativen Harmlosigkeit.

SQL Server kennt mehrere hundert Wait Types. Die allermeisten davon wirst du in der Praxis nie zu Gesicht bekommen — sie sind interne System-Wartetypen, die mit echter Performance-Diagnose nichts zu tun haben. Etwa 30 bis 40 Wait Types sind es, die in der Praxis den Unterschied machen. Und davon dominiert meistens einer oder zwei das Ranking. Das ist der Engpass. Den löst du.

 

Definition: Wait Type

Ein Wait Type ist eine kategorisierte Beschreibung dafür, worauf ein Worker-Thread wartet. SQL Server kennt über 900 Wait Types — von PAGEIOLATCH_SH (warte auf physischen IO) bis SLEEP_TASK (ich schlafe bewusst, ignorier mich). Die Kunst besteht darin, die relevanten von den harmlosen zu unterscheiden. Und die dringenden von den chronischen.

 

In diesem Kapitel lernst du, Wait Statistics zu lesen, zu interpretieren und — ganz wichtig — nicht falsch zu interpretieren. Denn ein hoher Wert ist nicht automatisch ein Problem. Und ein niedriger Wert ist nicht automatisch eine Entwarnung. Context is king, Baseline is queen. Beide braucht man.

Das konzeptuelle Fundament für alles, was in diesem Kapitel folgt, liegt in Kapitel 4: Dort haben wir den SQLOS-Scheduler, den Buffer Pool und die grundlegende Architektur von SQL Server behandelt. Wer noch nicht dort war, sollte das nachholen — der Wait-Mechanismus macht nur Sinn, wenn man versteht, wie SQL Server intern Threads und Ressourcen verwaltet. Und für die praktische, automatisierte Erhebung von Baseline-Daten über Wochen empfehle ich schon jetzt den Blick auf Kapitel 31, wo das PowerShell-Skript Collect-SqlPerf.ps1 vorgestellt wird. Das Tool nimmt uns einen Großteil der manuellen Snapshot-Arbeit ab.

NUMA-Topologie spielt bei der CPU-Diagnose via Wait Statistics ebenfalls eine wichtige Rolle — wer auf einem NUMA-System mehrere CPU-Knoten hat, wird unterschiedliche Scheduler-Lasten sehen. Das haben wir in Kapitel 1 (Hardware-Grundlagen) und Kapitel 3 (Virtualisierung) grundlegend behandelt, und in Kapitel 11 (Memory Management) kommen wir auf die Wechselwirkung zwischen NUMA und dem Buffer Pool zurück.

Signal Wait vs. Resource Wait: Der fundamentale Unterschied

Bevor wir in einzelne Wait Types eintauchen, müssen wir eine Unterscheidung verstehen, die alles andere strukturiert. SQL Server teilt Wartezeiten in zwei Kategorien auf — und diese Unterscheidung sagt mehr über die Problemursache als der Wait Type alleine.

 

Abb. 9.1: Signal Wait vs. Resource Wait — der fundamentale Unterschied

Resource Wait: Der externe Engpass

Ein Resource Wait entsteht, wenn ein Thread auf eine externe Ressource wartet, die noch nicht verfügbar ist. Die Ressource ist noch nicht da — der Thread kann nicht weiterarbeiten, egal wie viel CPU frei wäre. Typische Beispiele:

  • PAGEIOLATCH_SH/EX: Eine Datenbankseite muss von der Disk gelesen werden. Disk ist zu langsam.
  • LCK_M_X: Ein anderer Thread hält einen Lock, der gerade benötigt wird. Blocking.
  • WRITELOG: Das Transaktionslog-IO ist zu langsam. Log-Subsystem überlastet.
  • ASYNC_NETWORK_IO: Der Client verarbeitet Daten zu langsam. SQL Server wartet auf Bestätigung.
  • RESOURCE_SEMAPHORE: Kein Memory-Grant verfügbar. SQL Server muss auf Speicherfreigabe warten.
  • Resource Waits sind "ehrliche" Waits: Hier gibt es tatsächlich eine externe Ressource, die den Fortschritt blockiert. Das Problem liegt nicht bei der CPU, sondern beim IO, beim Netzwerk, beim Locking oder beim Speicher. Die Lösung liegt entsprechend auch dort — nicht beim CPU-Upgrade.

    Signal Wait: Die unsichtbare CPU-Sättigung

    Ein Signal Wait ist subtiler — und oft gefährlicher, weil er im Task Manager unsichtbar bleibt. Er entsteht, wenn ein Thread seine Arbeit eigentlich erledigt hat, die benötigte Ressource längst verfügbar ist, aber kein Scheduler-Slot frei ist. Der Thread ist runnable — bereit zum Laufen — aber kein CPU-Kern hat gerade Zeit für ihn.

    Das passiert genau dann, wenn die CPU-Last so hoch ist, dass die SQLOS-Scheduler durchgehend besetzt sind. Der Thread landet in der Runnable Queue und wartet dort. Diese Wartezeit erscheint in der signal_wait_time_ms — und genau das ist der CPU-Druck-Indikator. Nicht die CPU-Auslastungsanzeige im Task Manager. Die zeigt nur tatsächlich auf der CPU verbrachte Zeit, nicht die Zeit in der Runnable Queue.

    Was das in der Praxis bedeutet: Du kannst 75% CPU-Last im Performance Monitor sehen und trotzdem massiven CPU-Druck haben — weil die restlichen 25% "Leerlauf" eigentlich Threads sind, die in der Runnable Queue stecken und auf ihren Scheduler-Slot warten. Wait Statistics zeigen, was wirklich los ist.

     

    Warnung: Signal Wait > 20% = CPU unter Druck

    Wenn der Anteil der Signal Waits an der Gesamt-Wartezeit über 20% steigt, ist das ein klares Zeichen für CPU-Sättigung. Nicht "CPU ist ein bisschen beschäftigt" — sondern "der Scheduler hat mehr Arbeit als er abarbeiten kann". Das ist der Moment, ab dem SQL Server anfängt, messbar langsamer zu werden, auch, wenn Top-Level-Metriken noch harmlos aussehen. Typische Richtwerte als Orientierung: unter 5% entspannt, 5–10% normaler Betrieb, 10–20% erhöhte Last (beobachten), 20–30% problematisch, über 30% Notfall.

     

    Wie man Signal Wait vs. Resource Wait berechnet

    In sys.dm_os_wait_stats gibt es zwei Felder pro Wait Type: wait_time_ms und signal_wait_time_ms. Die Differenz ist die reine Resource-Wait-Zeit. Das Verhältnis verrät die CPU-Situation:

    -- Signal Wait Anteil berechnen — der CPU-Gesundheitscheck
    -- signal_wait_time_ms ist die Zeit, die im Runnable-State verbracht wurde
    -- Wert > 20% → CPU unter Druck, Scheduler überlastet
    SELECT
        SUM(signal_wait_time_ms)                        AS SignalWait_ms,
        SUM(wait_time_ms - signal_wait_time_ms)         AS ResourceWait_ms,
        SUM(wait_time_ms)                               AS GesamtWait_ms,
        CAST(100.0 * SUM(signal_wait_time_ms)
             / NULLIF(SUM(wait_time_ms), 0) AS DECIMAL(5,2)) AS SignalWaitAnteil_Pct
    FROM sys.dm_os_wait_stats
    -- Idle-Waits ausfiltern, die das Bild verzerren würden
    WHERE wait_type NOT IN (
        'SLEEP_TASK', 'WAITFOR', 'BROKER_TO_FLUSH', 'BROKER_TASK_STOP',
        'CLR_AUTO_EVENT', 'DISPATCHER_QUEUE_SEMAPHORE', 'FT_IFTS_SCHEDULER_IDLE_WAIT',
        'HADR_WORK_QUEUE', 'ONDEMAND_TASK_QUEUE', 'REQUEST_FOR_DEADLOCK_SEARCH',
        'RESOURCE_QUEUE', 'SERVER_IDLE_CHECK', 'SLEEP_DBSTARTUP', 'SLEEP_DBREPLICATION',
        'SLEEP_MASTERDBREADY', 'SLEEP_MASTERMDREADY', 'SLEEP_MASTERUPGRADED',
        'SLEEP_MSDBSTARTUP', 'SLEEP_TEMPDBSTARTUP', 'SNI_HTTP_ACCEPT',
        'SP_SERVER_DIAGNOSTICS_SLEEP', 'SQLTRACE_BUFFER_FLUSH', 'WAIT_XTP_OFFLINE_CKPT_NEW_LOG',
        'XE_DISPATCHER_WAIT', 'XE_TIMER_EVENT'
    );

    Das Ergebnis dieser Query ist eine Sofortdiagnose in einer Zahl. Ein Signal-Wait-Anteil von 4% sagt: CPU entspannt, schau woanders. Ein Wert von 34% — wie wir ihn bei Sparfuchs & Partner (Kapitel 33) gesehen haben — sagt: CPU ist der primäre Engpass, und zwar nachhaltig.

    sys.dm_os_wait_stats: Die Schaltzentrale der Diagnose

    Die View sys.dm_os_wait_stats ist die zentrale DMV für die Wait-Analyse. Sie enthält kumulierte Wartezeiten seit dem letzten SQL Server-Start — alle Wait-Ereignisse seit dem Neustart summiert. Das macht sie zur Basis für Delta-Berechnungen: Wir wollen nie den absoluten Wert seit Serverstart, sondern die Veränderung in einem definierten Zeitfenster. Ein Server der seit 6 Monaten läuft, hat riesige absolute Werte — die meisten davon aus Zeiten die längst vergangen sind.

    Folgende Query ist der Startpunkt für jede Wait-Analyse. Sie filtert Idle-Waits (die keinen echten Engpass darstellen), berechnet Prozentwerte und sortiert nach der höchsten Wartezeit:

    -- Top-20 Wait Types, bereinigt um harmlose Idle-Waits
    -- Grundlage für jede Performance-Analyse — diese Query täglich kennen!
    WITH FilteredWaits AS (
        SELECT
            wait_type,
            wait_time_ms,
            signal_wait_time_ms,
            waiting_tasks_count,
            -- Durchschnittliche Wartezeit pro Ereignis
            CASE WHEN waiting_tasks_count > 0
                 THEN wait_time_ms / waiting_tasks_count
                 ELSE 0 END                              AS avg_wait_ms
        FROM sys.dm_os_wait_stats
        -- Diese Wait Types sind systeminterne Idle-Wartezeiten
        -- Sie sehen nach Problemen aus, sind aber komplett normal
        WHERE wait_type NOT IN (
            'SLEEP_TASK', 'WAITFOR', 'BROKER_TO_FLUSH', 'BROKER_TASK_STOP',
            'CLR_AUTO_EVENT', 'DISPATCHER_QUEUE_SEMAPHORE', 'FT_IFTS_SCHEDULER_IDLE_WAIT',
            'HADR_WORK_QUEUE', 'ONDEMAND_TASK_QUEUE', 'REQUEST_FOR_DEADLOCK_SEARCH',
            'RESOURCE_QUEUE', 'SERVER_IDLE_CHECK', 'SLEEP_DBSTARTUP',
            'SLEEP_DBREPLICATION', 'SLEEP_MASTERDBREADY', 'SLEEP_MASTERMDREADY',
            'SLEEP_MASTERUPGRADED', 'SLEEP_MSDBSTARTUP', 'SLEEP_TEMPDBSTARTUP',
            'SNI_HTTP_ACCEPT', 'SP_SERVER_DIAGNOSTICS_SLEEP', 'SQLTRACE_BUFFER_FLUSH',
            'WAIT_XTP_OFFLINE_CKPT_NEW_LOG', 'XE_DISPATCHER_WAIT', 'XE_TIMER_EVENT'
        )
        AND wait_time_ms > 0
    ),
    Summe AS (
        SELECT SUM(wait_time_ms) AS total_ms FROM FilteredWaits
    )
    SELECT TOP 20
        fw.wait_type,
        fw.waiting_tasks_count,
        fw.wait_time_ms,
        fw.signal_wait_time_ms,
        fw.wait_time_ms - fw.signal_wait_time_ms       AS resource_wait_ms,
        fw.avg_wait_ms,
        -- Prozentualer Anteil am Gesamtwait — das ist die wichtigste Spalte
        CAST(100.0 * fw.wait_time_ms / s.total_ms AS DECIMAL(5,2)) AS pct_total
    FROM FilteredWaits fw
    CROSS JOIN Summe s
    ORDER BY fw.wait_time_ms DESC;

     

    Abb. 9.2: Beispiel Top-10 Wait Types — MWSQL01 während der Stoßzeit

    Delta-Technik: Was in diesem Zeitfenster passiert ist

    Das Ergebnis der obigen Query ist kein Absolutwert-Ranking seit Serverstart, sondern ein Snapshot aller kumulierten Werte. Für echte Diagnose brauchst du Deltas: Nimm einen Snapshot, warte 15–30 Minuten (idealerweise während der Stoßzeit), nimm einen zweiten Snapshot, bilde die Differenz. Was in diesem Zeitfenster gewachsen ist — das ist das, worauf dein Server gerade am meisten wartet.

    -- Snapshot-Technik: Zwei Messungen, dann Differenz berechnen
    -- Schritt 1: Snapshot in eine temporäre Tabelle schreiben
    IF OBJECT_ID('tempdb..#WaitSnapshot1') IS NOT NULL DROP TABLE #WaitSnapshot1;
    SELECT
        wait_type,
        wait_time_ms,
        signal_wait_time_ms,
        waiting_tasks_count,
        GETDATE() AS snapshot_time
    INTO #WaitSnapshot1
    FROM sys.dm_os_wait_stats
    WHERE wait_type NOT IN (
        'SLEEP_TASK', 'WAITFOR', 'CLR_AUTO_EVENT', 'DISPATCHER_QUEUE_SEMAPHORE',
        'HADR_WORK_QUEUE', 'ONDEMAND_TASK_QUEUE', 'REQUEST_FOR_DEADLOCK_SEARCH',
        'RESOURCE_QUEUE', 'SERVER_IDLE_CHECK', 'XE_DISPATCHER_WAIT', 'XE_TIMER_EVENT'
    );
    -- Schritt 2: Warte 15–30 Minuten — idealerweise während der Problemzeit
    -- In Produktivumgebungen via SQL Agent Job steuern, nicht manuell sitzen
    -- Schritt 3: Delta berechnen — das ist was in diesem Zeitfenster passiert ist
    SELECT TOP 20
        w2.wait_type,
        w2.wait_time_ms - ISNULL(w1.wait_time_ms, 0)           AS delta_wait_ms,
        w2.signal_wait_time_ms - ISNULL(w1.signal_wait_time_ms, 0) AS delta_signal_ms,
        w2.waiting_tasks_count - ISNULL(w1.waiting_tasks_count, 0) AS delta_count,
        -- Durchschnittliche Wartezeit pro Ereignis im Messfenster
        CASE WHEN (w2.waiting_tasks_count - ISNULL(w1.waiting_tasks_count, 0)) > 0
             THEN (w2.wait_time_ms - ISNULL(w1.wait_time_ms, 0))
                  / (w2.waiting_tasks_count - ISNULL(w1.waiting_tasks_count, 0))
             ELSE 0 END                                         AS avg_delta_wait_ms
    FROM sys.dm_os_wait_stats w2
    LEFT JOIN #WaitSnapshot1 w1 ON w1.wait_type = w2.wait_type
    WHERE (w2.wait_time_ms - ISNULL(w1.wait_time_ms, 0)) > 0
    ORDER BY delta_wait_ms DESC;

    sys.dm_os_waiting_tasks: Die Echtzeit-Perspektive

    Während sys.dm_os_wait_stats die historische Aggregation zeigt, liefert sys.dm_os_waiting_tasks einen Live-Blick: Welche Sessions warten gerade jetzt auf welche Ressource? Das ist besonders nützlich, wenn ein Benutzer anruft und sagt "es hängt gerade" — dann nicht erst Snapshots bauen, sondern direkt nachschauen:

    -- Aktuelle Wartezeiten in Echtzeit — wer wartet gerade auf was?
    -- Diese Query ausführen, wenn ein Problem akut ist, nicht danach
    SELECT
        wt.session_id,
        wt.wait_type,
        wt.wait_duration_ms,               -- Wie lange wartet dieser Thread schon?
        wt.blocking_session_id,            -- NULL, wenn kein Blocking, sonst: der Schuldige
        wt.resource_description,           -- Welche Ressource konkret? (Seite, Lock-Object...)
        s.status,
        s.login_name,
        s.program_name,
        -- Letzter ausgeführter SQL-Text — Achtung: kann NULL sein
        SUBSTRING(t.text, 1, 300)          AS query_text
    FROM sys.dm_os_waiting_tasks wt
    JOIN sys.dm_exec_sessions s
        ON wt.session_id = s.session_id
    -- Optionaler Join auf SQL-Text — kann CROSS APPLY Performance kosten
    OUTER APPLY sys.dm_exec_sql_text(
        (SELECT sql_handle FROM sys.dm_exec_requests r
         WHERE r.session_id = wt.session_id)
    ) t
    WHERE wt.session_id > 50              -- System-Sessions ausblenden
    ORDER BY wt.wait_duration_ms DESC;

     

    Tipp: Delta-Snapshots mit Collect-SqlPerf.ps1

    Das PowerShell-Skript Collect-SqlPerf.ps1 aus Kapitel 31 nimmt automatisiert Wait-Snapshots in konfigurierbaren Intervallen und speichert die Deltas in einer Tabelle. Damit baust du über Wochen eine Baseline auf, die dir zeigt, was "normal" für deine Workload ist — und was als Anomalie auffällt. Ohne dieses Langzeit-Bild ist jede Momentaufnahme wertlos.

     

    Die wichtigsten Wait Types im Detail

    SQL Server kennt über 900 Wait Types. Die meisten davon wirst du nie sehen — oder wenn, dann so selten, dass sie das Ranking nicht dominieren. Wir konzentrieren uns auf die Wait Types, die in der Praxis den Unterschied machen — und die ausreichend verstanden werden müssen, um die richtige Diagnoserichtung einzuschlagen.

    PAGEIOLATCH_SH und PAGEIOLATCH_EX: Der IO-Klassiker

    PAGEIOLATCH-Waits entstehen, wenn SQL Server eine Datenbankseite (8 KB) aus dem Buffer Pool lesen will, die Seite aber noch nicht dort ist und von der Disk geladen werden muss. SH (Shared) bedeutet, jemand will die Seite lesen. EX (Exclusive) bedeutet, jemand will sie schreiben. Das ist physischer Disk-IO — und, wenn er langsam ist, siehst du hier die Wartezeit.

    PAGEIOLATCH_SH als dominanter Wait Type ist meistens ein klarer Befund: Der Buffer Pool ist zu klein (Working Set passt nicht in den Cache), die Disk ist zu langsam, oder es gibt einen Full Table Scan der unnötigerweise riesige Mengen an Daten liest und den Cache überschwemmt. Konkrete Latenz-Richtwerte für physischen IO:

  • NVMe SSD: unter 0,2 ms pro IO — PAGEIOLATCH_SH unter 1 ms ist absolut normal
  • SATA/SAS SSD: 0,5 bis 2 ms — bis 5 ms noch akzeptabel bei Stoßlast
  • Spindel (7.200 RPM): 5 bis 20 ms — Einzelzugriffe, bei Parallelzugriffen schlechter
  • Sparfuchs-Szenario (alle Dateien auf einer Spindel): p95 bei 312 ms Write
  • Die Diagnose führt fast immer nach Kapitel 10 (IO-Performance) oder Kapitel 11 (Memory Management) — je, nachdem ob das Problem das Disk-Subsystem selbst ist oder der Buffer Pool zu klein ist um das Working Set zu halten.

     

    Hintergrund: PAGEIOLATCH vs. PAGELATCH — der entscheidende Unterschied

    PAGEIOLATCH (mit IO) = physischer Disk-IO. Die Seite ist nicht im Cache, SQL Server wartet auf die Disk. PAGELATCH (ohne IO) = In-Memory-Zugriff auf eine Seite, die bereits im Buffer Pool ist. Kein Disk-IO. Kann TempDB-Contention sein (PFS/GAM-Seiten) oder andere Hot-Pages. Disk-Upgrade hilft hier nicht — das ist ein vollkommen anderes Problem. Wer PAGELATCH_EX mit einem neuen SSD lösen will, hat das Problem falsch diagnostiziert. Der häufigste Grund für PAGELATCH_EX in TempDB ist zu wenige TempDB-Datendateien — nicht zu langsame Disk. Kapitel 13 behandelt das ausführlich.

     

    PAGELATCH_EX und PAGELATCH_SH: TempDB-Contention

    PAGELATCH ohne das "IO" im Namen bedeutet: Die Seite ist im Buffer Pool, aber mehrere Threads konkurrieren gleichzeitig um Schreibzugriff auf sie. Das häufigste Szenario in der Praxis ist TempDB-Contention auf den PFS- und GAM-Seiten — spezielle Verwaltungsseiten, die SQL Server aktualisiert, wenn temporäre Objekte angelegt oder gelöscht werden.

    Bei einem System mit vielen parallelen Sitzungen die alle TempDB nutzen — Sortierungen, Hashjoins, temporäre Tabellen, Tabellenvariablen — wird die PFS-Seite zum Flaschenhals. Die Lösung ist nicht mehr RAM, nicht schnellere Disk. Die Lösung ist mehr TempDB-Datendateien. Wie viele? Das schauen wir uns in Kapitel 13 (TempDB) genau an.

    LCK_M_*: Die Locking-Familie

    LCK_M_S, LCK_M_X, LCK_M_U und ihre Varianten (IS, IX, SIX…) sind Lock-Wait-Types. Ein Thread wartet auf einen Lock, den ein anderer Thread hält. Das ist Blocking — und es hat nichts mit IO oder CPU zu tun. Die detaillierte Diagnose folgt in Kapitel 14 (Blocking und Deadlocks). Hier nur die wichtigsten drei:

  • LCK_M_X: Exclusive Lock — jemand will schreiben, ein anderer liest oder schreibt noch. Klassische Blocking-Situation.
  • LCK_M_S: Shared Lock — jemand will lesen, ein anderer hält noch einen X-Lock. Lesen muss warten bis Schreiben fertig.
  • LCK_M_U: Update Lock — Vorstufe zum X-Lock. Wenn zwei Transaktionen gleichzeitig U-Locks anfragen, droht Deadlock.
  • Wenn LCK_M_*-Wait-Types prominent sind, ist die erste Anlaufstelle sys.dm_exec_requests mit dem Feld blocking_session_id. Der Wert dort zeigt die Session, die den Lock hält. Was macht sie? Warum hält sie den Lock so lange? Meistens ist die Antwort: offene Transaktion, die sich nicht beeilt.

    -- Blocking-Kette sichtbar machen — wer blockiert wen?
    -- blocking_session_id = NULL heißt: dieser Thread ist selbst nicht geblockt
    SELECT
        r.session_id,
        r.status,                          -- 'suspended' = wartet auf etwas
        r.wait_type,
        r.wait_time,                       -- ms bereits gewartet
        r.blocking_session_id,             -- 0 oder NULL = nicht geblockt
        r.command,
        DB_NAME(r.database_id)             AS Datenbank,
        SUBSTRING(t.text, 1, 200)          AS query_text
    FROM sys.dm_exec_requests r
    CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
    -- Nur Sessions die gerade geblockt sind
    WHERE r.blocking_session_id IS NOT NULL
      AND r.blocking_session_id > 0
    ORDER BY r.wait_time DESC;

    CXPACKET und CXCONSUMER: Parallelismus — aber welcher?

    CXPACKET ist der Wait Type, der am häufigsten falsch interpretiert wird. Er entsteht bei parallelen Abfrageausführungen — Threads eines Parallel-Plans warten aufeinander. Das sieht dramatisch aus. Meistens ist es harmlos.

    Seit SQL Server 2016 SP1 gibt es CXCONSUMER als separaten Wait Type. CXCONSUMER ist fast immer harmlos — er entsteht beim Konsumieren von Daten aus dem parallelen Dataflow. Consumer warten darauf, dass Producer ihnen Daten liefern, und das ist normales Parallelismus-Verhalten. CXPACKET dagegen kann auf echten Parallelismus-Skew hindeuten: Ein Thread hat deutlich mehr Arbeit als die anderen und alle müssen warten bis er fertig ist.

    Parallelismus-Skew entsteht klassischerweise durch schlechte Kardinalitätsschätzungen (Kapitel 16) oder ungleiche Datendistribution. SQL Server verteilt Arbeit auf Basis seiner Schätzungen — wenn die Schätzung falsch ist, bekommt ein Thread überproportional viel Arbeit. Das ist dann ein Problem das in Kapitel 15 (Ausführungspläne) gelöst wird.

     

    Hinweis: CXPACKET: Erst den Signal-Wait-Anteil prüfen

    CXPACKET alleine sagt wenig. Prüfe zuerst den Signal Wait-Anteil. Wenn der niedrig ist, läuft die CPU gut und der CXPACKET-Wait ist normaler Koordinations-Overhead. Erst, wenn Signal Waits gleichzeitig hoch sind, wird CXPACKET zum CPU-Problem — dann haben die Parallel-Threads tatsächlich keinen freien Scheduler. In diesem Fall hilft MAXDOP-Reduzierung: Weniger parallele Threads bedeuten weniger Scheduler-Bedarf. MAXDOP-Berechnung haben wir in Kapitel 5 (Serverkonfiguration) behandelt.

     

    SOS_SCHEDULER_YIELD: Der direkteste CPU-Hunger-Indikator

    SOS_SCHEDULER_YIELD ist das direkteste Zeichen für CPU-Sättigung. Dieser Wait Type entsteht, wenn ein Worker-Thread seinen Zeitschlitz (Quantum) auf dem Scheduler aufgebraucht hat und freiwillig zurückgibt, um anderen Threads CPU-Zeit zu ermöglichen. Er tritt in die Runnable Queue — und wartet dort.

    In einer entspannten Situation ist SOS_SCHEDULER_YIELD selten und die Wartezeiten sind kurz. Wenn dieser Wait Type in den Top-5 erscheint und die Wartezeiten hoch sind, ist die CPU nachhaltig überlastet. Der SQLOS-Scheduler (Kapitel 4) gibt jedem Thread ein Quantum von ca. 4 ms. Wenn ein Thread dieses Quantum aufbraucht, ohne fertig zu werden, muss er nachgeben. Bei einfachen, schnellen Abfragen kommt das selten vor — bei komplexen, CPU-intensiven Berechnungen sehr häufig.

    Bei Sparfuchs & Partner (Kapitel 33) fanden wir SOS_SCHEDULER_YIELD auf Platz 2 der Wait-Statistik — direkt nach WRITELOG. 4 vCPUs, 360.000 Funktionsaufrufe pro Query. Die Funktion fn_GetSteuersatz wurde in 23 von 31 Stored Procedures eingesetzt, wurde als skalare UDF implementiert (also nicht parallelisierbar) und erzwang für jeden der 360.000 Aufrufe einen separaten Scheduler-Durchgang. Die CPU hatte buchstäblich keine Chance. Parameter Sniffing und skalare UDFs als CPU-Kostenfaktor kommen in Kapitel 18 und Kapitel 26 noch einmal zur Sprache.

    THREADPOOL: Der Notfallknopf

    THREADPOOL ist der Wait Type, bei dem alle anderen Probleme in den Hintergrund treten. Er entsteht, wenn keine freien Worker Threads mehr verfügbar sind. SQL Server versucht, eine neue Anfrage auszuführen, aber alle Worker sind beschäftigt. Die Anfrage wartet, bis ein Thread frei wird. Im schlimmsten Fall wird sie gar nicht mehr angenommen.

    Das SQL Server Error Log zeigt in diesem Zustand Fehler 17189: "SQL Server konnte keinen neuen Thread anlegen." Neue Verbindungsversuche schlagen fehl. Das System ist de facto nicht mehr erreichbar. Ursache ist fast immer eine Blocking-Kaskade: Wenige Blocker halten viele Threads fest, die Threads stauen sich, der Pool läuft voll.

     

    Warnung: THREADPOOL = sofortige Eskalation

    Wenn THREADPOOL in den Wait Statistics erscheint und wächst, ist das keine "beobachte ich mal"-Situation. Neue Anfragen werden möglicherweise nicht mehr angenommen. Erste Maßnahme: max worker threads temporär erhöhen (sp_configure max worker threads). Das ist ein Pflaster, keine Heilung. Danach sofort Blocking-Analyse: Wer hält die langen Locks? Was macht die Blocker-Session? Meistens ist die Antwort eine lange offene Transaktion oder ein fehlgeschlagener Client der seinen Commit nie gesendet hat.

     

    WRITELOG: Das Transaktionslog kämpft

    WRITELOG entsteht, wenn SQL Server darauf wartet, dass Transaktions-Log-Einträge auf die Disk geschrieben werden. Transaktionen können erst als committed gelten, wenn der Log-Flush abgeschlossen ist (Write-Ahead Logging). Ein langsames Log-Subsystem erzeugt direkt WRITELOG-Waits — und langsame Transaktionen.

    Typische Ursachen für hohe WRITELOG-Wartezeiten:

  • Log-Datei auf einem langsamen Volume — besonders schlimm: Log und Data auf dem gleichen Volume oder gar auf einer Spindel
  • Zu viele kleine Transaktionen — jede braucht einen Log-Flush, auch, wenn sie nur eine Zeile ändert
  • Explosive VLF-Anzahl — bei Sparfuchs & Partner: 48.312 VLFs. Das letzte Log-Backup war nie gemacht worden.
  • Log-Backup zu selten — das Log kann nicht truncated werden, wächst weiter, VLFs multiplizieren sich
  • Der Zusammenhang zwischen WRITELOG und VLFs ist subtil aber real: Ein Transaktionslog mit tausenden VLFs (Virtual Log Files) erzeugt mehr internes Management-Overhead beim Log-Flush. In Kapitel 6 (Datenbankeinstellungen) haben wir die VLF-Thematik ausführlich behandelt — wer das noch nicht gelesen hat, bekommt dort den vollen Kontext für das Sparfuchs-Desaster.

     

    Tipp: Commit-Latenz mit Extended Events messen

    Um die tatsächliche Commit-Latenz zu messen, kann man ein Extended Event auf sql_transaction mit opcode=commit setzen. Das zeigt, wie lange ein einzelner COMMIT tatsächlich dauert — inklusive WRITELOG-Zeit. Liegt die durchschnittliche Commit-Latenz über 5 ms, ist das Log-Subsystem der Engpass. Delayed Durability ist ein temporärer Ausweg für weniger kritische Workloads: Sie reduziert WRITELOG-Waits drastisch auf Kosten von Datenverlustrisiko bei Serverabsturz. Nur mit Bedacht einsetzen.

     

    ASYNC_NETWORK_IO: Der unterschätzte Kandidat

    ASYNC_NETWORK_IO ist der Wait Type, bei dem SQL Server auf den Client wartet — nicht umgekehrt. Die Abfrage ist fertig, das Ergebnis steht bereit, aber der Client verarbeitet die Daten nicht schnell genug. SQL Server schickt einen Batch Ergebnisdaten, wartet bis der Client bereit ist für den nächsten Batch, und das dauert.

    Wenn ASYNC_NETWORK_IO prominent erscheint: Überprüfe die Result-Set-Größen. Schickt deine Anwendung SELECT * auf große Tabellen? Gibt es N+1-Probleme die tausende Ergebnismengen produzieren? Manchmal ist ASYNC_NETWORK_IO auch ein Symptom für langsame Netzwerkverbindungen zum Applikationsserver oder für einen Applikationsserver der schlicht ausgelastet ist.

    Bei Trendforge Digital GmbH (Kapitel 34) war ASYNC_NETWORK_IO einer der Top-Wait-Types — direkt verursacht durch Entity Framework N+1-Probleme. Für jedes Elternobjekt wurde eine separate Abfrage für die Kindobjekte ausgeführt. 500 Bestellungen → 500 separate Abfragen für die Bestellpositionen → 500 Mal ASYNC_NETWORK_IO. Der DBA konnte das Problem ohne Mitarbeit der Entwickler nicht lösen — eine typische Trendforge-Situation. Kapitel 30 (ORM & Applikationsdesign) behandelt N+1 ausführlich.

    RESOURCE_SEMAPHORE: Memory Grants im Stau

    RESOURCE_SEMAPHORE entsteht, wenn eine Abfrage auf ihren Memory Grant wartet — den Speicher, der für Sortierungen, Hash Joins und andere speicherhungrige Operationen reserviert wird. SQL Server verwaltet diese Grants aus einem begrenzten Pool (standardmäßig 25% des Buffer Pool). Wenn alle Grants vergeben sind, warten neue Abfragen in der Grant-Warteschlange.

    Fehlende Indizes sind ein häufiger Auslöser: Ohne Index wählt SQL Server oft einen Hash Join, der mehr Memory benötigt als ein Nested Loop über einen Index. Bei Trendforge hatten fehlende Indizes genau diesen Effekt — Hash Joins überall, hohe Memory-Grant-Anfragen, RESOURCE_SEMAPHORE als Folge. Kapitel 12 (Memory Grants und Spills) behandelt das Thema vollständig.

    IO_COMPLETION und DISKIO_SUSPEND: Async IO

    IO_COMPLETION und der seltenere DISKIO_SUSPEND treten auf, wenn asynchrones IO noch nicht abgeschlossen ist. SQL Server nutzt für viele IO-Operationen Async-IO: Die Anfrage wird abgeschickt, der Thread macht etwas anderes, und wartet dann auf IO_COMPLETION, wenn er das Ergebnis braucht. Das ist kein Problemsignal per se — sondern normales Async-IO-Verhalten. Auffällig wird es, wenn die Wartezeiten sehr hoch sind oder IO_COMPLETION häufig bei TempDB-Operationen erscheint.

    Baseline: Ohne Vergleichswert ist alles Rätselraten

    Hier ist die unbequeme Wahrheit: Ein PAGEIOLATCH_SH-Wait von 45.000 ms sagt dir absolut gar nichts. Ob das gut oder schlecht ist, hängt vollständig davon ab, wie lange der Messzeitraum war, wie viele IO-Operationen stattgefunden haben, und wie das System normalerweise aussieht. Ohne Baseline ist Performance-Analyse Rätselraten mit ernstem Gesicht.

    Das gilt für jeden einzelnen Messwert in diesem Buch. Ein avg_wait_ms von 8 ms bei PAGEIOLATCH_SH — gut oder schlecht? Auf einem NVMe-System: katastrophal. Auf einem SAN mit Spindeln: möglicherweise akzeptabel. Bei einer Datenbank, die normalerweise 0,3 ms hat und jetzt 8 ms zeigt: Alarmstufe Rot. Zahlen haben keine Bedeutung ohne Kontext. Kontext ist die Baseline.

    Konkrete Richtwerte als Orientierungspunkte — nicht als absolute Grenzwerte:

     

    Wait Type

    Gut

    Erhöht

    Kritisch

    Hinweis

    PAGEIOLATCH_SH (NVMe)

    < 1 ms avg

    1–5 ms

    > 10 ms

    Buffer Pool oder IO-Problem

    PAGEIOLATCH_SH (SSD)

    < 5 ms avg

    5–20 ms

    > 50 ms

    IO-Subsystem analysieren

    PAGEIOLATCH_SH (Spindel)

    < 20 ms avg

    20–50 ms

    > 100 ms

    Working Set vs. RAM

    WRITELOG

    < 2 ms avg

    2–10 ms

    > 20 ms

    Log-Volume, VLF-Anzahl

    LCK_M_* (avg)

    < 500 ms

    0,5–5 s

    > 10 s

    Blocking-Analyse, Kap. 14

    SOS_SCHEDULER_YIELD (%)

    < 1% gesamt

    1–5%

    > 10%

    Signal Wait prüfen

    Signal Wait-Anteil

    < 5%

    5–20%

    > 20%

    CPU unter Druck

    Tabelle 9.1: Richtwerte für Wait-Statistiken — immer im Kontext der Baseline interpretieren

     

    Die Baseline-Erhebung ist keine einmalige Aufgabe — es ist ein kontinuierlicher Prozess. Ein Server der im Januar unter normaler Last gemessen wird, zeigt andere Werte als derselbe Server im Dezember, wenn das Jahresabschlussgeschäft läuft. Saisonale Muster, Monatsende-Spitzen, wöchentliche Batch-Jobs: All das muss in die Baseline einfließen.

    Praktische Empfehlung: Baue über mindestens 4 Wochen normale Betriebsdaten auf, bevor du Auffälligkeiten bewertest. Collect-SqlPerf.ps1 (Kapitel 31) nimmt stündliche Snapshots und speichert sie in einer Verlaufstabelle. Nach 4 Wochen hast du genug Daten um Trends, Ausreißer und echte Anomalien zu unterscheiden.

     

    Tipp: Was ist "normal" für PAGEIOLATCH_SH?

    Das hängt vom Storage-Typ ab. Auf einem System mit NVMe-SSDs sind PAGEIOLATCH_SH avg_wait_ms unter 0,5 ms normal. Auf einem klassischen SAN mit Spindeln können 15 ms noch akzeptabel sein — wenn die Baseline das bestätigt. Neu ist die Frage: Liegt der aktuelle Wert signifikant über der Baseline? Wenn ein System normalerweise 0,8 ms zeigt und plötzlich 12 ms zeigt, ist das ein Alarm — auch, wenn 12 ms "objektiv" nicht dramatisch klingt.

     

    Langfristige Baseline-Strategie für produktive Umgebungen:

  • Täglich: Stündliche Wait-Snapshots (via Collect-SqlPerf.ps1 oder SQL Agent Job)
  • Wöchentlich: Top-Wait-Types im Trend — nimmt WRITELOG zu? Steigt PAGEIOLATCH_SH?
  • Monatlich: Vergleich mit Vormonat — gibt es strukturelle Veränderungen?
  • Nach Änderungen: Snapshot unmittelbar vor und nach Patches, neuen Indizes, Konfigurationsänderungen
  • CPU als Diagnoseachse: Signal Waits, Scheduler und THREADPOOL

    CPU ist kein einzelner Wait Type, sondern eine Diagnoseachse die sich durch mehrere Wait Types und DMVs zieht. Das ist wichtig zu verstehen: Wenn CPU das Problem ist, siehst du das nicht nur an einem einzigen Wait Type — du siehst es als Muster über mehrere Indikatoren hinweg. In Kapitel 15 (Ausführungspläne), Kapitel 16 (Statistiken und Plan Regression) und Kapitel 18 (Parameter Sniffing) kommen wir jeweils auf CPU als Thema zurück — weil schlechte Pläne, zu viele Recompilations und Parameter-Sniffing-Probleme alle auf CPU-Ebene sichtbar werden.

     

    Abb. 9.3: CPU-Diagnoseachse: Von Signal Waits bis THREADPOOL

    Der vollständige CPU-Diagnose-Workflow hat drei Stufen — von der aggregierten Sicht zur Session-genauen Analyse:

    Stufe 1: Signal Wait-Anteil prüfen

    Das Signal Wait-Verhältnis ist der erste Screening-Test. Unter 10%: CPU ist entspannt, die Wartezeiten kommen aus anderen Quellen. 10–20%: erste Zeichen von Druck, beobachten und Trend verfolgen. Über 20%: CPU unter ernsthafter Last, jetzt aktiv werden. Über 30%: Die Runnable Queue läuft voll, jede neue Anfrage spürt die CPU-Latenz unmittelbar.

    Stufe 2: sys.dm_os_schedulers analysieren

    Wenn der Signal Wait-Anteil erhöht ist, ist der nächste Schritt sys.dm_os_schedulers. Diese DMV zeigt den Zustand jedes einzelnen SQLOS-Schedulers:

    -- Scheduler-Auslastung analysieren — wo steckt der CPU-Druck?
    -- runnable_tasks_count > 1 pro Scheduler bedeutet Warteschlange
    -- work_queue_count = wartende Tasks die noch keinen Scheduler haben
    SELECT
        scheduler_id,
        cpu_id,                            -- Welcher physische CPU-Kern
        status,
        is_idle,
        -- Wieviele Tasks warten gerade auf diesen Scheduler?
        -- > 1 = Überlastung, dieser Scheduler hat mehr als er kann
        runnable_tasks_count,
        -- Wieviele Worker-Threads sind auf diesem Scheduler aktiv?
        active_workers_count,
        -- Wieviele Tasks warten noch ohne Scheduler-Zuweisung?
        work_queue_count,
        total_cpu_usage_ms,
        total_scheduler_delay_ms           -- Gesamte Verzögerungszeit durch Scheduler-Überlast
    FROM sys.dm_os_schedulers
    -- Nur Online-Scheduler, nicht interne System-Scheduler
    WHERE status = 'VISIBLE ONLINE'
    ORDER BY runnable_tasks_count DESC, work_queue_count DESC;

    Ein runnable_tasks_count von 0 oder 1 pro Scheduler bedeutet: Der Scheduler ist entspannt, Threads bekommen sofort CPU-Zeit. Werte von 3, 5, 10 bedeuten: Es bildet sich eine Schlange. Bei NUMA-Systemen mit mehreren CPU-Knoten (Kapitel 1, Kapitel 3) kann man hier auch NUMA-Imbalance erkennen: Wenn ein NUMA-Knoten dauerhaft höhere runnable_tasks_count hat als der andere, ist die Workload ungleich verteilt.

    Stufe 3: Runnable Requests identifizieren

    Sobald klar ist, dass CPU der Engpass ist, hilft sys.dm_exec_requests um herauszufinden, welche spezifischen Abfragen die CPU fressen:

    -- Wer wartet gerade auf CPU? Alle runnable Sessions
    -- status = 'runnable' bedeutet: bereit, aber kein Scheduler frei
    -- cpu_time zeigt den kumulativen CPU-Verbrauch dieser Session
    SELECT
        r.session_id,
        r.status,                          -- 'runnable' = wartet auf CPU
        r.command,
        r.wait_type,
        r.wait_time,                       -- ms bereits gewartet
        r.cpu_time,                        -- ms CPU bereits verbraucht — hohe Werte = CPU-Fresser
        r.total_elapsed_time,
        r.logical_reads,                   -- Viele logical reads + wenig IO = CPU-intensive Scans
        DB_NAME(r.database_id)             AS Datenbank,
        SUBSTRING(t.text, 1, 200)          AS query_text
    FROM sys.dm_exec_requests r
    CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
    WHERE r.status = 'runnable'
       OR r.cpu_time > 10000               -- Auch laufende Sessions mit hohem CPU-Verbrauch
    ORDER BY r.cpu_time DESC;

    Die Kombination aus hohem cpu_time und vielen logical_reads ohne entsprechende physische IO-Waits ist ein klassisches Zeichen für CPU-intensive Table Scans — Abfragen die viele Seiten aus dem Buffer Pool lesen und durchsuchen. Kein Disk-IO, aber immenser CPU-Aufwand. Die Lösung liegt in den Ausführungsplänen (Kapitel 15) und häufig in fehlenden Indizes (Kapitel 17).

    Scheduler Pressure: Wenn der Stau unsichtbar wird

    Es gibt einen Effekt, der bei hoher CPU-Last besonders tückisch ist: Scheduler Pressure macht sich oft nicht in der CPU-Auslastungsanzeige bemerkbar. Du siehst 85% CPU-Last im Performance Monitor. Das klingt besorgniserregend, aber nicht katastrophal. Die Abfragen laufen aber dreimal langsamer als normal. Was ist passiert?

    Das Problem ist, dass die CPU-Auslastungsanzeige im Task Manager oder Performance Monitor nur die tatsächlich auf der CPU verbrachte Zeit misst. Die Zeit, die Threads in der Runnable Queue warten — also Signal Waits — erscheint dort nicht als CPU-Last, sondern als "Leerlauf". Scheduler Pressure diagnostiziert man nicht über den Task Manager, sondern über Signal Wait-Anteil und sys.dm_os_schedulers.

    Scheduler Pressure als eigenständiges Konzept: SQL Server braucht nicht nur freie CPU-Kapazität (kein 100%-CPU), um reibungslos zu funktionieren. Er braucht freie Scheduler-Slots. Der SQLOS-Scheduler (Kapitel 4) hat genau so viele Slots wie es logische CPUs gibt. Wenn alle Slots besetzt sind und neue Threads ankommen, entsteht Scheduler Pressure — auch, wenn die physische CPU-Auslastung noch unter 100% liegt. Ein Thread kann durchaus auf einen Scheduler warten, während ein anderer Scheduler denselben Thread nach 4 ms wieder freigibt.

     

    Praxisbeispiel: Scheduler Pressure bei Musterwerk GmbH

    Bei Musterwerk GmbH (MWSQL01, Kapitel 32) sah die CPU-Last morgens um 9 Uhr nach 78% aus — was der DBA als "ein bisschen viel, aber okay" eingestuft hatte. Der Signal Wait-Anteil in sys.dm_os_wait_stats lag gleichzeitig bei 31%. runnable_tasks_count auf den meisten Schedulern war dauerhaft bei 3 bis 4. Ursache: Ein parametrisierter Report lief mit DOP 16 und schluckte die meisten Scheduler. MAXDOP auf 4 für die Reporting-Workload gesetzt — Signal Wait-Anteil sank auf 8%. CPU-Auslastung: immer noch 74%. Aber die OLTP-Abfragen hatten wieder freie Scheduler und die p95-Latenzen halbierten sich.

     

    MAXDOP als Gegenmaßnahme bei Scheduler Pressure: Wenn parallele Abfragen den Scheduler-Pool dominieren, hilft MAXDOP-Reduktion. Weniger parallele Threads pro Abfrage bedeuten weniger gleichzeitig benötigte Scheduler. Das ist nicht immer die beste Lösung — manchmal ist MAXDOP-Reduktion ein Pflaster, das die eigentliche Ursache verdeckt (schlechte Statistiken, fehlende Indizes). Aber als Sofortmaßnahme ist es effektiv. Die vollständige MAXDOP-Entscheidungslogik haben wir in Kapitel 5 behandelt und kommen in Kapitel 15 darauf zurück.

    Wait Type Referenz: Die wichtigsten auf einen Blick

    Die folgende Tabelle enthält die Wait Types, die in der Praxis am häufigsten auftreten — mit einer kurzen Einordnung der typischen Ursache und der ersten Diagnoserichtung. Anhang B enthält eine erweiterte Referenz aller relevanten Wait Types:

     

    Wait Type

    Kategorie

    Typische Ursache

    Erste Diagnoserichtung

    PAGEIOLATCH_SH

    IO

    Seite muss von Disk gelesen werden

    Buffer Pool zu klein / Disk zu langsam → Kap 10, 11

    PAGEIOLATCH_EX

    IO

    Seite muss auf Disk geschrieben werden

    Write-IO langsam / Checkpoint-Druck → Kap 10

    PAGELATCH_EX

    Memory

    Hot Page im Buffer Pool (kein Disk-IO!)

    TempDB PFS/GAM Contention prüfen → Kap 13

    PAGELATCH_SH

    Memory

    Shared Latch auf In-Memory-Seite

    Welche Seite? sys.dm_os_waiting_tasks

    LCK_M_X

    Locking

    Exclusive Lock gehalten von anderer Session

    Blocking-Analyse → Kap 14

    LCK_M_S

    Locking

    Shared Lock blockiert durch X-Lock

    Blocking, Isolation Level prüfen → Kap 14

    LCK_M_U

    Locking

    Update Lock — oft Vorstufe zum Deadlock

    Deadlock Graph → Kap 14

    WRITELOG

    IO

    Transaktionslog-IO zu langsam

    Log-Volume prüfen, VLFs → Kap 10, 06

    SOS_SCHEDULER_YIELD

    CPU

    Scheduler überlastet, Thread gibt auf

    Signal Wait-Anteil, Scheduler-Auslastung

    THREADPOOL

    CPU

    Kein freier Worker Thread

    max worker threads, Blocking-Kaskade

    CXPACKET

    CPU

    Parallelismus-Koordination

    Skew prüfen, MAXDOP, Signal Waits → Kap 15

    CXCONSUMER

    CPU

    Parallelismus-Consumer wartet

    Meist harmlos, Skew ausschließen → Kap 15

    ASYNC_NETWORK_IO

    Netzwerk

    Client liest Daten nicht schnell genug

    Result-Set-Größe, N+1 → Kap 30

    RESOURCE_SEMAPHORE

    Memory

    Memory Grant-Warteschlange

    Fehlende Indizes, große Hash Joins → Kap 12

    RESOURCE_SEMAPHORE_QUERY_COMPILE

    CPU

    Kompilierungsslot belegt

    Hohe Kompilierungsrate → Kap 18

    OLEDB

    Netzwerk

    Linked Server oder OLE DB Call

    Linked Server Performance prüfen

    IO_COMPLETION

    IO

    Async IO noch nicht abgeschlossen

    Disk-IO allgemein, oft TempDB

    HADR_SYNC_COMMIT

    HA

    Synchrones AG-Commit

    Secondary-Commit-Latenz prüfen

    LATCH_EX

    Memory

    Exklusive Sperre auf interne Struktur

    Welche Latch-Klasse? dm_os_latch_stats

    DBMIRROR_EVENTS_QUEUE

    HA

    AG-Synchronisation

    HA-Latenz, Netzwerk zu Secondary

    Tabelle 9.2: Wichtige Wait Types mit Diagnoserichtung und Kapitelverweisen

     

    Wait Statistics und Extended Events: Die Kombination

    sys.dm_os_wait_stats gibt dir die aggregierte Sicht — welcher Wait Type dominiert, wie oft, wie lange im Schnitt. Was du damit nicht siehst: Welche spezifische Abfrage verursacht den Wait Type? Welche Datenbank? Welche Tabelle? Das sind die Fragen für Extended Events.

    Das sqlos.wait_info-Ereignis zeichnet individuelle Wait-Ereignisse auf — jedes Mal, wenn ein Thread auf einen definierten Wait Type trifft und die Wartezeit überschritten ist. Das klingt harmlos, ist aber ein Hochvolumen-Event: Auf einem aktiven System können tausende Wait-Ereignisse pro Sekunde auftreten. Deshalb: Immer mit Filtern arbeiten, nie sqlos.wait_info ohne Wait-Type- und Dauer-Filter aktivieren. Eine detaillierte Einführung in Extended Events — Sessions, Targets, Filter — findest du in Kapitel 8.

    -- Extended Event Session für Wait-Diagnose
    -- Achtung: wait_info ist sehr volumenstark! Immer mit Filtern einsetzen.
    -- Hier: nur PAGEIOLATCH-Waits über 10 ms, um IO-Probleme zu finden
    CREATE EVENT SESSION [WaitDiagnose_IO] ON SERVER
    ADD EVENT sqlos.wait_info(
        WHERE
            opcode = 1          -- 1 = End (Warteende erfassen, nicht den Beginn)
            AND wait_type = 67  -- 67 = PAGEIOLATCH_SH (Tabelle wait_types für alle IDs)
            AND duration > 10   -- Nur Waits über 10 ms — kurze rausfiltern
    ),
    -- Optional: auch WRITELOG miterfassen für gleichzeitige Log-IO-Analyse
    ADD EVENT sqlos.wait_info(
        WHERE
            opcode = 1
            AND wait_type = 180 -- 180 = WRITELOG
            AND duration > 5    -- Log-IO: schon 5 ms ist erhöht
    )
    ADD TARGET package0.ring_buffer(SET max_memory = 51200)
    WITH (MAX_DISPATCH_LATENCY = 5 SECONDS);
    -- Session starten
    ALTER EVENT SESSION [WaitDiagnose_IO] ON SERVER STATE = START;
    -- Ergebnisse lesen — zeigt welche Sessions die langen IOs haben
    SELECT
        xdr.value('@timestamp', 'datetime2') AS event_time,
        xdr.value('(data[@name="wait_type"]/text)[1]', 'varchar(60)') AS wait_type,
        xdr.value('(data[@name="duration"]/value)[1]', 'bigint')      AS duration_ms,
        xdr.value('(action[@name="session_id"]/value)[1]', 'int')     AS session_id,
        xdr.value('(action[@name="sql_text"]/value)[1]', 'nvarchar(1000)') AS sql_text
    FROM (
        SELECT CAST(target_data AS XML) AS target_xml
        FROM sys.dm_xe_session_targets t
        JOIN sys.dm_xe_sessions s ON t.event_session_address = s.address
        WHERE s.name = 'WaitDiagnose_IO'
          AND t.target_name = 'ring_buffer'
    ) AS data
    CROSS APPLY target_xml.nodes('//RingBufferTarget/event') AS xe(xdr)
    ORDER BY duration_ms DESC;
    -- Session stoppen und löschen, wenn fertig
    -- ALTER EVENT SESSION [WaitDiagnose_IO] ON SERVER STATE = STOP;
    -- DROP EVENT SESSION [WaitDiagnose_IO] ON SERVER;

    Die Kombination aus Wait Statistics (wo ist das Problem?) und Extended Events (welche Abfrage verursacht es?) ist das vollständige Diagnose-Bild. Wait Statistics zeigt dir das Symptom — Extended Events zeigt dir den Täter. Beide zusammen führen zur Lösung.

    Wait Statistics in den Fallstudien — drei Muster, drei Lösungsansätze

    Die drei Fallstudien in Teil V zeigen Wait Statistics in echten Szenarien — jedes mit einem anderen dominierenden Muster und einem anderen Lösungsansatz. Wenn du jetzt denkst "das klingt alles sehr theoretisch" — warte auf Kapitel 33. Sparfuchs & Partner wird dir jeden theoretischen Punkt sehr konkret veranschaulichen.

    Musterwerk GmbH (MWSQL01, Kapitel 32): Der klassische Mixed-Workload

    Musterwerk ist der "normale" Kunde — gute Hardware, grundsolide Konfiguration, aber ein paar blinde Flecken. Das Wait-Profil ist gemischt, kein einzelner Wait Type dominiert katastrophal, aber die Kombination zeigt typische Mixed-Workload-Probleme:

  • PAGEIOLATCH_SH auf Platz 1: Der Buffer Pool wird jeden Morgen um 08:30 Uhr durch einen Report-Job geleert, der fast die gesamte Datenbank sequenziell liest. Das verdrängt die "heißen" Seiten der OLTP-Workload aus dem Cache. Nach dem Report: erhöhte IO-Waits bis der Buffer Pool sich wieder aufgewärmt hat.
  • LCK_M_S auf Platz 2: Lange offene Transaktionen im ERP-System — ein Benutzer der eine Bestellung öffnet, "kurz" zum Mittagessen geht, und die Transaktion offen lässt. Klassisch.
  • Signal Wait-Anteil: 12% — erhöht, aber nicht kritisch. Der Report-Job hat temporär CPU-Druck durch parallele IO-Verarbeitung.
  • Die gute Nachricht bei Musterwerk: Alle Probleme sind lösbar ohne neue Hardware. Report-Job in ein anderes Zeitfenster verlegen, Resource Governor für Reporting-Workload (Kapitel 12), Transaktions-Timeouts im ERP konfigurieren. In Kapitel 32 gehen wir durch alle Schritte.

    Sparfuchs & Partner (BUCHSQL01, Kapitel 33): Die komplette Sammlung

    Sparfuchs & Partner ist das Extrembeispiel — jeder erdenkliche Fehler auf einmal. Das Wait-Profil ist kein "ein Problem dominiert", sondern ein komplettes Chaos bei dem man priorisieren muss:

  • WRITELOG auf Platz 1: Eine Spindel für OS + TempDB + Data + Log + Backup-Volume. Log-IO konkurriert mit Data-IO, OS-IO und Backup-IO. p95 Write-Latenz: 312 ms. NVMe würde 0,03 ms liefern — Faktor 10.000 schlechter.
  • SOS_SCHEDULER_YIELD auf Platz 2: 4 vCPUs, 360.000 Aufrufe von fn_GetSteuersatz pro Query. Die skalare UDF kann nicht parallelisiert werden und verbraucht CPU für jeden einzelnen Aufruf.
  • THREADPOOL taucht sporadisch auf: Der Worker-Thread-Pool läuft voll, wenn mehrere Nutzer gleichzeitig arbeiten und die ohnehin langsamen Queries weitere Threads belegen.
  • Signal Wait-Anteil: 34% — die CPU ist massiv überlastet, obwohl sie nur 4 Kerne hat.
  • Die Priorisierung der Maßnahmen bei Sparfuchs ist kritisch: Wenn man die CPU-Probleme löst ohne das IO-Problem zu beheben, wird das IO noch mehr zum Engpass. Wenn man das IO-Problem löst, wird die CPU mehr entlastet, weil IO-Wait-Zeit reduziert wird. Reihenfolge: zuerst Hardware (Storage, RAM), dann Konfiguration, dann Code. In Kapitel 33 erarbeiten wir den vollständigen Sanierungsplan.

    Trendforge Digital GmbH (TFSQL01, Kapitel 34): Gute Hardware, kaputte Applikation

    Trendforge ist das Gegenteil von Sparfuchs: Die Hardware ist gut, die SQL Server-Konfiguration ist korrekt, die Infrastruktur stimmt. Und trotzdem gibt es Performance-Probleme — weil die Applikation die Hardware kaputt macht. Das Wait-Profil verrät sofort wo das Problem liegt:

  • ASYNC_NETWORK_IO prominent: Entity Framework N+1-Queries. Für jede Bestellung eine separate Abfrage für die Bestellpositionen. 500 Bestellungen = 500 + 1 = 501 Queries. SQL Server wartet nach jeder Abfrage auf den Client.
  • RESOURCE_SEMAPHORE auf Platz 3: Fehlende Indizes führen zu Hash Joins, die hohe Memory Grants anfordern. Bei 50 gleichzeitigen Nutzern entsteht eine Memory-Grant-Warteschlange.
  • Signal Wait-Anteil: 8% — CPU ist nicht das primäre Problem.
  • Die Herausforderung bei Trendforge: Der DBA kann das Problem mit Wait Statistics exakt diagnostizieren und benennen — aber ohne Mitarbeit der Entwickler nicht lösen. Fehlende Indizes kann der DBA noch hinzufügen. N+1-Probleme im ORM nicht. Das ist die politische Dimension von Performance-Analyse, die in Kapitel 34 ausführlich behandelt wird.

    Diagnose: Wait Statistics strukturiert anwenden

    Symptome

     

    Hinweis: Woran erkennst du ein Wait Statistics-Problem?

    Nutzer berichten über sporadisch langsame Abfragen ohne erkennbares Muster — mal schnell, mal langsam, keine klare Korrelation

    CPU-Auslastung schwankt stark, ohne, dass der Task Manager einen klaren Täter zeigt — Signal Waits als unsichtbare Last

    Bestimmte Stoßzeiten (morgens 9 Uhr, Monatsende) sind systematisch langsamer als Normalzeiten

    Fehler 17189 im SQL Server Error Log: "Konnte keinen neuen Thread erstellen" — THREADPOOL-Problem

    sys.dm_exec_requests zeigt viele Sessions im Status "suspended" oder "runnable" gleichzeitig

    PAGEIOLATCH-Waits bei einem System, das eigentlich genug RAM haben sollte — Buffer Pool kalt nach Serverstart, oder Working Set zu groß

    WRITELOG-Waits bei normaler Transaktionslast — Log-Volume checken, VLF-Anzahl prüfen

    ASYNC_NETWORK_IO prominent — Client-seitige Verarbeitung zu langsam oder N+1-Probleme

     

    So misst du das

     

    Tipp: Sofortdiagnose in 3 Schritten

    Schritt 1 — Signal Wait-Anteil: Führe die Signal/Resource-Wait-Query aus (s.o.). Über 20% = CPU-Problem, weiter mit sys.dm_os_schedulers.

    Schritt 2 — Top-Waits: Führe die Top-20-Wait-Query aus. Welcher Wait Type dominiert? Das gibt die Diagnoserichtung.

    Schritt 3 — Aktive Sessions: SELECT session_id, status, wait_type, wait_time, blocking_session_id FROM sys.dm_exec_requests WHERE status NOT IN ('background', 'sleeping') ORDER BY wait_time DESC.

     

    Typische Fehlinterpretationen

     

    Warnung: Was harmlos aussieht aber keins ist — und umgekehrt

    CXPACKET hoch = Problem? Nicht automatisch. Erst Signal Wait-Anteil prüfen. Bei niedrigem Signal Wait ist CXPACKET normaler Parallelismus-Overhead.

    PAGEIOLATCH_SH hoch nach Serverstart? Normal — der Buffer Pool ist leer und wärmt sich auf. Erst nach der Warmlaufphase (30–60 min) messen.

    Kleine absolute Wait-Werte = kein Problem? Nur, wenn die Anzahl der Ereignisse auch klein ist. 1 ms × 500.000 Ereignisse = ernst nehmen.

    THREADPOOL kurz gesehen und dann nicht mehr? Schon einmal ist zu oft. Suche die Ursache im Error Log (17189) und in Blocking-Ketten.

    WRITELOG niedrig = Log-IO okay? Nur, wenn gleichzeitig avg_wait_ms niedrig ist. Ein Wait Type mit wenigen Ereignissen aber 80 ms Durchschnitt ist ernst.

     

    Erste Gegenmaßnahmen

     

    Tipp: Triage — nicht Volltherapie

    IO-dominiert (PAGEIOLATCH_SH/EX): Buffer Pool voll? sys.dm_os_performance_counters: Page Life Expectancy prüfen. Unter 300 Sekunden: Buffer Pool zu klein. Max Server Memory richtig gesetzt? Kapitel 11.

    CPU-dominiert (SOS_SCHEDULER_YIELD, Signal Wait > 25%): sys.dm_exec_requests nach cpu_time sortieren. Top-Abfragen identifizieren. MAXDOP für Reporting-Workload prüfen.

    Locking (LCK_M_*): sys.dm_exec_requests nach blocking_session_id filtern. Wer ist der Blocker? Was macht er? KILL nur als absoluter letzter Ausweg und mit Vorsicht.

    THREADPOOL: Sofort max worker threads temporär erhöhen (sp_configure). Dann Blocking-Kette suchen die den Pool leert. Ursache immer finden.

    WRITELOG: Log-Volume IO-Latenz prüfen (sys.dm_io_virtual_file_stats). VLF-Anzahl prüfen (DBCC LOGINFO). Log-Backup ausführen, wenn nötig. Kapitel 10.

     

    Zusammenfassung

    Wait Statistics sind das erste Werkzeug in jedem Performance-Troubleshooting — nicht, weil alles andere unwichtig ist, sondern, weil sie die Richtung vorgeben. Ohne diese Orientierung läufst du blind. Mit ihr weißt du nach zwei Minuten Diagnose, ob du als nächstes nach Kapitel 10, 11, 12, 14 oder 15 greifen sollst.

  • sys.dm_os_wait_stats liefert kumulierte Wait-Zeiten seit Serverstart — für echte Diagnose immer Deltas über definierte Zeitfenster berechnen.
  • Signal Wait vs. Resource Wait ist die fundamentale Unterscheidung: Resource Wait = externe Ressource zu langsam; Signal Wait = CPU zu beschäftigt.
  • Signal Wait-Anteil > 20% = CPU-Druck — auch, wenn der Task Manager nichts Dramatisches zeigt. Scheduler Pressure ist im Task Manager unsichtbar.
  • SOS_SCHEDULER_YIELD, CXPACKET und THREADPOOL sind die CPU-Signalwarten — in dieser Schwere-Reihenfolge.
  • PAGEIOLATCH (mit IO!) = Disk-IO; PAGELATCH (ohne IO!) = In-Memory-Contention. Der Unterschied ist entscheidend für die richtige Lösung.
  • THREADPOOL ist ein Notfall-Signal — sofort handeln, nicht beobachten. Fehler 17189 im Error Log bestätigt den Befund.
  • Eine Baseline ist keine einmalige Aufgabe, sondern ein kontinuierlicher Prozess. Zahlen ohne Vergleichswert sind wertlos.
  • Extended Events (Kapitel 8) ergänzen Wait Statistics, wenn du auf Einzelabfragen-Ebene diagnostizierst — Wait Stats zeigt "wo", XE zeigt "wer".
  •  

    Im nächsten Kapitel gehen wir tiefer in das IO-Subsystem — mit konkreten Latenzwerten aus sys.dm_io_virtual_file_stats, der Frage warum Autogrowth-Events die Latenz explodieren lassen, und dem Sparfuchs-Szenario: p95-Schreib-Latenzen von 312 ms auf einer einzigen Spindel, die OS, Data, Log, TempDB und Backup gleichzeitig bedient.

     

    Kapitel 10