Sql Locking Analysis

Open three query windows like so

select request_session_id, resource_type, request_mode, request_status
from sys.dm_tran_locks
where request_session_id in (64, 65)

Use this query to determine the locks showing

request_session_id resource_type request_mode request_status
64 DATABASE S GRANT
65 DATABASE S GRANT

I'm using the AdventureWorks2017 database available here, instructions provided

I would like to update the first available match where the Person.AddressLine2 IS NULL and the StateProvince.Name = 'Texas'

BEGIN TRAN 
	MERGE Person.Address t
		USING (
			SELECT TOP 1 pa.*, sp.Name
			FROM Person.Address pa
			INNER JOIN Person.StateProvince sp
			ON pa.StateProvinceID = sp.StateProvinceID
			WHERE sp.Name = 'Texas'
			AND pa.AddressLine2 IS NULL
			ORDER BY pa.AddressId
		) s
		on (s.AddressId = t.AddressId)
	WHEN MATCHED
		THEN UPDATE SET
			t.AddressLine2 = s.Name
	OUTPUT
	   $action,
	   inserted.*,
	   deleted.*;
COMMIT TRAN

I'm doing the above query update to demonstrate a complicated merge statement as shown with the inner join and the order of the Person.AddressId for numeric ordering to give deterministic ordering so that the TOP 1 items should move out of the system like a Queue First In/First Out

TLDR;  I want to take a table numerically order by ids and move down the list of items in a first in order and update the record.  If the record is in use I want to skip to the next one so I will attempt to tell the row that information with the table hints (READPAST, UPDLOCK, ROWLOCK)

First find the expected rows to be affected

USE [AdventureWorks2017]

SELECT TOP 2 pa.*, sp.Name
FROM Person.Address pa
INNER JOIN Person.StateProvince sp
ON pa.StateProvinceID = sp.StateProvinceID
WHERE sp.Name = 'Texas'
AND pa.AddressLine2 IS NULL
ORDER BY pa.AddressId
AddressID AddressLine1 AddressLine2 City StateProvinceID PostalCode SpatialLocation rowguid ModifiedDate Name
25 9178 Jumping St. NULL Dallas 73 75201 0xE6100000010C10A810D1886240403A0F0653663158C0 C8DF3BD9-48F0-4654-A8DD-14A67A84D3C6 2012-07-31 00:00:00.000 Texas
325 9491 Toyon Dr NULL Dallas 73 75201 0xE6100000010C4346160AA26440406340F0E64F3B58C0 567E6863-FCFF-47A8-9913-EF8BF3E38932 2011-12-24 00:00:00.000 Texas

In the above if I start a transaction and put an arbitrary lock on the table to hold it for 5 seconds in one query window I want it to take the record matched AddressId of 25 and hold it exclusively until the transaction finishes.  If I start another concurrent query without the arbitrary wait it should grab the next ordered id 325 in this case

Window 1

USE [AdventureWorks2017]

DECLARE @startTime DATETIME2 = SYSUTCDATETIME()

BEGIN TRAN 
	MERGE Person.Address t
		USING (
			select TOP 1 pa.*, sp.Name
			FROM Person.Address pa
			INNER JOIN Person.StateProvince sp
			ON pa.StateProvinceID = sp.StateProvinceID
			WHERE sp.Name = 'Texas'
			AND pa.AddressLine2 IS NULL
			ORDER BY pa.AddressId
		) s
		on (s.AddressId = t.AddressId)
	WHEN MATCHED
		THEN UPDATE SET
			t.AddressLine2 = s.Name
	OUTPUT
	   $action,
	   inserted.*,
	   deleted.*;
	WAITFOR DELAY '00:00:05'
COMMIT TRAN

PRINT DATEDIFF(ss, @startTime, SYSUTCDATETIME())

Window 2

USE [AdventureWorks2017]

DECLARE @startTime DATETIME2 = SYSUTCDATETIME()

