Aus dem Labor · Query Store

Wo steckt die Zeit?

Ein UPDATE braucht 7,4 Sekunden. Die Procedure, die sein Trigger aufruft, kommt im Query Store auf 0,16 Millisekunden. Beide Zahlen stimmen. Wir haben nachgemessen, wie Query Store die Duration bei Triggern, Procedures und Funktionen erfasst, und wo eine Summe oder ein Ranking in die falsche Richtung führt.

30.09.2026 · Sascha Lorenz · ca. 10 Minuten

Gemessen SQL Server 2019Fälle 14Metrik DurationMitraten 7 Fragen ↓

Der Aufbau

Eine kleine Steuertabelle hat einen AFTER UPDATE-Trigger. Der Trigger ruft synchron eine Procedure auf. Das äußere UPDATE ist erst fertig, wenn auch diese Arbeit abgeschlossen ist:

UPDATE→Trigger→Procedure→inneres Statement

Eine zweite Session blockiert das innere Statement für einige Sekunden. Danach lesen wir aus, was Query Store für diese eine Ausführung festgehalten hat, und vergleichen es mit den Modulstatistiken des Servers.

Der Trigger-Pfad im TRUNCATE-Versuch (gekürzt)
CREATE TRIGGER dbo.ControlUpdate ON dbo.Control AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    EXEC dbo.WorkTruncate;
END;

CREATE PROCEDURE dbo.WorkTruncate
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @before int, @after int;
    SELECT @before = Value FROM dbo.WorkTarget WHERE Id = 1;
    TRUNCATE TABLE dbo.TruncateTarget;   -- wartet auf eine Sch-M-Sperre
    SELECT @after  = Value FROM dbo.WorkTarget WHERE Id = 1;
END;
Interaktiv · Die Aufrufkette im Zeitraffer

Jedes erfasste Statement hat seine eigene Uhr.

Query Store misst die Duration je Statement, vom Start bis zum Ende. Läuft innerhalb eines Statements weitere erfasste Arbeit, laufen zwei Uhren gleichzeitig. Arbeit ohne eigenes erfasstes Statement hat gar keine eigene Uhr.

Aufruf und EbeneZeitQuery Store
0,0 s

Dauern maßstäblich nach Messung, Startpunkte innerhalb der Kette schematisch. Jede Zeile wurde im jeweiligen Lauf genau einmal ausgeführt.

Was der Zeitraffer zeigt

Zwei Grenzen, die sich leicht übersehen lassen

1. Messbereiche überlappen

Im ersten Versuch enthält die Procedure ein UPDATE auf eine andere Tabelle, das von einer zweiten Session blockiert wird. Query Store zeigt danach:

Erfasstes StatementDuration
Äußeres UPDATE dbo.Control7.687,8 ms
UPDATE dbo.WorkTarget in der Procedure7.666,6 ms
SELECT im Trigger0,083 ms

Beide großen Werte passen zur Ausführung. Die äußere Duration enthält die synchron aufgerufene Arbeit, das innere UPDATE hat zusätzlich seine eigene. Wer sie addiert, kommt auf 15,35 Sekunden für einen Aufruf, der 7,69 Sekunden dauerte. Das ist keine doppelte Ausführung, sondern dieselbe Wartephase in zwei Messbereichen. Eine Deduplizierung nach Query- oder Plan-ID ändert daran nichts.

2. Arbeit ohne eigenen Eintrag

Im zweiten Versuch ersetzen wir das innere UPDATE durch TRUNCATE TABLE. Die zweite Session hält eine Tabellensperre; das TRUNCATE wartet auf eine Sch-M-Sperre (live beobachtet: LCK_M_SCH_M). Die benötigte Sperre beschreibt Microsofts TRUNCATE-Dokumentation.

MessungErgebnis
Äußeres UPDATE, Query Store7.369,6 ms
SELECT vor dem TRUNCATE, Query Store0,081 ms
TRUNCATE, Query Storekein eigener Eintrag
SELECT nach dem TRUNCATE, Query Store0,082 ms
Gesamte Procedure, sys.dm_exec_procedure_stats7.365,2 ms

