Atomic UPSERT в SQL Server 2005

Каков правильный шаблон для атомного "UPSERT" (UPDATE, где существует, INSERT в противном случае) в SQL Server 2005?

Я вижу много кода на SO (например, см. Проверьте, существует ли строка, иначе вставьте) со следующим двухчастным шаблоном:

UPDATE ...
FROM ...
WHERE <condition>
-- race condition risk here
IF @@ROWCOUNT = 0
  INSERT ...

или

IF (SELECT COUNT(*) FROM ... WHERE <condition>) = 0
  -- race condition risk here
  INSERT ...
ELSE
  UPDATE ...

где < условие > будет оценкой естественных ключей. Ни один из вышеперечисленных подходов, похоже, не справляется с concurrency. Если у меня не может быть двух строк с одним и тем же естественным ключом, кажется, что все перечисленные выше риски вставляют строки с одинаковыми естественными ключами в сценариях условий гонки.

Я использую следующий подход, но я удивлен, что не вижу его в ответах людей, поэтому мне интересно, что с ним не так:

INSERT INTO <table>
SELECT <natural keys>, <other stuff...>
FROM <table>
WHERE NOT EXISTS
   -- race condition risk here?
   ( SELECT 1 FROM <table> WHERE <natural keys> )

UPDATE ...
WHERE <natural keys>

Обратите внимание, что упомянутое здесь условие гонки отличается от условий предыдущего кода. В более раннем коде проблема заключалась в phantom чтениях (строки вставляются между UPDATE/IF или между SELECT/INSERT другим сеансом). В приведенном выше коде условие гонки связано с DELETE. Возможно ли, чтобы соответствующая строка была удалена другим сеансом ПОСЛЕ того, как выполняется (WHERE NOT EXISTS), но до выполнения INSERT? Неясно, где WHERE NOT EXISTS помещает блокировку на что-либо в связи с UPDATE.

Является ли это атомом? Я не могу найти, где это будет документировано в документации SQL Server.

EDIT: Я понимаю, что это можно сделать с транзакциями, но я думаю, что мне нужно установить уровень транзакции SERIALIZABLE, чтобы избежать проблемы с чтением phantom? Неужели это слишком сложно для такой общей проблемы?

Ответ 1

INSERT INTO <table>
SELECT <natural keys>, <other stuff...>
FROM <table>
WHERE NOT EXISTS
   -- race condition risk here?
   ( SELECT 1 FROM <table> WHERE <natural keys> )

UPDATE ...
WHERE <natural keys>
  • в первом INSERT есть условие гонки. Ключ не может существовать во время внутреннего запроса SELECT, но существует во время INSERT, что приводит к нарушению ключа.
  • между INSERT и UPDATE существует условие гонки. Ключ может существовать, когда он проверяется во внутреннем запросе INSERT, но ушел к моменту окончания UPDATE.

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

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

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

Помимо правильности, этот шаблон также оптимален для скорости: эффективнее пытаться вставлять и обрабатывать исключение, чем делать ложные блокировки. Блокировки означают чтение логических страниц (что может означать чтение физических страниц), а IO (даже логический) дороже SEH.

Обновить @Peter

Почему не один оператор "атомный"? Скажем, у нас есть тривиальная таблица:

create table Test (id int primary key);

Теперь, если бы я запускал этот единственный оператор из двух потоков, в цикле он был бы "атомарным", как вы говорите, не может существовать условие гонки:

  insert into Test (id)
    select top (1) id
    from Numbers n
    where not exists (select id from Test where id = n.id); 

Однако за пару секунд происходит нарушение первичного ключа:

Msg 2627, уровень 14, состояние 1, строка 4
Нарушение ограничения PRIMARY KEY "PK__Test__24927208". Невозможно вставить дубликат ключа в объект "dbo.Test".

Почему? Вы правы в том, что в плане запросов SQL будет "правильная вещь" на DELETE ... FROM ... JOIN, на WITH cte AS (SELECT...FROM ) DELETE FROM cte и во многих других случаях. Но в этих случаях есть решающее различие: "подзапрос" относится к цели операции update или удалить. Для таких случаев в плане запроса действительно будет использоваться соответствующая блокировка, на самом деле это поведение имеет решающее значение в некоторых случаях, например, при реализации очередей Использование таблиц как очередей.

Но в исходном вопросе, как и в моем примере, подзапрос рассматривается оптимизатором запросов как подзапрос в запросе, а не как специальный запрос типа "проверка на обновление", для которого требуется специальная защита от блокировки. В результате выполнение подзапроса может наблюдаться как отдельная операция созерцательного наблюдателя, тем самым нарушая "атомное" поведение оператора. Если не будет предпринята специальная предосторожность, несколько потоков могут попытаться вставить одно и то же значение, оба убеждены, что они проверили, а значение еще не существует. Только один может преуспеть, другой попадет в нарушение ПК. Что и требовалось доказать.

Ответ 2

При проверке существования строки передавайте подсказки, блокировки, блокировки, блокировки. Holdlock гарантирует, что все вставки сериализованы; rowlock разрешает одновременное обновление существующих строк.

Обновления могут по-прежнему блокироваться, если ваш PK является bigint, поскольку внутреннее хеширование вырождено для 64-битных значений.

begin tran -- default read committed isolation level is fine

if not exists (select * from <table> with (updlock, rowlock, holdlock) where <PK = ...>
    -- insert
else
    -- update

commit

Ответ 3

РЕДАКТИРОВАТЬ: Remus правильный, условная вставка w/where предложение не гарантирует согласованного состояния между коррелированным подзапросом и таблицей.

Возможно, правильные подсказки таблицы могут привести к согласованному состоянию. INSERT <table> WITH (TABLOCKX, HOLDLOCK), похоже, работает, но я понятия не имею, является ли это оптимальным уровнем блокировки для условной вставки.

В тривиальном тесте, таком как описанный Ремус, TABLOCKX, HOLDLOCK показал ~ 5x объем вставки без табличных подсказок и без ошибок PK или курса.

ОРИГИНАЛЬНЫЙ ОТВЕТ, НЕПРАВИЛЬНЫЙ:

Является ли это атомом?

Да, условная вставка w/where является атомарной, а ваша форма INSERT ... WHERE NOT EXISTS() ... UPDATE - это правильный способ выполнить UPSERT.

Я бы добавил IF @@ROWCOUNT = 0 между INSERT и UPDATE:

INSERT INTO <table>
SELECT <natural keys>, <other stuff...>
WHERE NOT EXISTS
   -- no race condition here
   ( SELECT 1 FROM <table> WHERE <natural keys> )

IF @@ROWCOUNT = 0 BEGIN
  UPDATE ...
  WHERE <natural keys>
END

Одиночные утверждения всегда выполняются внутри транзакции, либо их собственные (autocommit, и неявно) или вместе с другими операторами (явным).

Ответ 4

Один трюк, который я видел, - это попробовать INSERT, и если он не сработает, выполните UPDATE.