Aus dem Labor · Locking

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.

07.10.2026 · ca. 12 Minuten

Gemessen SQL Server 2019 · 2022 · 2025Einfrierpunkte bis auf die ZeileGegenmittel 12 im TestMitraten 10 Fragen ↓

Der Fall in 30 Sekunden

  1. Parameter, erster Aufruf@p1 = 0.8Plan für wenige Zeilen. Der erste Aufruf mit 0.8 erzeugt zwei Nested Loops mit Prefetch und einen Key Lookup.
  2. Parameter, zweiter Aufruf@p1 = 800Ausführung mit allen Zeilen. Mit 800 liest derselbe Plan alle 10.000 Zeilen von TestTab2. Für jede Zeile aus ix_ID_Tab1 bleibt ein S-Lock stehen.
  3. 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“.
  4. Wartetyp der SchreiberLCK_M_IXDann wartet jeder Schreiber. INSERT, UPDATE und DELETE auf TestTab2 warten mit LCK_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:

Procedure dbo.p2
CREATE 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;
Zwei Aufrufe, ein Plan
EXEC dbo.p2 0.8;   -- legt den Plan in den Cache: 37 Zeilen
EXEC dbo.p2 800;   -- benutzt ihn weiter: 10.000 Zeilen

Der 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.

Interaktiv · Der Plan im Cache Plan nach 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.

Tatsächlicher Ausführungsplan · Zeilen geschätzt → tatsächlich…

Kompiliert für
–
Geschätzte Zeilen
–
Tatsächlich bei 800
–
Eskalierte Sperren (XE)
–
Darstellung wie im Kompendium Optimizer verstehen: Ergebnis links, Datenzugriffe rechts, die Pfeilstärke zeigt die Zeilen. Auf der inneren Seite eines Nested Loops gilt die Schätzung je Ausführung. Operatoren mit Maus, Finger oder Tabulator wählen.
Interaktiv · Einfrieren und nachzählen

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_Tab1 Zeilen 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 attempt steigt schon an Prüfpunkten, an denen nichts eskalieren kann. index_lock_promotion_attempt_count stieg bei uns beim Überschreiten von 2.500, 3.750 und 5.000 Sperren, in 2019 und 2022 jeweils um 4. index_lock_promotion_count steigt 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.

Index Seekix_ID_Tab1Zeiger sammelnPrefetchliest vorausLückeKey LookupPK_TestTab2
S-Lock der Seek-Zeile bleibt stehen, bis das Statement endet

Das ist kein Fehler, sondern Absicht:

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.

Interaktiv · Blocker → Leser → Schreiber

Was die eskalierte Lesersperre anrichtet

Schreiber
Phase

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 von Col1 laufen trotzdem durch, weil sie diesen Index nicht berühren. Nur ein DELETE einer bereits gelesenen Zeile wartet mit LCK_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.

Interaktiv · Der erste Aufruf entscheidet

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.

Interaktiv · Gegenmittel-Labor

Gegenmittel im Test

✓ keine Escalation ✗ eskaliert – nicht gemessen
Escalation beim Aufruf EXEC p2 800, je Version vier Läufe. „keine“ heißt: keine Escalation.
Variante201920222025Einordnung
Ausgangsfalleskalierteskalierteskaliert
RCSIkeinekeinekeineLeser nehmen keine S-Locks. Version Store in tempdb einplanen, Code prüfen, der auf blockierendes Lesen baut.
SNAPSHOT (Session)keinekeinekeineLeser 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))keinekeinekeinekein Lookup, keine gehaltenen Sperren. Mehr Platz und Schreiblast.
OPTION (RECOMPILE)keinekeinekeineFür den aktuellen Wert kompiliert. Kompilierkosten, hier langsamerer paralleler Plan.
QUERYTRACEON 8744keinekeineeskaliertin 2025 hält der Batch Sort die Sperren. Laut Doku nur global/session vorgesehen.
8744 + DISABLE_OPTIMIZED_NESTED_LOOPkeinekeinekeineerst beides zusammen hilft in 2025
LOCK_ESCALATION = DISABLEkeinekeinekeineaber 8.001 (2025: 7.555) gehaltene Einzelsperren am selben Punkt. Symptom verschoben.
NOLOCKkeinekeinekeinenicht empfohlen: Dirty Reads, fehlende oder doppelte Zeilen
Optimized Locking, RCSI aus––eskaliertwirkt auf Schreiber, nicht auf diese Lesersperren
Optimized Locking + RCSI––keinedie Wirkung kommt von RCSI

