Der Leser, der die Tabelle sperrt
Ein SELECT unter READ COMMITTED, ohne Hint und ohne offene Transaktion. Trotzdem hält es am Ende ein S-Lock auf der ganzen Tabelle, und jedes INSERT muss warten. 2022 haben wir das auf LinkedIn gezeigt. Jetzt haben wir es mit SQL Server 2019, 2022 und 2025 nachgemessen und der Abfrage dabei mitten in der Ausführung über die Schulter geschaut.
Der Fall in 30 Sekunden
- Parameter, erster Aufruf
@p1 = 0.8Plan für wenige Zeilen. Der erste Aufruf mit0.8erzeugt zwei Nested Loops mit Prefetch und einen Key Lookup. - Parameter, zweiter Aufruf
@p1 = 800Ausführung mit allen Zeilen. Mit800liest derselbe Plan alle 10.000 Zeilen von TestTab2. Für jede Zeile ausix_ID_Tab1bleibt ein S-Lock stehen. - gelesene Zeilen von TestTab26.222Escalation bei 6.222. Bis 6.220 gehaltene Sperren bleibt es bei Zeilensperren. Zwei Zeilen später steht ein S-Lock auf der ganzen Tabelle. Das Extended Event meldet 6.248 eskalierte Sperren, Ursache „Lock threshold“.
- Wartetyp der Schreiber
LCK_M_IXDann wartet jeder Schreiber. INSERT, UPDATE und DELETE auf TestTab2 warten mitLCK_M_IX, bis das SELECT fertig ist. Das gilt auch für Zeilen, die das SELECT nie anfasst.
Nicht die Isolationsstufe hält die Sperren, sondern der Plan.
Das ist die These dieses Beitrags. Steht ein Key Lookup hinter einem Nested Loop mit Prefetch, hält SQL Server die Sperren der gelesenen Indexzeilen bis zum Ende des Statements. Warum, erklärt der Abschnitt „Warum hält ein Leser Sperren fest?“.
Der Aufbau
Wie schon 2022 verwenden wir die Tabellen aus Fabiano Amorims Artikel über Prefetching von 2012:
- TestTab1 mit 30.000 Zeilen und einem Index auf
Col4. - TestTab2 mit 10.000 Zeilen und einem Index auf
ID_Tab1. - Breite
char-Spalten sorgen dafür, dass fast jede Zeile auf einer eigenen Seite liegt.
Die Daten erzeugen wir deterministisch, damit alle drei Versionen exakt dieselben Zeilen sehen.
Die Abfrage steckt in einer Procedure:
dbo.p2CREATE OR ALTER PROCEDURE dbo.p2 @p1 decimal(18,2)
AS
SELECT TestTab1.Col4, TestTab2.Col1
FROM dbo.TestTab1
INNER JOIN dbo.TestTab2 ON TestTab1.ID = TestTab2.ID_Tab1
WHERE TestTab1.Col4 < @p1;EXEC dbo.p2 0.8; -- legt den Plan in den Cache: 37 Zeilen
EXEC dbo.p2 800; -- benutzt ihn weiter: 10.000 ZeilenDer erste Aufruf bestimmt den Plan, der zweite erbt ihn: klassisches Parameter Sniffing. Der Optimizer schätzt für den Cache-Plan 273 Ergebniszeilen. Beim zweiten Aufruf werden es 10.000.
EXEC dbo.p2 0.8, gemessen mit SQL Server 2019
Wählen Sie die Version. Jeder Kasten zeigt die Zeilen geschätzt beim Kompilieren für 0.8 → tatsächlich beim Aufruf mit 800.
- Kompiliert für
- –
- Geschätzte Zeilen
- –
- Tatsächlich bei 800
- –
- Eskalierte Sperren (XE)
- –
Röntgenbild einer laufenden Abfrage
Wie sieht man, welche Sperren eine Abfrage mitten in der Ausführung hält? Wir halten sie an.
Eine zweite Session ändert in einer offenen Transaktion den Col4-Wert einer bestimmten Zeile von TestTab1. Erreicht das SELECT diese Zeile, muss es warten. Genau in diesem Moment lesen wir sys.dm_tran_locks für die Session des SELECT. Die Wahl der gesperrten Zeile bestimmt, wie weit das SELECT vorher kommt. So entsteht ein Röntgenbild in Einzelbildern.
1 Punkt = 50 S-Key-Locks auf ix_ID_Tab1 · das Raster fasst alle 10.000 Zeilen von TestTab2
- gelesene TestTab2-Zeilen
- –
- gehaltene S-Key-Locks
- –
- Sperren gesamt
- –
- TestTab2
- –
index_lock_promotion_attempt_count- –
index_lock_promotion_count- –
Was das Röntgenbild zeigt
- Eine Sperre je Zeile, nur auf einem Index. Vor der Escalation hält das SELECT genau so viele S-Key-Locks, wie der Index Seek auf
ix_ID_Tab1Zeilen geliefert hat. Auf dem Clustered Index von TestTab2 hält es keine Zeilensperren, auf TestTab1 nur Intent Locks. - Ohne Prefetch keine einzige, in 2019 und 2022. Mit demselben Plan ohne Prefetch (TF 8744) hielt das SELECT an keinem Einfrierpunkt eine Schlüsselsperre. So sieht READ COMMITTED normalerweise aus. In 2025 hält der zusätzliche Batch Sort die Sperren auch ohne Prefetch; erst ohne beides fallen sie dort weg.
- Die Schwelle heißt nicht 5.000. Microsoft nennt 5.000 Sperren als Auslöser. Geprüft wird aber nur an Prüfpunkten im Abstand von 1.250 Sperren, und eskaliert wird erst, wenn an einem Prüfpunkt mindestens 5.000 Sperren auf einem Index liegen. In unserem Fall ist das der Prüfpunkt bei 6.250 Sperren insgesamt. Mit 6.220 Key-Locks plus Intent Locks liegt das SELECT knapp darunter, zwei Zeilen später eskaliert es. Paul White hat dieses Verhalten 2022 ausführlich hergeleitet. In SQL Server 2019 und 2022 trifft unsere Messung es auf die Zeile genau. In 2025 meldet das Extended Event dieselbe Zahl, eingefroren lässt es sich dort wegen des Batch Sorts nur blockweise.
- Der Zähler
attemptsteigt schon an Prüfpunkten, an denen nichts eskalieren kann.index_lock_promotion_attempt_countstieg bei uns beim Überschreiten von 2.500, 3.750 und 5.000 Sperren, in 2019 und 2022 jeweils um 4.index_lock_promotion_countsteigt genau einmal.
Warum hält ein Leser Sperren fest?
Unter dem sperrenden READ COMMITTED gibt SQL Server die Sperre einer Zeile normalerweise frei, sobald die Zeile gelesen ist.
Beim Prefetch liest SQL Server voraus. Er sammelt die Zeiger aus dem Index Seek, stößt die Lesezugriffe für die Lookups gebündelt an und verarbeitet die Zeilen, sobald sie im Speicher sind. Zwischen Seek und Lookup entsteht dadurch eine Lücke. Fiele die Sperre der Seek-Zeile sofort, könnte eine andere Session die Zeile in dieser Lücke ändern oder löschen. Der Lookup fände dann etwas anderes als der Seek. Deshalb hält SQL Server diese Sperren bis zum Ende des Statements.
ix_ID_Tab1Zeiger sammelnPrefetchliest vorausLückeKey LookupPK_TestTab2Das ist kein Fehler, sondern Absicht:
- Craig Freedman beschrieb es 2007 für Lookups hinter Prefetch, Batch Sort und Sort.
- Paul White nennt solche Sperren „lock classes“.
- Microsoft dokumentiert, dass dafür ein Teil der Abfrage auf Repeatable Read angehoben wird.
SQL Server 2022 und 2025 sagen es sogar selbst. Ihr Extended Event lock_escalation meldet in unserem Fall lock_escalation_with_lock_class = true.
Zwei Präzisierungen aus unserer Messung:
- Microsoft schreibt, die Sperren lägen auf dem Clustered und auf einem Nonclustered Index. Bei uns lagen sie nur auf dem Nonclustered Index des Seeks.
- Prefetch ohne Lookup hielt bei uns keine Sperren. Mit einem Covering Index war das Problem weg, obwohl der Nested Loop seinen Prefetch behielt.
Entscheidend ist also der Plan, nicht die Isolationsstufe: ein Lookup hinter einem vorauslesenden Operator.
Was die eskalierte Lesersperre anrichtet
Zeitachse schematisch. Wartezeiten sind Labwerte, keine Betriebswerte: Sie hängen davon ab, wie lange wir den Leser künstlich anhalten. Je Fall drei Läufe.
- Vor der Escalation stehen tausende S-Key-Locks auf
ix_ID_Tab1. INSERT und ein UPDATE vonCol1laufen trotzdem durch, weil sie diesen Index nicht berühren. Nur ein DELETE einer bereits gelesenen Zeile wartet mitLCK_M_X, denn es muss den gesperrten Indexeintrag entfernen. - Nach der Escalation wartet jeder Schreiber auf TestTab2 mit
LCK_M_IX, auch das INSERT einer völlig neuen Zeile. Die Kette lautet Blocker → Leser → Schreiber. Der Schreiber wird 0 bis 4 ms nach dem Leser fertig: Er wartet praktisch so lange, wie das SELECT läuft. - Ohne Prefetch wartet in 2019 und 2022 niemand.
Im Lab dauert das SELECT nur Sekunden, weil wir es künstlich anhalten. In einer großen OLTP-Umgebung kann ein Bericht reichen, der ein paar Sekunden auf I/O wartet. Eskaliert er, stehen alle Schreiber dieser Tabelle hinter ihm, und hinter ihnen die Anwendung. Genau dieses Bild hatten wir 2022 in mehreren großen Umgebungen gesehen.
Parameter Sniffing als Weiche
Ob ein Plan Prefetch bekommt, entscheidet der Optimizer beim Kompilieren anhand der geschätzten Zeilen auf der äußeren Seite des Nested Loops. In allen drei Versionen kippt es bei 25:
- Bei 24,92 geschätzten Zeilen gibt es kein Prefetch.
- Bei 25,14 Zeilen gibt es Prefetch.
Quelle: Sniffing-Matrix des Labs je Version, Kippstellen auf 0,01 genau bisektiert. Logarithmische Skala von 0,02 bis 214,74, dem kleinsten und größten Col4-Wert.
Die gefährlichen Werte sind also nicht die extremen. In unserem Aufbau reichte ein Erstaufruf mit etwas mehr als 25 geschätzten Zeilen, und der Plan bleibt im Cache, bis ihn etwas verdrängt.
Ein Detail für die Bewertung von Gegenmitteln: Der frisch kompilierte Plan für 800 (OPTION (RECOMPILE)) eskaliert nicht. Er läuft aber parallel mit DOP 8 und brauchte im Lab 1,7 bis 2,8 Sekunden. Der gesniffte Plan, der die Tabelle sperrt, war je nach Version mit 0,36 bis 0,75 Sekunden (Median) deutlich schneller. Der schnelle Plan ist hier also gerade der, der sperrt.
Gegenmittel im Test
| Variante | 2019 | 2022 | 2025 | Einordnung |
|---|---|---|---|---|
| Ausgangsfall | eskaliert | eskaliert | eskaliert | |
| RCSI | keine | keine | keine | Leser nehmen keine S-Locks. Version Store in tempdb einplanen, Code prüfen, der auf blockierendes Lesen baut. |
| SNAPSHOT (Session) | keine | keine | keine | Leser nehmen keine S-Locks wie bei RCSI. Setzt ALLOW_SNAPSHOT_ISOLATION voraus, die Session wählt die Stufe selbst; Updatekonflikte möglich. |
Covering Index (INCLUDE (Col1)) | keine | keine | keine | kein Lookup, keine gehaltenen Sperren. Mehr Platz und Schreiblast. |
OPTION (RECOMPILE) | keine | keine | keine | Für den aktuellen Wert kompiliert. Kompilierkosten, hier langsamerer paralleler Plan. |
QUERYTRACEON 8744 | keine | keine | eskaliert | in 2025 hält der Batch Sort die Sperren. Laut Doku nur global/session vorgesehen. |
8744 + DISABLE_OPTIMIZED_NESTED_LOOP | keine | keine | keine | erst beides zusammen hilft in 2025 |
LOCK_ESCALATION = DISABLE | keine | keine | keine | aber 8.001 (2025: 7.555) gehaltene Einzelsperren am selben Punkt. Symptom verschoben. |
NOLOCK | keine | keine | keine | nicht empfohlen: Dirty Reads, fehlende oder doppelte Zeilen |
| Optimized Locking, RCSI aus | – | – | eskaliert | wirkt auf Schreiber, nicht auf diese Lesersperren |
| Optimized Locking + RCSI | – | – | keine | die Wirkung kommt von RCSI |
Unsere Reihenfolge in der Praxis:
- Den Lookup loswerden, wenn die Abfrage wichtig ist. Covering Index oder schmalere Spaltenliste.
- RCSI prüfen, wenn die Anwendung es verträgt. Vorher klären, ob Code auf blockierendes Lesen baut.
- Verhindern, dass ein Plan für wenige Zeilen mit sehr vielen Zeilen läuft, je nach Muster mit RECOMPILE, Plan Guide oder einem im Query Store festgelegten Plan.
Trace Flags, LOCK_ESCALATION = DISABLE und NOLOCK sind keine Standardrezepte.
Und SQL Server 2025 mit Optimized Locking?
Naheliegend wäre: SQL Server 2025 mit Optimized Locking löst das. Gemessen: nein, jedenfalls nicht ohne RCSI.
Optimized Locking ändert, wie Schreiber sperren. Sie sperren ihre Transaktion statt jeder einzelnen Zeile und prüfen Zeilen erst, bevor sie sie sperren. Unsere Sperren hält aber der Leser. Mit Optimized Locking und ohne RCSI eskaliert das SELECT genauso, und das INSERT wartet genauso. Erst mit RCSI verschwindet das Problem, und das läge dann an RCSI. Microsoft beschreibt Optimized Locking nur für INSERT, UPDATE, DELETE und MERGE (Doku).
SQL Server 2025 heilt manchmal selbst
Mit aktivem Query Store hat SQL Server 2025 in unserem Lauf den gesnifften Plan nach 16 eskalierenden Aufrufen von sich aus ersetzt. Grund war „Cardinality estimation feedback“ (CE_FEEDBACK_FOR_EXPRESSIONS). Danach lief ein paralleler Hash-Plan ohne Escalation. Auch SQL Server 2022 hat einmal einen Verifikationsplan probiert und ist dann zum alten Plan zurückgekehrt. Ohne Query Store blieb der Plan stabil.
Darauf verlassen würden wir uns nicht. Der Zeitpunkt hängt vom Workload ab, und bis dahin sperrt jeder Aufruf.
Drei Versionen, ein Muster
| 2019 CU32 | 2022 CU13 | 2025 CU7 | |
|---|---|---|---|
Plan nach 0.8 | 2 × NL mit Prefetch + Key Lookup | gleich | gleich, zusätzlich Batch Sort |
Escalation bei 800 | ja, 6.248 Sperren | ja, 6.248 | ja, 6.247 |
| Letzter Stand ohne Escalation | 6.220 Key-Locks | 6.220 | 5.109 (Batch Sort sammelt blockweise) |
| XE meldet „lock class“ | Feld fehlt | true | true |
| TF 8744 hilft | ja | ja | nein, erst mit DISABLE_OPTIMIZED_NESTED_LOOP |
| Optimized Locking | – | – | hilft nur mit RCSI |
| Plan bleibt stabil | ja | einmal Verifikation | nach 16 Aufrufen ersetzt (CE Feedback) |
So finden Sie das bei sich
Alle vier Abfragen haben wir im Lab gegen den Fall geprüft. Wählen Sie eine Frage. Der Kopierknopf liefert jeweils die vollständige Datei, mit Kommentarkopf.
Welche Indizes eskalieren?
Was es zeigt: Je Index die Zähler index_lock_promotion_attempt_count (steigt an Prüfpunkten) und index_lock_promotion_count (erfolgte Escalations) aus sys.dm_db_index_operational_stats.
Wann nutzen: Als erster Blick auf einen Server. Die Werte sind kumulativ seit dem Laden der Metadaten, aussagekräftig sind nur Differenzen zwischen zwei Messungen.
Hinweise aus dem Kommentarkopf
- index_lock_promotion_attempt_count steigt an Prüfpunkten (im Lab auch ohne folgende Escalation), index_lock_promotion_count zählt die erfolgten Escalations.
- Die Werte sind kumuliert seit dem Laden der Metadaten (Neustart, Datenbank offline, Verdrängung aus dem Metadaten-Cache). Aussagekräftig sind daher Differenzen zwischen zwei Messungen.
/*
Diagnose (a): Lock-Escalation-Zähler je Index der aktuellen Datenbank.
- index_lock_promotion_attempt_count steigt an Prüfpunkten (im Lab auch ohne folgende
Escalation), index_lock_promotion_count zählt die erfolgten Escalations.
- Die Werte sind kumuliert seit dem Laden der Metadaten (Neustart, Datenbank offline,
Verdrängung aus dem Metadaten-Cache). Aussagekräftig sind daher Differenzen zwischen zwei Messungen.
Getestet im Lock-Escalation-Prefetch-Lab (SQL Server 2019/2022/2025), siehe README.
*/
SELECT OBJECT_SCHEMA_NAME(s.object_id) AS schema_name,
OBJECT_NAME(s.object_id) AS table_name,
s.index_id,
i.name AS index_name,
s.partition_number,
s.index_lock_promotion_attempt_count,
s.index_lock_promotion_count,
s.row_lock_count,
s.row_lock_wait_count,
s.row_lock_wait_in_ms,
s.page_lock_count,
s.page_lock_wait_count,
s.page_lock_wait_in_ms
FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS s
JOIN sys.indexes AS i
ON i.object_id = s.object_id
AND i.index_id = s.index_id
WHERE OBJECTPROPERTY(s.object_id, 'IsUserTable') = 1
AND s.index_lock_promotion_attempt_count > 0
ORDER BY s.index_lock_promotion_count DESC, s.index_lock_promotion_attempt_count DESC;
| table_name | index_name | Δ index_lock_promotion_attempt_count | Δ index_lock_promotion_count |
|---|---|---|---|
| TestTab2 | ix_ID_Tab1 | 13 | 1 |
| TestTab2 | PK_TestTab2 | 13 | 0 |
| TestTab1 | ix_Col4 | 7 | 0 |
| table_name | index_name | Δ index_lock_promotion_attempt_count | Δ index_lock_promotion_count |
|---|---|---|---|
| TestTab2 | ix_ID_Tab1 | 13 | 1 |
| TestTab2 | PK_TestTab2 | 13 | 0 |
| TestTab1 | ix_Col4 | 7 | 0 |
| table_name | index_name | Δ index_lock_promotion_attempt_count | Δ index_lock_promotion_count |
|---|---|---|---|
| TestTab2 | ix_ID_Tab1 | 7 | 1 |
| TestTab2 | PK_TestTab2 | 7 | 0 |
| TestTab1 | ix_Col4 | 4 | 0 |
Welche Pläne haben Prefetch vor einem Lookup?
Was es zeigt: Jeden Nested Loops mit WithUnorderedPrefetch oder WithOrderedPrefetch im Plan-Cache, den Lookup auf seiner inneren Seite und die Schätzung, für die der Plan kompiliert wurde.
Wann nutzen: Wenn die Zähler steigen und Sie den Plan dazu suchen. Auf großen Systemen teuer, deshalb zuerst eingrenzen.
Hinweise aus dem Kommentarkopf
- Liefert je gecachtem Statement jeden Nested-Loops-Operator mit WithUnorderedPrefetch oder WithOrderedPrefetch und zeigt, ob seine innere Seite einen Key/RID Lookup enthält (inner_lookup_table/inner_lookup_index). Prefetch vor einem Lookup ist das Muster, bei dem ein SELECT unter READ COMMITTED die S-Sperren der äußeren Zeilen bis Statement-Ende hält.
- outer_estimate_rows zeigt, für welche Menge der Plan kompiliert wurde.
- Die XQuery über den ganzen Plan-Cache ist teuer. Auf großen Produktivsystemen zuerst eingrenzen, z. B. auf eine Datenbank (qp.dbid) oder auf Pläne mit hoher Ausführungszahl.
/*
Diagnose (b): Plan-Cache-Suche nach Nested Loops mit Prefetch.
- Liefert je gecachtem Statement jeden Nested-Loops-Operator mit WithUnorderedPrefetch oder
WithOrderedPrefetch und zeigt, ob seine innere Seite einen Key/RID Lookup enthält
(inner_lookup_table/inner_lookup_index). Prefetch vor einem Lookup ist das Muster, bei dem ein
SELECT unter READ COMMITTED die S-Sperren der äußeren Zeilen bis Statement-Ende hält.
- outer_estimate_rows zeigt, für welche Menge der Plan kompiliert wurde.
- Die XQuery über den ganzen Plan-Cache ist teuer. Auf großen Produktivsystemen zuerst eingrenzen,
z. B. auf eine Datenbank (qp.dbid) oder auf Pläne mit hoher Ausführungszahl.
Getestet im Lock-Escalation-Prefetch-Lab (SQL Server 2019/2022/2025), siehe README.
*/
WITH XMLNAMESPACES (DEFAULT 'http://schemas.microsoft.com/sqlserver/2004/07/showplan')
SELECT DB_NAME(qp.dbid) AS database_name,
OBJECT_SCHEMA_NAME(qp.objectid, qp.dbid) + N'.'
+ OBJECT_NAME(qp.objectid, qp.dbid) AS object_name,
cp.objtype,
cp.usecounts,
LEFT(stmt.n.value('@StatementText', 'nvarchar(max)'), 300) AS statement_text,
nl.n.value('@NodeId', 'int') AS nested_loops_node_id,
CASE WHEN nl.n.exist('NestedLoops[@WithUnorderedPrefetch = ("1", "true")]') = 1
THEN N'unordered' ELSE N'ordered' END AS prefetch,
nl.n.value('(NestedLoops/RelOp/@EstimateRows)[1]', 'float') AS outer_estimate_rows,
lk.n.value('(IndexScan/Object/@Table)[1]', 'sysname') AS inner_lookup_table,
lk.n.value('(IndexScan/Object/@Index)[1]', 'sysname') AS inner_lookup_index,
cp.plan_handle
FROM sys.dm_exec_cached_plans AS cp
CROSS APPLY sys.dm_exec_query_plan(cp.plan_handle) AS qp
CROSS APPLY qp.query_plan.nodes('//StmtSimple') AS stmt(n)
CROSS APPLY stmt.n.nodes('.//RelOp[NestedLoops[@WithUnorderedPrefetch = ("1", "true")
or @WithOrderedPrefetch = ("1", "true")]]') AS nl(n)
OUTER APPLY nl.n.nodes('(NestedLoops/RelOp[2]/descendant-or-self::RelOp[IndexScan/@Lookup = ("1", "true")])[1]') AS lk(n)
WHERE cp.cacheobjtype = N'Compiled Plan'
AND qp.query_plan.exist('//NestedLoops[@WithUnorderedPrefetch = ("1", "true")
or @WithOrderedPrefetch = ("1", "true")]') = 1
ORDER BY cp.usecounts DESC, database_name, object_name, nested_loops_node_id;
| nested_loops_node_id | prefetch | outer_estimate_rows | inner_lookup_table | inner_lookup_index |
|---|---|---|---|---|
| 0 | unordered | 273.045 | [TestTab2] | [PK_TestTab2] |
| 2 | unordered | 117.846 | NULL | NULL |
| nested_loops_node_id | prefetch | outer_estimate_rows | inner_lookup_table | inner_lookup_index |
|---|---|---|---|---|
| 0 | unordered | 273.045 | [TestTab2] | [PK_TestTab2] |
| 2 | unordered | 117.846 | NULL | NULL |
| nested_loops_node_id | prefetch | outer_estimate_rows | inner_lookup_table | inner_lookup_index |
|---|---|---|---|---|
| 0 | unordered | 273.045 | [TestTab2] | [PK_TestTab2] |
| 3 | unordered | 117.846 | NULL | NULL |
Wer eskaliert wann?
Was es zeigt: Eine Session auf sqlserver.lock_escalation mit Statement, Session und Host, dazu die Abfrage, die die Ereignisse aus dem ring_buffer liest.
Wann nutzen: Wenn Sie das eskalierende Statement finden und über Stunden beobachten wollen. Danach die Session wieder entfernen.
Achtung: nicht unüberlegt auf einem Produktivsystem starten
Eine Extended-Events-Session gilt für den ganzen Server und kostet bei jedem erfassten Ereignis Arbeit. lock_escalation ist meist ein seltenes Ereignis. Auf einem System mit vielen Escalations erzeugen die Aktionen dieser Session (SQL-Text, Plan-Handle) aber zusätzliche Last.
- Erst auf einem Testsystem ausprobieren. Auf Produktion nur nach Abstimmung mit dem Betrieb und im üblichen Änderungsverfahren.
- Mit einem Prädikat auf die betroffene Datenbank eingrenzen, die Laufzeit begrenzen und Last sowie Ereignisrate beobachten.
- Danach stoppen und löschen, der Aufräumblock steht am Ende der Datei. Die Session ist mit
STARTUP_STATE = OFFangelegt und startet nach einem Neustart nicht von selbst. - Keine Ereignisse wie
lock_acquiredoderlock_releasedergänzen. Sie feuern bei jeder einzelnen Sperre und können einen Server deutlich ausbremsen.
Hinweise aus dem Kommentarkopf
- Ereignis sqlserver.lock_escalation mit Statement und Datenbankname, dazu Session, SQL-Text, Hostname, Anwendung und Plan-Handle. Ab SQL Server 2022 enthält das Ereignis zusätzlich das Feld lock_escalation_with_lock_class (im Lab für 2019 nicht vorhanden).
- Ziel ring_buffer: nur im Speicher, geht beim Stoppen der Session verloren. Für Dauerbetrieb stattdessen ein event_file-Ziel verwenden.
- Optional per Prädikat auf eine Datenbank eingrenzen, z. B. ADD EVENT sqlserver.lock_escalation (... WHERE (sqlserver.database_name = N'MeineDatenbank'))
- mode S oder X auf resource_type OBJECT ist die eskalierte Tabellensperre.
- escalated_lock_count: Zahl der dabei ersetzten feineren Sperren; hobt_lock_count: Sperren auf dem eskalierenden HoBt zum Zeitpunkt der Escalation.
- Ereignisse erscheinen erst nach der Dispatch-Latenz der Session (hier bis 5 Sekunden).
- Zeitstempel sind UTC.
/*
Diagnose (c), Teil 1: Extended-Events-Session für Lock Escalations.
! ACHTUNG: Nicht unüberlegt auf einem Produktivsystem starten.
! Erst auf einem Testsystem ausprobieren, auf Produktion nur nach Abstimmung mit dem Betrieb.
! Per Prädikat auf die betroffene Datenbank eingrenzen, Laufzeit begrenzen, Last beobachten.
! Danach stoppen und löschen (Aufräumblock am Ende). Keine Ereignisse wie lock_acquired ergänzen.
- Ereignis sqlserver.lock_escalation mit Statement und Datenbankname, dazu Session, SQL-Text,
Hostname, Anwendung und Plan-Handle. Ab SQL Server 2022 enthält das Ereignis zusätzlich das Feld
lock_escalation_with_lock_class (im Lab für 2019 nicht vorhanden).
- Ziel ring_buffer: nur im Speicher, geht beim Stoppen der Session verloren. Für Dauerbetrieb
stattdessen ein event_file-Ziel verwenden.
- Optional per Prädikat auf eine Datenbank eingrenzen, z. B.
ADD EVENT sqlserver.lock_escalation (... WHERE (sqlserver.database_name = N'MeineDatenbank'))
Getestet im Lock-Escalation-Prefetch-Lab (SQL Server 2019/2022/2025), siehe README.
*/
IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'psg_lock_escalation')
DROP EVENT SESSION psg_lock_escalation ON SERVER;
CREATE EVENT SESSION psg_lock_escalation ON SERVER
ADD EVENT sqlserver.lock_escalation (
SET collect_database_name = (1), collect_statement = (1)
ACTION (sqlserver.session_id, sqlserver.sql_text, sqlserver.client_hostname,
sqlserver.client_app_name, sqlserver.plan_handle)
)
ADD TARGET package0.ring_buffer (SET max_events_limit = (1000))
WITH (MAX_DISPATCH_LATENCY = 5 SECONDS, STARTUP_STATE = OFF);
ALTER EVENT SESSION psg_lock_escalation ON SERVER STATE = START;
/* Aufräumen, wenn die Session nicht mehr gebraucht wird:
ALTER EVENT SESSION psg_lock_escalation ON SERVER STATE = STOP;
DROP EVENT SESSION psg_lock_escalation ON SERVER;
*/
/*
Diagnose (c), Teil 2: Lock-Escalation-Ereignisse aus dem ring_buffer der Session
psg_lock_escalation lesen (siehe c-xe-lock-escalation-session.sql).
- mode S oder X auf resource_type OBJECT ist die eskalierte Tabellensperre.
- escalated_lock_count: Zahl der dabei ersetzten feineren Sperren; hobt_lock_count: Sperren auf dem
eskalierenden HoBt zum Zeitpunkt der Escalation.
- Ereignisse erscheinen erst nach der Dispatch-Latenz der Session (hier bis 5 Sekunden).
- Zeitstempel sind UTC.
Getestet im Lock-Escalation-Prefetch-Lab (SQL Server 2019/2022/2025), siehe README.
*/
WITH rb AS (
SELECT CAST(t.target_data AS xml) AS x
FROM sys.dm_xe_sessions AS s
JOIN sys.dm_xe_session_targets AS t
ON t.event_session_address = s.address
WHERE s.name = N'psg_lock_escalation'
AND t.target_name = N'ring_buffer'
)
SELECT e.n.value('@timestamp', 'datetime2(3)') AS utc_time,
e.n.value('(data[@name = "database_name"]/value)[1]', 'sysname') AS database_name,
OBJECT_NAME(e.n.value('(data[@name = "object_id"]/value)[1]', 'int'),
e.n.value('(data[@name = "database_id"]/value)[1]', 'int')) AS object_name,
e.n.value('(data[@name = "hobt_id"]/value)[1]', 'bigint') AS hobt_id,
e.n.value('(data[@name = "resource_type"]/text)[1]', 'nvarchar(60)') AS resource_type,
e.n.value('(data[@name = "mode"]/text)[1]', 'nvarchar(60)') AS mode,
e.n.value('(data[@name = "escalation_cause"]/text)[1]', 'nvarchar(60)') AS escalation_cause,
e.n.value('(data[@name = "escalated_lock_count"]/value)[1]', 'int') AS escalated_lock_count,
e.n.value('(data[@name = "hobt_lock_count"]/value)[1]', 'int') AS hobt_lock_count,
e.n.value('(data[@name = "lock_escalation_with_lock_class"]/value)[1]',
'nvarchar(10)') AS with_lock_class, -- ab 2022
e.n.value('(action[@name = "session_id"]/value)[1]', 'int') AS session_id,
e.n.value('(action[@name = "client_hostname"]/value)[1]', 'nvarchar(128)') AS client_hostname,
e.n.value('(data[@name = "statement"]/value)[1]', 'nvarchar(max)') AS statement,
e.n.value('(action[@name = "sql_text"]/value)[1]', 'nvarchar(max)') AS sql_text
FROM rb
CROSS APPLY rb.x.nodes('/RingBufferTarget/event[@name = "lock_escalation"]') AS e(n)
ORDER BY utc_time;
| object_name | resource_type | mode | escalation_cause | escalated_lock_count | hobt_lock_count | with_lock_class | client_hostname |
|---|---|---|---|---|---|---|---|
| TestTab2 | OBJECT | S | Lock threshold | 6248 | 6245 | NULL | LESC-RDR |
| object_name | resource_type | mode | escalation_cause | escalated_lock_count | hobt_lock_count | with_lock_class | client_hostname |
|---|---|---|---|---|---|---|---|
| TestTab2 | OBJECT | S | Lock threshold | 6248 | 6245 | true | LESC-RDR |
| object_name | resource_type | mode | escalation_cause | escalated_lock_count | hobt_lock_count | with_lock_class | client_hostname |
|---|---|---|---|---|---|---|---|
| TestTab2 | OBJECT | S | Lock threshold | 6247 | 6245 | true | LESC-RDR |
Was hält eine Session gerade?
Was es zeigt: Gehaltene und wartende Sperren je Session, Objekt, Index und Modus, verdichtet, mit Wartetyp und blockierender Session.
Wann nutzen: Mitten im Blocking. Viele KEY-Sperren mit Modus S bei einem Leser oder OBJECT mit S statt IS zeigen das Muster aus diesem Beitrag.
Hinweise aus dem Kommentarkopf
- Viele KEY-Sperren mit Modus S bei einer lesenden Session unter READ COMMITTED sind ungewöhnlich: Hinweis auf Sperren bis Statement-Ende, z. B. Nested Loops mit Prefetch vor einem Lookup.
- OBJECT mit Modus S (statt IS) bedeutet eine Tabellensperre, bei einem Leser typischerweise nach einer Lock Escalation.
/*
Diagnose (d): aktuell gehaltene und wartende Sperren je Session, Objekt, Index, Ressourcentyp und
Modus in der aktuellen Datenbank, verdichtet; dazu Wartetyp und blockierende Session der Anfrage.
- Viele KEY-Sperren mit Modus S bei einer lesenden Session unter READ COMMITTED sind ungewöhnlich:
Hinweis auf Sperren bis Statement-Ende, z. B. Nested Loops mit Prefetch vor einem Lookup.
- OBJECT mit Modus S (statt IS) bedeutet eine Tabellensperre, bei einem Leser typischerweise nach
einer Lock Escalation.
Getestet im Lock-Escalation-Prefetch-Lab (SQL Server 2019/2022/2025), siehe README.
*/
SELECT l.request_session_id AS session_id,
s.host_name,
r.wait_type,
r.blocking_session_id,
l.resource_type,
COALESCE(OBJECT_NAME(p.object_id),
CASE WHEN l.resource_type = N'OBJECT'
THEN OBJECT_NAME(l.resource_associated_entity_id) END) AS object_name,
p.index_id,
i.name AS index_name,
l.request_mode,
l.request_status,
COUNT(*) AS lock_count
FROM sys.dm_tran_locks AS l
LEFT JOIN sys.partitions AS p
ON p.hobt_id = l.resource_associated_entity_id
AND l.resource_type IN (N'KEY', N'PAGE', N'RID', N'HOBT')
LEFT JOIN sys.indexes AS i
ON i.object_id = p.object_id
AND i.index_id = p.index_id
LEFT JOIN sys.dm_exec_sessions AS s
ON s.session_id = l.request_session_id
LEFT JOIN sys.dm_exec_requests AS r
ON r.session_id = l.request_session_id
WHERE l.resource_database_id = DB_ID()
AND l.request_session_id <> @@SPID
GROUP BY l.request_session_id, s.host_name, r.wait_type, r.blocking_session_id, l.resource_type,
p.object_id, l.resource_associated_entity_id, p.index_id, i.name,
l.request_mode, l.request_status
ORDER BY session_id, object_name, index_id, resource_type, request_mode;
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| KEY | TestTab2 | ix_ID_Tab1 | S | GRANT | 2000 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab2 | ix_ID_Tab1 | IS | GRANT | 23 | LCK_M_S |
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | S | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| KEY | TestTab2 | ix_ID_Tab1 | S | GRANT | 2000 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab2 | ix_ID_Tab1 | IS | GRANT | 23 | LCK_M_S |
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | S | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| KEY | TestTab2 | ix_ID_Tab1 | S | GRANT | 1188 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
| PAGE | TestTab2 | ix_ID_Tab1 | IS | GRANT | 23 | LCK_M_S |
| resource_type | object_name | index_name | request_mode | request_status | lock_count | wait_type |
|---|---|---|---|---|---|---|
| DATABASE | NULL | NULL | S | GRANT | 1 | LCK_M_S |
| KEY | TestTab1 | ix_Col4 | S | WAIT | 1 | LCK_M_S |
| OBJECT | TestTab1 | NULL | IS | GRANT | 1 | LCK_M_S |
| OBJECT | TestTab2 | NULL | S | GRANT | 1 | LCK_M_S |
| PAGE | TestTab1 | ix_Col4 | IS | GRANT | 1 | LCK_M_S |
Methode und Grenzen
- SQL Server 2019 CU32 (15.0.4490.9), 2022 CU13 (16.0.4125.3) und 2025 CU7 (17.0.4065.4), jeweils Developer Edition in einem eigenen Container auf Linux.
- Daten nach Fabianos Aufbau, deterministisch erzeugt und in allen Versionen identisch. Synthetisch, keine Produktionsdaten.
- Zählerwerte und Sperren je Zustand einmal gemessen und an Stichpunkten wiederholt, mit identischem Ergebnis. Laufzeiten je dreimal.
- Die Wartezeiten der Schreiber hängen vom künstlichen Anhalten ab und sind keine Betriebswerte.
- 2022 nur mit CU13. Parallele Nested-Loops-Pläne haben wir nicht untersucht.
- Die Escalation-Schwelle gilt pro Thread. In parallelen Plänen verteilt sie sich anders (Paul White, Teil 2).
Wie gut kennen Sie Ihre Sperren?
Jede Antwort wird sofort aufgelöst, mit Verweis auf die Stelle im Beitrag. Am Ende gibt es eine Isolationsstufe und einen Link, mit dem Sie Ihr Ergebnis teilen können. Ohne Cookies.
Weiterlesen
- Craig Freedman, 2007: Read Committed and Bookmark Lookup
- Fabiano Amorim, 2012: SQL Server Prefetch and Query Performance
- Paul White, 2022: The Lock Escalation Threshold, Teil 1, Teil 2 und Teil 3
- LinkedIn 2022: A simple SELECT caused an index lock escalation. Why?
- Microsoft Learn: Resolve blocking problems caused by lock escalation, Optimized locking
- Bei uns: Wo steckt die Zeit? Query Store bei Triggern, Procedures und Funktionen