Isolation levels, locking and blocking
What happens when two sessions use the same rows at once: shared and exclusive locks, blocking and how to find the blocker, dirty, non-repeatable and phantom reads, the four isolation levels, lock escalation, lost updates and UPDLOCK, and LOCK_TIMEOUT.
Locks a transaction holds
sys.dm_tran_locks lists the locks every session holds or is waiting for:
BEGIN TRANSACTION; UPDATE dbo.Products SET Price = 95.00 WHERE ProductId = 2; SELECT resource_type, request_mode, request_statusFROM sys.dm_tran_locksWHERE request_session_id = @@SPID AND resource_type <> 'DATABASE'ORDER BY resource_type; ROLLBACK;resource_type request_mode request_status ------------- ------------ -------------- KEY X GRANT OBJECT IX GRANT PAGE IX GRANT
| Mode | Taken by | Allows others to |
|---|---|---|
Blocking
BEGIN TRANSACTION;UPDATE dbo.Products SET Price = 95.00 WHERE ProductId = 2;SELECT Name, Price FROM dbo.Products WHERE ProductId = 2;sys.dm_exec_requests shows each running request and the session blocking it:
SELECT r.session_id, r.blocking_session_id, r.wait_type, r.command, t.text AS waiting_sqlFROM sys.dm_exec_requests AS rCROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS tWHERE r.blocking_session_id <> 0; SELECT resource_type, request_mode, request_status, request_session_idFROM sys.dm_tran_locksWHERE resource_database_id = DB_ID() AND resource_type <> 'DATABASE'ORDER BY request_session_id, resource_type;session_id blocking_session_id wait_type command waiting_sql
---------- ------------------- --------- ------- ----------------------------------------------------------------------------
54 51 LCK_M_S SELECT (@1 tinyint)SELECT [Name],[Price] FROM [dbo].[Products] WHERE [ProductId]=@1
resource_type request_mode request_status request_session_id
------------- ------------ -------------- ------------------
KEY X GRANT 51
OBJECT IX GRANT 51
PAGE IX GRANT 51
KEY S WAIT 54
OBJECT IS GRANT 54
PAGE IS GRANT 54LCK_M_S: waiting for a shared lock) on session 51. The lock list shows the conflict exactly: 51 has been granted X on the key and 54 is waiting for S on it. The waiting query text also shows that SQL Server turned the literal 2 into a parameter (@1) so the plan can be reused.
COMMIT;SELECT Name, Price FROM dbo.Products WHERE ProductId = 2;Name Price -------------- ----- Cordless drill 95.00
Three things that can go wrong
| Problem | What the reader sees |
|---|---|
SET TRANSACTION ISOLATION LEVEL; it lasts until the session changes it.
READ UNCOMMITTED and NOLOCK: dirty reads
BEGIN TRANSACTION;UPDATE dbo.Products SET Price = 9.99 WHERE ProductId = 2;SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;SELECT Name, Price FROM dbo.Products WHERE ProductId = 2;Name Price -------------- ----- Cordless drill 9.99
ROLLBACK;WITH (NOLOCK) does the same for one table in one query, and gave the same 9.99 here.
READ COMMITTED: the default
BEGIN TRANSACTION;SELECT Name, Stock FROM dbo.Products WHERE ProductId = 2;Name Stock -------------- ----- Cordless drill 12
UPDATE dbo.Products SET Stock = Stock - 10 WHERE ProductId = 2;SELECT Name, Stock FROM dbo.Products WHERE ProductId = 2;COMMIT;Name Stock -------------- ----- Cordless drill 2
REPEATABLE READ: keep the read locks
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;BEGIN TRANSACTION;SELECT COUNT(*) AS Tools FROM dbo.Products WHERE CategoryId = 1;Tools
-----
3INSERT INTO dbo.Products (Name, CategoryId, Price, Stock, Unit, AddedOn)VALUES (N'Spirit level', 1, 14.00, 20, N'each', '2024-07-01');SELECT COUNT(*) AS Tools FROM dbo.Products WHERE CategoryId = 1;COMMIT;Tools
-----
4SERIALIZABLE: lock the ranges too
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;BEGIN TRANSACTION; SELECT ProductId, Name FROM dbo.Products WHERE ProductId BETWEEN 2 AND 4; SELECT resource_type, request_mode, COUNT(*) AS LocksFROM sys.dm_tran_locksWHERE request_session_id = @@SPID AND resource_type <> 'DATABASE'GROUP BY resource_type, request_modeORDER BY resource_type; ROLLBACK;SET TRANSACTION ISOLATION LEVEL READ COMMITTED;ProductId Name
--------- ----------------
2 Cordless drill
3 Tape measure 8 m
4 Cement 25 kg
resource_type request_mode Locks
------------- ------------ -----
KEY RangeS-S 4
OBJECT IS 1
PAGE IS 1RangeS-S is a shared lock on a key and the gap before it. There are 4: on keys 2, 3 and 4, and on key 5, which closes the gap after 4 so nothing can be inserted between 4 and 5. Without an index to lock ranges on, SERIALIZABLE has to lock much more, often the whole table.
| Isolation level | Dirty read | Non-repeatable read | Phantom | Readers hold S locks |
|---|---|---|---|---|
Lock escalation
DROP TABLE IF EXISTS dbo.Sales;CREATE TABLE dbo.Sales (SaleId int NOT NULL PRIMARY KEY, Amount decimal(10,2) NOT NULL);INSERT INTO dbo.Sales (SaleId, Amount)SELECT value, 10.00 FROM GENERATE_SERIES(1, 10000);GOBEGIN TRANSACTION;UPDATE dbo.Sales SET Amount += 1 WHERE SaleId <= 4000; SELECT resource_type, request_mode, COUNT(*) AS LocksFROM sys.dm_tran_locksWHERE request_session_id = @@SPID AND resource_type <> 'DATABASE'GROUP BY resource_type, request_modeORDER BY resource_type;ROLLBACK; BEGIN TRANSACTION;UPDATE dbo.Sales SET Amount += 1 WHERE SaleId <= 7000; SELECT resource_type, request_mode, COUNT(*) AS LocksFROM sys.dm_tran_locksWHERE request_session_id = @@SPID AND resource_type <> 'DATABASE'GROUP BY resource_type, request_modeORDER BY resource_type;ROLLBACK;resource_type request_mode Locks ------------- ------------ ----- KEY X 4000 OBJECT IX 1 PAGE IX 11 resource_type request_mode Locks ------------- ------------ ----- OBJECT X 1
Lost updates
BEGIN TRANSACTION;DECLARE @Stock int = (SELECT Stock FROM dbo.Products WHERE ProductId = 2);WAITFOR DELAY '00:00:02';UPDATE dbo.Products SET Stock = @Stock - 1 WHERE ProductId = 2;COMMIT;SELECT Name, Stock FROM dbo.Products WHERE ProductId = 2;Name Stock -------------- ----- Cordless drill 11
UPDATE dbo.ProductsSET Stock = Stock - 1OUTPUT inserted.Name, deleted.Stock AS Before, inserted.Stock AS AfterWHERE ProductId = 2 AND Stock >= 1;Name Before After -------------- ------ ----- Cordless drill 12 11
WITH (UPDLOCK). It takes an update lock and keeps it to the end of the transaction. Plain readers are not blocked, but a second UPDLOCK reader waits. With UPDLOCK in both sessions above, Session 2 waited, read 11, and the drill ended at 10, which is right.
Show a hint
Show the solution
UPDATE dbo.ProductsSET Stock = Stock - 5OUTPUT inserted.Name, deleted.Stock AS Before, inserted.Stock AS AfterWHERE ProductId = 8 AND Stock >= 5; UPDATE dbo.ProductsSET Stock = Stock - 5OUTPUT inserted.Name, deleted.Stock AS Before, inserted.Stock AS AfterWHERE ProductId = 9 AND Stock >= 5; IF @@ROWCOUNT = 0 PRINT 'Out of stock';Name Before After ----------------- ------ ----- Wood screws (200) 150 145 Name Before After ---- ------ ----- Out of stock
Remember
Writers take X locks and keep them to the end of the transaction; readers take S locks. A conflicting lock request waits: that is blocking. sys.dm_exec_requests shows blocking_session_id; sys.dm_tran_locks shows granted and waiting locks. Dirty reads, non-repeatable reads and phantoms are the three problems isolation levels prevent. READ UNCOMMITTED and NOLOCK read uncommitted data and can miss or double-read rows. READ COMMITTED (the default) releases S locks after each row; REPEATABLE READ keeps them; SERIALIZABLE also locks key ranges. Past about 5,000 locks on one table, one statement’s row locks escalate to a single table lock; change big tables in batches. Read-then-write loses updates: do it in one UPDATE (Stock = Stock - 1 … WHERE Stock >= 1), or read WITH (UPDLOCK). SET LOCK_TIMEOUT stops a session waiting for ever (Msg 1222).