É uma prática melhor do MERGE with OUTPUT do que um INSERT e SELECT condicional?

12

Muitas vezes encontramos a situação "Se não existe, insira". Blog de Dan Guzman tem uma excelente investigação sobre como tornar esse processo seguro.

Eu tenho uma tabela básica que simplesmente cataloga uma seqüência de caracteres para um número inteiro de um SEQUENCE. Em um procedimento armazenado, preciso obter a chave inteira para o valor, se existir, ou INSERTobter o valor resultante. Existe uma restrição de exclusividade nodbo.NameLookup.ItemName coluna, portanto a integridade dos dados não corre risco, mas não quero encontrar as exceções.

Não é IDENTITYassim que eu não consigo entender SCOPE_IDENTITYe o valor pode estar NULLem certos casos.

Na minha situação, eu só tenho que lidar com a INSERTsegurança na mesa, então estou tentando decidir se é uma prática melhor usar MERGEdessa maneira:

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 MERGEapenas um condicional INSERTseguido de um SELECT Eu acho que essa segunda abordagem é mais clara para o leitor, mas não estou convencido de que seja uma "melhor" prática

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 eu não considerei

Eu pesquisei e referenciei outras perguntas. Este: /programming/5288283/sql-server-insert-if-not-exists-best-practice é o mais apropriado que eu poderia encontrar, mas não parece muito aplicável ao meu caso de uso. Outras questões para a IF NOT EXISTS() THENabordagem que não considero aceitáveis.

Mateus
fonte
Você já tentou experimentar tabelas maiores que seu buffer, tive experiências em que o desempenho da mesclagem diminui quando a tabela atinge um determinado tamanho.
pacreely 14/12/16

Respostas:

8

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 Idcampo Chave primária - para gerar um novo Idvalor 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 OUTPUTcláusula ou de fazer um adicional SELECTpara obter o novo valor; você terá o valor antes de fazer o mesmo INSERTe 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...CATCHconstrução, você pode efetivamente interceptar um erro específico (neste caso: "violação de restrição exclusiva", Msg 2601) e executar novamente o SELECTpara obter o Idvalor, pois sabemos que ele existe agora por estar no CATCHbloco com esse particular erro. Outros erros podem ser tratados da maneira típica RAISERROR/ RETURNou 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 TRYusar a WHERE NOT EXISTScláusula?

MERGEtem 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 é:

  1. Se você tiver execuções suficientes desse procedimento para se preocupar com colisões, não será necessário:
    1. tome mais medidas do que o necessário
    2. reter bloqueios em qualquer recurso por mais tempo do que o necessário
  2. Como as colisões só podem ocorrer com novas entradas (novas entradas enviadas exatamente ao mesmo tempo ), a frequência de queda no CATCHbloco 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 serializableseja 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 OUTPUTclá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 OUTPUTcláusula possa ser usada de maneira a retornar um OUTPUTparâ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 OUTPUTparâ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 serializableno cenário, conforme apresentado na pergunta original.

Sim, admito que sou tendencioso, embora seja justo:

  1. É impossível para um ser humano não ser tendencioso, pelo menos em algum grau, e eu tento mantê-lo no mínimo,
  2. 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.
  3. 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").
  4. Eu encontrei (e voluntariamente) o "custo oculto" da minha abordagem, sendo as quatro entradas extras do Tran Log cada vez que a INSERTfalha 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 SELECTpara obter o IDvalor dos registros existentes. Esse SELECT atua como IF EXISTSverificaçã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 EXISTSpara 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 CATCHesse ponto com tanta frequência. Só poderia ser situações em que duas sessões,ItemNameINSERT...SELECTexatamente no mesmo momento, de modo que ambas as sessões recebam um "verdadeiro" para WHERE NOT EXISTSo exato momento e, portanto, ambas tentam fazer INSERTexatamente no mesmo momento. Esse cenário muito específico acontece com muito menos frequência do que selecionar um existente ItemNameou inserir um novo ItemNamequando 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, Ge M.
  • D, o intervalo # 2 (de Ca 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, Ge M.
  • M, o intervalo # 4 (de Ja $) está bloqueado: a sessão 56 não pode inserir um valor de X(ainda). Mas sessão 56 pode inserir valores de A, De 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:

  1. Todas as solicitações são de valores-chave exclusivos:

    Nesse caso, o CATCHbloco 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).

  2. 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 CATCHbloco 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 serializablecomportamento impede apenas INSERToperações no intervalo que foi lido e, portanto, bloqueado; isso não impede SELECToperações nesse intervalo.

    A serializableabordagem, 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 :).

Solomon Rutzky
fonte
7

Resposta atualizada


Resposta a @srutzky

Outro problema, ainda que menor, com essa abordagem "transação serializável + cláusula OUTPUT" é que a cláusula OUTPUT (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 simples parâmetro OUTPUT. 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.

Concordo e, pelas mesmas razões, uso parâmetros de saída quando prudentes . Foi meu erro não usar um parâmetro de saída na minha resposta inicial, eu estava sendo preguiçoso.

Aqui está um procedimento revisado usando um parâmetro de saída, otimizações adicionais, juntamente com o next value forque @srutzky explica em sua resposta :

create procedure dbo.NameLookup_getset_byName (@vName nvarchar(50), @vValueId int output) as
begin
  set nocount on;
  set xact_abort on;
  set @vValueId = null;
  if nullif(@vName,'') is null                                 
    return;                                        /* if @vName is empty, return early */
  select  @vValueId = Id                                              /* go get the Id */
    from  dbo.NameLookup
    where ItemName = @vName;
  if @vValueId is not null                                 /* if we got the id, return */
    return;
  begin try;                                  /* if it is not there, then get the lock */
    begin tran;
      select  @vValueId = Id
        from  dbo.NameLookup with (updlock, serializable) /* hold key range for @vName */
        where ItemName = @vName;
      if @@rowcount = 0                    /* if we still do not have an Id for @vName */
      begin;                                         /* get a new Id and insert @vName */
        set @vValueId = next value for dbo.IdSequence;      /* get next sequence value */
        insert into dbo.NameLookup (ItemName, Id)
          values (@vName, @vValueId);
      end;
    commit tran;
  end try
  begin catch;
    if @@trancount > 0 
      begin;
        rollback transaction;
        throw;
      end;
  end catch;
end;

nota de atualização : Incluir updlockcom o select irá capturar os bloqueios adequados nesse cenário. Obrigado a @srutzky, que apontou que isso pode causar conflitos ao usar apenas serializableno select.

Nota: Pode não ser o caso, mas se for possível, o procedimento será chamado com um valor para @vValueId, include set @vValueId = null;after set xact_abort on;, caso contrário, poderá ser removido.


Sobre os exemplos de comportamento de bloqueio do intervalo de chaves de @ srutzky:

O @srutzky usa apenas um valor em sua tabela e bloqueia a tecla "next" / "infinito" nos testes para ilustrar o bloqueio do intervalo de teclas. Embora seus testes ilustrem o que acontece nessas situações, 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 serializableno cenário, conforme apresentado na pergunta original.

Mesmo que eu perceba um viés (talvez falsamente) na maneira como ele apresenta sua explicação e exemplos de bloqueio de intervalo de teclas, eles ainda estão corretos.


Após mais pesquisas, encontrei um artigo de blog particularmente pertinente de 2011 por Michael J. Swart: Mythbusting: soluções simultâneas de atualização / inserção . Nele, ele testa vários métodos para precisão e simultaneidade. Método 4: Maior isolamento + bloqueios de ajuste fino é baseado no padrão de inserção ou atualização de Sam Saffron para o SQL Server , e o único método no teste original para atender às suas expectativas (unidas posteriormente por merge with (holdlock)).

Em fevereiro de 2016, Michael J. Swart postou Ugg Pragmatism For The Win . Nesse post, ele aborda alguns ajustes adicionais que ele fez nos procedimentos de upsert do Saffron para reduzir o bloqueio (que eu incluí no procedimento acima).

Depois de fazer essas alterações, Michael não ficou satisfeito com o fato de seu procedimento começar a parecer mais complicado e consultou um colega chamado Chris. Chris leu todos os posts originais do Mythbusters, todos os comentários e perguntou sobre o padrão TRY CATCH JFDI da @ gbn . Esse padrão é semelhante à resposta de @ srutzky e é a solução que Michael acabou usando nessa instância.

Michael J Swart:

Ontem, minha mente mudou sobre a melhor maneira de fazer concorrência. Descrevo vários métodos no Mythbusting: soluções simultâneas de atualização / inserção. Meu método preferido é aumentar o nível de isolamento e ajustar os bloqueios.

Pelo menos essa era a minha preferência. Recentemente, mudei minha abordagem para usar um método que gbn sugeriu nos comentários. Ele descreve seu método como o "padrão TRY CATCH JFDI". Normalmente evito soluções como essa. Existe uma regra prática que diz que os desenvolvedores não devem confiar na captura de erros ou exceções para o fluxo de controle. Mas eu quebrei essa regra de ouro ontem.

A propósito, eu amo a descrição do gbn para o padrão "JFDI". Isso me lembra o vídeo motivacional de Shia Labeouf.


Na minha opinião, ambas as soluções são viáveis. Embora eu ainda prefira aumentar o nível de isolamento e ajustar os bloqueios, a resposta de @ srutzky também é válida e pode ou não ter melhor desempenho em sua situação específica.

Talvez no futuro eu também chegue à mesma conclusão que Michael J. Swart, mas ainda não estou lá.


Não é minha preferência, mas eis a minha adaptação da adaptação de Michael J. Stewart do procedimento Try Catch JFDI da @ gbn :

create procedure dbo.NameLookup_JFDI (
    @vName nvarchar(50)
  , @vValueId int output
  ) as
begin
  set nocount on;
  set xact_abort on;
  set @vValueId = null;
  if nullif(@vName,'') is null                                 
    return;                     /* if @vName is empty, return early */
  begin try                                                 /* JFDI */
    insert into dbo.NameLookup (ItemName)
      select @vName
      where not exists (
        select 1
          from dbo.NameLookup
          where ItemName = @vName);
  end try
  begin catch        /* ignore duplicate key errors, throw the rest */
    if error_number() not in (2601, 2627) throw;
  end catch
  select  @vValueId = Id                              /* get the Id */
    from  dbo.NameLookup
    where ItemName = @vName
  end;

Se você estiver inserindo novos valores com mais frequência do que selecionando valores existentes, isso pode ter um desempenho melhor do que a versão do @ srutzky . Caso contrário, eu preferiria a versão do @ srutzky a esta.

Os comentários de Aaron Bertrand no post de Michael J Swart vinculam-se a testes relevantes que ele fez e levaram a essa troca. Trecho da seção de comentários sobre o pragmatismo feio para a vitória :

Às vezes, porém, o JFDI leva a um desempenho geral pior, dependendo da porcentagem de chamadas que falham. A criação de exceções possui uma sobrecarga substancial. Eu mostrei isso em algumas postagens:

http://sqlperformance.com/2012/08/t-sql-queries/error-handling

https://www.mssqltips.com/sqlservertip/2632/checking-for-potential-constraint-violations-before-entering-sql-server-try-and-catch-logic/

Comentário por Aaron Bertrand - 11 de fevereiro de 2016 às 11:49

e a resposta de:

Você está certo Aaron, e nós o testamos.

Acontece que, no nosso caso, a porcentagem de chamadas que falharam foi 0 (quando arredondada para a porcentagem mais próxima).

Eu acho que você ilustra o ponto de que, tanto quanto possível, avalie as coisas caso a caso, seguindo as regras práticas.

É também por isso que adicionamos a cláusula WHERE NOT EXISTS não estritamente necessária.

Comentário por Michael J. Swart - 11 de fevereiro de 2016 às 11:57


Novos links:


Resposta original


Eu ainda prefiro a abordagem upsert Sam Saffron vs usar merge, especialmente quando se lida com uma única linha.

Eu adaptaria esse método upsert a esta situação como esta:

declare @vName nvarchar(50) = 'Invader';
declare @vValueId int       = null;

if nullif(@vName,'') is not null /* this gets your where condition taken care of before we start doing anything */
begin tran;
  select @vValueId = Id
    from dbo.NameLookup with (serializable) 
    where ItemName = @vName;
  if @@rowcount > 0 
    begin;
      select @vValueId as id;
    end;
    else
    begin;
      insert into dbo.NameLookup (ItemName)
        output inserted.id
          values (@vName);
      end;
commit tran;

Eu seria consistente com sua nomeação e, como serializableé o mesmo holdlock, escolha uma e seja consistente em seu uso. Eu costumo usar serializableporque é o mesmo nome usado ao especificar set transaction isolation level serializable.

Ao usar serializableou holdlockum bloqueio de intervalo é obtido com base no valor @vNameque faz com que outras operações esperem se elas selecionarem ou inserirem valores dbo.NameLookupque incluam o valor na wherecláusula.

Para que o bloqueio de intervalo funcione corretamente, é necessário haver um índice na ItemNamecoluna que se aplica ao usar mergetambém.


Aqui está como seria o procedimento, principalmente seguindo os documentos técnicos de Erland Sommarskog para o tratamento de erros , usando throw. Se thrownão é assim que você está gerando seus erros, altere-o para ser consistente com o restante de seus procedimentos:

create procedure dbo.NameLookup_getset_byName (@vName nvarchar(50) ) as
begin
  set nocount on;
  set xact_abort on;
  declare @vValueId int;
  if nullif(@vName,'') is null /* if @vName is null or empty, select Id as null */
    begin
      select Id = cast(null as int);
    end 
    else                       /* else go get the Id */
    begin try;
      begin tran;
        select @vValueId = Id
          from dbo.NameLookup with (serializable) /* hold key range for @vName */
          where ItemName = @vName;
        if @@rowcount > 0      /* if we have an Id for @vName select @vValueId */
          begin;
            select @vValueId as Id; 
          end;
          else                     /* else insert @vName and output the new Id */
          begin;
            insert into dbo.NameLookup (ItemName)
              output inserted.Id
                values (@vName);
            end;
      commit tran;
    end try
    begin catch;
      if @@trancount > 0 
        begin;
          rollback transaction;
          throw;
        end;
    end catch;
  end;
go

Para resumir o que está acontecendo no procedimento acima: set nocount on; set xact_abort on;como você sempre faz , então se a nossa entrada for variável is nullou vazia, select id = cast(null as int)como resultado. Se não for nulo ou vazio, obtenha a Idvariável for enquanto mantém esse ponto , caso não esteja lá. Se Idhouver, envie-o. Se não estiver lá, insira-o e envie o novo Id.

Enquanto isso, outras chamadas para esse procedimento que tentam encontrar o ID para o mesmo valor aguardam até a primeira transação ser concluída e, em seguida, selecionam e retornam. Outras chamadas para este procedimento ou outras instruções que procuram outros valores continuarão porque este não está no caminho.

Embora eu concorde com @srutzky que você pode lidar com colisões e engolir as exceções para esse tipo de problema, eu 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 serializableseja uma abordagem pesada, e eu estaria confiante de que lidaria bem com alta simultaneidade.

A citação da documentação do servidor sql nas dicas de tabela serializable/holdlock :

SERIALIZABLE

É equivalente a HOLDLOCK. Torna os bloqueios compartilhados mais restritivos, mantendo-os até que uma transação seja concluída, em vez de liberar o bloqueio compartilhado assim que a tabela ou página de dados necessária não for mais necessária, independentemente de a transação ter sido concluída ou não. A varredura é realizada com a mesma semântica que uma transação em execução no nível de isolamento SERIALIZABLE. Para obter mais informações sobre níveis de isolamento, consulte SET TRANSACTION ISOLATION LEVEL (Transact-SQL).

Citação da documentação do servidor sql no nível de isolamento da transaçãoserializable

SERIALIZABLE Especifica o seguinte:

  • As instruções não podem ler dados que foram modificados, mas ainda não confirmados por outras transações.

  • Nenhuma outra transação pode modificar os dados que foram lidos pela transação atual até que a transação atual seja concluída.

  • Outras transações não podem inserir novas linhas com valores de chave que cairiam no intervalo de chaves lidas por quaisquer instruções na transação atual até que a transação atual seja concluída.


Links relacionados à solução acima:

MERGEtem um histórico irregular e parece ser necessário bisbilhotar para garantir que o código esteja se comportando como você deseja, sob toda essa sintaxe. mergeArtigos relevantes :

Um último link, Kendra Little fez uma comparação aproximada de mergevsinsert with left join , com a ressalva em que ela diz: "Não fiz testes de carga completos nisso", mas ainda é uma boa leitura.

SqlZim
fonte