Обработка одновременного доступа к таблице ключей без тупиков в SQL Server

32

У меня есть таблица, которая используется устаревшим приложением в качестве замены IDENTITYполей в других таблицах.

В каждой строке таблицы хранится последний использованный идентификатор LastIDполя, названного в IDName.

Иногда хранимый процесс попадает в тупик - я считаю, что я создал соответствующий обработчик ошибок; однако мне интересно посмотреть, работает ли эта методология так, как я думаю, или я лаю здесь не то дерево.

Я вполне уверен, что должен быть способ получить доступ к этой таблице без каких-либо тупиков вообще.

Сама база данных настроена с READ_COMMITTED_SNAPSHOT = 1 .

Во-первых, вот таблица:

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

И некластеризованный индекс на 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

Некоторые примеры данных:

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

Хранимая процедура используется для обновления значений, хранящихся в таблице, и возврата следующего идентификатора:

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

Примеры выполнения хранимого процесса:

EXEC GetNextID 'SomeTestID';

NewID
2

EXEC GetNextID 'SomeTestID';

NewID
3

EXEC GetNextID 'SomeOtherTestID';

NewID
2

РЕДАКТИРОВАТЬ:

Я добавил новый индекс, поскольку существующий индекс IX_tblIDs_Name не используется SP; Я предполагаю, что обработчик запросов использует кластеризованный индекс, так как ему нужно значение, сохраненное в LastID. В любом случае, этот индекс используется фактическим планом выполнения:

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

РЕДАКТИРОВАНИЕ № 2:

Я воспользовался советом, который дал @AaronBertrand, и слегка его изменил. Общая идея здесь состоит в том, чтобы уточнить оператор, чтобы устранить ненужную блокировку, и в целом сделать SP более эффективным.

Код ниже заменяет код выше с BEGIN TRANSACTIONна 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;

Поскольку наш код никогда не добавляет запись в эту таблицу с 0, LastIDмы можем сделать предположение, что если @NewID равен 1, то мы намерены добавить новый идентификатор в список, в противном случае мы обновляем существующую строку в списке.

Макс Вернон
источник
То, как вы настроили базу данных для поддержки RCSI, не имеет значения. Вы намеренно увеличиваете SERIALIZABLEздесь.
Аарон Бертран
да, я просто хотел добавить всю необходимую информацию. Я рад, что вы подтверждаете, что это не имеет значения!
Макс Вернон
sp_getapplock очень легко стать жертвой взаимоблокировки, но если вы не начнете транзакцию, вызовите sp_getapplock один раз, чтобы получить эксклюзивную блокировку, и продолжайте вносить изменения.
АК
1
Является ли IDName уникальным? Затем рекомендуется «создать уникальный некластеризованный индекс». Однако если вам нужны нулевые значения, индекс также должен быть отфильтрован .
crokusek

Ответы:

15

Во-первых, я бы не стал совершать поездки в базу данных для каждого значения. Например, если ваше приложение знает, что ему нужно 20 новых идентификаторов, не совершайте 20 циклов. Сделайте только один вызов хранимой процедуры и увеличьте счетчик на 20. Также может быть лучше разделить вашу таблицу на несколько.

Можно вообще избежать тупиков. У меня вообще нет тупиков в моей системе. Есть несколько способов сделать это. Я покажу, как бы я использовал sp_getapplock для устранения тупиков. Я понятия не имею, будет ли это работать для вас, потому что SQL Server является закрытым исходным кодом, поэтому я не могу видеть исходный код, и поэтому я не знаю, проверил ли я все возможные случаи.

Ниже описано, что работает для меня. YMMV.

Во-первых, давайте начнем со сценария, в котором мы всегда получаем значительное количество тупиков. Во-вторых, мы будем использовать sp_getapplock для их устранения. Наиболее важным моментом здесь является стресс-тест вашего решения. Ваше решение может быть другим, но вам нужно выставить его на высокий уровень параллелизма, как я покажу позже.

Предпосылки

