INSERT <table> (x) VALUES (@x) WHERE NOT EXISTS ( SELECT * FROM <table> WHERE x = @x) 会导

Posted

技术标签:

【中文标题】INSERT <table> (x) VALUES (@x) WHERE NOT EXISTS ( SELECT * FROM <table> WHERE x = @x) 会导致重复吗?【英文标题】:Can INSERT <table> (x) VALUES (@x) WHERE NOT EXISTS ( SELECT * FROM <table> WHERE x = @x) cause duplicates? 【发布时间】:2013-09-30 13:23:50 【问题描述】:

在浏览时,我发现following 问题/讨论关于插入尚不存在的记录的“最佳”方法。让我印象深刻的陈述之一是 [Remus Rusanu] 说:

两种变体都不正确。您将插入重复的@value1、@value2 对,保证。

虽然我同意检查与插入“分离”的语法(并且不存在显式锁定/事务管理);我很难理解为什么以及何时对于看起来像这样的其他建议语法是正确的

INSERT INTO mytable (x)
SELECT @x WHERE NOT EXISTS (SELECT * FROM mytable WHERE x = @x);

我不想开始(另一个)什么是最好/最快的讨论,我也不认为语法可以“替换”唯一索引/约束(或 PK),但我真的需要知道这种构造在什么情况下会导致我过去一直在使用这种语法,所以我想知道将来继续这样做是否不安全。

我认为发生的情况是 INSERT 和 SELECT 都在同一个(隐式)事务中。查询将对相关记录(键)进行 IX 锁定,并且在整个查询完成之前不会释放它,因此只有在插入记录之后。 这个锁会阻止所有其他连接进行相同的 INSERT,因为在我们的插入完成之前它们自己无法获得锁;只有这样他们才能获得锁,并开始自己验证记录是否已经存在。

恕我直言,找出答案的最佳方法是通过测试,我已经在笔记本电脑上运行以下代码一段时间了:

创建表

CREATE TABLE t_test (x int NOT NULL PRIMARY KEY (x))

在下面并行运行很多很多连接)

SET NOCOUNT ON

WHILE 1 = 1
    BEGIN
        INSERT t_test (x)
        SELECT x = DatePart(ms, CURRENT_TIMESTAMP)
         WHERE NOT EXISTS ( SELECT *
                              FROM t_test old
                             WHERE old.x = DatePart(ms, CURRENT_TIMESTAMP) )
    END

到目前为止,唯一需要注意的是:

没有遇到错误(目前) CPU 运行非常热 =) 表快速保存了 300 条记录(由于日期时间的 3 毫秒“精度”),之后不再发生实际插入,正如预期的那样。

更新:

原来我上面的例子并没有按照我的意图去做。而不是多个连接尝试同时插入相同的记录,我只是让它在第一秒后不插入已经存在的记录。由于复制粘贴和在下一个连接上执行查询可能需要大约一秒钟,所以从来没有重复的危险。剩下的时间我都戴着驴耳朵……

无论如何,我已经调整了测试以更符合手头的问题(使用同一张表)

SET NOCOUNT ON

DECLARE @midnight datetime
SELECT @midnight = Convert(datetime, Convert(varchar, CURRENT_TIMESTAMP, 106), 106)

WHILE 1 = 1
    BEGIN
        INSERT t_test (x)
        SELECT x = DateDiff(ms, @midnight, CURRENT_TIMESTAMP)
         WHERE NOT EXISTS ( SELECT *
                              FROM t_test old
                             WHERE old.x = DateDiff(ms, @midnight, CURRENT_TIMESTAMP))
    END

你瞧,输出窗口现在包含大量错误

消息 2627,第 14 级,状态 1,第 8 行 违反主键约束“PK__t_test__3BD019E521C3B7EE”。无法在对象“dbo.t_test”中插入 > 重复键。重复键值为(57581873)。

仅供参考:正如 Andomar 所指出的,添加 HOLDLOCK 和/或 SERIALIZABLE 提示确实“解决”了问题,但结果却导致了很多死锁......当我认为这不是很好但也不意外它通过了。

我想我有很多代码审查要做......

【问题讨论】:

