Frequentemente encontramos a situação "Se não existir, insira". O blog de Dan Guzman tem uma excelente investigação sobre como tornar esse processo seguro para threads.
Eu tenho uma tabela básica que simplesmente cataloga uma string para um número inteiro de um arquivo SEQUENCE
. Em um procedimento armazenado, preciso obter a chave inteira para o valor, se existir, ou INSERT
obter o valor resultante. Há uma restrição de exclusividade na dbo.NameLookup.ItemName
coluna, portanto, a integridade dos dados não está em risco, mas não quero encontrar as exceções.
Não é um IDENTITY
então não consigo SCOPE_IDENTITY
e o valor pode ser NULL
em certos casos.
Na minha situação, só tenho que lidar com INSERT
a segurança na mesa, então estou tentando decidir se é melhor praticar usar MERGE
assim:
SET NOCOUNT, XACT_ABORT ON;
DECLARE @vValueId INT
DECLARE @inserted AS TABLE (Id INT NOT NULL)
MERGE
dbo.NameLookup WITH (HOLDLOCK) AS f
USING
(SELECT @vName AS val WHERE @vName IS NOT NULL AND LEN(@vName) > 0) AS new_item
ON f.ItemName= new_item.val
WHEN MATCHED THEN
UPDATE SET @vValueId = f.Id
WHEN NOT MATCHED BY TARGET THEN
INSERT
(ItemName)
VALUES
(@vName)
OUTPUT inserted.Id AS Id INTO @inserted;
SELECT @vValueId = s.Id FROM @inserted AS s
Eu poderia fazer isso sem usar MERGE
apenas um condicional INSERT
seguido por um SELECT
. Acho que esta segunda abordagem é mais clara para o leitor, mas não estou convencido de que seja uma prática "melhor"
SET NOCOUNT, XACT_ABORT ON;
INSERT INTO
dbo.NameLookup (ItemName)
SELECT
@vName
WHERE
NOT EXISTS (SELECT * FROM dbo.NameLookup AS t WHERE @vName IS NOT NULL AND LEN(@vName) > 0 AND t.ItemName = @vName)
DECLARE @vValueId int;
SELECT @vValueId = i.Id FROM dbo.NameLookup AS i WHERE i.ItemName = @vName
Ou talvez haja outra maneira melhor que não considerei
Pesquisei e fiz referência a outras perguntas. Este: https://stackoverflow.com/questions/5288283/sql-server-insert-if-not-exists-best-practice é o mais apropriado que encontrei, mas não parece muito aplicável ao meu caso de uso. Outras questões para a IF NOT EXISTS() THEN
abordagem que não acho aceitável.
Como você está usando uma Sequência, você pode usar a mesma função NEXT VALUE FOR -- que você já tem em uma Restrição Padrão no
Id
campo Chave Primária -- para gerar um novoId
valor antes do tempo. Gerar o valor primeiro significa que você não precisa se preocupar em não terSCOPE_IDENTITY
, o que significa que você não precisa daOUTPUT
cláusula ou fazer um adicionalSELECT
para obter o novo valor; você terá o valor antes de fazer oINSERT
, e nem precisa mexerSET 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, exatamente ao mesmo tempo, sem encontrar uma linha existente para a mesma string exata e prosseguir com o arquivo
INSERT
. A preocupação é evitar a violação de Unique Constraint que ocorreria.Uma maneira de lidar com esses tipos de problemas de simultaneidade é forçar essa operação específica a ser de thread único. 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 esta, onde a frequência de colisões é provavelmente bastante baixa.
A outra maneira de lidar com as colisões é aceitar que às vezes elas ocorrerão e lidar com elas, em vez de tentar evitá-las. Usando a
TRY...CATCH
construção, você pode interceptar efetivamente um erro específico (neste caso: "unique constraint violação", Msg 2601) e reexecutar oSELECT
para obter oId
valor, pois sabemos que ele agora existe devido a estar noCATCH
bloco com aquele determinado erro. Outros erros podem ser tratados da maneira típicaRAISERROR
/RETURN
ouTHROW
.Configuração do teste: sequência, tabela e índice exclusivo
Configuração do Teste: Procedimento Armazenado
O teste
Pergunta do OP
MERGE
tem vários "problemas" (várias referências estão vinculadas na resposta do @SqlZim, portanto, não há necessidade de duplicar essas informações aqui). E não há bloqueio adicional nessa abordagem (menos contenção), portanto, deve ser melhor na simultaneidade. Nesta abordagem, você nunca obterá uma violação de restrição única, tudo sem nenhumHOLDLOCK
, etc. É praticamente garantido que funcione.O raciocínio por trás dessa abordagem é:
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 do @SqlZim (ênfase adicionada)
Eu concordaria com esta primeira frase se fosse alterada para afirmar "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 isso.
O problema que vejo com essa abordagem é que ela bloqueia mais do que está sendo sugerido. É importante reler a documentação citada sobre "serializável", especificamente o seguinte (grifo nosso):
Agora, aqui está o comentário no código de exemplo:
A palavra operativa é "alcance". O bloqueio que está sendo feito não é apenas no valor em
@vName
, mas mais precisamente em um intervalo começando 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 do(s) valor(es) que estão sendo pesquisados. Se a pesquisa estiver sendo feita no topo do intervalo, a inserção de qualquer coisa que possa ocupar essa mesma posição será bloqueada. Por exemplo, se existem os valores "a", "b" e "d", então se um processo estiver fazendo o SELECT em "f", então não será possível inserir os valores "g" ou mesmo "e" ( já que qualquer um deles virá imediatamente após "d"). Mas, inserir 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)
(Na guia de consulta (ou seja, Sessão) nº 2)
Da mesma forma, se o valor "C" existir e o valor "A" estiver sendo selecionado (e, portanto, bloqueado), você poderá inserir um valor "D", mas não um valor "B":
(Na guia de consulta (ou seja, Sessão) nº 1)
(Na guia de consulta (ou seja, Sessão) nº 2)
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 nesta abordagem de "transação serializável". MAS, como eu disse acima, se a exceção acontecer 1% (ou mesmo 5%) do tempo, isso é muito menos impactante do que o caso muito mais provável do SELECT inicial bloqueando temporariamente as operações INSERT.
Outro problema, embora menor, com essa abordagem de "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 em ambos os lados: no SQL Server para gerenciar o cursor interno e na camada do aplicativo para gerenciar o objeto DataReader) do que umOUTPUT
parâmetro simples. 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 forma a retornar umOUTPUT
parâmetro, isso exigiria etapas adicionais para criar uma tabela temporária ou variável de tabela e, em seguida, selecionar o valor dessa tabela temporária/variável de tabela noOUTPUT
parâmetro.Esclarecimento adicional: Resposta à resposta de @SqlZim (resposta atualizada) à minha resposta à resposta de @SqlZim (na resposta original) à minha declaração sobre simultaneidade e desempenho ;-)
Desculpe se esta parte é um pouquinho longa, mas neste ponto estamos apenas nas nuances das duas abordagens.
Sim, admito que sou tendencioso, embora seja justo:
INSERT
falha devido a uma violação de restrição exclusiva. Não vi isso mencionado em nenhuma das outras respostas / postagens.Sobre a abordagem "JFDI" de @gbn, a postagem "Ugly Pragmatism For The Win" de Michael J. Swart e o comentário de Aaron Bertrand na postagem de Michael (sobre seus testes mostrando quais cenários diminuíram o desempenho) e seu comentário sobre sua "adaptação de Michael J . Adaptação de Stewart do procedimento Try Catch JFDI de @gbn" afirmando:
Com relação à discussão gbn/Michael/Aaron relacionada à abordagem "JFDI", seria incorreto igualar minha sugestão à abordagem "JFDI" da gbn. Devido à natureza da operação "Obter ou inserir", há uma necessidade explícita de fazer isso
SELECT
para obter oID
valor dos registros existentes. Esse SELECT atua como aIF 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 umWHERE NOT EXISTS
para fazer a mesma verificação primeiro. Conseqüentemente, minha sugestão (juntamente com o código final de Michael e sua adaptação do código final dele) não atingirá oCATCH
objetivo com tanta frequência. Só poderiam 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" paraWHERE NOT EXISTS
o exatamente no mesmo momento e, portanto, ambas tentem fazerINSERT
o exatamente no mesmo momento. Esse cenário muito específico acontece com muito menos frequência do que selecionar um existenteItemName
ou inserir um novoItemName
quando nenhum outro processo está tentando fazê-lo exatamente no mesmo momento .COM TUDO ACIMA EM MENTE: Por que prefiro minha abordagem?
Primeiro, vamos ver qual bloqueio ocorre na abordagem "serializável". Conforme mencionado acima, o "intervalo" bloqueado depende dos valores de chave existentes em ambos os lados de onde o novo valor de chave se encaixaria. O início ou o fim do intervalo também pode ser o início ou o fim do índice, respectivamente, se não houver nenhum valor de chave existente nessa direção. Suponha que temos o seguinte índice e chaves (
^
representa o início do índice enquanto$
representa o final dele):Se a sessão 55 tentar inserir um valor de chave de:
A
, então o intervalo # 1 (de^
aC
) é bloqueado: a sessão 56 não pode inserir um valor deB
, mesmo que único e válido (ainda). Mas a sessão 56 pode inserir valores deD
,G
eM
.D
, then range # 2 (fromC
toF
) is locked: session 56 cannot insert a value ofE
(yet). But session 56 can insert values ofA
,G
, andM
.M
, then range # 4 (fromJ
to$
) is locked: session 56 cannot insert a value ofX
(yet). But session 56 can insert values ofA
,D
, andG
.As more key values are added, the ranges between key values becomes narrower, hence reducing the probability / frequency of multiple values being inserted at the same time fighting over the same range. Admittedly, this is not a major problem, and fortunately it appears to be a problem that actually decreases over time.
The issue with my approach was described above: it only happens when two sessions attempt to insert the same key value at the same time. In this respect it comes down to what has the higher probability of happening: two different, yet close, key values are attempted at the same time, or the same key value is attempted at the same time? I suppose the answer lies in the structure of the app doing the inserts, but generally speaking I would assume it to be more likely that two different values that just happen to share the same range are being inserted. But the only way to really know would be to test both on the O.P.s system.
Next, let's consider two scenarios and how each approach handles them:
All requests being for unique key values:
Nesse caso, o
CATCH
bloco na minha sugestão nunca é inserido, portanto, não há "problema" (ou seja, 4 entradas de log de tran e o tempo que leva para fazer isso). Mas, na abordagem "serializável", mesmo com todas as inserções sendo únicas, sempre haverá algum potencial para bloquear outras inserções no mesmo intervalo (embora não por muito tempo).Alta frequência de solicitações para o mesmo valor de chave ao mesmo tempo:
Nesse caso -- um grau muito baixo de exclusividade em termos de solicitações de entrada para valores de chave inexistentes -- o
CATCH
bloco em minha sugestão será inserido regularmente. O efeito disso será que cada inserção com falha precisará retroceder automaticamente e gravar as 4 entradas no Log de transações, o que representa um leve impacto no desempenho a cada vez. Mas a operação geral nunca deve falhar (pelo menos não devido a isso).(There was an issue with the previous version of the "updated" approach that allowed it to suffer from deadlocks. An
updlock
hint was added to address this and it no longer gets deadlocks.)BUT, in the "serializable" approach (even the updated, optimized version), the operation will deadlock. Why? Because theserializable
behavior only preventsINSERT
operations in the range that has been read and hence locked; it doesn't preventSELECT
operations on that range.The
serializable
approach, in this case, would seem to have no additional overhead, and might perform slightly better than what I am suggesting.As with many / most discussions regarding performance, due to there being so many factors that can affect the outcome, the only way to really have a sense of how something will perform is to try it out in the target environment where it will run. At that point it won't be a matter of opinion :).
Updated Answer
Response to @srutzky
I agree, and for those same reasons I do use output parameters when prudent. It was my mistake not to use an output parameter on my initial answer, I was being lazy.
Here is a revised procedure using an output parameter, additional optimizations, along with
next value for
that @srutzky explains in his answer:update note: Including
updlock
with the select will grab the proper locks in this scenario. Thanks to @srutzky, who pointed out that this could cause deadlocks when only usingserializable
on theselect
.Note: This might not be the case, but if it is possible the procedure will be called with a value for
@vValueId
, includeset @vValueId = null;
afterset xact_abort on;
, otherwise it can be removed.Concerning @srutzky's examples of key range locking behavior:
@srutzky only uses one value in his table, and locks the "next"/"infinity" key for his tests to illustrate key range locking. While his tests illustrate what happens in those situations, I believe the way the information is presented could lead to false assumptions about the amount of locking one could expect to encounter when using
serializable
in the scenario as presented in the original question.Even though I perceive a bias (perhaps falsely) in the way he presents his explanation and examples of key range locking, they are still correct.
After more research, I found a particularly pertinent blog article from 2011 by Michael J. Swart: Mythbusting: Concurrent Update/Insert Solutions. In it, he tests multiple methods for accuracy and concurrency. Method 4: Increased Isolation + Fine Tuning Locks is based on Sam Saffron's post Insert or Update Pattern For SQL Server, and the only method in the original test to meet his expectations (joined later by
merge with (holdlock)
).In February of 2016, Michael J. Swart posted Ugly Pragmatism For The Win. In that post, he covers some additional tuning he made to his Saffron upsert procedures to reduce locking (which I included in the procedure above).
After making those changes, Michael wasn't happy that his procedure was starting to look more complicated and consulted with a colleage named Chris. Chris read all of the original Mythbusters post and read all the comments and asked about @gbn's TRY CATCH JFDI pattern. This pattern is similar to @srutzky's answer, and is the solution that Michael ended up using in that instance.
Michael J Swart:
In my opinion, both solutions are viable. While I still prefer to increase the isolation level and fine tune locks, @srutzky's answer is also valid and may or may not be more performant in your specific situation.
Perhaps in the future I too will arrive at the same conclusion that Michael J. Swart did, but I'm just not there yet.
It isn't my preference, but here is what my adapation of Michael J. Stewart's adaptation of @gbn's Try Catch JFDI procedure would look like:
If you are inserting new values more often than selecting existing values, this may be more performant than @srutzky's version. Otherwise I would prefer @srutzky's version over this one.
Aaron Bertrand's comments on Michael J Swart's post links to relevant testing he has done and led to this exchange. Excerpt from comment section on Ugly Pragmatism For the Win:
and the reply of:
New links:
Original answer
I still prefer the Sam Saffron upsert approach vs using
merge
, especially when dealing with a single row.I would adapt that upsert method to this situation like this:
I would be consistent with your naming, and as
serializable
is the same asholdlock
, pick one and be consistent in its use. I tend to useserializable
because it is the same name used as when specifyingset transaction isolation level serializable
.By using
serializable
orholdlock
a range lock is taken based on the value of@vName
which makes any other operations wait if they selecting or inserting values intodbo.NameLookup
that include the value in thewhere
clause.For the range lock to work properly, there needs to be an index on the
ItemName
column this applies when usingmerge
as well.Here is what the procedure would look like mostly following Erland Sommarskog's whitepapers for error handling, using
throw
. Ifthrow
isn't how you are raising your errors, change it to be consistent with the rest of your procedures:To summarize what is going on in the procedure above:
set nocount on; set xact_abort on;
like you always do, then if our input variableis null
or empty,select id = cast(null as int)
as the result. If it isn't null or empty, then get theId
for our variable while holding that spot in case it isn't there. If theId
is there, send it out. If it isn't there, insert it and send out that newId
.Meanwhile, other calls to this procedure trying to find the Id for the same value will wait until the first transaction is done and then select & return it. Other calls to this procedure or other statements looking for other values will continue on because this one isn't in the way.
While I agree with @srutzky that you can handle collisions and swallow the exceptions for this sort of issue, I personally prefer to try and tailor a solution to avoid doing that when possible. In this case, I don't feel that using the locks from
serializable
is a heavy handed approach, and I would be confident it would handle high concurrency well.Quote from sql server documentation on the table hints
serializable
/holdlock
:Quote from sql server documentation on transaction isolation level
serializable
Links related to the solution above:
Insert or Update pattern for Sql Server - Sam Saffron
Documentation on serializable and other Table Hints - MSDN
Error and Transaction Handling in SQL Server Part One – Jumpstart Error Handling - Erland Sommarskog
Erland Sommarskog's advice regarding @@rowcount, (which I didn't follow in this instance).
MERGE
has a spotty history, and it seems to take more poking around to make sure that the code is behaving how you want it to under all that syntax. Relevantmerge
articles:An Interesting MERGE Bug - Paul White
UPSERT Race Condition With Merge - sqlteam
Use Caution with SQL Server's MERGE Statement - Aaron Bertrand
Can I optimize this merge statement - Aaron Bertrand
If you are using indexed views and MERGE, please read this! - Aaron Bertrand
One last link, Kendra Little did a rough comparison of
merge
vsinsert with left join
, with the caveat where she says "I didn’t do thorough load testing on this", but it is still a good read.