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.
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.
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;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.
Dauern maßstäblich nach Messung, Startpunkte innerhalb der Kette schematisch. Jede Zeile wurde im jeweiligen Lauf genau einmal ausgeführt.
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 Statement | Duration |
|---|---|
Äußeres UPDATE dbo.Control | 7.687,8 ms |
UPDATE dbo.WorkTarget in der Procedure | 7.666,6 ms |
SELECT im Trigger | 0,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.
| Messung | Ergebnis |
|---|---|
| Äußeres UPDATE, Query Store | 7.369,6 ms |
| SELECT vor dem TRUNCATE, Query Store | 0,081 ms |
| TRUNCATE, Query Store | kein eigener Eintrag |
| SELECT nach dem TRUNCATE, Query Store | 0,082 ms |
Gesamte Procedure, sys.dm_exec_procedure_stats | 7.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 Lauf | Ergebnis |
|---|---|
| Äußeres UPDATE, Query-Store-Duration | 7.708,5 ms |
| Derselbe Plan, Wait-Kategorie „Lock“ | 7.693 ms |
| Eigener TRUNCATE-Eintrag | weiterhin 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.
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.
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.
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 Statement | Inneres Statement | Ergebnis |
|---|---|---|---|
| UPDATE → Trigger → Procedure, inneres UPDATE blockiert | 7.687,8 ms | 7.666,6 ms | überlappt |
| UPDATE → Trigger → Procedure, TRUNCATE blockiert | 7.369,6 ms | kein Eintrag | nur außen |
| UPDATE → Trigger → Procedure, WAITFOR 2 s | 2.002,2 ms | kein Eintrag | nur außen |
EXEC Level1 → Level2 → Level3, UPDATE blockiert | kein Eintrag | 4.302,9 ms | nur innen |
INSERT … EXEC Level1, UPDATE blockiert | 4.261,9 ms | 4.249,7 ms | überlappt |
EXEC-Kette, WAITFOR 2 s in Level3 | kein Eintrag | kein Eintrag | fehlt |
| Skalare UDF, nicht eingebettet | 4.496,6 ms | 4.495,9 ms | überlappt |
| Skalare UDF, tatsächlich eingebettet | 4.729,4 ms | kein Eintrag | nur außen |
| Inline-TVF | 4.457,2 ms | kein Eintrag | nur außen |
| Multi-Statement-TVF | 4.476,2 ms | 4.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.
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 nachobject_idgruppierter 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 zeigensys.dm_exec_requestswä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.