Lock contention is one of the most damaging and misunderstood performance problems in production databases. This expert-level lesson walks you through the complete diagnostic and resolution workflow — from tracing blocking chains with recursive CTEs and system views, to reading execution plans for lock behavior, to choosing the right isolation level and implementing fixes that actually stick.

It's 2:47 PM on a Tuesday. Your e-commerce platform is running a flash sale. Order volume is 10x normal. Your on-call alert fires: average query latency has jumped from 40ms to 12 seconds. You log into the database server and see hundreds of sessions queued up, most of them doing absolutely nothing — just waiting. The queries themselves aren't slow. They're blocked.
Lock contention is one of the most common and least understood performance problems in production database systems. Unlike a bad query plan that you can spot in development, lock contention is emergent — it appears only when multiple sessions compete for the same resources under real-world concurrency. It's also deceptive. The session you see waiting often isn't the problem; it's the victim. The real culprit is some other transaction holding a lock it hasn't released yet, possibly because of application logic you wrote months ago.
By the end of this lesson, you will know how to identify blocking chains using system catalog views, interpret wait statistics to find the specific resources causing contention, read query execution plans to understand why locks are being acquired, choose and apply the right transaction isolation level for your workload, and implement concrete SQL-level fixes — ranging from query rewrites to isolation level changes to architectural adjustments — that actually eliminate the problem rather than paper over it.
What you'll learn:
This lesson assumes you're comfortable writing multi-table joins, using CTEs for query organization, and reading basic execution plans. You should also have a working understanding of transactions and ACID properties. If you need a refresher on isolation levels and how the database engine manages concurrent access, work through SQL Transactions, Isolation Levels, and Locking: A Complete Guide to Concurrent Database Programming before continuing here — this lesson picks up where that one leaves off and goes significantly deeper.
Before we write a single diagnostic query, we need to be precise about what locks are and when they become a problem. This isn't just theoretical — if you don't understand the mechanics, you'll misinterpret your diagnostic data and apply the wrong fix.
The database engine uses locks to enforce the I in ACID: isolation. When session A modifies a row, the engine acquires an exclusive lock (X lock) on that row. When session B tries to read or modify the same row, it must first acquire its own lock. Whether B can proceed depends on the lock compatibility matrix — shared locks (S) are compatible with other shared locks, but not with exclusive locks. The result: B waits.
This is working as designed. The problem isn't that locking happens. The problem is that locks are held for too long, or acquired in too broad a scope, or acquired in conflicting orders across sessions.
There are three distinct failure modes in high-concurrency systems:
Blocking is the simple case: session B is waiting for a lock that session A holds. It will eventually proceed once A commits or rolls back. This is temporary but can cascade catastrophically when hundreds of sessions queue behind a single long-running transaction.
Deadlock is the circular case: session A holds a lock that B wants, while B holds a lock that A wants. Neither can proceed. The engine detects this cycle and kills one session (the deadlock victim) to let the other continue.
Lock escalation is the hidden multiplier: the engine promotes many fine-grained row locks into a single coarse table lock to conserve memory. This turns what would be isolated row-level contention into a situation where any writer on the table blocks all readers and writers.
Key insight
Most "slow query" complaints in OLTP systems under load are actually blocked queries. The query isn't slow — it's waiting. This distinction matters because the fix for a slow query (index, rewrite, stats) is completely different from the fix for a blocked query (transaction scope, isolation level, lock ordering).
SQL Server exposes a rich set of dynamic management views (DMVs) for diagnosing concurrency problems. Let's build a complete diagnostic toolkit from first principles.
The entry point is sys.dm_exec_requests, which shows currently executing requests and — critically — includes a blocking_session_id column. When this is nonzero, the request is waiting on another session.
SELECT
r.session_id,
r.blocking_session_id,
r.wait_type,
r.wait_time / 1000.0 AS wait_seconds,
r.status,
r.command,
DB_NAME(r.database_id) AS database_name,
SUBSTRING(
st.text,
(r.statement_start_offset / 2) + 1,
((CASE r.statement_end_offset
WHEN -1 THEN DATALENGTH(st.text)
ELSE r.statement_end_offset
END - r.statement_start_offset) / 2) + 1
) AS current_statement,
r.cpu_time,
r.logical_reads,
r.row_count
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) st
WHERE r.session_id != @@SPID -- exclude this diagnostic query itself
ORDER BY r.blocking_session_id DESC, r.wait_time DESC;
The CROSS APPLY sys.dm_exec_sql_text call retrieves the actual SQL batch. The statement offset math extracts just the specific statement within that batch that's currently executing — important when stored procedures contain many statements.
But this query only shows the blocked sessions. The blocker might not appear here if it's idle (transaction open but not currently running a query). To find the head blocker, you need to trace the chain.
A blocking chain can be multiple levels deep: session 55 is blocked by 42, which is blocked by 31. Session 31 is the head blocker — the one you actually need to investigate. Let's use a recursive CTE to traverse this chain:
WITH BlockingChain AS (
-- Anchor: find all head blockers (sessions that are blocking others
-- but are not themselves blocked)
SELECT
s.session_id,
s.login_name,
s.program_name,
s.host_name,
CAST(NULL AS INT) AS blocked_by,
0 AS chain_depth,
CAST(s.session_id AS VARCHAR(1000)) AS chain_path
FROM sys.dm_exec_sessions s
WHERE s.session_id IN (
SELECT DISTINCT blocking_session_id
FROM sys.dm_exec_requests
WHERE blocking_session_id != 0
)
AND s.session_id NOT IN (
SELECT session_id
FROM sys.dm_exec_requests
WHERE blocking_session_id != 0
)
UNION ALL
-- Recursive: find each session blocked by something in the chain so far
SELECT
r.session_id,
s.login_name,
s.program_name,
s.host_name,
r.blocking_session_id AS blocked_by,
bc.chain_depth + 1,
CAST(bc.chain_path + ' -> ' + CAST(r.session_id AS VARCHAR(10)) AS VARCHAR(1000))
FROM sys.dm_exec_requests r
JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id
JOIN BlockingChain bc ON bc.session_id = r.blocking_session_id
WHERE r.blocking_session_id != 0
)
SELECT
bc.chain_path,
bc.chain_depth,
bc.session_id,
bc.blocked_by,
bc.login_name,
bc.program_name,
bc.host_name,
r.wait_type,
r.wait_time / 1000.0 AS wait_seconds,
r.status,
SUBSTRING(
st.text,
(r.statement_start_offset / 2) + 1,
((CASE r.statement_end_offset
WHEN -1 THEN DATALENGTH(st.text)
ELSE r.statement_end_offset
END - r.statement_start_offset) / 2) + 1
) AS current_statement,
t.open_transaction_count,
s2.last_request_start_time,
s2.last_request_end_time
FROM BlockingChain bc
LEFT JOIN sys.dm_exec_requests r ON r.session_id = bc.session_id
LEFT JOIN sys.dm_exec_sessions s2 ON s2.session_id = bc.session_id
LEFT JOIN sys.dm_tran_session_transactions tst ON tst.session_id = bc.session_id
LEFT JOIN sys.dm_tran_active_transactions t ON t.transaction_id = tst.transaction_id
OUTER APPLY (
SELECT text
FROM sys.dm_exec_sql_text(r.sql_handle)
) st
ORDER BY bc.chain_depth, bc.wait_time DESC;
This query produces a chain_path like 31 -> 42 -> 55 -> 68, showing you exactly who's blocking whom. The open_transaction_count on the head blocker is diagnostic gold: if it's 1 or higher and last_request_end_time is well in the past, you have an idle open transaction — the most common and most fixable cause of blocking.
Warning
An idle open transaction (a session that opened a transaction, ran some DML, and then stopped without committing or rolling back) will hold its locks indefinitely. This often happens in application code that opens a transaction before making an API call, and the API call hangs or errors without the exception handler rolling back. The database can't tell the difference between "this transaction is thinking" and "this application is broken."
Once you know which sessions are involved, you want to know what they're locking. sys.dm_tran_locks shows every lock currently held or requested in the system:
SELECT
tl.resource_type,
tl.resource_database_id,
DB_NAME(tl.resource_database_id) AS database_name,
tl.resource_description,
-- For object locks, resolve the object name
CASE tl.resource_type
WHEN 'OBJECT' THEN OBJECT_NAME(tl.resource_associated_entity_id, tl.resource_database_id)
ELSE NULL
END AS object_name,
-- For page/row locks, resolve through partition to object
CASE tl.resource_type
WHEN 'PAGE' THEN
(SELECT OBJECT_NAME(object_id, tl.resource_database_id)
FROM sys.partitions
WHERE partition_id = tl.resource_associated_entity_id)
WHEN 'RID' THEN
(SELECT OBJECT_NAME(object_id, tl.resource_database_id)
FROM sys.partitions
WHERE partition_id = tl.resource_associated_entity_id)
WHEN 'KEY' THEN
(SELECT OBJECT_NAME(object_id, tl.resource_database_id)
FROM sys.partitions
WHERE partition_id = tl.resource_associated_entity_id)
ELSE NULL
END AS locked_table,
tl.resource_lock_partition,
tl.request_mode, -- S, X, U, IS, IX, SIX
tl.request_type, -- LOCK (held) or WAIT
tl.request_status, -- GRANT, WAIT, CONVERT
tl.request_session_id,
tl.request_owner_type
FROM sys.dm_tran_locks tl
WHERE tl.resource_database_id = DB_ID() -- current database only
AND tl.request_session_id != @@SPID
ORDER BY
tl.resource_type,
tl.resource_description,
tl.request_status;
The request_mode column tells you the type of lock: X (exclusive, held by writer), S (shared, held by reader), U (update lock — the engine takes this before converting to X to prevent deadlocks), IS/IX (intent locks at the page or table level, indicating that finer-grained locks exist below).
Look for rows where the same resource_description has one row with request_status = GRANT and another with request_status = WAIT. That resource is your contention point. Cross-reference the session IDs against your blocking chain output.
The DMV queries above show you the current state. But to understand chronic contention patterns — the kind that gets worse as load increases — you need aggregate wait statistics.
This view accumulates wait statistics since SQL Server last started (or since they were last cleared). The lock-related wait types to focus on:
| Wait Type | Meaning |
|---|---|
LCK_M_X |
Waiting for an exclusive lock |
LCK_M_S |
Waiting for a shared lock |
LCK_M_U |
Waiting for an update lock |
LCK_M_SCH_M |
Waiting for a schema modification lock (DDL blocking DML) |
LCK_M_RS_U |
Waiting for a range-scan update lock (snapshot isolation related) |
PAGEIOLATCH_* |
Waiting for a data page to be read from disk (not a lock, but often confused with one) |
SELECT
wait_type,
waiting_tasks_count,
wait_time_ms,
max_wait_time_ms,
signal_wait_time_ms,
-- Time spent actually waiting for the resource (vs. waiting to get CPU after wakeup)
wait_time_ms - signal_wait_time_ms AS resource_wait_time_ms,
CAST(100.0 * wait_time_ms / SUM(wait_time_ms) OVER () AS DECIMAL(5,2)) AS pct_of_total
FROM sys.dm_os_wait_stats
WHERE wait_type LIKE 'LCK%'
AND wait_type NOT IN (
-- Filter out background/benign waits
'LCK_M_SCH_S' -- Schema stability lock - low impact
)
AND waiting_tasks_count > 0
ORDER BY wait_time_ms DESC;
The signal_wait_time_ms column is worth understanding: it's the time a task spent waiting to get scheduled on a CPU after its lock was granted. High signal wait time relative to total wait time suggests CPU pressure, not lock contention. Low signal wait time with high resource_wait_time_ms confirms genuine lock contention.
Note
sys.dm_os_wait_stats is cumulative and resets on restart or when you run DBCC SQLPERF('sys.dm_os_wait_stats', CLEAR). For trend analysis, you want to capture snapshots over time and diff them. Consider scheduling a job that inserts rows from this view into a monitoring table every 5 minutes — then you can see when lock waits spike without having to be watching in real time.
PostgreSQL's approach to exposing concurrency problems is different but equally powerful. Let me show you the equivalent diagnostic stack.
The fundamental PostgreSQL diagnostic query joins pg_locks (which locks are held or awaited) with pg_stat_activity (what each backend is doing):
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocked_activity.application_name AS blocked_app,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocking_activity.application_name AS blocking_app,
blocked_activity.query AS blocked_query,
blocking_activity.query AS blocking_query,
blocked_activity.query_start AS blocked_query_start,
blocking_activity.query_start AS blocking_query_start,
now() - blocked_activity.query_start AS blocked_duration,
now() - blocking_activity.query_start AS blocking_duration,
blocked_locks.locktype,
blocked_locks.relation::regclass AS locked_relation,
blocked_locks.mode AS requested_mode,
blocking_locks.mode AS held_mode
FROM pg_locks blocked_locks
JOIN pg_stat_activity blocked_activity
ON blocked_activity.pid = blocked_locks.pid
JOIN pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_stat_activity blocking_activity
ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
The NOT blocked_locks.granted condition filters to only locks that are being requested but not yet acquired — these are your blocked sessions.
PostgreSQL 9.6+ exposes wait_event_type and wait_event in pg_stat_activity. For lock waits specifically:
SELECT
pid,
usename,
application_name,
state,
wait_event_type,
wait_event,
query_start,
now() - query_start AS query_duration,
LEFT(query, 200) AS query_snippet
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND pid != pg_backend_pid()
ORDER BY query_start ASC;
When wait_event_type = 'Lock', the wait_event column tells you the specific lock type being waited on: relation, tuple, transactionid, etc. A transactionid wait means you're waiting for another transaction to commit or rollback — the most common case in OLTP systems.
You can't fix lock contention without understanding why the queries acquire the locks they do. This is where execution plans become essential.
For a deep dive into reading plans in general, see SQL Query Optimization: Reading Execution Plans - Advanced Performance Analysis. Here we focus specifically on what plans reveal about locking behavior.
An index seek navigates directly to specific rows. An index scan reads pages sequentially. This distinction matters enormously for locking under READ COMMITTED or higher isolation levels because:
-- This query scans an index range and may hold significant locks:
SELECT order_id, customer_id, total_amount
FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31'
AND status = 'PENDING';
-- Get the actual plan with lock information
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
-- For SQL Server, capture the actual plan:
SELECT order_id, customer_id, total_amount
FROM orders WITH (NOLOCK) -- We'll discuss this tradeoff later
WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31'
AND status = 'PENDING';
In the execution plan, look for:
Tip
In SQL Server, right-click any operator in the graphical execution plan and look at "Actual Rows" vs. "Estimated Rows." A large discrepancy (say, estimated 100 rows, actual 1,200,000 rows) indicates stale statistics. The optimizer chose a plan expecting little data, got much more, and now it's holding many more locks than anticipated — a classic driver of unexpected contention.
SQL Server's lock escalation happens when a session acquires more than ~5,000 row or page locks on a single table. When this occurs, the engine converts those many fine-grained locks into a single table lock. You can see this happened by looking for LCK_M_TAB in your wait statistics, or by querying the escalation event in the extended events log.
-- Check if lock escalation is happening on specific tables
SELECT
OBJECT_NAME(dle.object_id) AS table_name,
dle.escalated_lock_count,
dle.escalation_reason
FROM sys.dm_exec_query_stats qs
-- Lock escalation DMV (available in SQL Server 2016+)
-- For older versions, use Extended Events
For production diagnosis, Extended Events is more reliable:
-- Create a session to capture lock escalation events
CREATE EVENT SESSION [LockEscalation_Monitor] ON SERVER
ADD EVENT sqlserver.lock_escalation(
ACTION(
sqlserver.sql_text,
sqlserver.username,
sqlserver.database_name,
sqlserver.session_id
)
WHERE (
[sqlserver].[database_name] = N'YourDatabase'
)
)
ADD TARGET package0.ring_buffer(SET max_memory = 51200)
WITH (MAX_DISPATCH_LATENCY = 5 SECONDS);
ALTER EVENT SESSION [LockEscalation_Monitor] ON SERVER STATE = START;
Then read from it:
SELECT
xdr.value('@name', 'nvarchar(100)') AS event_name,
xdr.value('(data[@name="object_id"]/value)[1]', 'bigint') AS object_id,
OBJECT_NAME(xdr.value('(data[@name="object_id"]/value)[1]', 'bigint')) AS table_name,
xdr.value('(action[@name="sql_text"]/value)[1]', 'nvarchar(max)') AS sql_text,
xdr.value('(action[@name="username"]/value)[1]', 'nvarchar(100)') AS username,
xdr.value('@timestamp', 'datetime2') AS event_time
FROM (
SELECT CAST(target_data AS XML) AS target_data
FROM sys.dm_xe_sessions s
JOIN sys.dm_xe_session_targets t ON s.address = t.event_session_address
WHERE s.name = 'LockEscalation_Monitor'
AND t.target_name = 'ring_buffer'
) AS data
CROSS APPLY target_data.nodes('RingBufferTarget/event') AS XEventData(xdr)
ORDER BY xdr.value('@timestamp', 'datetime2') DESC;
The isolation level your application uses is the single most powerful lever for controlling lock behavior. You need to understand the tradeoffs deeply, not just the names.
Under READ UNCOMMITTED: No shared locks acquired for reads. You can read uncommitted data (dirty reads). This is the most aggressive concurrency option — almost never appropriate for financial or inventory data.
Under READ COMMITTED (the default in SQL Server and PostgreSQL): Shared locks are acquired and immediately released after each row is read. This prevents dirty reads but allows non-repeatable reads (the same row can change between two reads in the same transaction).
Under REPEATABLE READ: Shared locks are held until end of transaction. This prevents non-repeatable reads but allows phantom reads (new rows inserted by other transactions can appear in range scans).
Under SERIALIZABLE: Range locks (key range locks in SQL Server, predicate locks in PostgreSQL) are added to prevent phantom reads. This is the most restrictive and the most likely to cause contention.
Key insight
Moving from SERIALIZABLE to READ COMMITTED usually eliminates the majority of blocking in OLTP systems, because readers stop blocking writers. The tradeoff is that you must design your application logic to tolerate non-repeatable reads. This is almost always acceptable for read-heavy reporting queries, batch jobs reading for processing, and dashboards. It may not be acceptable for financial reconciliation or inventory deduction logic.
SQL Server's most powerful concurrency feature is RCSI (Read Committed Snapshot Isolation). When enabled, reads use the row version store rather than acquiring shared locks at all. Writers still acquire exclusive locks, but readers never block writers and writers never block readers.
-- Check if RCSI is enabled
SELECT
name,
is_read_committed_snapshot_on,
snapshot_isolation_state_desc
FROM sys.databases
WHERE name = DB_NAME();
-- Enable RCSI (requires brief exclusive access to the database)
ALTER DATABASE YourDatabase
SET READ_COMMITTED_SNAPSHOT ON;
Warning
Enabling RCSI adds overhead to tempdb (or the version store in SQL Server 2019+ with accelerated database recovery). Every modified row is written to the version store so readers can see the previous version. Under heavy write workloads, tempdb can become the bottleneck. Monitor sys.dm_db_xtp_checkpoint_files and tempdb space usage after enabling.
PostgreSQL uses MVCC (Multi-Version Concurrency Control) for all isolation levels, which means readers never block writers by default — unlike SQL Server's default behavior. The PostgreSQL equivalent of RCSI is effectively the default. However, PostgreSQL does have its own locking behaviors worth understanding:
Row-level locks in PostgreSQL are acquired for UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE. These are stored in the row header itself (not in a separate lock manager table like SQL Server), which means PostgreSQL doesn't escalate row locks to table locks the same way. However, SELECT FOR UPDATE does acquire row-level advisory locks that can cause contention.
-- PostgreSQL: Explicit locking for optimistic concurrency
-- Pattern: "I want to process this order exclusively"
BEGIN;
SELECT order_id, status, total_amount
FROM orders
WHERE order_id = 10045
AND status = 'PENDING'
FOR UPDATE SKIP LOCKED; -- Skip rows already locked by another session
-- If we got a row, process it
UPDATE orders
SET status = 'PROCESSING', updated_at = NOW()
WHERE order_id = 10045;
COMMIT;
SKIP LOCKED is a game-changer for queue-style workloads. Without it, every worker session would block each other trying to claim the same "next pending order." With SKIP LOCKED, each session instantly moves past locked rows and finds an available one, eliminating contention entirely.
Now we bring it together. You've identified a blocking problem. Here are the concrete techniques to resolve it, ordered roughly from least invasive to most invasive.
The most common fix. If you find head blockers that are idle open transactions, the solution is in the application code, not the database. The pattern to look for:
-- Anti-pattern: Long-running work inside a transaction
BEGIN TRANSACTION;
-- Step 1: Read data to determine what to do
SELECT @inventory_count = quantity
FROM inventory WHERE product_id = @product_id;
-- Step 2: Call external API (THIS IS THE PROBLEM)
-- The external call might take seconds or fail entirely
-- Meanwhile, locks are held on inventory
-- Step 3: Update based on API result
UPDATE inventory
SET quantity = quantity - @order_qty
WHERE product_id = @product_id;
COMMIT;
-- Better pattern: Minimize what happens inside the transaction
-- Step 1: Do all reads and external calls BEFORE opening transaction
SELECT @inventory_count = quantity
FROM inventory WHERE product_id = @product_id;
-- Make external API call here, with no transaction open
-- Step 2: Transaction only covers the atomic write
BEGIN TRANSACTION;
UPDATE inventory
SET quantity = quantity - @order_qty
WHERE product_id = @product_id
AND quantity >= @order_qty; -- Guard condition
IF @@ROWCOUNT = 0
ROLLBACK; -- Insufficient inventory or concurrent update won
ELSE
COMMIT;
A missing index forces the engine to scan more rows, acquiring more locks than necessary. If your blocking query is a SELECT with a WHERE clause that isn't covered by an index, adding a covering index can eliminate the blocking entirely by allowing a precise seek instead of a scan.
-- Scenario: This UPDATE is blocking many readers
UPDATE orders
SET status = 'SHIPPED', shipped_at = GETDATE()
WHERE customer_id = 8471
AND status = 'PROCESSING';
-- Without an index on (customer_id, status), this scans the orders table
-- With a large orders table, this holds locks on potentially millions of rows
-- Check the missing index DMV in SQL Server
SELECT
mid.statement AS table_name,
mid.equality_columns,
mid.inequality_columns,
mid.included_columns,
migs.unique_compiles,
migs.user_seeks,
migs.user_scans,
migs.avg_total_user_cost * migs.avg_user_impact * (migs.user_seeks + migs.user_scans)
AS estimated_improvement_score
FROM sys.dm_db_missing_index_details mid
JOIN sys.dm_db_missing_index_groups mig
ON mid.index_handle = mig.index_handle
JOIN sys.dm_db_missing_index_group_stats migs
ON mig.index_group_handle = migs.group_handle
WHERE mid.database_id = DB_ID()
ORDER BY estimated_improvement_score DESC;
-- Add the targeted index
CREATE INDEX IX_orders_customer_status
ON orders (customer_id, status)
INCLUDE (shipped_at); -- Cover the columns being written so we avoid a lookup
For more on choosing and creating effective indexes, see Indexing Fundamentals for Query Performance: How to Choose, Create, and Evaluate Indexes on Real Tables and Database Performance Tuning: Advanced Indexing Strategies and Query Rewriting for Production Systems.
Sometimes you can restructure a query to do less work inside a transaction. The batch delete pattern is a classic example:
-- Anti-pattern: Delete 500,000 rows in one transaction
-- This holds locks on all 500,000 rows simultaneously
-- and may escalate to a table lock
DELETE FROM audit_log
WHERE created_at < DATEADD(DAY, -365, GETDATE());
-- Better: Batch deletes with small transactions
DECLARE @batch_size INT = 5000;
DECLARE @rows_deleted INT = 1;
WHILE @rows_deleted > 0
BEGIN
DELETE TOP (@batch_size) FROM audit_log
WHERE created_at < DATEADD(DAY, -365, GETDATE());
SET @rows_deleted = @@ROWCOUNT;
-- Small delay to let other sessions get in
-- This is optional but courteous in OLTP environments
IF @rows_deleted = @batch_size
WAITFOR DELAY '00:00:00.100';
END;
Each iteration holds locks only on 5,000 rows, commits them, and releases. Other sessions get windows to execute between batches. The total work is the same, but the peak lock footprint is a small fraction of the original.
For tables that see heavy concurrent access, you can prevent the engine from escalating to table locks:
-- SQL Server: Disable lock escalation on a specific table
-- This keeps locks at the row or page level even when thresholds are exceeded
-- Only do this on tables with good row-level indexes; otherwise memory pressure from
-- many small locks can cause other problems
ALTER TABLE orders SET (LOCK_ESCALATION = DISABLE);
-- Or use partition-level escalation for partitioned tables
-- (escalates to partition lock rather than table lock)
ALTER TABLE orders SET (LOCK_ESCALATION = AUTO);
Warning
Disabling lock escalation increases the memory footprint of the lock manager. Each lock is a 64-byte structure. If you disable escalation on a table and a query starts acquiring millions of row locks, the lock manager's memory allocation can grow dramatically. Use this only on tables where you've confirmed lock escalation is causing contention, and monitor sys.dm_os_memory_clerks where type = 'MEMORYCLERK_SQLGENERAL' afterward.
Deadlocks are almost always caused by two transactions acquiring the same locks in opposite orders. The fix is to enforce a consistent acquisition order across all transactions that touch the same resources.
-- Anti-pattern: Two stored procedures that deadlock
-- Proc A: Updates account 1, then account 2
-- Proc B: Updates account 2, then account 1
-- Proc A
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 500 WHERE account_id = 1001;
-- <-- If Proc B runs here, it locks account 2 and then tries for account 1 (DEADLOCK)
UPDATE accounts SET balance = balance + 500 WHERE account_id = 2001;
COMMIT;
-- Fix: Always acquire locks in the same order (lower account_id first)
-- Proc A (transfer FROM 1001 TO 2001):
BEGIN TRANSACTION;
-- Lock the lower account_id first, always
UPDATE accounts SET balance = balance - 500 WHERE account_id = 1001; -- 1001 < 2001, so this first
UPDATE accounts SET balance = balance + 500 WHERE account_id = 2001;
COMMIT;
-- Proc B (transfer FROM 2001 TO 1001):
BEGIN TRANSACTION;
-- Still lock lower account_id first (even though we're debiting from the higher one)
UPDATE accounts SET balance = balance + 500 WHERE account_id = 1001; -- 1001 < 2001, still first
UPDATE accounts SET balance = balance - 500 WHERE account_id = 2001;
COMMIT;
This consistent ordering prevents the circular dependency that causes deadlocks. It's a discipline that must be enforced across all code paths that touch these resources — which is a compelling reason to use stored procedures and user-defined functions to encapsulate this logic rather than spreading it across application code.
SQL Server offers several lock-related query hints. Use these only when you understand exactly what you're trading away:
-- NOLOCK: Read uncommitted data. Fast, but you may read in-flight data
-- that gets rolled back (phantom data). Never use for financial calculations.
SELECT product_id, SUM(quantity) AS total_ordered
FROM order_items WITH (NOLOCK)
WHERE order_date >= '2024-01-01'
GROUP BY product_id;
-- UPDLOCK: Acquire update locks during read, converting to X lock on write
-- Use when you read a row intending to update it, to prevent another session
-- from modifying it between your read and write
SELECT quantity
FROM inventory WITH (UPDLOCK)
WHERE product_id = @product_id;
-- ROWLOCK: Force row-level locking (prevent page or table lock escalation for this query)
UPDATE order_items WITH (ROWLOCK)
SET status = 'SHIPPED'
WHERE order_id = @order_id;
-- PAGLOCK: Force page-level locking (sometimes more efficient than row for bulk ops)
-- Rarely needed in modern SQL Server versions
Note
WITH (NOLOCK) is not equivalent to "read-only" or "safe for reporting." It can return rows that were never committed, miss rows that exist, and in some edge cases return the same row twice or miss pages during a scan due to page splits. It's appropriate for large aggregate reports where approximate results are acceptable and freshness matters more than precision. It's never appropriate for order processing, inventory management, or anything that drives a business decision.
You'll need a SQL Server instance (or Azure SQL Database) with the AdventureWorks sample database, or you can create the scenario using the scripts below.
Scenario: A high-traffic retail system is experiencing blocking during the end-of-day order processing. Orders are being read and updated by multiple concurrent sessions, causing significant latency.
Setup:
-- Create a minimal test scenario
CREATE TABLE dbo.CustomerOrders (
order_id INT IDENTITY(1,1) PRIMARY KEY CLUSTERED,
customer_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at DATETIME2 NOT NULL DEFAULT GETDATE(),
processed_at DATETIME2 NULL
);
CREATE TABLE dbo.ProductInventory (
product_id INT PRIMARY KEY,
product_name VARCHAR(100) NOT NULL,
quantity_on_hand INT NOT NULL,
reserved_quantity INT NOT NULL DEFAULT 0,
last_updated DATETIME2 NOT NULL DEFAULT GETDATE()
);
-- Populate with realistic data volumes
INSERT INTO dbo.ProductInventory (product_id, product_name, quantity_on_hand)
SELECT
n,
'Product_' + CAST(n AS VARCHAR(10)),
1000 + (n * 7 % 500)
FROM (
SELECT TOP 1000 ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
FROM sys.objects a CROSS JOIN sys.objects b
) nums;
INSERT INTO dbo.CustomerOrders (customer_id, product_id, quantity, unit_price, status)
SELECT
1 + (ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 5000),
1 + (ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 1000),
1 + (ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 10),
9.99 + (ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100),
CASE ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 5
WHEN 0 THEN 'PENDING'
WHEN 1 THEN 'PROCESSING'
WHEN 2 THEN 'SHIPPED'
WHEN 3 THEN 'DELIVERED'
ELSE 'PENDING'
END
FROM (
SELECT TOP 100000 ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
FROM sys.objects a CROSS JOIN sys.objects b CROSS JOIN sys.objects c
) nums;
Exercise Tasks:
Task 1: Open Session 1 and begin (but don't commit) an update:
-- Session 1: Simulate a slow order processing transaction
BEGIN TRANSACTION;
UPDATE dbo.ProductInventory
SET reserved_quantity = reserved_quantity + 5,
last_updated = GETDATE()
WHERE product_id BETWEEN 100 AND 200;
-- DO NOT COMMIT YET. Keep this session open.
-- Simulate the application doing external work (sleeping)
WAITFOR DELAY '00:00:30';
COMMIT;
Task 2: In Session 2, run your blocking chain diagnostic query and observe:
Task 3: In Session 3, try to run this query and observe it blocking:
SELECT product_id, product_name, quantity_on_hand, reserved_quantity
FROM dbo.ProductInventory
WHERE product_id BETWEEN 150 AND 250; -- Overlaps with the locked range
Task 4: Use sys.dm_tran_locks to identify the specific lock resources. Answer: What is the lock type (resource_type) you see, and what mode does Session 2 show for the conflicting resource?
Task 5: Now implement the fix. Modify the Session 1 transaction to use row-level targeted updates instead of a range update, and add an appropriate index. Verify that Session 3 no longer blocks.
Expected solution involves:
CREATE INDEX IX_ProductInventory_product_id ON dbo.ProductInventory (product_id) (which is the PK anyway — in this case, the real fix is using WHERE product_id IN (SELECT specific_ids) to avoid escalation, or batching the update)The session you see waiting is almost never the problem. When you see blocking_session_id = 55, your instinct is to look at session 55. But session 55 might itself be blocked by session 31. Session 31 is the one to investigate. Always trace to the head blocker before forming any hypothesis.
WITH (NOLOCK) does not make queries faster by itself in any meaningful way on modern SQL Server with RCSI enabled. It was a 2000s-era workaround for systems that hadn't enabled RCSI. If you're applying NOLOCK to avoid blocking, the right answer is almost always to enable RCSI instead — you get the same concurrency benefit without the dirty read risk.
It's tempting to kill the head blocker session when users are complaining. KILL 31 will unblock everything immediately. But if the underlying cause (long transaction in application code, missing index causing lock escalation, deadlock-prone lock ordering) isn't fixed, the same blocking pattern will recur within minutes. Kill the session as a temporary relief measure while you deploy the actual fix.
Some teams choose SERIALIZABLE because it sounds safest. It is the safest in terms of data consistency, but it's also the most aggressive in terms of locking. For the vast majority of OLTP applications, READ COMMITTED or READ COMMITTED SNAPSHOT provides sufficient consistency guarantees while dramatically reducing contention. Only use SERIALIZABLE when you have a specific, identified consistency requirement that lesser isolation levels genuinely cannot satisfy.
We covered disabling lock escalation per-table. Some DBAs, faced with pervasive escalation problems, are tempted to disable it at the server level via trace flag 1211 or 1224. Don't. This can cause the lock manager's memory to grow unboundedly under heavy load, potentially causing out-of-memory conditions that are far worse than the original blocking.
This usually means one of:
sys.dm_os_wait_stats for PAGEIOLATCH_SH.2:1:1, 2:1:2, 2:1:3) can be contended in systems with many small temporary objects. Enable multiple tempdb data files equal to the number of CPU cores.Lock contention is a production problem that requires a layered diagnostic approach. You can't fix what you can't see, so we started by building queries against system views that expose blocking chains, lock types, and wait statistics. We then moved to execution plans to understand why those locks are being acquired — the difference between a seek and a scan, between a short transaction and one that calls an API mid-flight, is the difference between no contention and system-wide blocking.
The resolution toolkit is a stack of increasingly invasive interventions: minimize transaction duration first, add targeted indexes second, adjust isolation levels or use row versioning third, and reach for query hints and architectural changes only when the simpler options won't work. Each intervention requires you to understand what you're trading away: NOLOCK trades correctness for concurrency; SERIALIZABLE trades performance for safety; disabling lock escalation trades memory for granularity.
The core skill is matching the diagnostic signal to the right intervention. When you see LCK_M_X waits on a specific table from your wait statistics, combined with a head blocker that's been open for 45 seconds, combined with a plan showing a clustered index scan — that's a complete picture that points to a specific fix: shorten the transaction, add an index to enable a seek, or move to RCSI.
Where to go next: