DBA Scripts: Get Lock Escalation, Contention Analysis, and Blocking Chains with Plan

🔧Part of the DBA-Tools Project, copy/paste SQL Server scripts and health checks.In: Performance & Troubleshooting › Blocking & Locking

Three Different Views of the Same Underlying Problem

Blocking Chains shows what’s blocked right now. These 3 scripts go wider and deeper: Get-LockEscalationStats finds which tables have converted row/page locks into full table locks (a common, self-inflicted cause of blocking), Get-ContentionAnalysis rolls up lock waits, latch waits, page latch waits and spinlock collisions into one summary since the last restart, and Get-BlockingChainsWithPlan is Blocking Chains with the actual execution plan attached for every blocked or blocking session, so you can see not just who’s blocked but what query is doing it.

Together they cover 3 different angles on the same underlying question: is locking or latching actually costing this server anything, and if so, where. If something is blocked right now and you want the order of work rather than the scripts, start at Troubleshoot SQL Server Blocking, Step by Step and come back here for the detail.


Why Lock Escalation, Contention, and Blocking Plans Matter

  • Lock escalation converts many fine-grained row/page locks into one table lock, which can block readers and writers that would have coexisted fine under row-level locking, a frequent, avoidable blocking cause
  • Cumulative wait statistics (lock, latch, TempDB, spinlock) since the last restart tell you where contention has actually cost time, not just where it’s theoretically possible
  • A blocking chain with the query plan attached turns “session 62 is blocking session 78” into “this specific query, with this specific plan, is the actual problem,” the difference between guessing and fixing
  • SQL Server Cannot Obtain a LOCK Resource at This Time (Error 1204), where lock escalation being switched off turns into this exact error
  • Page latch waits (PAGELATCH_UP and PAGELATCH_EX) have a well-known direct fix when they land on TempDB allocation pages, adding TempDB data files, and no fix at all when they land somewhere else, which is why the database has to be confirmed before the change

When to Run These Scripts

  • Get-LockEscalationStats, when investigating blocking that seems disproportionate to the actual data touched, escalation is a common hidden cause
  • Get-ContentionAnalysis, as a general health check or when investigating a vague “performance feels off” report, it surfaces contention across 4 different resource types in one pass
  • Get-BlockingChainsWithPlan, during an active blocking incident, when Get-BlockingChains has identified the sessions involved and you need the actual query and plan to fix the root cause
  • Routine SQL Server health checks, alongside the rest of the Blocking and Locking cluster

The Scripts

Get-LockEscalationStats: Tables With the Most Lock Escalations

