it-swarm-eu.dev

Zpracování souběžného přístupu k tabulce klíčů bez zablokování na serveru SQL

Mám tabulku, kterou používá starší aplikace jako náhradu za pole IDENTITY v různých jiných tabulkách.

Každý řádek v tabulce ukládá poslední použité ID LastID pro pole pojmenované v IDName.

Občas se uložený proc dostane do slepé uličky - věřím, že jsem vytvořil vhodný obslužný program chyb; Mám však zájem zjistit, zda tato metodika funguje tak, jak si myslím, že ano, nebo jestli tady štěkám špatný strom.

Jsem si docela jistý, že by měl existovat způsob, jak získat přístup k této tabulce bez uváznutí.

Samotná databáze je konfigurována pomocí READ_COMMITTED_SNAPSHOT = 1.

Nejprve je zde tabulka:

CREATE TABLE [dbo].[tblIDs](
    [IDListID] [int] NOT NULL 
        CONSTRAINT PK_tblIDs 
        PRIMARY KEY CLUSTERED 
        IDENTITY(1,1) ,
    [IDName] [nvarchar](255) NULL,
    [LastID] [int] NULL,
);

A neclusterovaný index v poli IDName:

CREATE NONCLUSTERED INDEX [IX_tblIDs_IDName] 
ON [dbo].[tblIDs]
(
    [IDName] ASC
) 
WITH (
    PAD_INDEX = OFF
    , STATISTICS_NORECOMPUTE = OFF
    , SORT_IN_TEMPDB = OFF
    , DROP_EXISTING = OFF
    , ONLINE = OFF
    , ALLOW_ROW_LOCKS = ON
    , ALLOW_PAGE_LOCKS = ON
    , FILLFACTOR = 80
);

GO

Ukázková data:

INSERT INTO tblIDs (IDName, LastID) 
    VALUES ('SomeTestID', 1);
INSERT INTO tblIDs (IDName, LastID) 
    VALUES ('SomeOtherTestID', 1);
GO

Uložená procedura použitá k aktualizaci hodnot uložených v tabulce a vrácení dalšího ID:

CREATE PROCEDURE [dbo].[GetNextID](
    @IDName nvarchar(255)
)
AS
BEGIN
    /*
        Description:    Increments and returns the LastID value from tblIDs
        for a given IDName
        Author:         Max Vernon
        Date:           2012-07-19
    */

    DECLARE @Retry int;
    DECLARE @EN int, @ES int, @ET int;
    SET @Retry = 5;
    DECLARE @NewID int;
    SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    SET NOCOUNT ON;
    WHILE @Retry > 0
    BEGIN
        BEGIN TRY
            BEGIN TRANSACTION;
            SET @NewID = COALESCE((SELECT LastID 
                FROM tblIDs 
                WHERE IDName = @IDName),0)+1;
            IF (SELECT COUNT(IDName) 
                FROM tblIDs 
                WHERE IDName = @IDName) = 0 
                    INSERT INTO tblIDs (IDName, LastID) 
                    VALUES (@IDName, @NewID)
            ELSE
                UPDATE tblIDs 
                SET LastID = @NewID 
                WHERE IDName = @IDName;
            COMMIT TRANSACTION;
            SET @Retry = -2; /* no need to retry since the operation completed */
        END TRY
        BEGIN CATCH
            IF (ERROR_NUMBER() = 1205) /* DEADLOCK */
                SET @Retry = @Retry - 1;
            ELSE
                BEGIN
                SET @Retry = -1;
                SET @EN = ERROR_NUMBER();
                SET @ES = ERROR_SEVERITY();
                SET @ET = ERROR_STATE()
                RAISERROR (@EN,@ES,@ET);
                END
            ROLLBACK TRANSACTION;
        END CATCH
    END
    IF @Retry = 0 /* must have deadlock'd 5 times. */
    BEGIN
        SET @EN = 1205;
        SET @ES = 13;
        SET @ET = 1
        RAISERROR (@EN,@ES,@ET);
    END
    ELSE
        SELECT @NewID AS NewID;
END
GO

Ukázkové provedení uloženého procesu:

EXEC GetNextID 'SomeTestID';

NewID
2

EXEC GetNextID 'SomeTestID';

NewID
3

EXEC GetNextID 'SomeOtherTestID';

NewID
2

UPRAVIT:

Přidal jsem nový index, protože stávající index IX_tblIDs_Name není používán SP; Předpokládám, že procesor dotazu používá seskupený index, protože potřebuje hodnotu uloženou v LastID. Nicméně tento index IS používaný skutečným prováděcím plánem:

CREATE NONCLUSTERED INDEX IX_tblIDs_IDName_LastID 
ON dbo.tblIDs
(
    IDName ASC
) 
INCLUDE
(
    LastID
)
WITH (FILLFACTOR = 100
    , ONLINE=ON
    , ALLOW_ROW_LOCKS = ON
    , ALLOW_PAGE_LOCKS = ON);