Die sichtbaren Statements der Procedure ergeben zusammen 0,163 Millisekunden. Die Procedure selbst brauchte über sieben Sekunden. Query Store erfasst Pläne und Laufzeiten für DML-Statements, DDL-Pläne grundsätzlich nicht; den Erfassungsrahmen beschreibt Microsoft in der Query-Store-Übersicht. Dass dem blockierten TRUNCATE damit eine eigene Zeile fehlt und seine Wartezeit stattdessen im äußeren UPDATE steckt, haben wir gemessen.

Fehlt damit auch die Lock-Wartezeit?

Nein. Wir haben dieselbe Kette in einem neuen Lauf mit aktivierter Wait-Erfassung wiederholt:

Messung im neuen LaufErgebnis
Äußeres UPDATE, Query-Store-Duration7.708,5 ms
Derselbe Plan, Wait-Kategorie „Lock“7.693 ms
Eigener TRUNCATE-Eintragweiterhin keiner

Die Lock-Wartezeit kommt im Query Store an, und zwar beim äußeren UPDATE. Für die inneren SELECTs gab es keine eigenen Wait-Zeilen. Query Store führt dabei nur die Kategorie, nicht den konkreten Wait-Typ und nicht den Blocker (sys.query_store_wait_stats). Dass die Session am TRUNCATE auf LCK_M_SCH_M wartete, wissen wir aus der Live-Beobachtung, nicht aus der Query-Store-Zeile.

Interaktiv · Dieselben Zahlen, drei Reports

Ein Aufruf, drei Antworten.

Die Rohdaten im Query Store stimmen. Was ein Report daraus macht, hängt davon ab, wie er sie zusammenfasst. Wählen Sie einen Versuch und eine typische Sicht.

Gegenproben

Ist jede Verschachtelung betroffen?

Nein, und genau deshalb lohnt der genaue Blick. Wir haben die Frage mit weiteren Versuchen ohne Trigger geprüft: drei normale Procedures, die einander per EXEC aufrufen, dieselbe Kette unter INSERT … EXEC, eine skalare Funktion mit und ohne Inlining, eine Inline- und eine Multi-Statement-Table-Valued-Function. In jedem Fall hält eine zweite Session die gelesene oder geänderte Zeile einige Sekunden fest.

Bevor Sie die Ergebnisse sehen: Raten Sie mit.

Interaktiv · Wo landet die Wartezeit?

Die Faustregel aus unseren Messungen

Doppelt erscheint Zeit, wenn ein erfasstes Statement die innere Arbeit umschließt und die innere Arbeit selbst erfasste Statements hat: DML auf einer Tabelle mit Trigger, INSERT … EXEC, nicht eingebettete skalare UDFs, Multi-Statement-TVFs. Eine Lücke entsteht, wenn Arbeit kein eigenes erfasstes Statement hat, etwa DDL wie TRUNCATE oder WAITFOR. Umschließt kein erfasstes Statement diese Arbeit, wie in einer reinen EXEC-Kette, fehlt sie in der Query-Store-Duration ganz.

Alle Fälle im Überblick
AufrufÄußeres StatementInneres StatementErgebnis
UPDATE → Trigger → Procedure, inneres UPDATE blockiert7.687,8 ms7.666,6 msüberlappt
UPDATE → Trigger → Procedure, TRUNCATE blockiert7.369,6 mskein Eintragnur außen
UPDATE → Trigger → Procedure, WAITFOR 2 s2.002,2 mskein Eintragnur außen
EXEC Level1 → Level2 → Level3, UPDATE blockiertkein Eintrag4.302,9 msnur innen
INSERT … EXEC Level1, UPDATE blockiert4.261,9 ms4.249,7 msüberlappt
EXEC-Kette, WAITFOR 2 s in Level3kein Eintragkein Eintragfehlt
Skalare UDF, nicht eingebettet4.496,6 ms4.495,9 msüberlappt
Skalare UDF, tatsächlich eingebettet4.729,4 mskein Eintragnur außen
Inline-TVF4.457,2 mskein Eintragnur außen
Multi-Statement-TVF4.476,2 ms4.474,4 msüberlappt

„Inneres Statement“ meint das blockierte bzw. wartende Statement. Bei der Multi-Statement-TVF zusätzlich gemessen mit deaktivierter Interleaved Execution: 4.752,8 ms außen, 4.751,6 ms innen. Eine skalare Wrapper-Funktion, die die lesende Funktion aufruft, erhielt keine eigene Query-Store-Zeile; dort überlappten wieder nur äußeres SELECT und innerer Lesezugriff (4.780,2 ms und 4.779,4 ms).

