带有输出的 MERGE 是否比条件插入和选择更好的做法?

Mat*_*hew 12 sql-server t-sql merge sql-server-2014

我们经常会遇到“如果不存在,则插入”的情况。Dan Guzman 的博客对如何使此进程线程安全进行了出色的调查。

我有一个基本表,它只是将字符串从SEQUENCE. 在存储过程中,我需要获取该值的整数键(如果它存在),或者INSERT它然后获取结果值。该dbo.NameLookup.ItemName列有一个唯一性约束,因此数据完整性没有风险,但我不想遇到异常。

这不是一个IDENTITY所以我无法获得,SCOPE_IDENTITY并且NULL在某些情况下价值可能是。

在我的情况下,我只需要处理INSERT桌面上的安全问题,所以我试图决定是否使用MERGE这样的方法更好:

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
Run Code Online (Sandbox Code Playgroud)

我可以不使用MERGE条件来做到这一点,INSERT然后是 aSELECT 我认为第二种方法对读者来说更清楚,但我不相信这是“更好”的做法

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
Run Code Online (Sandbox Code Playgroud)

或者也许还有另一种我没有考虑过的更好的方法

我确实搜索并参考了其他问题。这个:https : //stackoverflow.com/questions/5288283/sql-server-insert-if-not-exists-best-practice是我能找到的最合适的,但似乎不太适用于我的用例。IF NOT EXISTS() THEN我认为不可接受的方法的其他问题。

Sol*_*zky 9

因为您使用的是序列,所以您可以使用相同的NEXT VALUE FOR函数(您已经在Id主键字段的默认约束中使用)来Id提前生成新值。首先生成值意味着您无需担心没有SCOPE_IDENTITY,这意味着您不需要OUTPUT子句或执行额外操作SELECT来获取新值;在你做之前你就拥有了价值,你INSERT甚至不需要搞砸SET IDENTITY INSERT ON / OFF:-)

所以这照顾了整体情况的一部分。另一部分是在完全相同的时间处理两个进程的并发问题,没有为完全相同的字符串找到现有行,并继续处理INSERT. 关注的是避免可能发生的唯一约束违规。

处理这些类型的并发问题的一种方法是强制此特定操作为单线程。这样做的方法是使用应用程序锁(跨会话工作)。虽然有效,但在这种碰撞频率可能相当低的情况下,它们可能有点笨手笨脚。

处理冲突的另一种方法是接受它们有时会发生并处理它们,而不是试图避免它们。使用该TRY...CATCH构造,您可以有效地捕获特定错误(在这种情况下:“违反唯一约束”,消息 2601)并重新执行SELECT以获取Id值,因为我们知道它现在存在于CATCH具有该特定的块中错误。其他错误可以以典型RAISERROR/RETURNTHROW方式处理。

测试设置:序列、表和唯一索引

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
Run Code Online (Sandbox Code Playgroud)

测试设置:存储过程

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
Run Code Online (Sandbox Code Playgroud)

考试

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
Run Code Online (Sandbox Code Playgroud)

来自 OP 的问题

为什么这比MERGE? 如果不TRY使用WHERE NOT EXISTS子句,我不会获得相同的功能吗?

MERGE有各种“问题”(@SqlZim 的答案中链接了一些参考资料,因此无需在此处复制该信息)。而且,这种方法没有额外的锁定(更少的争用),所以它在并发性上应该更好。在这种方法中,您永远不会遇到 Unique Constraint 违规,所有这些都没有任何HOLDLOCK,等等。它几乎可以保证工作。

这种方法背后的原因是:

  1. 如果您执行了足够多的此过程以至于您需要担心冲突,那么您不希望:
    1. 采取任何不必要的步骤
    2. 对任何资源持有超过必要的锁
  2. 由于碰撞只会发生在新条目(同时提交的新条目)上,CATCH因此首先掉入区块的频率将非常低。优化运行时间为 99% 的代码而不是运行时间为 1% 的代码更有意义(除非优化两者都没有成本,但这里的情况并非如此)。

来自@SqlZim 的回答的评论(强调)

我个人更喜欢尝试和定制解决方案,以避免在可能的情况下这样做。在这种情况下,我不认为使用 from 的锁serializable是一种笨手笨脚的方法,我相信它可以很好地处理高并发。

如果将其修改为“and _when prudent”,我会同意这第一句话。仅仅因为某事在技术上是可行的并不意味着该情况(即预期的用例)会从中受益。

我看到这种方法的问题是它锁定的比建议的多。重新阅读有关“可序列化”的引用文档很重要,特别是以下内容(强调):

  • 在当前事务完成之前,其他事务不能插入键值落在当前事务中任何语句读取的键范围内的新行。

现在,这是示例代码中的注释:

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
Run Code Online (Sandbox Code Playgroud)

那里的有效词是“范围”。所采取的锁定不仅仅是在 中的值@vName,而是更准确的范围从 开始这个新值应该去的位置(即在新值适合的任一侧的现有键值之间),而不是值本身。这意味着,其他进程将被阻止插入新值,具体取决于当前正在查找的值。如果在范围的顶部进行查找,那么插入任何可能占据相同位置的内容都将被阻止。例如,如果存在值“a”、“b”和“d”,那么如果一个进程正在对“f”执行 SELECT,则不可能插入值“g”甚至“e”(因为其中任何一个都会在“d”之后立即出现)。但是,插入值“c”将是可能的,因为它不会被放置在“保留”范围内。

以下示例应说明此行为:

(在查询选项卡(即会话)#1 中)

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
Run Code Online (Sandbox Code Playgroud)

(在查询选项卡中(即会话)#2)

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
Run Code Online (Sandbox Code Playgroud)

同样,如果值“C”存在,并且值“A”被选择(并因此被锁定),那么您可以插入值“D”,但不能插入值“B”:

(在查询选项卡(即会话)#1 中)

SELECT [Id]
FROM   dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
Run Code Online (Sandbox Code Playgroud)

(在查询选项卡中(即会话)#2)

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;
Run Code Online (Sandbox Code Playgroud)

公平地说,在我建议的方法中,当出现异常时,事务日志中将有 4 个条目在这种“可序列化事务”方法中不会发生。但是,正如我上面所说的,如果异常发生率为 1%(甚至 5%),那么其影响远小于初始 SELECT 临时阻塞 INSERT 操作的可能性更大的情况。

这种“可序列化事务 + OUTPUT 子句”方法的另一个(尽管是次要的)问题是该OUTPUT子句(在目前的用法中)将数据作为结果集发回。结果集需要比简单OUTPUT参数更多的开销(可能在双方:在 SQL Server 中管理内部游标,在应用程序层中管理 DataReader 对象)。鉴于我们只处理单个标量值,并且假设执行频率很高,结果集的额外开销可能会加起来。

虽然OUTPUT子句可以以返回OUTPUT参数的方式使用,但这需要额外的步骤来创建临时表或表变量,然后从该临时表/表变量中选择值到OUTPUT参数中。

进一步说明:对@SqlZim's Response(更新后的答案)对我对@SqlZim's Response(在原始答案中)对我关于并发性和性能的声明的回应;-)

对不起,如果这部分有点长,但在这一点上,我们只关注两种方法的细微差别。

我相信信息的呈现方式可能会导致对serializable在原始问题中提出的场景中使用时可能会遇到的锁定量的错误假设。

是的,我承认我有偏见,但公平地说:

  1. 一个人不可能没有偏见,至少在某种程度上是不可能的,我确实尽量将其保持在最低限度,
  2. 给出的例子很简单,但这是为了说明目的,在不过度复杂的情况下传达行为。无意暗示频率过高,尽管我确实明白我也没有明确说明,并且可以理解为暗示存在比实际存在的问题更大的问题。我将在下面尝试澄清这一点。
  3. 我还包括一个锁定两个现有键之间范围的示例(第二组“查询选项卡 1”和“查询选项卡 2”块)。
  4. 我确实发现(并自愿)我的方法的“隐藏成本”,即每次INSERT由于违反唯一约束而失败时的四个额外的 Tran Log 条目。我没有看到任何其他答案/帖子中提到的。

关于@gbn 的“JFDI”方法,Michael J. Swart 的“Ugly Pragmatism For The Win”帖子,以及 Aaron Bertrand 对 Michael 帖子的评论(关于他的测试显示哪些场景会降低性能),以及您对“Michael J 的改编”的评论. Stewart 对@gbn 的 Try Catch JFDI 程序的改编”说明:

如果您比选择现有值更频繁地插入新值,这可能比 @srutzky 的版本性能更高。否则我更喜欢@srutzky 的版本而不是这个版本。

关于与“JFDI”方法相关的 gbn / Michael / Aaron 讨论,将我的建议等同于 gbn 的“JFDI”方法是不正确的。由于“获取或插入”操作的性质,明确需要执行SELECT获取ID现有记录的值。这个 SELECT 充当IF EXISTS检查,这使得这种方法更等同于 Aaron 测试的“CheckTryCatch”变体。Michael 重新编写的代码(以及您对 Michael 改编版的最终改编版)还包括WHERE NOT EXISTS首先进行相同的检查。因此,我的建议(连同 Michael 的最终代码和您对他的最终代码的改编)实际上不会CATCH经常遇到问题。可能只有两个会话的情况,ItemNameINSERT...SELECT在完全相同的时刻,这样两个会话都在完全相同的时刻收到“真” WHERE NOT EXISTS,因此都试图INSERT在完全相同的时刻执行。这种非常具体的情况比选择现有的ItemName或插入新的ItemName没有其他进程在完全相同的时刻尝试这样做的情况要少得多。

考虑到以上所有因素:为什么我更喜欢我的方法?

首先,让我们看看在“可序列化”方法中发生了什么锁定。如上所述,被锁定的“范围”取决于新键值适合的任一侧的现有键值。如果在该方向上没有现有的键值,则范围的开始或结束也可以分别是索引的开始或结束。假设我们有以下索引和键(^代表索引的开头,$代表索引的结尾):

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
Run Code Online (Sandbox Code Playgroud)

如果会话 55 尝试插入以下键值:

  • A,然后范围 #1(从^C)被锁定:会话 56 不能插入 的值B,即使是唯一且有效的(还)。但会议56可以插入值DGM
  • D,然后范围#2(从CF)被锁定:会话56不能插入值E(还)。但会议56可以插入值AGM
  • M,然后范围#4(从J$)被锁定:会话56不能插入值X(还)。但会议56可以插入值ADG

随着更多的键值被添加,键值之间的范围变得更窄,因此降低了同时插入多个值的概率/频率在同一范围内争斗。诚然,这不是一个问题,幸运的是,它似乎是一个随着时间的推移实际上会减少的问题。

上面描述了我的方法的问题:它仅在两个会话尝试同时插入相同的键值时发生。在这方面,它归结为发生概率较高的情况:同时尝试两个不同但接近的键值,还是同时尝试相同的键值?我想答案在于执行插入的应用程序的结构,但一般来说,我认为更有可能插入两个恰好共享相同范围的不同值。但真正知道的唯一方法是在 OP 系统上测试两者。

接下来,让我们考虑两种场景以及每种方法如何处理它们:

  1. 所有针对唯一键值的请求:

    在这种情况下,CATCH我的建议中的块永远不会输入,因此没有“问题”(即 4 个 tran 日志条目以及这样做所需的时间)。但是,在“可序列化”方法中,即使所有插入都是唯一的,也总会有一些潜在的可能会阻塞同一范围内的其他插入(尽管不会持续很长时间)。

  2. 同时请求同一个key值的频率高:

    在这种情况下——对于不存在的键值的传入请求的唯一性程度非常低——CATCH我的建议中的块将被定期输入。这样做的结果是,每次失败的插入都需要自动回滚并将 4 个条目写入事务日志,每次都会对性能造成轻微影响。但是整个操作永远不会失败(至少不会因此而失败)。

    (之前版本的“更新”方法存在一个问题,导致它陷入死锁。updlock添加了一个提示来解决这个问题,它不再陷入死锁。)但是,在“可序列化”方法中(即使是更新、优化的版本),操作会死锁。为什么?因为该serializable行为只会阻止INSERT在已读取并因此锁定的范围内进行操作;它不会阻止SELECT该范围内的操作。

    serializable在这种情况下,该方法似乎没有额外的开销,并且性能可能比我建议的略好。

与许多/大多数关于性能的讨论一样,由于影响结果的因素太多,真正了解某事将如何执行的唯一方法是在它将运行的目标环境中进行尝试。到那时,这将不是一个意见问题:)。


Sql*_*Zim 7

更新答案


对@srutzky 的回应

这种“可序列化事务 + OUTPUT 子句”方法的另一个(尽管是次要的)问题是 OUTPUT 子句(在目前的用法中)将数据作为结果集发回。结果集需要比简单的 OUTPUT 参数更多的开销(可能在双方:在 SQL Server 中管理内部游标,在应用程序层中管理 DataReader 对象)。鉴于我们只处理单个标量值,并且假设执行频率很高,结果集的额外开销可能会加起来。

我同意,出于同样的原因,我确实在谨慎时使用输出参数。在我的初始答案中没有使用输出参数是我的错误,我太懒了。

下面是使用一个输出参数,额外的优化,随着修订过程next value for@srutzky在他的回答解释说

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;
Run Code Online (Sandbox Code Playgroud)

更新说明updlock在这种情况下,包含select 将获取正确的锁。感谢@srutzky,他指出仅serializableselect.

注意:这可能不是这种情况,但如果可能,该过程将使用 for @vValueId、include set @vValueId = null;after的值来调用set xact_abort on;,否则可以将其删除。


关于@srutzky 的键范围锁定行为示例:

@srutzky 在他的表中只使用一个值,并为他的测试锁定“next”/“infinity”键以说明键范围锁定。虽然他的测试说明了在这些情况下会发生什么,但我相信信息的呈现方式可能会导致对使用时可能遇到的锁定量的错误假设serializable在原始问题中提出的场景中。

尽管我在他介绍键范围锁定的解释和示例的方式中感受到了偏见(可能是错误的),但它们仍然是正确的。


经过更多研究,我发现了 Michael J. Swart 在 2011 年发表的一篇特别相关的博客文章:Mythbusting: Concurrent Update/Insert Solutions。在其中,他测试了多种方法的准确性和并发性。方法四:增加隔离+微调锁是基于Sam Saffron的帖子Insert or Update Pattern For SQL Server,也是原测试中唯一满足他期望的方法(后来加入merge with (holdlock))。

2016 年 2 月,Michael J. Swart 发布了Ugly Pragmatism For The Win。在那篇文章中,他介绍了他对 Saffron upsert 程序进行的一些额外调整,以减少锁定(我在上面的程序中包括了这些)。

做出这些改变后,迈克尔对他的程序开始变得更加复杂感到不高兴,并咨询了一位名叫克里斯的同事。克里斯阅读了所有流言终结者的原始帖子并阅读了所有评论并询问了@gbn TRY CATCH JFDI 模式。此模式类似于@srutzky 的答案,并且是 Michael 在该实例中最终使用的解决方案。

迈克尔·J·斯沃特:

昨天,我改变了关于实现并发的最佳方式的想法。我在 Mythbusting: Concurrent Update/Insert Solutions 中描述了几种方法。我的首选方法是提高隔离级别和微调锁。

至少这是我的偏好。我最近改变了我的方法,使用 gbn 在评论中建议的方法。他将他的方法描述为“TRY CATCH JFDI 模式”。通常我会避免这样的解决方案。有一条经验法则是,开发人员不应依赖捕获控制流的错误或异常。但我昨天打破了这个经验法则。

顺便说一句,我喜欢 gbn 对“JFDI”模式的描述。这让我想起了 Shia Labeouf 的励志视频。


在我看来,这两种解决方案都是可行的。虽然我仍然更喜欢提高隔离级别和微调锁,但@srutzky 的回答也是有效的,并且在您的特定情况下可能会或可能不会提高性能。

也许将来我也会得出与 Michael J. Swart 相同的结论,但我还没有到那里。


这不是我的偏好,但这是我对 Michael J. Stewart 对@gbn 的 Try Catch JFDI程序的改编的改编:

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;
Run Code Online (Sandbox Code Playgroud)

如果您比选择现有值更频繁地插入新值,这可能比@srutzky 的版本性能更高。否则我更喜欢@srutzky 的版本而不是这个版本

Aaron Bertrand 对 Michael J Swart 的帖子的评论链接到他所做的相关测试,并导致了这次交流。摘自关于胜利的丑陋实用主义评论部分:

但是,有时 JFDI 会导致整体性能变差,具体取决于调用失败的百分比。引发异常有大量开销。我在几个帖子中展示了这一点:

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/

亚伦·伯特兰 (Aaron Bertrand) 的评论 — 2016 年 2 月 11 日上午 11:49

和回复:

你是对的 Aaron,我们确实测试了它。

事实证明,在我们的案例中,失败的调用百分比为 0(四舍五入到最接近的百分比时)。

我认为你说明了这一点,即尽可能地根据经验来评估事物,而不是遵循经验法则。

这也是我们添加并非绝对必要的 WHERE NOT EXISTS 子句的原因。

Michael J. Swart 的评论 — 2016 年 2 月 11 日上午 11:57


新链接:


原答案


与使用 相比,我仍然更喜欢Sam Saffron upsert 方法merge,尤其是在处理单行时。

我会采用这种 upsert 方法来适应这种情况:

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;
Run Code Online (Sandbox Code Playgroud)

我会与您的命名保持一致,并且serializable与 相同holdlock,选择一个并在其使用中保持一致。我倾向于使用serializable它,因为它与指定set transaction isolation level serializable.

通过使用serializableholdlock基于其值获取范围锁定@vName,如果它们选择或插入值到dbo.NameLookup包含where子句中的值的任何其他操作等待。

为了使范围锁正常工作,列上需要有一个索引,ItemName这也适用于使用时merge


以下是该过程的主要内容,主要遵循Erland Sommarskog 的错误处理白皮书,使用throw. 如果throw不是您提出错误的方式,请将其更改为与您的其余程序一致:

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
Run Code Online (Sandbox Code Playgroud)

总结一下上面程序中发生的事情: set nocount on; set xact_abort on;就像你总是做的那样,如果我们的输入变量is null或空,select id = cast(null as int)作为结果。如果它不为 null 或为空,则Id保持该位置的同时获取我们的变量,以防它不存在。如果Id有,把它发送出去。如果它不存在,插入它并发送新的Id.

同时,对这个过程的其他调用试图找到相同值的 Id 将等到第一个事务完成,然后选择并返回它。对这个过程的其他调用或其他寻找其他值的语句将继续进行,因为这并不妨碍。

虽然我同意 @srutzky 的观点,即您可以处理此类问题的冲突并吞下异常,但我个人更喜欢尝试定制解决方案,以便在可能的情况下避免这样做。在这种情况下,我不认为使用 from 的锁serializable是一种笨手笨脚的方法,我相信它可以很好地处理高并发。

引用表提示serializable/上的 sql server 文档holdlock

可序列化

相当于HOLDLOCK。通过持有共享锁直到事务完成来使共享锁更具限制性,而不是在不再需要所需的表或数据页时立即释放共享锁,无论事务是否已完成。扫描使用与在 SERIALIZABLE 隔离级别运行的事务相同的语义执行。有关隔离级别的详细信息,请参阅 SET TRANSACTION ISOLATION LEVEL (Transact-SQL)。

引自sql server 文档中关于事务隔离级别的引用serializable

SERIALIZABLE 指定以下内容:

  • 语句无法读取已被其他事务修改但尚未提交的数据。

  • 在当前事务完成之前,没有其他事务可以修改当前事务已读取的数据。

  • 在当前事务完成之前,其他事务不能插入键值落在当前事务中任何语句读取的键范围内的新行。


与上述解决方案相关的链接:

MERGE有一个参差不齐的历史,似乎需要更多的探索来确保代码在所有这些语法下的行为都是你想要的。相关merge文章:

最后一个链接,Kendra Littlemergevsinsert with left join进行了粗略的比较,并警告说“我没有对此进行彻底的负载测试”,但这仍然是一本好书。