ÚPRAVA # 2:

Vzal jsem radu, kterou @AaronBertrand dal, a mírně ji upravil. Obecnou myšlenkou je upřesnit prohlášení, aby se zamezilo zbytečnému zamykání, a celkově zefektivnit SP).

Níže uvedený kód nahrazuje výše uvedený kód z BEGIN TRANSACTION to END TRANSACTION:

BEGIN TRANSACTION;
SET @NewID = COALESCE((SELECT LastID 
        FROM dbo.tblIDs 
        WHERE IDName = @IDName), 0) + 1;

IF @NewID = 1
    INSERT INTO tblIDs (IDName, LastID) 
    VALUES (@IDName, @NewID);
ELSE
    UPDATE dbo.tblIDs 
    SET LastID = @NewID 
    WHERE IDName = @IDName;

COMMIT TRANSACTION;

Protože náš kód do této tabulky nikdy nepřidává záznam s 0 v LastID, můžeme předpokládat, že pokud @NewID je 1, pak je záměrem přidat nový ID do seznamu, jinak aktualizujeme existující řádek v seznam.

32
Max Vernon

Nejprve bych se vyhnul okružní cestě do databáze pro každou hodnotu. Pokud například vaše aplikace ví, že potřebuje 20 nových ID, neprovádějte 20 zpátečních letů. Proveďte pouze jedno volání uložené procedury a zvyšte čítač o 20. Také by mohlo být lepší rozdělit tabulku na více.

Je možné se zcela vyhnout uváznutí. Ve svém systému nemám žádné uváznutí. Existuje několik způsobů, jak toho dosáhnout. Ukážu, jak bych použil sp_getapplock k odstranění zablokování. Nemám ponětí, jestli to pro vás bude fungovat, protože SQL Server je uzavřený zdroj, takže nevidím zdrojový kód, a proto nevím, zda jsem testoval všechny možné případy.

Následující text popisuje, co pro mě funguje. YMMV.

Nejprve začněme scénářem, kdy vždy dostáváme značné množství mrtvých míst. Za druhé, použijeme je k odstranění sp_getapplock. Nejdůležitějším bodem je zde zátěžové testování vašeho řešení. Vaše řešení se může lišit, ale musíte to vystavit vysoké souběžnosti, jak ukážu později.

Předpoklady

Připravme tabulku s několika testovacími daty:

CREATE TABLE dbo.Numbers(n INT NOT NULL PRIMARY KEY); 
GO 

INSERT INTO dbo.Numbers 
    ( n ) 
        VALUES  ( 1 ); 
GO 
DECLARE @i INT; 
    SET @i=0; 
WHILE @i<21  
    BEGIN 
    INSERT INTO dbo.Numbers 
        ( n ) 
        SELECT n + POWER(2, @i) 
        FROM dbo.Numbers; 
    SET @i = @i + 1; 
    END;  
GO

SELECT n AS ID, n AS Key1, n AS Key2, 0 AS Counter1, 0 AS Counter2
INTO dbo.DeadlockTest FROM dbo.Numbers
GO

ALTER TABLE dbo.DeadlockTest ADD CONSTRAINT PK_DeadlockTest PRIMARY KEY(ID);
GO

CREATE INDEX DeadlockTestKey1 ON dbo.DeadlockTest(Key1);
GO

CREATE INDEX DeadlockTestKey2 ON dbo.DeadlockTest(Key2);
GO

Následující dva postupy se pravděpodobně ocitnou v patové situaci:

CREATE PROCEDURE dbo.UpdateCounter1 @Key1 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE [email protected];
SET @[email protected];
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE [email protected];
COMMIT;
GO

CREATE PROCEDURE dbo.UpdateCounter2 @Key2 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
SET @[email protected];
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE [email protected];
SET @[email protected]+10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE [email protected];
COMMIT;
GO

Reprodukční uváznutí

Následující smyčky by měly reprodukovat více než 20 zablokování při každém spuštění. Pokud máte méně než 20, zvyšte počet iterací.

Na jedné kartě to spusťte;

DECLARE @i INT, @DeadlockCount INT;
SELECT @i=0, @DeadlockCount=0;

WHILE @i<5000 BEGIN ;
  BEGIN TRY 
    EXEC dbo.UpdateCounter1 @Key1=123456;
  END TRY
  BEGIN CATCH
    SET @DeadlockCount = @DeadlockCount + 1;
    ROLLBACK;
  END CATCH ;
  SET @i = @i + 1;
END;
SELECT 'Deadlocks caught: ', @DeadlockCount ;

Na jiné kartě spusťte tento skript.

DECLARE @i INT, @DeadlockCount INT;
SELECT @i=0, @DeadlockCount=0;