Unsere Reihenfolge in der Praxis:

  1. Den Lookup loswerden, wenn die Abfrage wichtig ist. Covering Index oder schmalere Spaltenliste.
  2. RCSI prüfen, wenn die Anwendung es verträgt. Vorher klären, ob Code auf blockierendes Lesen baut.
  3. 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

Gemessen mit SQL Server 2019 CU32 (15.0.4490.9), 2022 CU13 (16.0.4125.3) und 2025 CU7 (17.0.4065.4).
2019 CU322022 CU132025 CU7
Plan nach 0.82 × NL mit Prefetch + Key Lookupgleichgleich, zusätzlich Batch Sort
Escalation bei 800ja, 6.248 Sperrenja, 6.248ja, 6.247
Letzter Stand ohne Escalation6.220 Key-Locks6.2205.109 (Batch Sort sammelt blockweise)
XE meldet „lock class“Feld fehlttruetrue
TF 8744 hilftjajanein, erst mit DISABLE_OPTIMIZED_NESTED_LOOP
Optimized Locking––hilft nur mit RCSI
Plan bleibt stabiljaeinmal Verifikationnach 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.

a

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-escalation-zaehler.sql28 Zeilen
/*
  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;
So sah das im Lab aus
Differenz zweier Abfragen vor und nach einem Aufruf EXEC dbo.p2 800, Auszug der Spalten.
table_nameindex_nameΔ index_lock_promotion_attempt_countΔ index_lock_promotion_count
TestTab2ix_ID_Tab1131
TestTab2PK_TestTab2130
TestTab1ix_Col470
b

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-plancache-prefetch-lookup.sql35 Zeilen
/*
  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;
So sah das im Lab aus
Treffer für dbo.p2 nach EXEC dbo.p2 0.8, Auszug der Spalten.
nested_loops_node_idprefetchouter_estimate_rowsinner_lookup_tableinner_lookup_index
0unordered273.045[TestTab2][PK_TestTab2]
2unordered117.846NULLNULL
c

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 = OFF angelegt und startet nach einem Neustart nicht von selbst.
  • Keine Ereignisse wie lock_acquired oder lock_released ergä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.
Teil 1 · Session anlegendiagnose/c-xe-lock-escalation-session.sql33 Zeilen
/*
  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;
*/
Teil 2 · Ereignisse lesendiagnose/c-xe-lock-escalation-read.sql37 Zeilen
/*
  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;
So sah das im Lab aus
Ereignis nach EXEC dbo.p2 800, Auszug der Spalten (ohne Zeit, IDs, Statement und SQL-Text).
object_nameresource_typemodeescalation_causeescalated_lock_counthobt_lock_countwith_lock_classclient_hostname
TestTab2OBJECTSLock threshold62486245NULLLESC-RDR
d

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-gehaltene-locks.sql39 Zeilen
/*
  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;
So sah das im Lab aus
Session des eingefrorenen Lesers vor der Escalation, Auszug der Spalten.
resource_typeobject_nameindex_namerequest_moderequest_statuslock_countwait_type
DATABASENULLNULLSGRANT1LCK_M_S
KEYTestTab1ix_Col4SWAIT1LCK_M_S
KEYTestTab2ix_ID_Tab1SGRANT2000LCK_M_S
OBJECTTestTab1NULLISGRANT1LCK_M_S
OBJECTTestTab2NULLISGRANT1LCK_M_S
PAGETestTab1ix_Col4ISGRANT1LCK_M_S
PAGETestTab2ix_ID_Tab1ISGRANT23LCK_M_S
Dieselbe Session nach der Escalation: Die Key-Locks auf ix_ID_Tab1 sind einer Tabellensperre (OBJECT, S) gewichen.
resource_typeobject_nameindex_namerequest_moderequest_statuslock_countwait_type
DATABASENULLNULLSGRANT1LCK_M_S
KEYTestTab1ix_Col4SWAIT1LCK_M_S
OBJECTTestTab1NULLISGRANT1LCK_M_S
OBJECTTestTab2NULLSGRANT1LCK_M_S
PAGETestTab1ix_Col4ISGRANT1LCK_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).
Quiz · 10 Fragen

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