Manipulando o acesso simultâneo a uma tabela principal sem conflitos no SQL Server

32

Eu tenho uma tabela que é usada por um aplicativo herdado como um substituto para IDENTITYcampos em várias outras tabelas.

Cada linha na tabela armazena o último ID utilizado LastIDpara o campo com o nome no IDName.

Ocasionalmente, o processo armazenado recebe um impasse - acredito que criei um manipulador de erros apropriado; no entanto, estou interessado em ver se essa metodologia funciona da maneira que acho que funciona ou se estou latindo na árvore errada aqui.

Estou bastante certo de que deve haver uma maneira de acessar esta tabela sem nenhum conflito.

O próprio banco de dados está configurado com READ_COMMITTED_SNAPSHOT = 1.

Primeiro, aqui está a tabela:

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

E o índice não clusterizado no IDNamecampo:

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

Alguns dados de amostra:

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

O procedimento armazenado usado para atualizar os valores armazenados na tabela e retornar o próximo 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

Exemplo de execuções do processo armazenado:

EXEC GetNextID 'SomeTestID';

NewID
2

EXEC GetNextID 'SomeTestID';

NewID
3

EXEC GetNextID 'SomeOtherTestID';

NewID
2

EDITAR:

Eu adicionei um novo índice, pois o índice existente IX_tblIDs_Name não está sendo usado pelo SP; Presumo que o processador de consultas esteja usando o índice clusterizado, pois precisa do valor armazenado no LastID. De qualquer forma, esse índice é usado pelo plano de execução real:

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);

EDIT # 2:

Aceitei o conselho que @AaronBertrand deu e modifiquei-o levemente. A idéia geral aqui é refinar a declaração para eliminar bloqueios desnecessários e, em geral, tornar o SP mais eficiente.

O código abaixo substitui o código acima de BEGIN TRANSACTIONpara 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;

Como nosso código nunca adiciona um registro a esta tabela com 0 LastID, podemos assumir que se @NewID for 1, a intenção é acrescentar um novo ID à lista; caso contrário, estamos atualizando uma linha existente na lista.

Max Vernon
fonte
Como você configurou o banco de dados para oferecer suporte ao RCSI é irrelevante. Você está intencionalmente escalando para SERIALIZABLEaqui.
Aaron Bertrand
Sim, eu só queria adicionar todas as informações relevantes. Fico feliz que você esteja confirmando que é irrelevante!
Max Vernon
é muito fácil fazer com que o sp_getapplock se torne uma vítima de impasse, mas não se você iniciar uma transação, chame sp_getapplock uma vez para adquirir um bloqueio exclusivo e prossiga com sua modificação.
AK
1
O IDName é único? Em seguida, recomende "criar um índice não clusterizado exclusivo ". No entanto, se você precisar de valores nulos, o índice também precisará ser filtrado .
crokusek

Respostas:

15

Primeiro, eu evitaria fazer uma ida e volta ao banco de dados para todos os valores. Por exemplo, se seu aplicativo souber que precisa de 20 novos IDs, não faça 20 viagens de ida e volta. Faça apenas uma chamada de procedimento armazenado e aumente o contador em 20. Também pode ser melhor dividir sua tabela em várias.

É possível evitar completamente os impasses. Não tenho nenhum impasse no meu sistema. Existem várias maneiras de conseguir isso. Vou mostrar como eu usaria sp_getapplock para eliminar conflitos. Não tenho idéia se isso funcionará para você, porque o SQL Server é de código fechado, portanto não consigo ver o código-fonte e, como tal, não sei se testei todos os casos possíveis.

A seguir, descreve o que funciona para mim. YMMV.

Primeiro, vamos começar com um cenário em que sempre obtemos uma quantidade considerável de impasses. Segundo, usaremos sp_getapplock para eliminá-los. O ponto mais importante aqui é o teste de estresse de sua solução. Sua solução pode ser diferente, mas você precisa expô-la a alta simultaneidade, como demonstrarei mais adiante.

Pré-requisitos

Vamos configurar uma tabela com alguns dados de teste:

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