WHILE @i<5000 BEGIN ;
  BEGIN TRY 
    EXEC dbo.UpdateCounter2 @Key2=123456;
  END TRY
  BEGIN CATCH
    SET @DeadlockCount = @DeadlockCount + 1;
    ROLLBACK;
  END CATCH ;
  SET @i = @i + 1;
END;
SELECT 'Deadlocks caught: ', @DeadlockCount ;

Ujistěte se, že začnete obě během několika sekund.

Použití sp_getapplock k odstranění zablokování

Změňte oba postupy, znovu spusťte smyčku a uvidíte, že již nemáte zablokování:

ALTER PROCEDURE dbo.UpdateCounter1 @Key1 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE [email protected];
SET @[email protected];
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE [email protected];
COMMIT;
GO

ALTER PROCEDURE dbo.UpdateCounter2 @Key2 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';
SET @[email protected];
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE [email protected];
SET @[email protected]+10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE [email protected];
COMMIT;
GO

Použití tabulky s jedním řádkem k odstranění zablokování

Místo vyvolání sp_getapplock můžeme upravit následující tabulku:

CREATE TABLE dbo.DeadlockTestMutex(
ID INT NOT NULL,
CONSTRAINT PK_DeadlockTestMutex PRIMARY KEY(ID),
Toggle INT NOT NULL);
GO

INSERT INTO dbo.DeadlockTestMutex(ID, Toggle)
VALUES(1,0);

Jakmile bude tato tabulka vytvořena a naplněna, můžeme nahradit následující řádek

EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';

s tímto, v obou postupech:

UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;

Můžete znovu spustit zátěžový test a přesvědčte se sami, že nemáme žádné zablokování.

Závěr

Jak jsme viděli, sp_getapplock lze použít k serializaci přístupu k jiným zdrojům. Jako takový může být použit k odstranění zablokování.

Samozřejmě to může výrazně zpomalit úpravy. Abychom to vyřešili, musíme si vybrat exkluzivní zámek pro exkluzivní zámek a kdykoli je to možné, pracovat se sadami místo jednotlivých řádků.

Před použitím tohoto přístupu je nutné si to sami sami otestovat. Nejprve se musíte ujistit, že s vaším původním přístupem dostanete alespoň několik desítek mrtvých míst. Zadruhé byste neměli dostat žádné uváznutí, když znovu spustíte stejný repro skript pomocí upravené uložené procedury.

Obecně si nemyslím, že existuje dobrý způsob, jak zjistit, zda je váš T-SQL v bezpečí před zablokováním, a to pouhým prohlédnutím nebo provedením plánu provádění. IMO je jediný způsob, jak zjistit, zda je váš kód náchylný k zablokování, je vystavit ho vysoké souběžnosti.

Hodně štěstí s odstraněním zablokování! V našem systému nemáme žádné uváznutí, což je skvělé pro rovnováhu mezi pracovním a soukromým životem.

15
A-K

Použití nápovědy XLOCK při přístupu SELECT nebo následující UPDATE by mělo být imunní vůči tomuto typu zablokování:

DECLARE @Output TABLE ([NewId] INT);
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN TRANSACTION;

UPDATE
    dbo.tblIDs WITH (XLOCK)
SET 
    LastID = LastID + 1
OUTPUT
    INSERTED.[LastId] INTO @Output
WHERE
    IDName = @IDName;

IF(@@ROWCOUNT = 1)
BEGIN
    SELECT @NewId = [NewId] FROM @Output;
END
ELSE
BEGIN
    SET @NewId = 1;

    INSERT dbo.tblIDs
        (IDName, LastID)
    VALUES
        (@IDName, @NewId);
END

SELECT [NewId] = @NewId ;

COMMIT TRANSACTION;

Vrátí se s několika dalšími variantami (pokud k tomu nepřijde!).

9

Mike Defehr mi ukázal elegantní způsob, jak toho dosáhnout velmi lehkým způsobem:

