因为您使用的是序列,所以可以使用相同的NEXT VALUE FOR函数(您已经在Id
Primary Key字段的Default Constraint中拥有该Id
函数)提前生成新值。首先生成值意味着您不必担心没有它SCOPE_IDENTITY
,这意味着您不需要OUTPUT
子句或执行其他操作SELECT
即可获得新值;在执行之前,您将拥有值INSERT
,并且您甚至不需要弄乱SET IDENTITY INSERT ON / OFF
:-)
这样就可以照顾到整体情况。另一部分是在完全相同的时间处理两个进程的并发问题,而不是为完全相同的字符串找到现有的行,然后继续执行INSERT
。关注点在于避免发生唯一约束违规。
处理这些类型的并发问题的一种方法是强制此特定操作为单线程。做到这一点的方法是使用应用程序锁(跨会话工作)。虽然有效,但对于像这样的情况(碰撞频率可能很低),它们可能会显得有些笨拙。
处理冲突的另一种方法是接受它们有时会发生并处理它们,而不是避免它们。使用该TRY...CATCH
构造,您可以有效地捕获特定错误(在这种情况下:“唯一约束违规”,消息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
因此首先掉入该块的频率会非常低。优化将在99%的时间运行的代码而不是将在1%的时间运行的代码更有意义(除非对这两者进行优化没有成本,但是这里不是这种情况)。
@SqlZim的答案的注释(强调已添加)
我个人更喜欢尝试并制定解决方案,以尽可能避免这样做。在这种情况下,我不认为使用from锁serializable
是徒劳的方法,并且我相信它可以很好地处理高并发性。
如果将第一句话修改为“和_谨慎”,我将同意。仅仅因为某种技术上可行,并不意味着这种情况(即预期的用例)将从中受益。
我看到的这种方法的问题是,它的锁定程度超出了建议的范围。重读有关“可序列化”的引用文档很重要,特别是以下内容(添加了重点):
- 在当前事务完成之前,其他事务不能插入键值将落入当前事务中任何语句读取的键范围内的新行。
现在,这是示例代码中的注释:
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
这里的操作词是“范围”。锁定不仅取决于in中的值@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
由于唯一约束违规而失败时,都会有四个额外的交易日志条目。我还没有看到其他任何答案/帖子中提到的内容。
关于@gbn的“ JFDI”方法,Michael J. Swart的“ Ugly Pragmatism For The Win”帖子,以及Aaron Bertrand对Michael的帖子的评论(关于他的测试显示了哪些情况降低了性能),以及您对“适应Michael J的评论”斯图尔特对@gbn的“尝试捕获JFDI”过程的改编”指出:
如果您要比选择现有值更频繁地插入新值,则这可能比@srutzky的版本更具性能。否则,我更喜欢@srutzky的版本。
关于gbn / Michael / Aaron关于“ JFDI”方法的讨论,将我的建议等同于gbn的“ JFDI”方法是不正确的。由于“获取或插入”操作的性质,非常需要执行SELECT
来获取ID
现有记录的值。此SELECT用作IF EXISTS
检查,这使此方法更等同于Aaron测试的“ CheckTryCatch”变体。Michael的重写代码(以及您对Michael的改编的最终改编)还包括一个WHERE NOT EXISTS
先进行相同检查的代码。因此,我的建议(连同Michael的最终代码以及您对他的最终代码的修改)实际上并不会CATCH
经常出现。可能只有两次会议,ItemName
INSERT...SELECT
在完全相同的时刻,这两个会话在完全相同的时刻都收到“ true” 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
。
随着添加更多的键值,键值之间的范围会变窄,从而降低了在同一时间范围内同时插入多个值的可能性/频率。诚然,这不是主要问题,幸运的是,这似乎是随着时间的推移实际上减少的问题。
上面描述了我的方法的问题:仅当两个会话试图同时插入相同的键值时才会发生。在这方面,归结为发生的可能性更高:同时尝试两个不同但接近的键值,还是同时尝试相同的键值?我想答案就在于执行插入操作的应用程序的结构,但总的来说,我认为插入的是恰好共享同一范围的两个不同值的可能性更大。但是真正知道的唯一方法是在OPs系统上进行测试。
接下来,让我们考虑两种情况以及每种方法如何处理它们:
所有请求都是唯一的键值:
在这种情况下,CATCH
我的建议中的块永远不会输入,因此不会出现“问题”(即4个tran日志条目及其花费的时间)。但是,在“可序列化”方法中,即使所有插入都是唯一的,在相同范围内(尽管时间不会很长),总有可能会阻塞其他插入。
同时请求相同键值的频率很高:
在这种情况下-就传入请求中不存在的键值而言,唯一性非常低- CATCH
我会定期输入建议中的代码块。这样的结果是,每个失败的插入将需要自动回滚并将这4个条目写入事务日志,这每次都会对性能造成轻微的影响。但是整个操作永远都不会失败(至少不是由于这个原因)。
(以前版本的“更新”方法存在一个问题,使它遭受死锁。updlock
添加了提示以解决此问题,并且它不再陷入死锁。)但是,在“可序列化”方法(甚至是更新的优化版本)中,操作将死锁。为什么?因为该serializable
行为仅阻止INSERT
已读取并因此被锁定的范围内的操作;它不会阻止SELECT
在该范围内进行操作。
serializable
在这种情况下,该方法似乎没有额外的开销,并且可能比我建议的性能稍好。
与许多/大多数关于性能的讨论一样,由于会影响结果的因素太多,因此,真正了解某项效果的唯一方法就是在目标环境中进行测试。到那时,这将不再是意见问题了。