É provável que os dois procedimentos a seguir adotem um impasse:

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 Key1=@Key1;
SET @Key1=@Key1-10000;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
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 @Key2=@Key2-10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
SET @Key2=@Key2+10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
COMMIT;
GO

Reprodução de deadlocks

Os loops a seguir devem reproduzir mais de 20 bloqueios toda vez que você os executar. Se você obtiver menos de 20, aumente o número de iterações.

Em uma guia, execute isso;

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 ;

Em outra guia, execute este script.

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 ;

Certifique-se de iniciar os dois em alguns segundos.

Usando sp_getapplock para eliminar deadlocks

Altere os dois procedimentos, execute novamente o loop e verifique se você não possui mais conflitos:

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 Key1=@Key1;
SET @Key1=@Key1-10000;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
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 @Key2=@Key2-10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
SET @Key2=@Key2+10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
COMMIT;
GO

Usando uma tabela com uma linha para eliminar deadlocks

Em vez de chamar sp_getapplock, podemos modificar a seguinte tabela:

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);

Depois de criar e preencher esta tabela, podemos substituir a seguinte linha

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

com este, em ambos os procedimentos:

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

Você pode executar novamente o teste de estresse e verificar por si mesmo que não temos impasses.

Conclusão

Como vimos, o sp_getapplock pode ser usado para serializar o acesso a outros recursos. Como tal, ele pode ser usado para eliminar conflitos.

Obviamente, isso pode reduzir significativamente as modificações. Para resolver isso, precisamos escolher a granularidade certa para o bloqueio exclusivo e, sempre que possível, trabalhar com conjuntos em vez de linhas individuais.

Antes de usar essa abordagem, você precisa fazer o teste de estresse por conta própria. Primeiro, você precisa ter pelo menos uma dúzia de impasses com sua abordagem original. Segundo, você não deve obter conflitos quando executar novamente o mesmo script de reprodução usando o procedimento armazenado modificado.

Em geral, não acho que exista uma boa maneira de determinar se seu T-SQL está protegido contra conflitos apenas olhando para ele ou para o plano de execução. Na IMO, a única maneira de determinar se seu código está propenso a conflitos é expô-lo a alta simultaneidade.

Boa sorte com a eliminação de impasses! Não temos nenhum impasse em nosso sistema, o que é ótimo para nosso equilíbrio entre vida profissional e pessoal.

AK
fonte
2
O +1 como sp_getapplock é uma ferramenta útil que não é conhecida. Dada uma bagunça irresistível que pode levar algum tempo para separar, é um truque útil para serializar um processo que está em um impasse. Mas, deve ser a primeira escolha para um caso como esse que seja facilmente compreendido e possa (talvez deva) ser tratado por mecanismos de bloqueio padrão?
Mark Storey-Smith
2
@ MarkStorey-Smith É a minha primeira escolha, porque eu pesquisei e testei o estresse apenas uma vez e posso reutilizá-lo em qualquer situação - a serialização já aconteceu, então tudo o que acontece após sp_getapplock não afeta o resultado. Com mecanismos de bloqueio padrão, nunca posso ter tanta certeza - adicionar um índice ou apenas obter outro plano de execução pode causar bloqueios onde antes não havia. Me pergunte como eu sei.
AK
Acho que estou perdendo algo óbvio, mas como o uso UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;evita conflitos?
Dale K
9

O uso da XLOCKdica no seuSELECT abordagem ou no seguinte UPDATEdeve ser imune a esse tipo de impasse:

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;

Retornará com algumas outras variantes (se não for batida!).

Mark Storey-Smith
fonte
Embora XLOCKimpeça a atualização de um contador existente de várias conexões, você não precisaria de um TABLOCKXpara impedir que várias conexões adicionassem o mesmo novo contador?
Dale K
1
@DaleBurrell Não, você teria PK ou restrição exclusiva no IDName.
Mark Storey-Smith
7

Mike Defehr me mostrou uma maneira elegante de fazer isso de uma maneira muito leve:

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

(Para completar, eis a tabela associada ao processo armazenado)

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

