Оскільки ви використовуєте послідовність, ви можете використовувати ту саму функцію NEXT VALUE FOR, яку ви вже маєте у полі обмеження за замовчуванням у полі Id
первинного ключа, щоб генерувати нове Id
значення заздалегідь. Спочатку генерувати значення означає, що вам не потрібно турбуватися про відсутність SCOPE_IDENTITY
, а значить, для отримання нового значення вам не потрібно ні OUTPUT
пункту, ні додаткового SELECT
; у вас буде цінність до того, як ви зробите це INSERT
, і вам навіть не потрібно возитися з SET IDENTITY INSERT ON / OFF
:-)
Так що це піклується про частину загальної ситуації. Інша частина - це вирішення питання про одночасність двох процесів в той самий час, не знаходження існуючого рядка для такої самої рядки та продовження з INSERT
. Турбота полягає у тому, щоб уникнути порушення єдиного обмеження, яке могло б відбутися.
Одним із способів вирішення цих проблем з одночасністю є змушення цієї конкретної операції бути однопоточною. Це можна зробити за допомогою блокування програм (які працюють протягом сеансів). Незважаючи на те, що вони ефективні, вони можуть бути трохи важкими для такої ситуації, коли частота зіткнень, ймовірно, досить низька.
Інший спосіб впоратися зі зіткненнями - це визнати, що вони іноді трапляться, і впоратися з ними, а не намагатися їх уникати. Використовуючи TRY...CATCH
конструкцію, ви можете ефективно захопити конкретну помилку (в даному випадку: "унікальне порушення обмеження", Msg 2601) і повторно виконати значення SELECT
для отримання Id
значення, оскільки ми знаємо, що воно зараз існує завдяки знаходженню в CATCH
блоці саме з цим помилка. Інші помилки можна обробляти типово RAISERROR
/ RETURN
або THROW
способом.
Налаштування тесту: послідовність, таблиця та унікальний індекс
USE [tempdb];
CREATE SEQUENCE dbo.MagicNumber
AS INT
START WITH 1
INCREMENT BY 1;
CREATE TABLE dbo.NameLookup
(
[Id] INT NOT NULL
CONSTRAINT [PK_NameLookup] PRIMARY KEY CLUSTERED
CONSTRAINT [DF_NameLookup_Id] DEFAULT (NEXT VALUE FOR dbo.MagicNumber),
[ItemName] NVARCHAR(50) NOT NULL
);
CREATE UNIQUE NONCLUSTERED INDEX [UIX_NameLookup_ItemName]
ON dbo.NameLookup ([ItemName]);
GO
Налаштування тесту: збережена процедура
CREATE PROCEDURE dbo.GetOrInsertName
(
@SomeName NVARCHAR(50),
@ID INT OUTPUT,
@TestRaceCondition BIT = 0
)
AS
SET NOCOUNT ON;
BEGIN TRY
SELECT @ID = nl.[Id]
FROM dbo.NameLookup nl
WHERE nl.[ItemName] = @SomeName
AND @TestRaceCondition = 0;
IF (@ID IS NULL)
BEGIN
SET @ID = NEXT VALUE FOR dbo.MagicNumber;
INSERT INTO dbo.NameLookup ([Id], [ItemName])
VALUES (@ID, @SomeName);
END;
END TRY
BEGIN CATCH
IF (ERROR_NUMBER() = 2601) -- "Cannot insert duplicate key row in object"
BEGIN
SELECT @ID = nl.[Id]
FROM dbo.NameLookup nl
WHERE nl.[ItemName] = @SomeName;
END;
ELSE
BEGIN
;THROW; -- SQL Server 2012 or newer
/*
DECLARE @ErrorNumber INT = ERROR_NUMBER(),
@ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE();
RAISERROR(N'Msg %d: %s', 16, 1, @ErrorNumber, @ErrorMessage);
RETURN;
*/
END;
END CATCH;
GO
Тест
DECLARE @ItemID INT;
EXEC dbo.GetOrInsertName
@SomeName = N'test1',
@ID = @ItemID OUTPUT;
SELECT @ItemID AS [ItemID];
GO
DECLARE @ItemID INT;
EXEC dbo.GetOrInsertName
@SomeName = N'test1',
@ID = @ItemID OUTPUT,
@TestRaceCondition = 1;
SELECT @ItemID AS [ItemID];
GO
Питання від ОП
Чому це краще, ніж MERGE
? Чи не отримаю я таку ж функціональність без цього TRY
за допомогою WHERE NOT EXISTS
пункту?
MERGE
є різні "питання" (декілька посилань пов'язані у відповіді @ SqlZim, тому не потрібно дублювати цю інформацію тут). І в цьому підході немає додаткового блокування (менше суперечок), тому воно повинно бути кращим щодо одночасності. При такому підході ви ніколи не отримаєте Унікальне обмеження обмежень, все без будь-якого HOLDLOCK
тощо. Це майже гарантовано працює.
Обґрунтування такого підходу:
- Якщо у вас достатньо виконання цієї процедури, щоб вам потрібно було турбуватися про зіткнення, ви не хочете:
- зробіть більше кроків, ніж це необхідно
- утримуйте блокування на будь-яких ресурсах довше, ніж потрібно
- Оскільки зіткнення можуть статися лише при нових записах (нові записи, подані точно в той же час ), частота потрапляння в
CATCH
блок в першу чергу буде досить низькою. Більш сенсом є оптимізація коду, який буде запускати 99% часу замість коду, який запускатиме 1% часу (якщо тільки немає оптимізації обох, але це не так).
Коментар з відповіді @ SqlZim (наголос додано)
Я особисто вважаю за краще спробувати і адаптувати рішення, щоб уникнути цього, коли це можливо . У цьому випадку я не відчуваю, що використання замків із боку serializable
- це важкий підхід.
Я погодився б із цим першим реченням, якби воно було внесено до поправки до "і тоді, коли розумний". Тільки тому, що щось технічно можливо, не означає, що ситуація (тобто передбачуване використання) отримає від цього користь.
Проблема, яку я бачу при такому підході, полягає в тому, що він блокує більше, ніж пропонується. Важливо перечитати цитовану документацію про "серіалізацію", зокрема наступне (наголос додано):
- Інші транзакції не можуть вставляти нові рядки з ключовими значеннями, які потрапляли б у діапазон ключів, прочитаних будь-якими висловлюваннями в поточній транзакції до завершення поточної транзакції.
Тепер ось коментар у прикладі коду:
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
Оперативне слово є "дальність". Блокування, яке приймається, не є просто значенням @vName
, а точніше діапазоном, починаючи змісце, куди має перейти це нове значення (тобто між існуючими ключовими значеннями з того боку, де нове значення підходить), але не саме значення. Значить, інші процеси будуть заблоковані від вставки нових значень, залежно від значення, яке зараз шукається. Якщо пошук робиться у верхній частині діапазону, то вставлення всього, що могло б зайняти те саме положення, буде заблоковано. Наприклад, якщо існують значення "a", "b" і "d", то якщо один процес робить SELECT на "f", то вставити значення "g" або навіть "e" буде неможливо ( оскільки будь-який із них прийде одразу після "d"). Але вставити значення "c" буде можливо, оскільки воно не буде розміщене в "зарезервованому" діапазоні.
Наступний приклад повинен ілюструвати таку поведінку:
(На вкладці запиту (тобто сесія) №1)
INSERT INTO dbo.NameLookup ([ItemName]) VALUES (N'test5');
BEGIN TRAN;
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
WHERE ItemName = N'test8';
--ROLLBACK;
(На вкладці запиту (тобто сесія) №2)
EXEC dbo.NameLookup_getset_byName @vName = N'test4';
-- works just fine
EXEC dbo.NameLookup_getset_byName @vName = N'test9';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
EXEC dbo.NameLookup_getset_byName @vName = N'test7';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
EXEC dbo.NameLookup_getset_byName @vName = N's';
-- works just fine
EXEC dbo.NameLookup_getset_byName @vName = N'u';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
Так само, якщо значення "C" існує, а значення "A" вибирається (і, отже, блокується), то ви можете вставити значення "D", але не значення "B":
(На вкладці запиту (тобто сесія) №1)
INSERT INTO dbo.NameLookup ([ItemName]) VALUES (N'testC');
BEGIN TRAN
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
WHERE ItemName = N'testA';
--ROLLBACK;
(На вкладці запиту (тобто сесія) №2)
EXEC dbo.NameLookup_getset_byName @vName = N'testD';
-- works just fine
EXEC dbo.NameLookup_getset_byName @vName = N'testB';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
Якщо чесно, у моєму запропонованому підході, коли є виняток, у Журналі транзакцій буде 4 записи, які не будуть відбуватися при такому підході до "серіалізаційної транзакції". Але, як я вже говорив вище, якщо виняток трапляється 1% (або навіть 5%) часу, це набагато менше впливу, ніж набагато більш імовірний випадок первинних SELECT тимчасово блокуючих INSERT операцій.
Іншим, хоча і незначним, проблемою цього підходу "серіалізаційна транзакція + пункт OUTPUT" є те, що OUTPUT
стаття (при її нинішньому використанні) передає дані назад як набір результатів. Набір результатів вимагає більше накладних витрат (можливо, з обох сторін: у SQL Server для управління внутрішнім курсором, а в додатку рівня для управління об’єктом DataReader), ніж простого OUTPUT
параметра. З огляду на те, що ми маємо справу лише з одним скалярним значенням і що припущення є високою частотою виконання страт, то, можливо, додаткові надбавки набору результатів.
Хоча OUTPUT
пункт може бути використаний таким чином, щоб повернути OUTPUT
параметр, це вимагатиме додаткових кроків для створення тимчасової змінної таблиці або таблиці, а потім для вибору значення з цієї змінної таблиці / змінної таблиці в OUTPUT
параметр.
Подальше уточнення: відповідь на відповідь @ SqlZim (оновлена відповідь) на мою відповідь на відповідь @ SqlZim (в оригінальній відповіді) на мою заяву щодо одночасності та ефективності ;-)
Вибачте, якщо ця частина довга в тривалість, але в цьому моменті ми лише знизимось до нюансів двох підходів.
Я вважаю, що спосіб подання інформації може призвести до помилкових припущень щодо кількості блокувань, з якими можна було б стикатися при використанні serializable
в сценарії, представленому в оригінальному запитанні.
Так, я визнаю, що я упереджений, хоча справедливим:
- Неможливо, щоб людина не була упередженою, принаймні якоюсь мірою, і я намагаюся мінімізувати це,
- Наведений приклад був спрощеним, але це було для ілюстративних цілей, щоб передати поведінку, не надто ускладнюючи її. Передбачаючи надмірну частоту не передбачалося, хоча я розумію, що я також явно не заявляв інакше, і це можна вважати таким, що означає більшу проблему, ніж насправді існує. Спробую уточнити це нижче.
- Я також включив приклад блокування діапазону між двома існуючими ключами (другий набір блоків "Вкладка запитів 1" та "Вкладка запитів 2").
- Я знайшов (і добровільно) "приховану вартість" мого підходу, що бути чотирма додатковими записами Tran Log щоразу, коли
INSERT
не вдається через порушення єдиного обмеження. Я не бачив того, що згадується в жодній з інших відповідей / дописів.
Що стосується підходу "JFDI" @ gbn, повідомлення "Потворного прагматизму для виграшу" Майкла Дж. Сватара та коментаря Аарона Бертрана до повідомлення Майкла (щодо його тестів, що показують, які сценарії знизили ефективність), а також ваш коментар щодо вашої "адаптації Майкла Дж. . Адаптація Стюарта в процедурі "Спробуйте зловити JFDI" @ gbn ", вказуючи:
Якщо ви вводите нові значення частіше, ніж вибираєте наявні значення, це може бути ефективнішим, ніж версія @ srutzky. Інакше я віддаю перевагу версії @ srutzky над цією.
Стосовно цієї дискусії gbn / Michael / Aaron, що стосується підходу "JFDI", було б неправильним прирівнювати мою пропозицію до підходу gbn "JFDI". Зважаючи на характер операції "Отримати або вставити", явна потреба зробити це, SELECT
щоб отримати ID
значення для існуючих записів. Цей SELECT виконує функцію IF EXISTS
перевірки, що робить цей підхід більш рівним рівню варіації тестів "Аарона" "CheckTryCatch". Переписаний код Майкла (і ваша остаточна адаптація Майклівської адаптації) також включає в себе те, WHERE NOT EXISTS
щоб зробити цю ж перевірку спочатку. Отже, моя пропозиція (разом із остаточним кодом Майкла та вашою адаптацією його остаточного коду) насправді не вдарить до CATCH
блоку все так часто. Це можуть бути лише ситуації, коли два сеанси,ItemName
INSERT...SELECT
в той самий момент, такий, що обидва сеанси отримують "істину" для WHERE NOT EXISTS
точно в той самий момент, і таким чином обидва намагаються зробити той INSERT
самий момент. Цей дуже специфічний сценарій трапляється набагато рідше, ніж або вибір існуючого ItemName
або вставлення нового, ItemName
коли жоден інший процес не намагається зробити це в той самий момент .
З ВСІМ ПОЖИВАННЯМИ У РОКУ: Чому я віддаю перевагу своєму підходу?
Спочатку давайте розглянемо, що блокування відбувається в "серіалізаційному" підході. Як згадувалося вище, "діапазон", який заблокується, залежить від існуючих ключових значень з того боку, де нове значення ключа буде відповідати. Початком або закінченням діапазону також може бути початок або кінець індексу, відповідно, якщо в цьому напрямку немає існуючого ключового значення. Припустимо, у нас є наступний індекс і ключі ( ^
представляє початок індексу, а $
представляє його кінець):
Range #: |--- 1 ---|--- 2 ---|--- 3 ---|--- 4 ---|
Key Value: ^ C F J $
Якщо сеанс 55 намагається вставити ключове значення:
A
, тоді діапазон №1 (від ^
до C
) блокується: сеанс 56 не може вставити значення B
, навіть якщо унікальне та дійсне (поки що). Але сесія 56 може вставити значення D
, G
і M
.
D
, тоді діапазон №2 (від C
до F
) блокується: сеанс 56 не може вставити значення E
(поки). Але сесія 56 може вставити значення A
, G
і M
.
M
, тоді діапазон №4 (від J
до $
) блокується: сеанс 56 не може вставити значення X
(поки). Але сесія 56 може вставити значення A
, D
і G
.
Коли більше ключових значень додаються, діапазони між ключовими значеннями стають вужчими, отже, зменшується ймовірність / частота декількох значень, що вставляються при одночасному бою за один і той же діапазон. Справді, це не основна проблема, і на щастя, здається, це проблема, яка фактично зменшується з часом.
Проблема з моїм підходом була описана вище: це відбувається лише тоді, коли два сеанси намагаються одночасно вставити одне і те ж значення ключа. У цьому відношенні зводиться до того, що більша ймовірність того, що трапиться: одночасно намагаються спробувати два різні, але близькі, ключові значення або одночасно спробувати одне і те ж ключове значення? Я припускаю, що відповідь полягає в структурі додатка, який робить вставки, але, загалом кажучи, я вважаю, що більш ймовірно, що два різні значення, які просто мають спільний діапазон, вставляються. Але єдиним способом дійсно знати було б тестування обох на системі ОП.
Далі розглянемо два сценарії та як кожен підхід обробляє їх:
Усі запити щодо унікальних ключових значень:
У цьому випадку CATCH
блок моєї пропозиції ніколи не вводиться, отже, не виникає "проблема" (тобто 4 записи журналу транзиту та час, необхідний для цього). Але, при "серіалізаційному" підході, навіть якщо всі вставки унікальні, завжди буде певний потенціал для блокування інших вставок у тому ж діапазоні (хоча і не дуже довго).
Висока частота запитів на одне і те ж значення ключа:
У цьому випадку - дуже низька ступінь унікальності щодо вхідних запитів щодо неіснуючих ключових значень - CATCH
блок моєї пропозиції буде регулярно вводитися. Ефект цього полягатиме в тому, що кожен невдалий вкладиш повинен буде автоматично відкатати і записати 4 записи в Журнал транзакцій, що кожного разу є незначним результатом. Але загальна операція ніколи не повинна провалюватися (принаймні, не через це).
(Виникла проблема з попередньою версією "оновленого" підходу, яка дозволила йому страждати від тупиків. updlock
Додано підказку для вирішення цього питання, і він більше не отримує тупикових ситуацій.)АЛЕ, при "серіалізаційному" підході (навіть оновленій, оптимізованій версії) операція прийде в глухий кут. Чому? Тому що serializable
поведінка лише перешкоджає INSERT
операціям у діапазоні, який було прочитано і, отже, заблоковано; це не перешкоджає SELECT
операціям у цьому діапазоні.
У serializable
цьому випадку підхід, мабуть, не матиме додаткових накладних витрат і може бути трохи кращим, ніж те, що я пропоную.
Як і у багатьох / більшості дискусій щодо продуктивності, через те, що існує стільки факторів, які можуть вплинути на результат, єдиний спосіб по-справжньому відчути, як щось буде виконувати, - це спробувати його в цільовому середовищі, де воно буде працювати. Тоді це вже не буде питанням :).