Fixing Locking and Blocking Issues in SQL Server - Part 7 - Handling Deadlocks in T-SQL

Fixing Locking and Blocking Issues in SQL Server - Part 7 - Handling Deadlocks in T-SQL

This is part 7 in a series of posts:

  • Part 1 covered being sure there is a locking and blocking issue
  • Part 2 covered the impact of RCSI
  • Part 3 looked at the impacts of indexing on locking and blocking
  • Part 4 looked at what deadlocks really are and how SQL Server handles them
  • Part 5 looked at how applications should handle deadlocks
  • Part 6 looked at how to avoid deadlocks in the first place

Today, though, I want to look at how to handle deadlocks if you must do that in T-SQL.

The Situation

OK, let’s start with the following situation:

Your application is calling a stored procedure, and it regularly ends up being involved in deadlocks. To make this easy to demonstrate, let’s create a table of data:

[code language=“sql” gutter=“false”]

USE tempdb; GO

DROP TABLE IF EXISTS dbo.Products; GO

CREATE TABLE dbo.Products ( ProductID int NOT NULL CONSTRAINT PK_dbo_Products PRIMARY KEY, ProductName nvarchar(50) NOT NULL, UnitPrice decimal(18,2) NOT NULL, OnHandQuantity decimal(18,3) NULL ); GO

INSERT dbo.Products ( ProductID, ProductName, UnitPrice, OnHandQuantity ) VALUES (1, N’Product 1’, 12.35, 150), (2, N’Product 2’, 14.30, 155), (3, N’Product 3’, 15.80, 165); GO

[/code]

Then we’ll create a stored procedure that can be used to convert a quantity of one product into another product. This can lead to deadlocks:

[code language=“sql” gutter=“false”]

CREATE OR ALTER PROCEDURE dbo.ConvertProduct @FromProductID int, @FromProductQuantity decimal(18,3), @ToProductID int, @ToProductQuantity decimal(18,3) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON;

BEGIN TRAN;

UPDATE dbo.Products SET OnHandQuantity -= @FromProductQuantity WHERE ProductID = @FromProductID;

WAITFOR DELAY ‘00:00:10’;

UPDATE dbo.Products SET OnHandQuantity += @ToProductQuantity WHERE ProductID = @ToProductID;

COMMIT; END; GO

[/code]

Note that I added a delay of 10 seconds between the updates to make it easy for us to generate a deadlock. So let’s cause one now.

In one query window, execute the following:

[code language=“sql” gutter=“false”]

-- Query window 1

USE tempdb; GO

EXEC dbo.ConvertProduct @FromProductID = 2, @FromProductQuantity = 12.0, @ToProductID = 3, @ToProductQuantity = 1;

[/code]

And at the same time, in another query window, execute a similar but different query:

[code language=“sql” gutter=“false”]

-- Query window 2

USE tempdb; GO

EXEC dbo.ConvertProduct @FromProductID = 3, @FromProductQuantity = 1, @ToProductID = 2, @ToProductQuantity = 14.1;

[/code]

No surprise, we get the dreaded deadlock message in one window:

Adding Retry Logic

So how do we fix this? We can either change this procedure to have retry logic, or we can rename this procedure and call it from a wrapper procedure that has the deadlock retry logic.

Let’s do the second option this time.

We’ll recreate the procedure as ConvertProduct_Inner:

[code language=“sql” gutter=“false”]

CREATE OR ALTER PROCEDURE dbo.ConvertProduct_Inner @FromProductID int, @FromProductQuantity decimal(18,3), @ToProductID int, @ToProductQuantity decimal(18,3) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON;

BEGIN TRAN;

UPDATE dbo.Products SET OnHandQuantity -= @FromProductQuantity WHERE ProductID = @FromProductID;

WAITFOR DELAY ‘00:00:10’;

UPDATE dbo.Products SET OnHandQuantity += @ToProductQuantity WHERE ProductID = @ToProductID;

COMMIT; END; GO

[/code]

And then we’ll add a wrapper procedure:

[code language=“sql” gutter=“false”]

CREATE OR ALTER PROCEDURE dbo.ConvertProduct @FromProductID int, @FromProductQuantity decimal(18,3), @ToProductID int, @ToProductQuantity decimal(18,3) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON;

DECLARE @MAXIMUM_RETRIES int = 5;

DECLARE @RemainingRetries int = @MAXIMUM_RETRIES;

WHILE (@RemainingRetries > 0) – retry update for victim BEGIN BEGIN TRY BEGIN TRANSACTION;

EXEC dbo.ConvertProduct_Inner @FromProductID = @FromProductID, @FromProductQuantity = @FromProductQuantity, @ToProductID = @ToProductID, @ToProductQuantity = @ToProductQuantity;

SET @RemainingRetries = 0;

COMMIT; END TRY BEGIN CATCH IF (ERROR_NUMBER() = 1205) – deadlock victim BEGIN SET @RemainingRetries -= 1; PRINT ‘Warning: Deadlock occurred’; PRINT ‘Remaining retries: ’ + CAST(@RemainingRetries AS varchar(20)); END ELSE BEGIN SET @RemainingRetries = -1; END; IF XACT_STATE() <> 0 BEGIN ROLLBACK TRANSACTION; END; END CATCH; END; END; GO

[/code]

Now I’ll execute both procedures again, and notice that both complete as expected. One has a warning about a deadlock that had occurred but was retried:

This is great, and is something we couldn’t do before SQL Server 2005 so many people weren’t used to doing it. However, while this will help at the T-SQL end, most other types of retries need to happen at the client end.

2019-05-30