Este é o plano de execução para a versão mais recente:

insira a descrição da imagem aqui

E este é o plano de execução para a versão original (suscetível de conflito):

insira a descrição da imagem aqui

Claramente, a nova versão vence!

Para comparação, a versão intermediária com o (XLOCK)etc produz o seguinte plano:

insira a descrição da imagem aqui

Eu diria que é uma vitória! Obrigado pela ajuda de todos!

Max Vernon
fonte
2
De fato, deve funcionar, mas você está usando SERIALIZABLE onde não é aplicável. Linhas fantasmas não podem existir aqui, então por que usar um nível de isolamento existente para evitá-las? Além disso, se alguém chamar seu procedimento de outra ou de uma conexão em que uma transação externa foi iniciada, qualquer outra ação iniciada será realizada em SERIALIZABLE. Isso pode ficar confuso.
Mark Storey-Smith
2
SERIALIZABLEnão existe para impedir fantasmas. Existe para fornecer semântica de isolamento serializável , ou seja, o mesmo efeito persistente no banco de dados como se as transações envolvidas tivessem sido executadas serialmente em alguma ordem não especificada.
Paul White diz GoFundMonica
6

Não para roubar o trovão de Mark Storey-Smith, mas ele está em algo com o seu post acima (que recebeu incidentalmente o maior número de votos). O conselho que dei a Max estava centrado no construto "UPDATE set @variable = column = column + value", que eu acho muito legal, mas acho que pode não ser documentado (ele precisa ser suportado, embora seja específico para o TCP) benchmarks).

Aqui está uma variação da resposta de Mark - porque você está retornando o novo valor de ID como um conjunto de registros, pode acabar com a variável escalar completamente, nenhuma transação explícita também deve ser necessária e eu concordaria que mexer nos níveis de isolamento é desnecessário também. O resultado é muito limpo e bem liso ...

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
Mike DeFehr
fonte
3
Concordou que isso deve ser imune a impasse, mas é propenso a uma condição de corrida na inserção, se você omitir a transação.
Mark Storey-Smith
4

Corrigi um impasse semelhante em um sistema no ano passado, alterando isso:

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;

Para isso:

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

Em geral, selecionar um COUNTapenas para determinar a presença ou ausência é um grande desperdício. Nesse caso, como é 0 ou 1, não é muito trabalhoso, mas (a) esse hábito pode desaparecer em outros casos em que será muito mais caro (nesses casos, use em IF NOT EXISTSvez de IF COUNT() = 0) e (b) a verificação adicional é completamente desnecessária. oUPDATE executa essencialmente a mesma verificação.

Além disso, isso me parece um cheiro sério de código:

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

Qual é o ponto aqui? Por que não usar apenas uma coluna de identidade ou derivar essa sequência usando ROW_NUMBER()no momento da consulta?

Aaron Bertrand
fonte
A maioria das tabelas que temos estão usando um IDENTITY. Esta tabela suporta algum código legado escrito no MS Access que seria bastante envolvido na atualização. A SET @NewID=linha simplesmente incrementa o valor armazenado na tabela para o ID fornecido (mas você já sabe disso). Você pode expandir como eu poderia usar ROW_NUMBER()?
Max Vernon
@ MaxVernon não sem saber o que LastIDrealmente significa no seu modelo. Qual é seu propósito? O nome não é exatamente auto-explicativo. Como o Access o usa?
Aaron Bertrand
Uma função no Access deseja adicionar uma linha a qualquer tabela que não tenha uma IDENTITY. O First Access chama GetNextID('WhatevertheIDFieldIsCalled')para obter o próximo ID a ser usado e o insere na nova linha, juntamente com os dados necessários.
Max Vernon
Vou implementar sua alteração. Um caso puro de "menos é mais"!
Max Vernon
1
Seu impasse fixo pode ressurgir. Seu segundo padrão também é vulnerável: sqlblog.com/blogs/alexander_kuznetsov/archive/2010/01/12/… Para eliminar os deadlocks, eu usaria sp_getapplock. Pode sistema de carga mista com centenas de usuários não tem impasses.
AK