✓ Verified
  • Tested on: SQL Server 2025 (RTM CU8) 17.0.4075.5, Windows lab instance
  • Last verified: 2026-10-24 (all 3 scripts run, Get-BlockingChainsWithPlan against a live block of 2 readers behind an idle head blocker)
  • Permissions: VIEW SERVER STATE for all 3. Get-LockEscalationStats also needs VIEW DATABASE STATE in every database you want counted, otherwise that database is simply absent from the result rather than raising an error
  • Safety: read-only, impact low. Get-BlockingChainsWithPlan creates and drops one temp table (#ar) in your own session
The thresholds in these scripts are operational heuristics: 100 waits, 50,000 spinlock collisions, a spin ratio over 1,000. Lock escalation at roughly 5,000 locks per statement is documented SQL Server behaviour, not a heuristic.
/*
Script Name : Get-LockEscalationStats
Category    : performance
Purpose     : Shows tables with the most lock escalations since last restart.
              Lock escalation converts row/page locks to a table lock, increasing blocking.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-lock-contention-and-blocking-plans/)
Requires    : VIEW SERVER STATE
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;
SET QUOTED_IDENTIFIER ON;

/*
  DESIGN: sys.dm_db_index_operational_stats with NULL parameters covers all databases
  and all indexes. Aggregated per table (summing across all indexes) since escalation
  is a table-level event. Excludes system databases (database_id <= 4).

  High escalation counts on a table indicate:
    - Large batch operations scanning many rows (e.g. bulk updates, large deletes)
    - Queries that acquire more than 5,000 row/page locks in a single statement
  Remedies:
    - ALTER TABLE t SET (LOCK_ESCALATION = DISABLE), prevents escalation (use carefully)
    - Batch large DML into smaller chunks to stay below the lock threshold
    - Add covering indexes to reduce rows scanned per operation
    - Use READ_COMMITTED_SNAPSHOT isolation to eliminate shared locks on reads
*/

SELECT TOP 30
    DB_NAME(ios.database_id) AS database_name,
    OBJECT_NAME(ios.object_id, ios.database_id) AS table_name,
    SUM(ios.index_lock_promotion_count) AS lock_escalations,
    SUM(ios.row_lock_count) AS row_lock_count,
    SUM(ios.page_lock_count) AS page_lock_count,
    SUM(ios.row_lock_wait_count) AS row_lock_wait_count,
    SUM(ios.page_lock_wait_count) AS page_lock_wait_count,
    CAST(SUM(ios.row_lock_wait_in_ms) / 1000.0 AS decimal(12,2)) AS row_lock_wait_sec,
    CAST(SUM(ios.page_lock_wait_in_ms) / 1000.0 AS decimal(12,2)) AS page_lock_wait_sec
FROM sys.dm_db_index_operational_stats(NULL, NULL, NULL, NULL) ios
WHERE ios.database_id > 4
  AND ios.index_lock_promotion_count > 0
GROUP BY
    ios.database_id,
    ios.object_id
ORDER BY
    lock_escalations DESC;

Get-ContentionAnalysis: Lock, Latch, Page Latch and Spinlock Contention in One Pass

/*
Script Name : Get-ContentionAnalysis
Category    : performance
Purpose     : Unified contention summary across lock waits, latch waits, page latch
              (allocation) waits, and spinlock contention. All figures are cumulative
              since the last SQL Server restart, so high counts on a recently restarted
              instance are not necessarily concerning.
              sys.dm_os_wait_stats carries no database column, so the PAGE_LATCH rows
              are not attributed to a database. Confirm which database and page with
              sys.dm_os_waiting_tasks.resource_description while the wait is happening.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-lock-contention-and-blocking-plans/)
Requires    : VIEW SERVER STATE
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;
SET QUOTED_IDENTIFIER ON;

DECLARE @min_wait_count BIGINT = 100; -- suppress trivial entries with fewer wait occurrences
DECLARE @min_spinlock_collisions BIGINT = 50000; -- only flag high-volume spinlocks

SELECT
    contention_type,
    resource_name,
    wait_count,
    total_wait_ms,
    avg_wait_ms,
    days_since_restart,
    note
FROM (

    -- ── Lock waits ──────────────────────────────────────────────────────────────
    -- LCK_M_* waits represent time spent waiting for row/page/object locks.
    -- High values combined with active blocking indicate a locking hotspot.
    SELECT
        'LOCK' AS contention_type,
        ws.wait_type AS resource_name,
        ws.waiting_tasks_count AS wait_count,
        ws.wait_time_ms AS total_wait_ms,
        CAST(ws.wait_time_ms * 1.0
             / NULLIF(ws.waiting_tasks_count, 0) AS DECIMAL(12,2)) AS avg_wait_ms,
        uptime.days_since_restart,
        'Locking contention - run Get-BlockingChains for real-time chain analysis' AS note
    FROM sys.dm_os_wait_stats ws
    CROSS JOIN (
        SELECT DATEDIFF(DAY, sqlserver_start_time, GETDATE()) AS days_since_restart
        FROM sys.dm_os_sys_info
    ) uptime
    WHERE ws.wait_type LIKE 'LCK_M_%'
      AND ws.waiting_tasks_count >= @min_wait_count

    UNION ALL

    -- ── Latch waits ─────────────────────────────────────────────────────────────
    -- Latches protect internal memory structures. PAGEIOLATCH = I/O waits on data pages.
    -- ACCESS_METHODS / BUFFER / FGCB = internal structure contention.
    SELECT
        'LATCH',
        ls.latch_class,
        ls.waiting_requests_count,
        ls.wait_time_ms,
        CAST(ls.wait_time_ms * 1.0
             / NULLIF(ls.waiting_requests_count, 0) AS DECIMAL(12,2)),
        uptime.days_since_restart,
        CASE
            WHEN ls.latch_class LIKE 'PAGEIOLATCH%'
                THEN 'I/O latch - data page reads are slow; check disk latency'
            WHEN ls.latch_class LIKE 'ACCESS_METHODS%'
                THEN 'Scan/seek contention - high concurrent read/write activity'
            WHEN ls.latch_class = 'BUFFER'
                THEN 'Buffer pool contention - memory pressure or heavy page access'
            WHEN ls.latch_class LIKE 'FGCB%'
                THEN 'Filegroup cache contention - check filegroup configuration'
            ELSE 'Internal latch contention - investigate if avg_wait_ms is high'
        END
    FROM sys.dm_os_latch_stats ls
    CROSS JOIN (
        SELECT DATEDIFF(DAY, sqlserver_start_time, GETDATE()) AS days_since_restart
        FROM sys.dm_os_sys_info
    ) uptime
    WHERE ls.waiting_requests_count >= @min_wait_count
      AND ls.latch_class NOT LIKE 'LOG_%' -- log latches are usually expected
      AND ls.latch_class NOT LIKE 'LOGGING_%'

    UNION ALL

    -- ── TempDB allocation page contention ────────────────────────────────────────
    -- PAGELATCH_UP/EX on TempDB is classic allocation bitmap contention (PFS, GAM, SGAM).
    -- Symptom: many concurrent object creates/drops in TempDB (temp tables, table variables).
    -- Fix (TempDB only): add TempDB data files up to the number of logical CPUs (max 8).
    -- IMPORTANT: sys.dm_os_wait_stats returns 5 columns and none of them is a database,
    -- so these rows are instance-wide. PAGELATCH_* on a user database is ordinary page
    -- contention, not allocation-bitmap contention, and adding TempDB files will not
    -- help it. Confirm the database before acting: while the wait is live, read
    -- sys.dm_os_waiting_tasks.resource_description, which reads <db_id>:<file_id>:<page_id>
    -- (TempDB is database_id 2), and treat page ids 1, 2 and 3 as PFS, GAM and SGAM.
    SELECT
        'PAGE_LATCH',
        ws.wait_type,
        ws.waiting_tasks_count,
        ws.wait_time_ms,
        CAST(ws.wait_time_ms * 1.0
             / NULLIF(ws.waiting_tasks_count, 0) AS DECIMAL(12,2)),
        uptime.days_since_restart,
        CASE ws.wait_type
            WHEN 'PAGELATCH_UP' THEN 'Update page latch, the PFS/GAM/SGAM shape. Confirm the database in sys.dm_os_waiting_tasks before adding TempDB files'
            WHEN 'PAGELATCH_EX' THEN 'Exclusive page latch. Allocation bitmap contention if the pages are TempDB 2:1:1/2:1:2/2:1:3, otherwise ordinary page contention'
            ELSE 'Shared page latch, allocation page reads. Check the database in sys.dm_os_waiting_tasks'
        END
    FROM sys.dm_os_wait_stats ws
    CROSS JOIN (
        SELECT DATEDIFF(DAY, sqlserver_start_time, GETDATE()) AS days_since_restart
        FROM sys.dm_os_sys_info
    ) uptime
    WHERE ws.wait_type IN ('PAGELATCH_UP', 'PAGELATCH_EX', 'PAGELATCH_SH')
      AND ws.waiting_tasks_count >= @min_wait_count

    UNION ALL

    -- ── Spinlock contention ──────────────────────────────────────────────────────
    -- Spinlocks are lightweight CPU-spinning locks protecting very short-lived structures.
    -- High spins-per-collision ratio (>1000) on a specific spinlock indicates a hot path.
    -- Note: total_wait_ms = spins / 1000 (proxy, spinlocks do not track wall-clock time).
    -- avg_wait_ms = spins per collision (spin ratio, not milliseconds).
    SELECT
        'SPINLOCK',
        ss.name,
        ss.collisions,
        ss.spins / 1000, -- proxy: not ms, but comparable magnitude for sorting
        CAST(ss.spins * 1.0 / NULLIF(ss.collisions, 0) AS DECIMAL(12,2)), -- spin ratio
        uptime.days_since_restart,
        'Spin ratio: ' + CAST(CAST(ss.spins * 1.0 / NULLIF(ss.collisions, 0) AS DECIMAL(10,0)) AS VARCHAR)
            + ' spins/collision - avg_wait_ms column shows spin ratio, not ms'
    FROM sys.dm_os_spinlock_stats ss
    CROSS JOIN (
        SELECT DATEDIFF(DAY, sqlserver_start_time, GETDATE()) AS days_since_restart
        FROM sys.dm_os_sys_info
    ) uptime
    WHERE ss.collisions >= @min_spinlock_collisions
      AND ss.spins / NULLIF(ss.collisions, 0) > 1000

) contention
ORDER BY
    CASE contention_type
        WHEN 'LOCK' THEN 1
        WHEN 'LATCH' THEN 2
        WHEN 'PAGE_LATCH' THEN 3
        WHEN 'SPINLOCK' THEN 4
    END,
    total_wait_ms DESC;

Get-BlockingChainsWithPlan: Blocking Chains With the Query Plan Attached

/*
Script Name : Get-BlockingChainsWithPlan
Category    : diagnostics
Purpose     : Same as Get-BlockingChains.sql with the addition of query_plan for
              every session that has a cached plan. Use the PowerShell wrapper to
              extract plans to individual XML files. Plan will be NULL for idle
              head blockers (no active request) and sessions still in parse/compile.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-lock-contention-and-blocking-plans/)
Requires    : VIEW SERVER STATE

Performance note: dm_exec_requests is materialised into #ar once to avoid
repeated DMV scans. downstream_waiters is pre-aggregated rather than computed
via a correlated subquery. dm_exec_connections is joined only for sessions
that have no active request (idle head blockers).

query_plan is returned as xml, so in SSMS it is a link that opens the graphical plan.

Reading the output: the head blocker row is the one to act on. query_plan is NULL
on an idle head blocker because a sleeping session has no active request and so no
plan_handle; its sql_text comes from most_recent_sql_handle and is the whole last
batch, not one statement. wait_resource names the locked object for each waiter:
KEY: <db_id>:<hobt_id> (<hash>), PAGE: <db_id>:<file_id>:<page_id>,
OBJECT: <db_id>:<object_id>. Returns no rows when the server is not blocked.
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;
SET QUOTED_IDENTIFIER ON;

DROP TABLE IF EXISTS #ar;

SELECT
    session_id, blocking_session_id, status, wait_type, wait_resource, wait_time,
    cpu_time, logical_reads, writes, total_elapsed_time,
    sql_handle, plan_handle, database_id,
    statement_start_offset, statement_end_offset
INTO #ar
FROM sys.dm_exec_requests
WHERE session_id <> @@SPID;

WITH
DownstreamCounts AS (
    SELECT blocking_session_id,
           COUNT(*) AS downstream_waiters
    FROM #ar
    WHERE blocking_session_id > 0
    GROUP BY blocking_session_id
),
Chain AS (
    -- Anchor: head blockers, they block others but are not themselves blocked
    SELECT
        s.session_id AS chain_id,
        0 AS chain_level,
        CAST('head blocker' AS VARCHAR(16)) AS role,
        CAST(0 AS SMALLINT) AS blocker_session_id,
        s.session_id,
        s.login_name,
        s.host_name,
        s.program_name,
        s.open_transaction_count,
        COALESCE(r.status, s.status) AS status,
        r.wait_type,
        r.wait_resource,
        r.wait_time,
        r.cpu_time,
        r.logical_reads,
        r.writes,
        r.total_elapsed_time,
        COALESCE(r.database_id, s.database_id) AS database_id,
        COALESCE(r.statement_start_offset, 0) AS statement_start_offset,
        COALESCE(r.statement_end_offset, -1) AS statement_end_offset,
        COALESCE(r.sql_handle, c.most_recent_sql_handle) AS sql_handle,
        r.plan_handle
    FROM sys.dm_exec_sessions AS s
    LEFT JOIN #ar AS r ON r.session_id = s.session_id
    LEFT JOIN sys.dm_exec_connections AS c ON c.session_id = s.session_id
                                          AND r.session_id IS NULL
    WHERE s.is_user_process = 1
      AND EXISTS (SELECT 1 FROM #ar WHERE blocking_session_id = s.session_id)
      AND NOT EXISTS (SELECT 1 FROM #ar WHERE session_id = s.session_id AND blocking_session_id > 0)

    UNION ALL

    -- Recursive: victims blocked by the previous chain level
    SELECT
        ch.chain_id,
        ch.chain_level + 1,
        CAST('blocked' AS VARCHAR(16)),
        r.blocking_session_id,
        r.session_id,
        s.login_name,
        s.host_name,
        s.program_name,
        s.open_transaction_count,
        r.status,
        r.wait_type,
        r.wait_resource,
        r.wait_time,
        r.cpu_time,
        r.logical_reads,
        r.writes,
        r.total_elapsed_time,
        r.database_id,
        r.statement_start_offset,
        r.statement_end_offset,
        r.sql_handle,
        r.plan_handle
    FROM #ar AS r
    JOIN sys.dm_exec_sessions AS s ON s.session_id = r.session_id
    JOIN Chain AS ch ON ch.session_id = r.blocking_session_id
    WHERE r.blocking_session_id > 0
)
SELECT
    ch.chain_id,
    ch.chain_level,
    ch.role,
    ch.blocker_session_id,
    ch.session_id,
    ch.login_name,
    ch.host_name,
    ch.program_name,
    DB_NAME(ch.database_id) AS database_name,
    ch.open_transaction_count,
    ch.status,
    ch.wait_type,
    ch.wait_resource,
    ISNULL(ch.wait_time, 0) AS wait_time_ms,
    ISNULL(ch.cpu_time, 0) AS cpu_time_ms,
    ISNULL(ch.logical_reads, 0) AS logical_reads,
    ISNULL(ch.writes, 0) AS writes,
    ISNULL(ch.total_elapsed_time, 0) AS total_elapsed_time_ms,
    ISNULL(dc.downstream_waiters, 0) AS downstream_waiters,
    CAST(
        (ISNULL(su.user_objects_alloc_page_count, 0) +
         ISNULL(su.internal_objects_alloc_page_count, 0)) * 8
    AS BIGINT) AS tempdb_allocations_kb,
    CAST(
        (ISNULL(su.user_objects_alloc_page_count, 0) - ISNULL(su.user_objects_dealloc_page_count, 0) +
         ISNULL(su.internal_objects_alloc_page_count, 0) - ISNULL(su.internal_objects_dealloc_page_count, 0)) * 8
    AS BIGINT) AS tempdb_current_kb,
    SUBSTRING(
        ISNULL(qt.text, ''),
        (ch.statement_start_offset / 2) + 1,
        CASE
            WHEN ch.statement_end_offset = -1
                THEN LEN(ISNULL(qt.text, ''))
            ELSE (ch.statement_end_offset - ch.statement_start_offset) / 2 + 1
        END
    ) AS sql_text,
    qp.query_plan AS query_plan
FROM Chain AS ch
LEFT JOIN DownstreamCounts AS dc ON dc.blocking_session_id = ch.session_id
LEFT JOIN sys.dm_db_session_space_usage AS su ON su.session_id = ch.session_id
OUTER APPLY sys.dm_exec_sql_text(ch.sql_handle) AS qt
OUTER APPLY sys.dm_exec_query_plan(
    CASE WHEN ch.plan_handle <> 0x0000000000000000000000000000000000000000
         THEN ch.plan_handle END
) AS qp
ORDER BY
    ch.chain_id,
    ch.chain_level,
    ch.session_id
OPTION (MAXRECURSION 10);

How To Run From The Repo

Clone DBA Tools, initialize and run whichever script answers your question:

# Clone dba-tools repo:
git clone https://github.com/peterwhyte-lgtm/dba-tools

# Initialize environment:
cd dba-tools
.\Initialize-Environment.ps1

# Tables with the most lock escalations:
.\run.ps1 Get-LockEscalationStats

# Lock/latch/TempDB/spinlock contention summary since last restart:
.\run.ps1 Get-ContentionAnalysis

# Blocking chains with the query plan attached (use during an active incident):
.\run.ps1 Get-BlockingChainsWithPlan

# Any of these against a remote sql server:
.\run.ps1 Get-ContentionAnalysis -ServerInstance SQLSERVER01

These scripts live in the repo at:


Example Output

Get-BlockingChainsWithPlan, Caught During a Live Block

One session opened a transaction, updated a single row, then went idle without committing it. 2 other sessions tried to read that row. This is what the script is for: it names the one session you have to deal with.

SSMS results grid from Get-BlockingChainsWithPlan during a live block in the pass_lock database. One chain, chain_id 55: the head blocker, session 55, is sleeping with open_transaction_count 1 and no wait. Sessions 52 and 67 are blocked by 55, both suspended on LCK_M_S, one waiting on PAGE: 18:1:384 and the other on a KEY in the Orders primary key.

Scrolled right, the same grid ends in sql_text and query_plan:

The same Get-BlockingChainsWithPlan grid scrolled right to sql_text and query_plan. query_plan is NULL on the sleeping head blocker and a blue ShowPlanXML link on both blocked rows, which opens the graphical execution plan in SSMS.

Three things in that grid are worth reading slowly.

  • role is head blocker on session 55, and its status is sleeping, not running. It holds open_transaction_count 1 and nothing else. An idle session with an open transaction is the most common blocking cause there is, and no amount of tuning touches it: the transaction has to commit, roll back, or the session has to be killed
  • query_plan came back NULL on that row and populated on both blocked rows, 4,523 and 3,547 characters of ShowPlanXML built by 17.0.4075.5. In SSMS each one is a link: click it and the graphical plan opens, which is where you see the scan that widened the lock
  • wait_resource names the object, which is what tells you where to look. KEY: 18:72057594047234048 resolves to the primary key of Orders in database_id 18, found with SELECT OBJECT_NAME(object_id) FROM sys.partitions WHERE hobt_id = 72057594047234048 run inside that database. PAGE: 18:1:384 is database_id 18, file 1, page 384

Get-ContentionAnalysis, Cumulative Since the Last Restart

Thirteen rows against 3 days of uptime. The note column is cut from the capture for width; on the real grid it repeats in plain English what each row means.

Get-ContentionAnalysis on the lab instanceexample output, not something to copy
contention_type resource_name wait_count total_wait_ms avg_wait_ms days_since_restart --------------- -------------------------------- ---------- ------------- ----------- ------------------ LOCK LCK_M_X 29011 16103276 555.07 3 LOCK LCK_M_S 185 2209529 11943.40 3 LOCK LCK_M_SCH_M 36906 58898 1.60 3 LATCH ACCESS_METHODS_DATASET_PARENT 100749 21792856 216.31 3 LATCH BUFFER 5147702 9833493 1.91 3 LATCH ACCESS_METHODS_ACCESSOR_CACHE 36007 7234 0.20 3 LATCH NESTING_TRANSACTION_FULL 11904 4609 0.39 3 LATCH ACCESS_METHODS_HOBT_VIRTUAL_ROOT 792 2363 2.98 3 LATCH METADATA_SEQUENCE_GENERATOR 154 87 0.56 3 LATCH NESTING_TRANSACTION_READONLY 402 9 0.02 3 PAGE_LATCH PAGELATCH_EX 68431 62027 0.91 3 PAGE_LATCH PAGELATCH_SH 31972 10788 0.34 3 PAGE_LATCH PAGELATCH_UP 2432 2549 1.05 3

The LCK_M_S average of 11,943 ms is not a server problem. It is the block from the section above, still being held while this ran. That is worth knowing, because it is the shape a real incident leaves in cumulative statistics: a small wait_count with an enormous average. The opposite shape, a large count with a tiny average like the 36,906 LCK_M_SCH_M waits at 1.60 ms each, is ordinary schema-stability traffic and not worth chasing. The lock waits library entry has the per-wait meanings.

Get-LockEscalationStats, After One 20,000 Row Update

A single UPDATE over 20,000 rows in one statement crosses the escalation threshold, which the MS Docs locking and row versioning guide puts at roughly 5,000 locks taken on one table in one statement. Checking sys.dm_tran_locks for that session inside the transaction afterwards returns 1 OBJECT lock in X mode and nothing finer, where a sub-threshold update would still be holding thousands of row locks. That is escalation, and the script counts it.

Get-LockEscalationStats on the lab instanceexample output, not something to copy
database_name table_name lock_escalations row_lock_count page_lock_count row_lock_wait_count row_lock_wait_sec ------------- ---------- ---------------- -------------- --------------- ------------------- ----------------- pass_lock Orders 1 12416 1270 1 225.17

One escalation, and the 225 seconds of row_lock_wait_sec on the same row is what the 2 blocked readers paid for it. On an instance where nothing has crossed the threshold the script returns no rows at all, which is the answer you want rather than a failure.


Understanding the Results

role
chain_level
Read chain_level 0 first. That row is the head blocker, the session at the top of the chain, and it is the only one worth acting on. Every blocked row underneath it is a symptom. Chains can run deeper than 2 levels, and OPTION (MAXRECURSION 10) caps the recursion at 10, so a longer chain comes back short rather than failing.
open_transaction_count
status
sleeping with open_transaction_count above 0 is the classic one: an application opened a transaction and went quiet. No query is running, so there is nothing to tune.Act when you see it. The transaction has to end. Find out what the application is doing first, then commit, roll back, or kill the session and accept the rollback. The full order of work is in Troubleshoot SQL Server Blocking.
wait_resource
The object each waiter is stuck on. KEY: db_id:hobt_id (hash) resolves through sys.partitions inside that database, PAGE: db_id:file_id:page_id and OBJECT: db_id:object_id read straight off. Without this column you know who is blocked. With it you know what to fix.
downstream_waiters
How many sessions are waiting directly on this one, the next level down only. Sessions queue for the same lock in order, so a second reader stuck on the same key is reported as blocked by the first reader, not by the head blocker: with 2 readers stuck, the head blocker shows 1. Count the rows under a chain_id for the full size of a chain, and with 2 chains live at once break the bigger one first.
query_plan
NULL on an idle head blocker, and that is expected rather than broken: a sleeping session has no active request, so there is no plan_handle to pass to sys.dm_exec_query_plan. Its sql_text comes from most_recent_sql_handle instead, which returns the whole last batch rather than one statement. A plan is also NULL while a statement is still compiling, and for anything already evicted from the plan cache.
wait_type
On a blocked row this is always an LCK_M_ wait, and the suffix is the lock mode being requested: LCK_M_S shared, LCK_M_X exclusive, LCK_M_U update, LCK_M_SCH_M schema modification, with LCK_M_IX and LCK_M_IS the intent forms taken higher up the hierarchy. The lock waits entry covers each one.
contention_type = LOCK
Real blocking cost, cumulative since restart. Judge it on avg_wait_ms rather than total_wait_ms, and always against days_since_restart. A large total on a server that has been up for months can mean nothing at all.Act when avg_wait_ms runs into whole seconds. That is a held transaction rather than contention. Cross-reference Blocking Chains or Get-BlockingChainsWithPlan while it is still happening.
contention_type = LATCH
BUFFER accumulates on every page access on the instance, so it is usually the largest total on the page and usually means nothing. ACCESS_METHODS_DATASET_PARENT is parallel scan coordination. Read avg_wait_ms, not the total: the 5,147,702 BUFFER waits in the capture average 1.91 ms, which is healthy.
contention_type = PAGE_LATCH
sys.dm_os_wait_stats returns 5 columns and not one of them is a database, so these rows are instance-wide and do not prove TempDB is involved. The PFS, GAM and SGAM pages are pages 1, 2 and 3 of each data file.Act when you have confirmed the database first. While the wait is live, read sys.dm_os_waiting_tasks.resource_description, which reads db_id:file_id:page_id, and check that it says database_id 2. Adding TempDB data files does nothing for page latch waits on a user database, and TempDB Hotspots is the script that attributes them.
lock_escalations
Any value above 0 means a single statement took more than roughly 5,000 locks on that table and SQL Server swapped them for one table lock. Read it next to row_lock_wait_count and row_lock_wait_sec on the same row: escalation with no waiting costs nothing, escalation with waiting is the blocking you came here about.Act when the wait columns are non-zero too. Batch the DML below the threshold first, then look at READ_COMMITTED_SNAPSHOT. Leave LOCK_ESCALATION = DISABLE until last: it is what turns blocking into error 1204.

Best Practices

  • Run Get-ContentionAnalysis as a routine health check, and read days_since_restart before anything else. The figures are cumulative since restart, so a recently restarted instance shows low numbers whatever is wrong with it
  • Reach for Get-BlockingChainsWithPlan during an active incident, once you already know blocking is happening. Use the lighter Blocking Chains for routine checks, because retrieving a plan per session is work the routine check does not need
  • Act on the chain_level 0 row and nothing else. Killing a victim frees one session and changes nothing: the head blocker immediately blocks the next one in the queue
  • Treat any table with real lock escalations as a candidate for query batching or a READ_COMMITTED_SNAPSHOT review, not just a data point to note. Escalation with zero wait counts beside it is not costing anything yet
  • Confirm the database behind a PAGE_LATCH row in sys.dm_os_waiting_tasks before adding TempDB data files. The fix is standard and well tested for TempDB allocation pages, and irrelevant for page latch waits anywhere else
  • Capture the chain before you kill anything. Once the transaction is gone, so is wait_resource, the plan and the evidence of what caused it

MS Docs: sys.dm_os_waiting_tasks, the view that names the database and page behind a page latch while it is happening.


Frequently Asked Questions

Why is query_plan empty for the exact session causing the problem?

Because the head blocker is usually asleep. sys.dm_exec_query_plan needs a plan_handle, and a plan_handle only exists for a session with an active request. A session that opened a transaction and went idle has no request, so there is nothing to look up. The script falls back to most_recent_sql_handle for the text, which returns the whole last batch rather than one statement, so you still see what it ran. The plans you do get are for the blocked sessions, which is the opposite of what you want but still tells you what is queueing up.

If you need the blocker own plan, catch it while it is running. NULL here is not a bug and not a permissions problem.

The output says PAGE_LATCH. Should I add TempDB files?

Not on this evidence. sys.dm_os_wait_stats returns 5 columns, wait_type, waiting_tasks_count, wait_time_ms, max_wait_time_ms and signal_wait_time_ms, and none of them is a database. A PAGELATCH_UP total is instance-wide, so it cannot tell you whether the contention is on TempDB allocation bitmaps or on a hot page in a user table. Adding TempDB data files fixes the first and does nothing for the second.

Confirm first. While the wait is live, read resource_description in sys.dm_os_waiting_tasks: it reads db_id:file_id:page_id, TempDB is database_id 2, and pages 1, 2 and 3 of each file are PFS, GAM and SGAM. Allocation contention means repeated hits on those low page numbers in database 2.

What does a wait_resource like KEY: 18:72057594047234048 point at?

The first number is the database_id and the second is the hobt_id, the heap or B-tree the lock is on. Resolve it from inside that database with SELECT OBJECT_NAME(p.object_id), i.name FROM sys.partitions p JOIN sys.indexes i ON i.object_id = p.object_id AND i.index_id = p.index_id WHERE p.hobt_id = 72057594047234048. The trailing value in brackets is a hash of the key itself, which identifies the row but does not decode back into column values.

The other 2 shapes are simpler. PAGE: db_id:file_id:page_id is a page, and OBJECT: db_id:object_id is a whole table, which is what you see after lock escalation.

Why is total_wait_ms so much higher for LATCH than for LOCK?

Latch waits, and BUFFER in particular, accumulate on every page access on the instance. That is a far higher-frequency event than a lock wait, which only happens under genuine contention. A large LATCH total is normal and not automatically a problem.

Compare avg_wait_ms instead, and read it against days_since_restart. Millions of BUFFER waits averaging under 2 ms is a healthy instance. A few hundred lock waits averaging several seconds is one held transaction, and that is the one to chase.

Get-LockEscalationStats returns no rows. Does that mean escalation cannot happen here?

No. It means no table has crossed the threshold, roughly 5,000 locks in one statement, since the last restart. The counters live in sys.dm_db_index_operational_stats, which is memory-resident and cleared on restart, and the rows for an object can be dropped from the cache before that. A large batch job tomorrow can produce an escalation on a table that reads clean today.

One more reason for a missing database: the script calls the function with NULL parameters, which needs VIEW DATABASE STATE in each database. Where you do not have it the database is quietly absent from the result rather than raising an error.


Summary

Blocking and contention surface in different DMVs depending on what is actually causing them: lock escalation converting row locks into one table lock, cumulative wait statistics across 4 resource types, or a live chain with the query behind it. These 3 scripts cover that range, from has this ever been a problem to which session do I deal with right now.

In an incident, run Get-BlockingChainsWithPlan, read the chain_level 0 row, and use wait_resource to find the object. Keep Get-ContentionAnalysis and Get-LockEscalationStats for the question afterwards, which is whether it was a one-off or something the workload does every week.


Related

You may also find these useful:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *