시퀀스를 사용하고 있으므로 기본 키 필드 의 기본 제약 조건에있는 것과 동일한 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
OP로부터의 질문
왜 이것보다 낫 MERGE
습니까? 절 TRY
을 사용 하지 않으면 동일한 기능 을 사용할 수 WHERE NOT EXISTS
없습니까?
MERGE
다양한 "문제"가 있습니다 (여러 참조는 @SqlZim의 답변에 연결되어 있으므로 여기에서 해당 정보를 복제 할 필요가 없습니다). 그리고이 접근 방식에는 추가적인 잠금이 없으므로 (경쟁이 적음) 동시성에 더 좋습니다. 이 접근 방식에서는 고유 제약 조건 위반이 전혀 발생하지 않으며 모두 HOLDLOCK
등이 없습니다 . 작동하는 것이 거의 보장됩니다.
이 접근 방식의 이유는 다음과 같습니다.
- 충돌에 대해 걱정할 필요가있을 정도로이 절차를 충분히 실행 한 경우 다음을 원하지 않습니다.
- 필요한 것보다 더 많은 조치를 취하십시오
- 필요 이상으로 모든 자원에 대한 잠금 유지
- 새로운 항목 ( 정확히 동시에 제출 된 새로운 항목)에서만 충돌이 발생할 수 있기 때문에
CATCH
처음에 블록 으로 떨어지는 빈도는 매우 낮습니다. 1 %의 시간을 실행하는 코드 대신 99 %의 시간을 실행하는 코드를 최적화하는 것이 더 합리적입니다.
@SqlZim의 답변에 대한 의견 (강조 추가)
나는 개인적으로 가능한 한 그렇게하지 않도록 솔루션을 시도하고 조정하는 것을 선호한다 . 이 경우 잠금 장치를 사용하는 serializable
것이 어려운 방법 이라고 생각하지 않으며 높은 동시성을 잘 처리 할 것이라고 확신합니다.
나는 "그리고 신중할 때"라고 개정하기 위해이 첫 문장에 동의 할 것입니다. 기술적으로 가능한 것이 있다고해서 상황 (예 : 의도 된 사용 사례)이 혜택을받을 것이라는 의미는 아닙니다.
이 접근 방식에서 볼 수있는 문제는 제안 된 것보다 더 많이 잠겨 있다는 것입니다. "직렬화 가능"에 대해 인용 된 문서, 특히 다음을 강조하여 읽어야합니다 (강조 추가).
- 다른 트랜잭션은 현재 트랜잭션이 완료 될 때까지 현재 트랜잭션의 명령문이 읽은 키 범위에 해당하는 키 값을 가진 새 행을 삽입 할 수 없습니다 .
다음은 예제 코드의 주석입니다.
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
작동 단어에는 "범위"가 있습니다. 잠금은의 값에만 국한된 @vName
것이 아니라보다 정확하게 시작 되는 범위입니다 .이 새로운 값이 들어가야하는 위치 (즉, 새로운 값이 맞는 곳의 기존 키 값 사이)이지만 값 자체는 아닙니다. 즉, 현재 조회중인 값에 따라 다른 프로세스가 새 값을 삽입하지 못하도록 차단됩니다. 조회가 범위 상단에서 수행되는 경우 동일한 위치를 차지할 수있는 것을 삽입하면 차단됩니다. 예를 들어, "a", "b"및 "d"값이 존재하는 경우 한 프로세스가 "f"에서 SELECT를 수행하는 경우 "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
(현재 사용중인) 절이 데이터를 결과 세트로 다시 보내는 것입니다. 결과 집합에는 간단한 OUTPUT
매개 변수 보다 더 많은 오버 헤드 (아마도 양쪽에서 : 내부 커서를 관리하는 SQL Server 및 DataReader 개체를 관리하는 응용 프로그램 계층)가 더 필요합니다 . 단일 스칼라 값만 처리하고 있고 실행 빈도가 높다고 가정하면 결과 집합의 추가 오버 헤드가 더해질 수 있습니다.
이 OUTPUT
절은 OUTPUT
매개 변수 를 리턴하는 방식으로 사용될 수 있지만 임시 테이블 또는 테이블 변수를 작성하고 해당 임시 테이블 / 테이블 변수의 값을 OUTPUT
매개 변수로 선택하려면 추가 단계가 필요합니다 .
추가 설명 : @SqlZim의 응답에 대한 응답 (업데이트 된 답변) @SqlZim의 응답에 대한 응답 (원래 답변으로) 동시성 및 성능에 관한 내 진술에 대한 ;-)
이 부분이 길이가 길면 미안하지만이 시점에서 우리는 두 가지 접근 방식의 뉘앙스에 달려 있습니다.
나는 정보가 제시된 방식 serializable
이 원래의 질문에 제시된 시나리오에서 사용할 때 발생할 수있는 잠금의 양에 대한 잘못된 가정으로 이어질 수 있다고 생각합니다 .
예, 공정하지만 편견임을 인정합니다.
- 인간이 최소한 어느 정도 편견을 갖지 못하는 것은 불가능하며 최소한 최소한도를 유지하려고 노력합니다.
- 주어진 예는 단순하지만, 과도하게 복잡하게하지 않으면 서 행동을 전달하기위한 예시적인 목적으로 사용되었습니다. 과도한 빈도를 암시하지는 않았지만 명시 적으로 달리 명시하지 않았으며 실제로 존재하는 것보다 더 큰 문제를 암시하는 것으로 읽을 수 있음을 이해합니다. 나는 아래를 명확히하려고 노력할 것이다.
- 또한 두 개의 기존 키 (두 번째 "쿼리 탭 1"및 "쿼리 탭 2"블록) 사이의 범위를 잠그는 예도 포함 시켰습니다.
- 나는
INSERT
고유 제약 조건 위반으로 인해 실패 할 때마다 4 개의 추가 Tran Log 항목이 있다는 접근 방식의 "숨겨진 비용"을 찾았습니다 . 나는 다른 답변 / 게시물에서 언급 한 것을 보지 못했습니다.
@gbn의 "JFDI"접근 방식과 관련하여 Michael J. Swart의 "The Ugly Pragmatism For The Win"게시물과 Aaron Bertrand의 Michael 게시물에 대한 의견 (어떤 시나리오에서 성능이 저하되었는지를 보여주는 테스트와 관련)과 "Michael J의 적응에 대한 귀하의 의견 @gbn의 Try Catch JFDI 절차에 대한 Stewart의 적응 ":
기존 값을 선택하는 것보다 새 값을 더 자주 삽입하는 경우 @srutzky 버전보다 성능이 우수 할 수 있습니다. 그렇지 않으면이 버전보다 @srutzky 버전을 선호합니다.
"JFDI"접근법과 관련된 gbn / Michael / Aaron 토론과 관련하여 gbn의 "JFDI"접근법에 대한 제안을 동일시하는 것은 올바르지 않습니다. " SELECT
가져 오기 또는 삽입"작업의 특성으로 인해 ID
기존 레코드 의 값 을 가져 오려면 명시 적으로 수행해야 합니다. 이 SELECT는 IF EXISTS
검사 역할을 하므로이 검사 방식은 Aaron 테스트의 "CheckTryCatch"변형과 동일합니다. Michael의 재 작성 코드 (Michael의 적응에 대한 최종 적응)에는 WHERE NOT EXISTS
먼저 동일한 점검을 수행하는 것이 포함됩니다 . 따라서 내 제안 (Michael의 최종 코드 및 최종 코드의 적응과 함께)은 실제로 그 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
.
더 많은 키 값이 추가됨에 따라 키 값 사이의 범위가 좁아 지므로 같은 범위에서 싸우는 동시에 여러 값이 삽입 될 확률 / 빈도가 줄어 듭니다. 분명히 이것은 큰 문제 가 아니며 , 다행히 시간이 지남에 따라 실제로 감소하는 문제인 것처럼 보입니다.
내 접근 방식의 문제는 위에서 설명했습니다. 두 세션이 동일한 키 값을 동시에 삽입하려고 할 때만 발생합니다 . 이와 관련하여 그것은 일어날 가능성이 더 높은 것입니다. 두 개의 서로 다르지만 가까운 키 값이 동시에 시도되거나 동일한 키 값이 동시에 시도됩니까? 대답은 삽입을하는 응용 프로그램의 구조에 있다고 생각하지만 일반적으로 말하자면 동일한 범위를 공유하는 두 가지 다른 값이 삽입 될 가능성이 더 높다고 가정합니다. 그러나 실제로 아는 유일한 방법은 OP 시스템에서 두 가지를 모두 테스트하는 것입니다.
다음으로 두 가지 시나리오와 각 접근 방식이 시나리오를 처리하는 방법을 살펴 보겠습니다.
고유 키 값에 대한 모든 요청 :
이 경우 CATCH
제 제안 의 블록은 절대 입력되지 않으므로 "문제"(예 : 4 개의 트랜 로그 항목과 처리 시간)가 입력되지 않습니다. 그러나 "직렬화 가능"방식에서는 모든 인서트가 독창적 임에도 불구하고 항상 같은 범위에서 다른 인서트를 막을 가능성이 항상 있습니다 (매우 길지는 않지만).
동일한 키 값에 대한 요청 빈도가 높은 빈도 :
이 경우, 존재하지 않는 키 값에 대한 수신 요청 측면에서 매우 낮은 수준의 고유성으로 인해 CATCH
제안 의 블록이 정기적으로 입력됩니다. 이로 인해 실패한 각 삽입은 자동 롤백하고 4 개의 항목을 트랜잭션 로그에 기록해야합니다. 이는 매번 약간의 성능 저하입니다. 그러나 전체 작업이 실패해서는 안됩니다 (적어도 이것으로 인한 것이 아닙니다).
이전 버전의 "업데이트 된"접근 방식에 교착 상태가 발생할 수있는 문제가있었습니다.이를 해결하기위한 updlock
힌트가 추가되었으며 더 이상 교착 상태가 발생하지 않습니다.그러나 "직렬화 가능"접근 방식 (업데이트되고 최적화 된 버전조차도)에서는 작업이 교착 상태가됩니다. 왜? 이 serializable
동작 INSERT
은 읽혀 져서 잠긴 범위의 작업 만 방지 하기 때문에 ; SELECT
해당 범위에서 작업을 방해하지는 않습니다 .
serializable
접근 방식은,이 경우, 추가 오버 헤드가없는 것 같다, 내가 제안하고있는 것보다 약간 더 수행 할 수 있습니다.
성능에 관한 많은 / 대부분의 토론과 마찬가지로 결과에 영향을 줄 수있는 요소가 너무 많기 때문에 실제로 어떻게 수행되는지에 대한 감각을 가질 수있는 유일한 방법은 대상 환경에서 실행하는 것입니다. 그 시점에서 그것은 의견의 문제가되지 않습니다 :).