Bei Funktionen entscheidet die tatsächliche Planform, nicht der Funktionsname. Ob eine skalare UDF eingebettet wurde, haben wir am Plan geprüft: kein UDF-Operator, der Tabellenzugriff direkt im äußeren Plan. Die Metadaten is_inlineable sagen nur, dass Inlining möglich wäre (Scalar UDF Inlining). Das optionale Plan-Attribut für eingebettete UDFs fehlte in den gespeicherten Query-Store-Plänen dieses Builds übrigens auch im eingebetteten Fall. Sein Fehlen ist also kein Gegenbeweis.

Für die Analyse

Was wir daraus mitnehmen

Query Store bleibt für uns die wichtigste Quelle für die Laufzeitgeschichte einer Datenbank. Die Messungen schärfen, welche Frage man ihm stellen kann. Bevor wir aus einem Ranking eine Tuningmaßnahme ableiten, klären wir:

  • Welchen Teil der Ausführung umfasst dieser Messwert?
    Die Duration eines DML-Statements schließt seine Trigger ein. Ein langsames UPDATE beweist deshalb nicht, dass sein sichtbarer Plan die Ursache ist.
  • Umschließt ein erfasstes Statement weitere erfasste Arbeit?
    Dann sind die Zeilen nicht additiv. Anteile an einer Gesamtsumme sind in diesem Fall Anteile einer Summe mit Überlappungen, keine exklusiven Anteile.
  • Welche Arbeit hat keine eigene Zeile?
    DDL, WAITFOR, die EXEC-Aufrufe selbst. Schnelle Statements einer Procedure beweisen keine schnelle Procedure. Ein nach object_id gruppierter Wert enthält nur die eigenen erfassten Statements des Moduls.
  • Was sagen die Modulstatistiken?
    sys.dm_exec_procedure_stats, sys.dm_exec_trigger_stats und sys.dm_exec_function_stats liefern inklusive Gesamtlaufzeiten. Sie hängen am Plan-Cache und sind keine Historie; für TVFs und eingebettete skalare UDFs gibt es dort keine Zeile.
  • Wer hat wirklich gewartet, und auf wen?
    Query Store nennt die Wait-Kategorie am äußeren Statement. Das tatsächlich wartende Statement und die Blocking-Kette zeigen sys.dm_exec_requests während der Störung oder eine gezielte Aufzeichnung mit Extended Events.

Grenzen dieser Messung

Alle Versuche liefen auf SQL Server 2019 Developer, Build 15.0.4490.9 (CU32 GDR), Compatibility Level 150, mit Query Store READ_WRITE, Capture-Modus ALL und aktivierter Wait-Erfassung. Jeder Fall lief in einem eigenen Container mit synthetischen Tabellen; jedes ausgewiesene Statement wurde einmal ausgeführt. Das sind Messwerte kontrollierter Versuche, keine Produktionsbenchmarks. Am 01.10.2026 haben wir alle Fälle in frischen Containern mit demselben Image wiederholt. Jedes hier gezeigte Muster trat erneut auf; die Blockierdauern lagen um bis zu rund elf Prozent neben den gezeigten Werten, weil sie vom Startabstand der Sessions abhängen.

Geprüft haben wir ausschließlich die Duration. Ob CPU, Reads oder tempdb-Verbrauch ebenso überlappen, folgt daraus nicht. Die Wait-Zuordnung haben wir nur für die TRUNCATE-Kette ausgewertet. Nicht untersucht: andere Serverversionen, CLR- und nativ kompilierte Funktionen, Mehrfachaufrufe über viele Zeilen, dynamisches SQL, Rekursion und eine nachweislich verschachtelt ausgeführte (interleaved) Multi-Statement-TVF.

Weiterlesen

Wie sich Trigger mit Query Store und Extended Events nachverfolgen lassen, zeigt Kendra Little ausführlich. Erin Stellato erläutert, wie man die einzelnen Statements einer Procedure im Query Store auswertet. Unsere Messungen ergänzen diese Beiträge um die hier gezeigten Fälle.