ALTER PROCEDURE [dbo].[GetNextID](
    @IDName nvarchar(255)
)
AS
BEGIN
    /*
        Description:    Increments and returns the LastID value from tblIDs for a given IDName
        Author:         Max Vernon / Mike Defehr
        Date:           2012-07-19

    */

    DECLARE @Retry int;
    DECLARE @EN int, @ES int, @ET int;
    SET @Retry = 5;
    DECLARE @NewID int;
    SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    SET NOCOUNT ON;
    WHILE @Retry > 0
    BEGIN
        BEGIN TRY
            UPDATE dbo.tblIDs 
            SET @NewID = LastID = LastID + 1 
            WHERE IDName = @IDName;

            IF @NewID IS NULL
            BEGIN
                SET @NewID = 1;
                INSERT INTO tblIDs (IDName, LastID) VALUES (@IDName, @NewID);
            END
            SET @Retry = -2; /* no need to retry since the operation completed */
        END TRY
        BEGIN CATCH
            IF (ERROR_NUMBER() = 1205) /* DEADLOCK */
                SET @Retry = @Retry - 1;
            ELSE
                BEGIN
                SET @Retry = -1;
                SET @EN = ERROR_NUMBER();
                SET @ES = ERROR_SEVERITY();
                SET @ET = ERROR_STATE()
                RAISERROR (@EN,@ES,@ET);
                END
        END CATCH
    END
    IF @Retry = 0 /* must have deadlock'd 5 times. */
    BEGIN
        SET @EN = 1205;
        SET @ES = 13;
        SET @ET = 1
        RAISERROR (@EN,@ES,@ET);
    END
    ELSE
        SELECT @NewID AS NewID;
END
GO

(Pro úplnost je zde tabulka spojená s uloženým proc)

CREATE TABLE [dbo].[tblIDs]
(
    IDName nvarchar(255) NOT NULL,
    LastID int NULL,
    CONSTRAINT [PK_tblIDs] PRIMARY KEY CLUSTERED 
    (
        [IDName] ASC
    ) WITH 
    (
        PAD_INDEX = OFF
        , STATISTICS_NORECOMPUTE = OFF
        , IGNORE_DUP_KEY = OFF
        , ALLOW_ROW_LOCKS = ON
        , ALLOW_PAGE_LOCKS = ON
        , FILLFACTOR = 100
    ) 
);
GO

Toto je plán provádění nejnovější verze:

enter image description here

A toto je plán provedení pro původní verzi (uváznutí na mrtvém bodě):

enter image description here

Je zřejmé, že nová verze vyhraje!

Pro srovnání, střední verze s (XLOCK) atd., vytváří následující plán:

enter image description here

Řekl bych, že je to vítězství! Díky za pomoc všem!

7
Max Vernon

Neukradnout hrom Mark Storey-Smitha, ale je na něčem se svým příspěvkem výše (který náhodou obdržel nejvíce upvotů). Rada, kterou jsem dal Maxovi, byla soustředěna kolem konstrukce UPDATE set @variable = column = column + value, kterou považuji za skvělou, ale myslím, že může být nezdokumentovaná (musí být podporována, i když proto, že je zde konkrétně pro = TCP měřítka).

Zde je variace Markovy odpovědi - protože vracíte novou hodnotu ID jako sadu záznamů, můžete zcela odstranit skalární proměnnou, neměla by být nutná ani žádná explicitní transakce, a já bych souhlasil s tím, že není nutné používat zprávy s úrovněmi izolace. také. Výsledek je velmi čistý a pěkně úhledný ...

ALTER PROC [dbo].[GetNextID]
  @IDName nvarchar(255)
  AS
BEGIN
SET NOCOUNT ON;

DECLARE @Output TABLE ([NewID] INT);

UPDATE dbo.tblIDs SET LastID = LastID + 1
OUTPUT inserted.[LastId] INTO @Output
WHERE IDName = @IDName;

IF(@@ROWCOUNT = 1)
    SELECT [NewID] FROM @Output;
ELSE
    INSERT dbo.tblIDs (IDName, LastID)
    OUTPUT INSERTED.LastID AS [NewID]
    VALUES (@IDName,1);
END
6
Mike DeFehr

Minulý rok jsem opravil podobný mrtvý bod v systému změnou tohoto:

IF (SELECT COUNT(IDName) FROM tblIDs WHERE IDName = @IDName) = 0 
  INSERT INTO tblIDs (IDName, LastID) VALUES (@IDName, @NewID)
ELSE
  UPDATE tblIDs SET LastID = @NewID WHERE IDName = @IDName;

K tomuto:

UPDATE tblIDs SET LastID = @NewID WHERE IDName = @IDName;
IF @@ROWCOUNT = 0
BEGIN
  INSERT ...
END

Obecně platí, že výběr COUNT jen pro určení přítomnosti nebo nepřítomnosti je docela zbytečný. V tomto případě, protože je to buď 0 nebo 1, není to jako by to bylo hodně práce, ale (a) tento zvyk může krvácet do jiných případů, kdy je bude mnohem nákladnější (v těchto případech použijte IF NOT EXISTS Namísto IF COUNT() = 0) a (b) další skenování je zcela zbytečné. UPDATE provádí v podstatě stejnou kontrolu.

Vypadá to také jako vážná vůně kódu:

SET @NewID = COALESCE((SELECT LastID FROM tblIDs WHERE IDName = @IDName),0)+1;

Jaký to má smysl? Proč nepoužívat v okamžiku dotazu pouze sloupec identity nebo odvodit tuto sekvenci pomocí funkce ROW_NUMBER()?

4
Aaron Bertrand