你刚刚用你的标题在 SO 中创建了 sql 注入 呵呵,等到“我的儿子”报名参加吧! (xkcd.com/327) 为了避免死锁,您可以将UPDLOCK 添加到组合中。这将序列化对x 上的范围锁的访问。 @Martin Smith:同时使用HOLDLOCKUPDLOCK 确实可以解决问题。有趣的副作用是,当工作负载由于锁定而序列化时,它将活动减少到 2 个内核 100% 运行。 好,除了现在有一堆额外的工作来处理一堆(旧)代码之外,我现在还困扰关于如何“拆分”点...我要投票赞成 Andomar,因为它帮助我意识到我在(坏的)测试脚本中的错误,但接受 Remus 的回答,因为它提出了一个关于这将如何失败的示例。感谢@Martin 的 UPDLOCK 指针! 【参考方案1】:

感谢您发布单独的问题。你有几个误解:

查询将对相关记录(键)进行 IX 锁定,并且在整个查询完成之前不会释放它

INSERT 将锁定插入的行,X 锁定(像 IX 这样的意图锁定只能在锁定层次结构上的父实体上请求,而不能在记录上请求)。这个锁必须一直保持到事务提交(严格的two-phase locking 要求 X 锁总是只在事务结束时才释放)。

注意,INSERT 获得的锁不会阻止更多的插入即使是同一个键。防止重复的唯一方法是唯一索引,并且强制唯一性的机制不是基于锁的。是的,在主键上,由于其唯一性,可以防止重复,但作用不同,即使锁定确实起作用。

在您的示例中,由于新插入行上的 X 与 S 锁冲突,操作将序列化,因为 INSERT 上的 SELECT 阻塞。另一个需要考虑的想法是 300 条 INT 类型的记录可以放在一个页面上,并且会进行很多优化(例如,使用扫描而不是多次搜索)并且会改变测试结果。请记住,一个有很多肯定但没有证据的假设仍然只是一个猜想......

要测试问题,您需要确保 INSERT 不会阻塞并发 SELECT。在 RCSI 或快照隔离下运行是实现这一目标的一种方法(并且可能会在生产中不由自主地“实现”它并破坏做出上述所有假设的应用程序......) WHERE 子句是另一种方式。一个非常大的表和二级索引是另一种方式。

这就是我的测试方法:

set nocount on;
go

drop database test;
go

create database test;
go

use test;
go

create table test (id int primary key, filler char(200));
go

-- seed 10000 values, fill some pages
declare @i int = 0;
begin transaction
while @i < 10000
begin
    insert into test (id) values (@i);
    set @i += 1;
end
commit;

现在从几个并行连接运行这个(我用了 3 个):

use test;
go

set nocount on;
go

declare @i int;
while (1=1)
begin
    -- This is not cheating. This ensures that many concurrent SELECT attempt 
    -- to insert the same values, and all of them believe the values are 'free'
    select @i = max(id) from test with (readpast);
    insert into test (id)
    select id
        from (values (@i), (@i+1), (@i+2), (@i+3), (@i+4), (@i+5)) as t(id)
        where t.id not in (select id from test);
end

以下是一些结果:

Msg 2627, Level 14, State 1, Line 6
Violation of PRIMARY KEY constraint 'PK__test__3213E83FD9281543'. Cannot insert duplicate key in object 'dbo.test'. The duplicate key value is (130076).
The statement has been terminated.
Msg 2627, Level 14, State 1, Line 6
Violation of PRIMARY KEY constraint 'PK__test__3213E83FD9281543'. Cannot insert duplicate key in object 'dbo.test'. The duplicate key value is (130096).
The statement has been terminated.
Msg 2627, Level 14, State 1, Line 6
Violation of PRIMARY KEY constraint 'PK__test__3213E83FD9281543'. Cannot insert duplicate key in object 'dbo.test'. The duplicate key value is (130106).
The statement has been terminated.
Msg 2627, Level 14, State 1, Line 6
Violation of PRIMARY KEY constraint 'PK__test__3213E83FD9281543'. Cannot insert duplicate key in object 'dbo.test'. The duplicate key value is (130121).
The statement has been terminated.
Msg 2627, Level 14, State 1, Line 6
Violation of PRIMARY KEY constraint 'PK__test__3213E83FD9281543'. Cannot insert duplicate key in object 'dbo.test'. The duplicate key value is (130141).
The statement has been terminated.
Msg 2627, Level 14, State 1, Line 6
Violation of PRIMARY KEY constraint 'PK__test__3213E83FD9281543'. Cannot insert duplicate key in object 'dbo.test'. The duplicate key value is (130151).
The statement has been terminated.
Msg 2627, Level 14, State 1, Line 6
Violation of PRIMARY KEY constraint 'PK__test__3213E83FD9281543'. Cannot insert duplicate key in object 'dbo.test'. The duplicate key value is (130176).
The statement has been terminated.
Msg 2627, Level 14, State 1, Line 6

即使有锁定、没有快照隔离、没有 RCSI。当每个 SELECT 尝试插入 @i+1...@i+5 时,他们都会发现这些值不存在,然后他们都会继续进行 INSERT。一名幸运的获胜者将成功,其余的将导致PK违例。频繁地。我故意使用@i=MAX(id) 来显着增加冲突的追逐,但这不是必需的。我将把弄清楚为什么所有违规都发生在值 %5+1 上的问题留作练习。

【讨论】:

“请注意,INSERT 获得的锁不会阻止更多的插入,即使是同一个键”我同意非唯一索引。但这不能通过 X 范围锁定(通常的 XLOCK, ROWLOCK, HOLDLOCK)来解决吗? @Usr:是的,它可以,但是 OP(以及开始讨论的原始问题)假设它即使没有额外的提示也能工作。我的意思是证明它不是。 @Remus:感谢您的详尽回答;事实证明我错了很多很多年;-(我知道我很久以前就测试过了,但可能像我刚才那样犯了一个(同样的?)错误,叹息。很高兴我发现您的帖子。似乎我们很幸运,多年来我们从未遇到过重复/PK 违规;可能是因为我们的软件比 OLTP 应用程序具有更多的 DWH...【参考方案2】:

您正在从单个连接进行测试,因此您根本没有测试并发性。从不同的窗口运行脚本两次,您将开始看到冲突。

冲突的原因有多种:

默认情况下,直到(隐式)事务结束时才会持有锁。使用 with (holdlock) 查询提示更改此行为。 查询的并发问题称为“幻读”。默认事务隔离级别是“已提交读”,它不能防止幻读。使用with (serializable) 查询提示来提高隔离级别。 (尽量避免set transaction isolation level命令,因为isolation level is not cleared当一个连接返回到连接池。)

始终强制执行主键约束。因此,您的查询将尝试插入重复行并通过抛出重复键错误而失败。

一个好的方法是使用您的查询(这将在 99% 的时间内有效)并让客户端以优雅的方式处理偶尔出现的重复键异常。

***有一个很棒的explanation of isolation levels。

【讨论】:

实际上,正如代码中的注释所说:(创建表一次,在许多并行连接上运行下面)不确定我同时运行了多少个连接,但它很接近到 20 岁。 不确定寻求查询的冲突程度。通过在 while 循环中插入 2 个窗口 max(id) + 1 很容易重现。 显然不是;我有大约 20 个连接并行运行循环大约半小时。他们都没有提出PK违规。 datepart(ms, current_timestamp) 1 秒后不会返回任何新值。跑半个小时是没有意义的。您可以看到 SQL Server 不会通过如下查询保持读取锁定:begin transaction; select * from AnyTable; select * from sys.dm_tran_locks where request_session_id = @@spid; commit transaction;。您将看到的唯一锁是连接的活动数据库锁。 with (holdlock) 确实表示可序列化。

以上是关于INSERT <table> (x) VALUES (@x) WHERE NOT EXISTS ( SELECT * FROM <table> WHERE x = @x) 会导的主要内容,如果未能解决你的问题,请参考以下文章

Laravel DB::insert() 和 DB::table()->insert() 的区别

zf2 插入使用 $db->insert($table, $data);风格

如何使用 INSERT-SELECT 查询加载具有复杂数据类型的 Hive 表

有20万条数据,使用mysql数据库,insert与update哪个速度快;

[Mybatis][Generator,Insert返回自增id]

[Mybatis][Generator,Insert返回自增id]