Como você está usando uma Sequência, pode usar a mesma função NEXT VALUE FOR - que você já possui em uma restrição padrão no Id
campo Chave primária - para gerar um novo Id
valor antecipadamente. Gerar o valor primeiro significa que você não precisa se preocupar em não ter SCOPE_IDENTITY
, o que significa que você não precisa da OUTPUT
cláusula ou de fazer um adicional SELECT
para obter o novo valor; você terá o valor antes de fazer o mesmo INSERT
e nem precisará mexer com SET IDENTITY INSERT ON / OFF
:-)
Então, isso cuida de parte da situação geral. A outra parte é lidar com o problema de simultaneidade de dois processos, ao mesmo tempo, sem localizar uma linha existente para a mesma seqüência exata e prosseguir com o INSERT
. A preocupação é evitar a violação de restrição exclusiva que ocorreria.
Uma maneira de lidar com esses tipos de problemas de simultaneidade é forçar essa operação específica a ser encadeada única. A maneira de fazer isso é usando bloqueios de aplicativos (que funcionam entre sessões). Embora eficazes, eles podem ser um pouco pesados para uma situação como essa em que a frequência de colisões é provavelmente bastante baixa.
A outra maneira de lidar com as colisões é aceitar que elas às vezes ocorrem e lidar com elas, em vez de tentar evitá-las. Usando a TRY...CATCH
construção, você pode efetivamente interceptar um erro específico (neste caso: "violação de restrição exclusiva", Msg 2601) e executar novamente o SELECT
para obter o Id
valor, pois sabemos que ele existe agora por estar no CATCH
bloco com esse particular erro. Outros erros podem ser tratados da maneira típica RAISERROR
/ RETURN
ou THROW
.
Configuração de teste: sequência, tabela e índice exclusivo
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
Configuração de teste: procedimento armazenado
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
O teste
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
Pergunta do OP
Por que isso é melhor que o MERGE
? Não terei a mesma funcionalidade sem TRY
usar a WHERE NOT EXISTS
cláusula?
MERGE
tem vários "problemas" (várias referências estão vinculadas na resposta do @ SqlZim, portanto, não é necessário duplicar essa informação aqui). E, como não há bloqueio adicional nessa abordagem (menos contenção), deve ser melhor em simultaneidade. Nesta abordagem, você nunca terá uma violação de restrição exclusiva, tudo sem nenhuma HOLDLOCK
, etc. É praticamente garantido que funcione.
O raciocínio por trás dessa abordagem é:
- Se você tiver execuções suficientes desse procedimento para se preocupar com colisões, não será necessário:
- tome mais medidas do que o necessário
- reter bloqueios em qualquer recurso por mais tempo do que o necessário
- Como as colisões só podem ocorrer com novas entradas (novas entradas enviadas exatamente ao mesmo tempo ), a frequência de queda no
CATCH
bloco em primeiro lugar será bem baixa. Faz mais sentido otimizar o código que será executado 99% do tempo, em vez do código que será executado 1% do tempo (a menos que não haja custo para otimizar ambos, mas esse não é o caso aqui).
Comentário da resposta de @ SqlZim (ênfase adicionada)
Pessoalmente, prefiro tentar adaptar uma solução para evitar fazer isso sempre que possível . Nesse caso, não acho que o uso dos bloqueios serializable
seja uma abordagem pesada, e eu estaria confiante de que lidaria bem com alta simultaneidade.
Eu concordaria com esta primeira frase se ela fosse alterada para indicar "e _quando prudente". Só porque algo é tecnicamente possível, não significa que a situação (ou seja, caso de uso pretendido) seria beneficiada por ela.
O problema que vejo com essa abordagem é que ela bloqueia mais do que o que está sendo sugerido. É importante reler a documentação citada em "serializable", especificamente o seguinte (ênfase adicionada):
- Outras transações não podem inserir novas linhas com valores de chave que se enquadram no intervalo de chaves lidas por quaisquer instruções na transação atual até que a transação atual seja concluída.
Agora, aqui está o comentário no código de exemplo:
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
A palavra operativa existe "alcance". O bloqueio que está sendo realizado não está apenas no valor @vName
, mas com mais precisão, um intervalo que começa emo local onde esse novo valor deve ir (ou seja, entre os valores-chave existentes em ambos os lados de onde o novo valor se encaixa), mas não o valor em si. Ou seja, outros processos serão impedidos de inserir novos valores, dependendo dos valores que estão sendo pesquisados no momento. Se a pesquisa estiver sendo feita na parte superior do intervalo, a inserção de qualquer coisa que possa ocupar a mesma posição será bloqueada. Por exemplo, se os valores "a", "b" e "d" existirem, se um processo estiver executando o SELECT em "f", não será possível inserir os valores "g" ou mesmo "e" ( já que qualquer um desses virá imediatamente após "d"). Mas, a inserção de um valor de "c" será possível, pois não seria colocado no intervalo "reservado".
O exemplo a seguir deve ilustrar esse comportamento:
(Na guia de consulta (ou seja, Sessão) nº 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;
(Na guia de consulta (ou seja, Sessão) nº 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
Da mesma forma, se o valor "C" existir e o valor "A" estiver sendo selecionado (e, portanto, bloqueado), você poderá inserir um valor de "D", mas não um valor de "B":
(Na guia de consulta (ou seja, Sessão) nº 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;
(Na guia de consulta (ou seja, Sessão) nº 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
Para ser justo, na minha abordagem sugerida, quando houver uma exceção, haverá 4 entradas no log de transações que não acontecerão nessa abordagem de "transação serializável". MAS, como eu disse acima, se a exceção ocorrer 1% (ou mesmo 5%) das vezes, isso será muito menos impactante do que o caso muito mais provável do SELECT inicial que bloqueia temporariamente as operações INSERT.
Outro problema, ainda que menor, com essa abordagem "transação serializável + cláusula OUTPUT" é que a OUTPUT
cláusula (em seu uso atual) envia os dados de volta como um conjunto de resultados. Um conjunto de resultados requer mais sobrecarga (provavelmente nos dois lados: no SQL Server para gerenciar o cursor interno e na camada de aplicativo para gerenciar o objeto DataReader) do que um simplesOUTPUT
parâmetro . Dado que estamos lidando apenas com um único valor escalar e que a suposição é uma alta frequência de execuções, essa sobrecarga extra do conjunto de resultados provavelmente aumenta.
Embora a OUTPUT
cláusula possa ser usada de maneira a retornar um OUTPUT
parâmetro, isso exigiria etapas adicionais para criar uma tabela ou variável de tabela temporária e, em seguida, selecionar o valor dessa variável de tabela / tabela temporária no OUTPUT
parâmetro
Esclarecimentos adicionais: Resposta à resposta de @ SqlZim (resposta atualizada) à minha Resposta à resposta de @ SqlZim (na resposta original) à minha declaração sobre concorrência e desempenho ;-)
Desculpe se esta parte é um pouquinho longa, mas neste momento estamos apenas nas nuances das duas abordagens.
Acredito que a maneira como as informações são apresentadas pode levar a falsas suposições sobre a quantidade de bloqueios que se poderia esperar ao usar serializable
no cenário, conforme apresentado na pergunta original.
Sim, admito que sou tendencioso, embora seja justo:
- É impossível para um ser humano não ser tendencioso, pelo menos em algum grau, e eu tento mantê-lo no mínimo,
- O exemplo dado foi simplista, mas foi para fins ilustrativos transmitir o comportamento sem complicar demais. Implicar frequência excessiva não era intencional, embora eu entenda que também não afirmei explicitamente o contrário, e isso pode ser interpretado como um problema maior do que realmente existe. Vou tentar esclarecer isso abaixo.
- Também incluí um exemplo de bloqueio de um intervalo entre duas chaves existentes (o segundo conjunto de blocos "Query tab 1" e "Query tab 2").
- Eu encontrei (e voluntariamente) o "custo oculto" da minha abordagem, sendo as quatro entradas extras do Tran Log cada vez que a
INSERT
falha ocorre devido a uma violação de restrição exclusiva. Eu não vi isso mencionado em nenhuma das outras respostas / postagens.
Com relação à abordagem "JFDI" do @ gbn, o post "Ugly Pragmatism For The Win" de Michael J. Swart e o comentário de Aaron Bertrand no post de Michael (sobre seus testes mostrando quais cenários tiveram desempenho reduzido), e seu comentário sobre a "adaptação de Michael J" A adaptação de Stewart do procedimento Try Catch JFDI da @ gbn "afirmando:
Se você estiver inserindo novos valores com mais frequência do que selecionando valores existentes, isso pode ser mais eficiente que a versão do @ srutzky. Caso contrário, eu preferiria a versão do @ srutzky a esta.
Com relação à discussão do gbn / Michael / Aaron relacionada à abordagem "JFDI", seria incorreto equiparar minha sugestão à abordagem "JFDI" do gbn. Devido à natureza da operação "Obter ou Inserir", há uma necessidade explícita de fazer isso SELECT
para obter o ID
valor dos registros existentes. Esse SELECT atua como IF EXISTS
verificação, o que torna essa abordagem mais igual à variação "CheckTryCatch" dos testes de Aaron. O código reescrito de Michael (e sua adaptação final da adaptação de Michael) também inclui um teste WHERE NOT EXISTS
para fazer a mesma verificação primeiro. Portanto, minha sugestão (junto com o código final de Michael e sua adaptação do código final) não chega a CATCH
esse ponto com tanta frequência. Só poderia ser situações em que duas sessões,ItemName
INSERT...SELECT
exatamente no mesmo momento, de modo que ambas as sessões recebam um "verdadeiro" para WHERE NOT EXISTS
o exato momento e, portanto, ambas tentam fazer INSERT
exatamente no mesmo momento. Esse cenário muito específico acontece com muito menos frequência do que selecionar um existente ItemName
ou inserir um novo ItemName
quando nenhum outro processo está tentando fazê-lo no mesmo momento .
COM TODOS OS ACIMA EM MENTE: Por que prefiro minha abordagem?
Primeiro, vejamos qual bloqueio ocorre na abordagem "serializável". Como mencionado acima, o "intervalo" bloqueado depende dos valores da chave existentes em ambos os lados de onde o novo valor da chave se ajustaria. O início ou o final do intervalo também pode ser o início ou o final do índice, respectivamente, se não houver um valor de chave existente nessa direção. Suponha que temos o seguinte índice e chaves ( ^
representa o início do índice e $
o final dele):
Range #: |--- 1 ---|--- 2 ---|--- 3 ---|--- 4 ---|
Key Value: ^ C F J $
Se a sessão 55 tentar inserir um valor-chave de:
A
, o intervalo # 1 (de ^
a C
) está bloqueado: a sessão 56 não pode inserir um valor de B
, mesmo que único e válido (ainda). Mas sessão 56 pode inserir valores de D
, G
e M
.
D
, o intervalo # 2 (de C
a F
) está bloqueado: a sessão 56 não pode inserir um valor de E
(ainda). Mas sessão 56 pode inserir valores de A
, G
e M
.
M
, o intervalo # 4 (de J
a $
) está bloqueado: a sessão 56 não pode inserir um valor de X
(ainda). Mas sessão 56 pode inserir valores de A
, D
e G
.
À medida que mais valores-chave são adicionados, os intervalos entre os valores-chave se tornam mais estreitos, reduzindo assim a probabilidade / frequência de vários valores serem inseridos ao mesmo tempo, lutando pelo mesmo intervalo. É certo que este não é um problema grave e, felizmente, parece ser um problema que realmente diminui com o tempo.
O problema com minha abordagem foi descrito acima: só acontece quando duas sessões tentam inserir o mesmo valor de chave ao mesmo tempo. A esse respeito, resume-se a qual tem a maior probabilidade de acontecer: dois valores-chave diferentes, mas próximos, são tentados ao mesmo tempo ou o mesmo valor-chave é tentado ao mesmo tempo? Suponho que a resposta esteja na estrutura do aplicativo que faz as inserções, mas, em geral, eu diria que é mais provável que dois valores diferentes que compartilham o mesmo intervalo estejam sendo inseridos. Mas a única maneira de realmente saber seria testar os dois no sistema de OPs.
Em seguida, vamos considerar dois cenários e como cada abordagem os trata:
Todas as solicitações são de valores-chave exclusivos:
Nesse caso, o CATCH
bloco na minha sugestão nunca é inserido, portanto, não há "problema" (ou seja, quatro entradas de log e o tempo necessário para fazer isso). Porém, na abordagem "serializável", mesmo com todas as pastilhas sendo únicas, sempre haverá algum potencial para bloquear outras pastilhas no mesmo intervalo (embora não por muito tempo).
Alta frequência de solicitações para o mesmo valor da chave ao mesmo tempo:
Nesse caso - um grau muito baixo de exclusividade em termos de solicitações de entrada para valores-chave inexistentes - o CATCH
bloco na minha sugestão será inserido regularmente. O efeito disso será que cada inserção com falha precisará reverter automaticamente e gravar as 4 entradas no log de transações, que é um pequeno desempenho atingido a cada vez. Mas a operação geral nunca deve falhar (pelo menos não devido a isso).
(Houve um problema com a versão anterior da abordagem "atualizada" que permitia sofrer conflitos. updlock
adicionada dica para resolver isso e ela não recebe mais conflitos.)MAS, na abordagem "serializável" (mesmo na versão otimizada e atualizada), a operação entra em conflito. Por quê? Porque o serializable
comportamento impede apenas INSERT
operações no intervalo que foi lido e, portanto, bloqueado; isso não impede SELECT
operações nesse intervalo.
A serializable
abordagem, neste caso, parece não ter sobrecarga adicional e pode ter um desempenho um pouco melhor do que estou sugerindo.
Como ocorre com muitas / a maioria das discussões sobre desempenho, devido à existência de muitos fatores que podem afetar o resultado, a única maneira de realmente ter uma noção de como algo será executado é testá-lo no ambiente de destino onde será executado. Nesse ponto, não será uma questão de opinião :).