BEGIN TRAN 
	MERGE Person.Address t
		USING (
			select TOP 1 pa.*, sp.Name
			FROM Person.Address pa
			INNER JOIN Person.StateProvince sp
			ON pa.StateProvinceID = sp.StateProvinceID
			WHERE sp.Name = 'Texas'
			AND pa.AddressLine2 IS NULL
			ORDER BY pa.AddressId
		) s
		on (s.AddressId = t.AddressId)
	WHEN MATCHED
		THEN UPDATE SET
			t.AddressLine2 = s.Name
	OUTPUT
	   $action,
	   inserted.*,
	   deleted.*;
	WAITFOR DELAY '00:00:05'
COMMIT TRAN

PRINT DATEDIFF(ss, @startTime, SYSUTCDATETIME())

What happens

Ok this is without any locking so it is up to SQL Server to interpret and run the transactions in order and as you can see the transactions do not collide and pick the proper order of actions to execute the delayed transaction ran and picked the first order id 25, but the second concurrent transaction 325 did not immediately pick up the next  value there was a delay as the table lock escalation was validated and reviewed and it took 3 whole seconds for it to figure this out.  This effect will only be exacerbated by multiple concurrent transactions hitting the same table and causing table locking in order to sort out the proper order.

There is a way to instruct the table locking to occur without delay and read into the next available, use a combination of locks and hints to instruct the transaction to pass over immediately even if there is a delay in concurrent transactions.

READPAST

Specifies that the Database Engine not read rows that are locked by other transactions. When READPAST is specified, row-level locks are skipped but page-level locks are not skipped.

SO that also means I need to use ROWLOCK to inform READPAST what to skip

ROWLOCK
Specifies that row locks are taken when page or table locks are ordinarily taken. When specified in transactions operating at the SNAPSHOT isolation level, row locks are not taken unless ROWLOCK is combined with other table hints that require locks, such as UPDLOCK and HOLDLOCK.

I am doing an UPDATE so this means I need to add another hint UPDLOCK

UPDLOCK
Specifies that update locks are to be taken and held until the transaction completes. UPDLOCK takes update locks for read operations only at the row-level or page-level. If UPDLOCK is combined with TABLOCK, or a table-level lock is taken for some other reason, an exclusive (X) lock will be taken instead.

That means I will use the following table hints in my concurrent transactions then

READPAST, UPDLOCK, ROWLOCK

Now try this again

Reset the data

-- Reset
USE [AdventureWorks2017]
update Person.Address
set AddressLine2 = NULL
WHERE AddressLine2 = 'Texas'

Window 1

BEGIN TRAN 
	MERGE Person.Address t
		USING (
			select TOP 1 pa.*, sp.Name
			FROM Person.Address pa WITH (READPAST, UPDLOCK, ROWLOCK)
			INNER JOIN Person.StateProvince sp
			ON pa.StateProvinceID = sp.StateProvinceID
			WHERE sp.Name = 'Texas'
			AND pa.AddressLine2 IS NULL
			ORDER BY pa.AddressId
		) s
		on (s.AddressId = t.AddressId)
	WHEN MATCHED
		THEN UPDATE SET
			t.AddressLine2 = s.Name
	OUTPUT
	   $action,
	   inserted.*,
	   deleted.*;
	WAITFOR DELAY '00:00:05'
COMMIT TRAN

Window 2

BEGIN TRAN 
	MERGE Person.Address t
		USING (
			select TOP 1 pa.*, sp.Name
			FROM Person.Address pa WITH (READPAST, UPDLOCK, ROWLOCK)
			INNER JOIN Person.StateProvince sp
			ON pa.StateProvinceID = sp.StateProvinceID
			WHERE sp.Name = 'Texas'
			AND pa.AddressLine2 IS NULL
			ORDER BY pa.AddressId
		) s
		on (s.AddressId = t.AddressId)
	WHEN MATCHED
		THEN UPDATE SET
			t.AddressLine2 = s.Name
	OUTPUT
	   $action,
	   inserted.*,
	   deleted.*;

COMMIT TRAN

Results - Yes it works!

As you can see the first transaction picks up id 25 immediately and then holds it for the 5 seconds, but the key difference here is the second transaction picks up the second item 325 and runs immediately without any delay as you can see

Run the test slowed down to see locks in progress

I am changing the first WAITFOR DELAY to 15 seconds and putting 5 seconds on the second transaction

Yay!