Почему мой код SQL Server UPSERT иногда не блокируется?

У меня есть таблица ImportSourceMetadata, которую я использую для управления пакетным процессом импорта. Он содержит столбец PK SourceId и столбец данных LastCheckpoint. Пакетный процесс импорта считывает LastCheckpoint для данного SourceId, выполняет некоторую логику (в других таблицах), затем обновляет LastCheckpoint для этого SourceId или вставляет его, если он еще не существует.

Несколько экземпляров процесса запускаются одновременно, обычно с дизъюнктным SourceIds, и для таких случаев мне нужна высокая степень параллелизма. Однако может случиться так, что два процесса будут запущены для одного и того же SourceId; в этом случае мне нужно, чтобы экземпляры блокировали друг друга.

Поэтому мой код выглядит следующим образом:

BEGIN TRAN
SET TRANSACTION ISOLATION LEVEL READ COMMITTED
SELECT LastCheckpoint FROM ImportSourceMetadata WITH (UPDLOCK) WHERE SourceId = 'Source'

-- Perform some processing

-- UPSERT: if the SELECT above yielded no value, then
INSERT INTO ImportSourceMetadata(SourceId, LastCheckpoint) VALUES ('Source', '2013-12-21')
-- otherwise, we'd do this: UPDATE ImportSourceMetadata SET LastCheckpoint = '2013-12-21' WHERE SourceId = 'Source'

COMMIT TRAN

Я использую транзакцию для достижения атомарности, но могу использовать только уровень изоляции READ COMMITTED (из-за требований параллелизма в блоке «Выполнить некоторую обработку»). Поэтому (и чтобы избежать взаимоблокировок) я включаю подсказку UPDLOCK с оператором SELECT, чтобы получить «критическую секцию», параметризованную для SourceIdvalue.

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

Я нашел подсказки в Интернете (например, здесь и здесь, в комментарии), что мне нужно указать WITH (UPDLOCK,HOLDLOCK) (соответственно WITH (UPDLOCK,SERIALIZABLE)), а не просто взяв UPDLOCK на SELECT, но я действительно не понимаю, почему это так. Документы MSDN говорят,

UPDLOCK
Указывает, что блокировки обновления должны быть установлены и удерживаться до завершения транзакции.

Блокировки обновления, которая устанавливается и удерживается до завершения транзакции, должно быть достаточно, чтобы заблокировать последующую вставку, и фактически, когда я пробую ее в SQL Server Management Studio, она действительно блокирует мою вставку. Однако в некоторых редких случаях он внезапно перестает работать.

Итак, почему именно UPDLOCK недостаточно, и почему его достаточно в 99% моих тестовых прогонов (и при их симуляции в SQL Server Management Studio)?

Обновление: теперь я обнаружил, что могу надежно воспроизвести неблокирующее поведение, выполнив приведенный выше код в двух разных окнах SQL Server Management Studio одновременно вплоть до непосредственно перед INSERT, но только в первый раз после создания базы данных. После этого (даже несмотря на то, что я удалил содержимое таблицы ImportSourceMetadata), SELECT WITH (UPDLOCK) действительно заблокируется, и код больше не дает сбоев. Действительно, в sys.dm_tran_locks я вижу взятие U-блокировки, хотя строки не существует при последующих запусках теста, но не при первом запуске после создания таблицы.

Это полный пример, показывающий разницу в блокировках между «ново созданной таблицей» и «старой таблицей»:

DROP TABLE ImportSourceMetadata
CREATE TABLE ImportSourceMetadata(SourceId nvarchar(50) PRIMARY KEY, LastCheckpoint datetime)

BEGIN TRAN
SET TRANSACTION ISOLATION LEVEL READ COMMITTED
SELECT LastCheckpoint FROM ImportSourceMetadata WITH (UPDLOCK) WHERE SourceId='Source'

SELECT * 
FROM sys.dm_tran_locks l 
JOIN sys.partitions p 
ON l.resource_associated_entity_id = p.hobt_id JOIN sys.objects o 
ON p.object_id = o.object_id

INSERT INTO ImportSourceMetadata VALUES('Source', '2013-12-21')
ROLLBACK TRAN

BEGIN TRAN
SET TRANSACTION ISOLATION LEVEL READ COMMITTED
SELECT LastCheckpoint FROM ImportSourceMetadata WITH (UPDLOCK) WHERE SourceId='Source'

SELECT * 
FROM sys.dm_tran_locks l 
JOIN sys.partitions p 
ON l.resource_associated_entity_id = p.hobt_id JOIN sys.objects o 
ON p.object_id = o.object_id

ROLLBACK TRAN

В моей системе (с SQL Server 2012) первый запрос не показывает блокировки на ImportSourceMetadata, а второй запрос показывает блокировку KEY на ImportSourceMetadata.

Другими словами, HOLDLOCK действительно требуется, но только если таблица была только что создана. Почему это?


person Fabian Schmied    schedule 21.12.2013    source источник
comment
Это реальный код T-SQL, который выполняется. Я немного изменил его, чтобы вы могли выполнять его непосредственно в SQL Server Management Studio.   -  person Fabian Schmied    schedule 21.12.2013
comment
И теперь я добавил полный образец.   -  person Fabian Schmied    schedule 22.12.2013


Ответы (1)