Давайте создадим таблицу с некоторыми тестовыми данными:

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

Следующие две процедуры могут оказаться в тупике:

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

Воспроизведение тупиков

Следующие циклы должны воспроизводить более 20 взаимоблокировок при каждом их запуске. Если вы получите меньше 20, увеличьте количество итераций.

В одной вкладке запустите это;

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 ;

В другой вкладке запустите этот скрипт.

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 ;

Убедитесь, что вы запускаете оба в течение нескольких секунд.

Использование sp_getapplock для устранения тупиков

Измените обе процедуры, перезапустите цикл и увидите, что у вас больше нет тупиков:

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

Использование таблицы с одной строкой для устранения тупиков

Вместо вызова sp_getapplock, мы можем изменить следующую таблицу:

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

После того, как эта таблица создана и заполнена, мы можем заменить следующую строку

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

с этим, в обеих процедурах:

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

Вы можете повторно запустить стресс-тест и убедиться, что у нас нет тупиков.

Вывод

Как мы уже видели, sp_getapplock может использоваться для сериализации доступа к другим ресурсам. Как таковой он может быть использован для устранения тупиков.

Конечно, это может значительно замедлить модификации. Чтобы решить эту проблему, нам нужно выбрать правильную гранулярность для монопольной блокировки и, когда это возможно, работать с наборами вместо отдельных строк.

Прежде чем использовать этот подход, вам необходимо провести стресс-тестирование самостоятельно. Во-первых, вам нужно убедиться, что вы получите как минимум пару дюжин тупиковых ситуаций с вашим оригинальным подходом. Во-вторых, вы не должны получать взаимоблокировки при повторном запуске того же сценария repro с использованием измененной хранимой процедуры.

В целом, я не думаю, что есть хороший способ определить, защищен ли ваш T-SQL от тупиков, просто взглянув на него или взглянув на план выполнения. IMO единственный способ определить, склонен ли ваш код к взаимоблокировкам, - это подвергнуть его высокому параллелизму.

Удачи в устранении тупиков! У нас вообще нет тупиков в нашей системе, что отлично подходит для баланса между работой и личной жизнью.

Аляска
источник
2
+1 как sp_getapplock - полезный инструмент, который не очень известен. Учитывая «ужасный беспорядок, который может занять время, чтобы разобрать его, это удобный трюк для сериализации процесса, который находится в тупике. Но должен ли он быть первым выбором для такого случая, который легко понять и можно (возможно, следует) решить с помощью стандартных механизмов блокировки?
Марк Стори-Смит
2
@ MarkStorey-Smith Это мой первый выбор, потому что я исследовал и провел стресс-тестирование только один раз, и я могу использовать его повторно в любой ситуации - сериализация уже произошла, поэтому все, что происходит после sp_getapplock, не влияет на результат. Со стандартными механизмами блокировки я никогда не могу быть так уверен - добавление индекса или просто получение другого плана выполнения может вызвать взаимоблокировки там, где раньше их не было. Спроси меня, откуда я знаю.
AK
Я предполагаю, что упускаю что-то очевидное, но как использование UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;предотвращает взаимные блокировки?
Дейл К
9

Использование XLOCKподсказки на вашем SELECTподходе или на следующем UPDATEдолжно быть защищено от этого типа тупика:

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;

Вернется с парой других вариантов (если не побежден!).

Марк Стори-Смит
источник
Несмотря на XLOCKто, что существующий счетчик не будет обновляться из нескольких подключений, вам не нужно, TABLOCKXчтобы несколько соединений не добавляли один и тот же новый счетчик?
Дейл К
1
@DaleBurrell Нет, у вас будет PK или уникальное ограничение на IDName.
Марк Стори-Смит
7

Майк Дефер показал мне элегантный способ сделать это очень легким способом:

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

(Для полноты вот таблица, связанная с сохраненным процессом)

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

Это план выполнения для последней версии:

введите описание изображения здесь

И это план выполнения для оригинальной версии (возможна взаимоблокировка):

введите описание изображения здесь

