Quick Answer
LCK_M_RS_S waits occur when a session needs both a shared lock on the current key value and a shared range lock between the current and previous keys in an index scan under serializable isolation. This happens during range queries requiring gap protection in highly concurrent OLTP environments.
Root Cause Analysis
LCK_M_RS_S waits trigger when SQL Server's lock manager attempts to acquire a Range-Shared/Shared lock during serializable isolation level operations. The lock manager requests two components: a shared lock on the current key value and a shared range lock protecting the gap between the current key and the previous key in the index key sequence.
This wait type occurs on both clustered and non-clustered indexes when queries require gap protection to prevent phantom reads. The lock manager must coordinate with the scheduler to suspend the requesting thread until both lock components become available. Range locks protect logical gaps in index key sequences, ensuring no other transaction can insert values within the protected range.
SQL Server 2016 and later versions improved range lock detection efficiency through enhanced deadlock detection algorithms, reducing false deadlock scenarios involving range locks. SQL Server 2019 introduced Accelerated Database Recovery (ADR), which affects lock retention patterns during long-running transactions but doesn't change the fundamental LCK_M_RS_S behavior.
The lock manager grants Range-Shared/Shared locks when no conflicting Range-Exclusive or Exclusive locks exist on the same key range. These waits intensify during high-concurrency scenarios where multiple sessions scan overlapping index ranges under serializable isolation, particularly in OLTP workloads with frequent range queries.
AutoDBA checks isolation level configurations, index design patterns, and range query optimization strategies across your entire SQL Server instance in 60 seconds. Download the free diagnostic script and see what else needs attention.
Diagnostic Queries
-- Identify current LCK_M_RS_S blocking chains
SELECT
blocking_session_id,
session_id,
wait_type,
wait_time_ms,
resource_description,
DB_NAME(resource_database_id) as database_name
FROM sys.dm_exec_requests
WHERE wait_type = 'LCK_M_RS_S'
AND blocking_session_id > 0
ORDER BY wait_time_ms DESC;
-- Analyze lock requests and granted locks for range scenarios
SELECT
tl.resource_type,
tl.resource_database_id,
tl.resource_associated_entity_id,
tl.request_mode,
tl.request_type,
tl.request_status,
tl.request_session_id,
DB_NAME(tl.resource_database_id) as database_name
FROM sys.dm_tran_locks tl
INNER JOIN sys.dm_exec_requests er ON tl.request_session_id = er.session_id
WHERE tl.resource_type IN ('KEY', 'RID')
AND tl.request_mode LIKE '%RS%'
AND er.wait_type = 'LCK_M_RS_S';
-- Find queries causing range lock contention
SELECT
r.session_id,
r.blocking_session_id,
s.program_name,
s.host_name,
s.login_name,
t.text as query_text,
r.cpu_time,
r.reads,
r.writes
FROM sys.dm_exec_requests r
INNER JOIN sys.dm_exec_sessions s ON r.session_id = s.session_id
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.wait_type = 'LCK_M_RS_S'
AND r.wait_time_ms > 1000;
-- Identify indexes involved in range lock waits
SELECT
OBJECT_NAME(p.object_id) as table_name,
i.name as index_name,
i.type_desc,
COUNT(*) as lock_requests
FROM sys.dm_tran_locks tl
INNER JOIN sys.partitions p ON tl.resource_associated_entity_id = p.hobt_id
INNER JOIN sys.indexes i ON p.object_id = i.object_id AND p.index_id = i.index_id
INNER JOIN sys.dm_exec_requests er ON tl.request_session_id = er.session_id
WHERE er.wait_type = 'LCK_M_RS_S'
AND tl.resource_type = 'KEY'
GROUP BY OBJECT_NAME(p.object_id), i.name, i.type_desc
ORDER BY lock_requests DESC;
-- Monitor isolation levels contributing to range lock waits
SELECT
s.session_id,
s.transaction_isolation_level,
CASE s.transaction_isolation_level
WHEN 1 THEN 'READ UNCOMMITTED'
WHEN 2 THEN 'READ COMMITTED'
WHEN 3 THEN 'REPEATABLE READ'
WHEN 4 THEN 'SERIALIZABLE'
WHEN 5 THEN 'SNAPSHOT'
END as isolation_level_desc,
r.wait_time_ms
FROM sys.dm_exec_sessions s
INNER JOIN sys.dm_exec_requests r ON s.session_id = r.session_id
WHERE r.wait_type = 'LCK_M_RS_S';
Fix Scripts
Reduce isolation level for specific queries Lowers isolation level from SERIALIZABLE to READ COMMITTED for queries that don't require gap protection. Test thoroughly to ensure phantom reads are acceptable for your use case.
-- Modify application queries to use READ COMMITTED isolation
-- Replace SERIALIZABLE transactions with READ COMMITTED where phantom prevention isn't critical
-- Example pattern:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN TRANSACTION;
-- Your range query here
SELECT * FROM Orders WHERE OrderDate BETWEEN '2023-01-01' AND '2023-12-31';
COMMIT TRANSACTION;
Optimize query patterns to reduce range scanning Restructures queries to access specific keys rather than ranges, eliminating the need for range locks.
-- Replace range queries with specific key lookups where possible
-- Instead of: SELECT * FROM Orders WHERE OrderID BETWEEN 1000 AND 2000
-- Use individual lookups or table-valued parameters for known key sets
DECLARE @OrderIDs TABLE (OrderID INT);
INSERT INTO @OrderIDs VALUES (1001), (1055), (1234), (1567), (1999);
SELECT o.*
FROM Orders o
INNER JOIN @OrderIDs oi ON o.OrderID = oi.OrderID;
Implement query hints to control locking behavior Forces query optimizer to use specific locking strategies that avoid range locks when phantom protection isn't required. Test impact on query performance and result consistency.
-- Use READPAST hint to skip locked rows in range scans
SELECT * FROM Orders WITH (READPAST)
WHERE OrderDate BETWEEN @StartDate AND @EndDate;
-- Alternative: Use NOLOCK for read-only operations (dirty reads acceptable)
-- WARNING: Only use when dirty reads are acceptable
SELECT COUNT(*) FROM Orders WITH (NOLOCK)
WHERE OrderDate BETWEEN @StartDate AND @EndDate;
Restructure indexes to minimize range lock contention Creates covering indexes that allow queries to complete without accessing base table, reducing lock duration and scope.
-- Create covering index to eliminate key lookups during range scans
-- Analyze current indexes first using sys.dm_db_missing_index_details
CREATE NONCLUSTERED INDEX IX_Orders_DateRange_Covering
ON Orders (OrderDate)
INCLUDE (CustomerID, OrderTotal, Status)
WITH (PAD_INDEX = ON, FILLFACTOR = 90, ONLINE = ON);
-- Note: ONLINE = ON requires Enterprise Edition
-- Test with representative workload before implementing in production
AutoDBA generates fix scripts like these automatically, with impact estimates and rollback SQL included.
Prevention
Configure applications to use READ COMMITTED SNAPSHOT isolation level where phantom read protection isn't mandatory. This eliminates range locking for most read operations while maintaining row-level consistency. Enable using ALTER DATABASE SET READ_COMMITTED_SNAPSHOT ON during maintenance windows.
Design indexes with high selectivity leading columns to reduce range scan scope. Covering indexes eliminate key lookups that extend lock duration during range operations. Monitor index fragmentation weekly, as fragmented indexes increase range scan costs and lock contention.
Implement connection pooling with proper transaction scope management. Long-running transactions holding range locks block concurrent operations. Configure applications to commit transactions promptly after completing logical units of work.
Establish query performance baselines using Query Store to identify queries causing excessive range lock waits. Queries scanning large index ranges under SERIALIZABLE isolation are primary contributors to LCK_M_RS_S waits.
Configure lock timeout settings appropriately for your application's tolerance for blocking. Set LOCK_TIMEOUT to prevent indefinite waits while allowing reasonable completion times for legitimate range operations.
Need hands-on help?
Dealing with persistent lck_m_rs_s issues across your environment? Samix Technology provides hands-on SQL Server performance consulting with 15+ years of production DBA experience.