Deadlocks with UPDATE statements using serializable transaction isolation level

  • We are seeing frequent deadlocks occurring due to a particular stored procedure that is using the SERIALIZABLE transaction isolation level. The stored procedure is essentially trying to ensure that the same reference number (concatenated from multiple fields) is never returned more than once.

    CREATE PROCEDURE dbo.sp_GenerateNextNumber (
    @SequenceKey nvarchar(10),
    @ReferenceNumber nvarchar(25) = NULL OUTPUT
    )
    AS
    DECLARE @ReferenceNoPrefix nvarchar(20),
    @NextReferenceNo int,
    @MaxReferenceNo int,
    @IDLength smallint;

    SELECT @ReferenceNumber = NULL;

    SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

    BEGIN TRANSACTION

    IF NOT EXISTS
    (
    SELECT 1
    FROM dbo.Reference_Numbers
    WHERE SequenceKey = @SequenceKey
    AND Active = 1
    AND NextReferenceNo <= MaxReferenceNo
    )
    BEGIN
    RAISERROR ('No more numbers are available within this sequence (%s)!', 11, 1, @SequenceKey);
    ROLLBACK;
    RETURN 1;
    END

    SELECT
    @ReferenceNoPrefix = ReferenceNoPrefix,
    @NextReferenceNo = NextReferenceNo,
    @MaxReferenceNo = MaxReferenceNo,
    @IDLength = IDLength
    FROM dbo.Reference_Numbers
    WHERE SequenceKey = @SequenceKey
    AND Active = 1;

    SELECT @ReferenceNumber = ISNULL(@ReferenceNoPrefix, N'') + RIGHT(REPLICATE('0', @IDLength) + CONVERT(nvarchar(11), @NextReferenceNo), @IDLength - LEN(@ReferenceNoPrefix));

    SET @NextReferenceNo = @NextReferenceNo + 1;

    UPDATE dbo.Reference_Numbers
    SET NextReferenceNo = @NextReferenceNo
    WHERE SequenceKey = @SequenceKey
    AND Active = 1;

    COMMIT TRANSACTION;

    GO

     

    The table definition and some sample data are provided below.

    DROP TABLE IF EXISTS dbo.Reference_Numbers;
    GO
    CREATE TABLE dbo.Reference_Numbers (
    SequenceKey nvarchar(10) NOT NULL,
    UsageSequence smallint NOT NULL,
    Active bit NOT NULL,
    Description nvarchar(30) NULL,
    ReferenceNoPrefix nvarchar(8) NULL,
    NextReferenceNo int NULL,
    MaxReferenceNo int NULL,
    IDLength smallint NOT NULL,
    CONSTRAINT PK_Reference_Numbers PRIMARY KEY CLUSTERED (SequenceKey, UsageSequence)
    );
    GO
    INSERT dbo.Reference_Numbers
    (
    SequenceKey,
    UsageSequence,
    Active,
    Description,
    ReferenceNoPrefix,
    NextReferenceNo,
    MaxReferenceNo,
    IDLength
    )
    VALUES
    (
    N'NEWSEQ',
    1,
    1,
    N'Reference number',
    N'A',
    24,
    30,
    6
    ),
    (
    N'NEWSEQ',
    2,
    0,
    N'Reference number',
    N'B',
    1,
    30,
    6
    );
    GO

     

    The following is a typical example of the deadlock report XML we are seeing.

    <deadlock>
    <victim-list>
    <victimProcess id="process19d3ca9a8c8"/>
    </victim-list>
    <process-list>
    <process id="process19d3ca9a8c8" taskpriority="0" logused="0" waitresource="KEY: 30:72057596356984832 (23c22f825099)" waittime="949" ownerId="1628625887" transactionname="user_transaction" lasttranstarted="2024-07-21T12:18:52.190" XDES="0x1a982655310" lockMode="U" schedulerid="5" kpid="8976" status="suspended" spid="197" sbid="0" ecid="0" priority="0" trancount="2" lastbatchstarted="2024-07-21T12:18:52.190" lastbatchcompleted="2024-07-21T12:18:52.183" lastattention="1900-01-01T00:00:00.183" clientapp=".Net SqlClient Data Provider" hostname="********" hostpid="7028" loginname="********" isolationlevel="serializable (4)" xactid="1628625887" currentdb="30" currentdbname="MyDatabase" lockTimeout="4294967295" clientoption1="671088672" clientoption2="128056">
    <executionStack>
    <frame procname="MyDatabase.dbo.sp_GenerateNextNumber" line="90" stmtstart="5968" stmtend="6248" sqlhandle="0x03001e00bd3589296b1a18016aaa000001000000000000000000000000000000000000000000000000000000"> UPDATE dbo.Reference_Numbers SET NextReferenceNo = @NextReferenceNo WHERE SequenceKey = @SequenceKey AND Active = </frame>
    </executionStack>
    <inputbuf> Proc [Database Id = 30 Object Id = 2082731118] </inputbuf>
    </process>
    <process id="process1930a615848" taskpriority="0" logused="0" waitresource="KEY: 30:72057596356984832 (23c22f825099)" waittime="949" ownerId="1628625886" transactionname="user_transaction" lasttranstarted="2024-07-21T12:18:52.190" XDES="0x1a969798720" lockMode="X" schedulerid="1" kpid="7456" status="suspended" spid="136" sbid="0" ecid="0" priority="0" trancount="2" lastbatchstarted="2024-07-21T12:18:52.187" lastbatchcompleted="2024-07-21T12:18:52.183" lastattention="1900-01-01T00:00:00.183" clientapp=".Net SqlClient Data Provider" hostname="********" hostpid="7028" loginname="********" isolationlevel="serializable (4)" xactid="1628625886" currentdb="30" currentdbname="MyDatabase" lockTimeout="4294967295" clientoption1="671088672" clientoption2="128056">
    <executionStack>
    <frame procname="MyDatabase.dbo.sp_GenerateNextNumber" line="90" stmtstart="5968" stmtend="6248" sqlhandle="0x03001e00bd3589296b1a18016aaa000001000000000000000000000000000000000000000000000000000000"> UPDATE dbo.Reference_Numbers SET NextReferenceNo = @NextReferenceNo WHERE SequenceKey = @SequenceKey AND Active = </frame>
    </executionStack>
    <inputbuf> Proc [Database Id = 30 Object Id = 2082731118] </inputbuf>
    </process>
    </process-list>
    <resource-list>
    <keylock hobtid="72057596356984832" dbid="30" objectname="MyDatabase.dbo.Reference_Numbers" indexname="PK_Reference_Numbers" id="lock19a60536a00" mode="U" associatedObjectId="72057596356984832">
    <owner-list>
    <owner id="process1930a615848" mode="U"/>
    <owner id="process1930a615848" mode="X" requestType="convert"/>
    </owner-list>
    <waiter-list>
    <waiter id="process19d3ca9a8c8" mode="U" requestType="convert"/>
    </waiter-list>
    </keylock>
    <keylock hobtid="72057596356984832" dbid="30" objectname="MyDatabase.dbo.Reference_Numbers" indexname="PK_Reference_Numbers" id="lock19a60536a00" mode="U" associatedObjectId="72057596356984832">
    <owner-list>
    <owner id="process19d3ca9a8c8" mode="S"/>
    <owner id="process19d3ca9a8c8" mode="U" requestType="convert"/>
    </owner-list>
    <waiter-list>
    <waiter id="process1930a615848" mode="X" requestType="convert"/>
    </waiter-list>
    </keylock>
    </resource-list>
    </deadlock>

     

    We understand that the SERIALIZABLE transaction isolation level is the least concurrent of the isolation levels, but we need some way of ensuring that only one instance of the stored procedure can provide a given reference number, to avoid duplicate reference numbers from being generated.

    Can anyone please suggest a better way of achieving this, whilst improving concurrency?

  • That first ROLLBACK appears to roll nothing back!

    Also, you define @ReferenceNumber but don't use it. Are you sure that this is your current proc?

    As it's written, I'd be tempted to rewrite as a single UPDATE statement.

    The absence of evidence is not evidence of absence.
    Martin Rees

    You can lead a horse to water, but a pencil must be lead.
    Stan Laurel

  • Fair point on the ROLLBACK, although it would close the BEGIN TRANSACTION (rather than leaving it open). @ReferenceNumber is used lower down.

    SELECT @ReferenceNumber = ISNULL(@ReferenceNoPrefix, N'') + RIGHT(REPLICATE('0', @IDLength) + CONVERT(nvarchar(11), @NextReferenceNo), @IDLength - LEN(@ReferenceNoPrefix));

    There are some other lightweight checks performed (i.e. Luhn) just before @ReferenceNumber is set, but this code is not currently being called in the scenarios where we are seeing deadlocks (and so has been removed from this post for simplicity). But performing this in one update statement would not be quite as straightforward as a result.

    Are you suggesting we use an OUTPUT clause on the UPDATE statement, to advance the number and get the deleted value for use by the @ReferenceNumber output parameter in one go?

  • Fair point on the ROLLBACK, although it would close the BEGIN TRANSACTION (rather than leaving it open).

    Correct, though resolved by moving the BEGIN TRAN down a few rows.

    @ReferenceNumber is used lower down.

    Apologies, I see now that it is an OUTPUT variable.

    Yes, using OUTPUT. I was thinking something like this (completely untested):

    CREATE PROCEDURE dbo.sp_GenerateNextNumber
    (
    @SequenceKey NVARCHAR(10)
    ,@ReferenceNumber NVARCHAR(25) = NULL OUTPUT
    )
    AS
    SELECT @ReferenceNumber = NULL;

    SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

    IF NOT EXISTS
    (
    SELECT 1
    FROM dbo.Reference_Numbers
    WHERE SequenceKey = @SequenceKey
    AND Active = 1
    AND NextReferenceNo <= MaxReferenceNo
    )
    BEGIN
    RAISERROR ('No more numbers are available within this sequence (%s)!', 11, 1, @SequenceKey);

    RETURN 1;
    END;

    DECLARE @RefNo TABLE
    (
    ReferenceNumber NVARCHAR(25) NOT NULL
    );

    UPDATE r
    SET r.NextReferenceNo = c1.NextReferenceNo
    OUTPUT CONCAT (
    inserted.ReferenceNoPrefix
    ,RIGHT(REPLICATE ('0', c1.IDLength), CONVERT (NVARCHAR(11), c1.NextReferenceNo), c1.IDLength
    - LEN (c1.ReferenceNoPrefix))
    )
    INTO @RefNo
    FROM dbo.Reference_Numbers r
    CROSS APPLY
    (
    SELECT r2.ReferenceNoPrefix
    ,NextReferenceNo = r2.NextReferenceNo + 1
    ,r2.MaxReferenceNo
    ,r2.IDLength
    FROM dbo.Reference_Numbers r2
    WHERE r2.SequenceKey = @SequenceKey
    AND r2.Active = 1
    ) c1
    WHERE r.SequenceKey = @SequenceKey
    AND r.Active = 1;

    SELECT TOP(1)
    @ReferenceNumber = rn.ReferenceNumber
    FROM @RefNo rn;

     

     

    The absence of evidence is not evidence of absence.
    Martin Rees

    You can lead a horse to water, but a pencil must be lead.
    Stan Laurel

  • Using UPDLOCK and HOLDLOCK

    This approach allows you to acquire the necessary locks to ensure serialization without using the SERIALIZABLE isolation level.

    CREATE PROCEDURE dbo.sp_GenerateNextNumber (
    @SequenceKey nvarchar(10),
    @ReferenceNumber nvarchar(25) = NULL OUTPUT
    )
    AS
    DECLARE @ReferenceNoPrefix nvarchar(20),
    @NextReferenceNo int,
    @MaxReferenceNo int,
    @IDLength smallint;

    SELECT @ReferenceNumber = NULL;

    -- SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

    BEGIN TRANSACTION

    IF NOT EXISTS
    (
    SELECT 1
    FROM dbo.Reference_Numbers WITH (UPDLOCK, HOLDLOCK)
    WHERE SequenceKey = @SequenceKey
    AND Active = 1
    AND NextReferenceNo <= MaxReferenceNo
    )
    BEGIN
    RAISERROR ('No more numbers are available within this sequence (%s)!', 11, 1, @SequenceKey);
    ROLLBACK;
    RETURN 1;
    END

    SELECT
    @ReferenceNoPrefix = ReferenceNoPrefix,
    @NextReferenceNo = NextReferenceNo,
    @MaxReferenceNo = MaxReferenceNo,
    @IDLength = IDLength
    FROM dbo.Reference_Numbers WITH (UPDLOCK, HOLDLOCK)
    WHERE SequenceKey = @SequenceKey
    AND Active = 1;

    SELECT @ReferenceNumber = ISNULL(@ReferenceNoPrefix, N'') + RIGHT(REPLICATE('0', @IDLength) + CONVERT(nvarchar(11), @NextReferenceNo), @IDLength - LEN(@ReferenceNoPrefix));

    SET @NextReferenceNo = @NextReferenceNo + 1;

    UPDATE dbo.Reference_Numbers
    SET NextReferenceNo = @NextReferenceNo
    WHERE SequenceKey = @SequenceKey
    AND Active = 1;

    COMMIT TRANSACTION;

    GO

    The use of UPDLOCK and HOLDLOCK table hints will ensure that incoming transactions wait for the currently running transaction to complete, reducing the likelihood of deadlocks

     

  • deleted duplicate

  • Jonathan AC Reports - is HOLDLOCK not equivalent to the highest isolation level of SERIALIZABLE?

    https://bobpusateri.com/2023/08/holdlock-serializable/

  • I think if you do the UPDATE and lookup all in one go, you won't have dups nor deadlocks:

    ...

    UPDATE dbo.Reference_Numbers
    SET @NextReferenceNo = NexNextReferenceNo = NextReferenceNo + 1 --<<--
    WHERE SequenceKey = @SequenceKey
    AND Active = 1;

    SELECT @ReferenceNoPrefix = ReferenceNoPrefix,
    @MaxReferenceNo = MaxReferenceNo,
    @IDLength = IDLength
    FROM dbo.Reference_Numbers
    WHERE SequenceKey = @SequenceKey
    AND Active = 1;

    SELECT @ReferenceNumber = ISNULL(@ReferenceNoPrefix, N'') + RIGHT(REPLICATE('0', @IDLength) +
    CONVERT(nvarchar(11), @NextReferenceNo - 1), --<<--
    @IDLength - LEN(@ReferenceNoPrefix));

    COMMIT TRANSACTION;
    GO

    SQL DBA,SQL Server MVP(07, 08, 09) "It's a dog-eat-dog world, and I'm wearing Milk-Bone underwear." "Norm", on "Cheers". Also from "Cheers", from "Carla": "You need to know 3 things about Tortelli men: Tortelli men draw women like flies; Tortelli men treat women like flies; Tortelli men's brains are in their flies".

  • Thank you for your responses. We will look into the suggestions posted above to see if there is a way to recode this, and will come back to you.

Viewing 9 posts - 1 through 8 (of 8 total)

You must be logged in to reply to this topic. Login to reply