Очевидно, что новая версия выигрывает!

Для сравнения, промежуточная версия с (XLOCK)etc, выдает следующий план:

введите описание изображения здесь

Я бы сказал, что это победа! Спасибо всем за помощь!

Макс Вернон
источник
2
Должно действительно работать, но вы используете SERIALIZABLE, где это не применимо. Фантомные строки здесь не могут существовать, так зачем использовать уровень изоляции, который существует для их предотвращения? Кроме того, если кто-то вызывает вашу процедуру из другого или из соединения, где была запущена внешняя транзакция, любые дальнейшие действия, которые он инициирует, будут выполняться в SERIALIZABLE. Это может стать грязным.
Марк Стори-Смит
2
SERIALIZABLEне существует, чтобы предотвратить фантомы. Он существует для обеспечения сериализуемой семантики изоляции , то есть того же постоянного воздействия на базу данных, как если бы соответствующие транзакции выполнялись последовательно в некотором неопределенном порядке.
Пол Уайт говорит GoFundMonica
6

Не для того, чтобы украсть гром Марка Стори-Смита, но он на что-то со своим постом выше (который, кстати, получил наибольшее количество голосов). Совет, который я дал Максу, был сосредоточен вокруг конструкции «UPDATE set @variable = column = column + value», которую я нахожу действительно классной, но я думаю, что она может быть недокументирована (хотя и должна поддерживаться, хотя и предназначена специально для TCP тесты).

Вот вариант ответа Марка - поскольку вы возвращаете новое значение идентификатора в виде набора записей, вы можете полностью отказаться от скалярной переменной, явной транзакции также не требуется, и я согласен, что возиться с уровнями изоляции не нужно также. Результат очень чистый и довольно гладкий ...

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
Майк дефер
источник
3
Согласитесь, это должно быть защищено от тупиковой ситуации, но это может привести к гонке на вставке, если вы пропустите транзакцию.
Марк Стори-Смит
4

Я исправил подобный тупик в системе в прошлом году, изменив это:

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;

К этому:

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

В общем, выбор COUNTпросто определить наличие или отсутствие довольно расточительно. В этом случае, так как это либо 0, либо 1, это не значит, что это большая работа, но (а) эта привычка может распространиться на другие случаи, когда она будет намного дороже (в этих случаях использовать IF NOT EXISTSвместо IF COUNT() = 0), и (б) дополнительное сканирование совершенно не нужно. По UPDATEсути, выполняет ту же проверку.

Кроме того, это выглядит как серьезный запах кода для меня:

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

Какой смысл здесь? Почему бы просто не использовать столбец идентификации или получить эту последовательность, используя ROW_NUMBER()во время запроса?

Аарон Бертран
источник
Большинство таблиц, которые у нас есть, используют IDENTITY. Эта таблица поддерживает некоторый унаследованный код, написанный в MS Access, который может быть использован для модернизации. SET @NewID=Линия просто увеличивает значение , сохраненное в таблице для данного ID (но вы уже знаете , что). Можете ли вы рассказать о том, как я мог бы использовать ROW_NUMBER()?
Макс Вернон
@MaxVernon не зная, что на LastIDсамом деле означает в вашей модели. Какова его цель? Название не совсем понятно. Как Access использует это?
Аарон Бертран
Функция в Access хочет добавить строку в любую таблицу, которая не имеет IDENTITY. Сначала Access вызывает, GetNextID('WhatevertheIDFieldIsCalled')чтобы получить следующий идентификатор для использования, затем вставляет его в новую строку вместе с любыми необходимыми данными.
Макс Вернон
Я буду осуществлять ваши изменения. Чистый случай «меньше значит больше»!
Макс Вернон
1
Ваш фиксированный тупик может появиться снова. Ваш второй шаблон также уязвим: sqlblog.com/blogs/alexander_kuznetsov/archive/2010/01/12/… Чтобы устранить тупики, я бы использовал sp_getapplock. Может смешанная загрузка системы с сотнями пользователей, не имеет тупиков.
AK