Вам также понадобится HOLDLOCK.

Если строка существует, то ваш оператор SELECT снимет блокировку U по крайней мере с этой строки и сохранит ее до конца транзакции.

Если строка не существует, нет строки, которую можно взять и удерживать блокировку U, поэтому вы ничего не блокируете. HOLDLOCK заблокирует по крайней мере диапазон, в который поместится строка.

Без HOLDLOCK две параллельные транзакции могут выполнить SELECT для несуществующей строки. Не оставляйте конфликтующие блокировки, и оба переходят на INSERT.

Что касается воспроизведения в вашем вопросе, кажется, что проблема «строка не существует» немного сложнее, чем я думал сначала.

Если строка ранее существовала, но с тех пор была логически удалена, но все еще физически существует на странице в виде «призрачной» записи, блокировка U все еще может быть снята с фантома, что объясняет блокировку, которую вы видите.

Вы можете использовать DBCC PAGE для просмотра записей-призраков, как в этой небольшой поправке к вашему коду.

SET NOCOUNT ON;

DROP TABLE ImportSourceMetadata

CREATE TABLE ImportSourceMetadata
  (
     SourceId       NVARCHAR(50),
     LastCheckpoint DATETIME,
     PRIMARY KEY(SourceId)
  )

BEGIN TRAN

SET TRANSACTION ISOLATION LEVEL READ COMMITTED

SELECT LastCheckpoint
FROM   ImportSourceMetadata WITH (UPDLOCK)
WHERE  SourceId = 'Source'

INSERT INTO ImportSourceMetadata
VALUES      ('Source',  '2013-12-21')

DECLARE @DBCCPAGE NVARCHAR(100)

SELECT TOP 1 @DBCCPAGE = 'DBCC PAGE(0,' + CAST(file_id AS VARCHAR) + ',' + CAST(page_id AS VARCHAR) + ',3) WITH NO_INFOMSGS'
FROM   ImportSourceMetadata
       CROSS APPLY  sys.fn_physloccracker(%%physloc%%)

ROLLBACK TRAN

DBCC TRACEON(3604)

EXEC (@DBCCPAGE)

DBCC TRACEOFF(3604)

На вкладке сообщений SSMS отображается

Slot 0 Offset 0x60 Length 31

Record Type = GHOST_DATA_RECORD      Record Attributes =  NULL_BITMAP VARIABLE_COLUMNS
Record Size = 31                     
Memory Dump @0x000000001215A060

0000000000000000:   3c000c00 00000000 9ba20000 02000001 †<.......¢...... 
0000000000000010:   001f0053 006f0075 00720063 006500††††...S.o.u.r.c.e.  

Slot 0 Column 1 Offset 0x13 Length 12 Length (physical) 12
person Martin Smith    schedule 21.12.2013
comment
Вы правы, и теперь я смог воспроизвести HOLDLOCK требование, воссоздав рассматриваемую таблицу. Однако для меня остается загадкой, почему SELECTс UPDLOCK действительно берет блокировку U, когда я запускаю тест во второй раз, даже если я удаляю все из таблицы. В чем разница? - person Fabian Schmied; 21.12.2013
comment
@FabianSchmied - Что ж, если в таблице нет строк, она не может блокировать строку U! На каком ресурсе он находится и каково ваше определение таблицы, включая индексы? - person Martin Smith; 21.12.2013
comment
Блокировка U взята resource_type KEY. Таблица имеет кластеризованный индекс (первичный ключ) в столбце SourceId. Я добавил полный образец к вопросу выше. - person Fabian Schmied; 22.12.2013
comment
@FabianSchmied - Хорошо, я понимаю, что ты имеешь в виду. Должно быть, он блокирует призрачную запись. - person Martin Smith; 22.12.2013
comment
Когда строки удаляются, они остаются на странице и помечаются как фантомы. Они периодически очищаются другим процессом. Если вы TRUNCATE таблицу, это освободит последнюю страницу и будет таким же, как новая таблица. - person Martin Smith; 22.12.2013
comment
Если вы измените ПК на PRIMARY KEY(SourceId,LastCheckpoint), это больше не происходит. - person Martin Smith; 22.12.2013
comment
Спасибо, это очень хорошее объяснение. Однако эта блокировка записей о призраках вызвала у меня большое замешательство. - person Fabian Schmied; 22.12.2013
comment
@FabianSchmied - я знал, что это может произойти с блокировками диапазона из этой статьи. Не знал, что это может произойти и с замками с ключом. - person Martin Smith; 22.12.2013
comment
+1 За это хорошее объяснение и за сообщение MJ Swart. Вместо использования SELECT ... FROM ... HOLDLOCK... WHERE SourceId=@SourceID другим решением могут быть блокировки приложений: BEGIN TRANSACTION; DECLARE @res NVARCHAR(255); SET @res='@SourceId.' + CONVERT(NVARCHAR(...), @Source); sp_getapplock @Resource=@res, @LockMod='Exclusive'; ...;sp_releaseapplock @Resource=@res;. Аргумент: уменьшаются накладные расходы на блокировку (это зависит от того, сколько строк блокируется SELECT WITH HOLDLOCK). Я думаю, что если заблокировано слишком много записей, это может привести к эскалации блокировки. Может быть, я пессимист. - person Bogdan Sahlean; 22.12.2013