47

在 SQL Server 2005 中执行原子“UPSERT”(如果存在则更新,否则插入)的正确模式是什么?

我在 SO 上看到了很多代码(例如,请参阅Check if a row exists, else insert)具有以下两部分模式:

UPDATE ...
FROM ...
WHERE <condition>
-- race condition risk here
IF @@ROWCOUNT = 0
  INSERT ...

或者

IF (SELECT COUNT(*) FROM ... WHERE <condition>) = 0
  -- race condition risk here
  INSERT ...
ELSE
  UPDATE ...

其中 < 条件 > 将是对自然键的评估。上述方法似乎都不能很好地处理并发。如果我不能有两行具有相同的自然键,那么上述所有风险似乎都存在在竞争条件场景中插入具有相同自然键的行的风险。

我一直在使用以下方法,但我很惊讶在人们的反应中没有看到它,所以我想知道它有什么问题:

INSERT INTO <table>
SELECT <natural keys>, <other stuff...>
FROM <table>
WHERE NOT EXISTS
   -- race condition risk here?
   ( SELECT 1 FROM <table> WHERE <natural keys> )

UPDATE ...
WHERE <natural keys>

请注意,这里提到的竞争条件与前面代码中的竞争条件不同。在较早的代码中,问题是幻读(行被另一个会话插入到 UPDATE/IF 之间或 SELECT/INSERT 之间)。在上面的代码中,竞争条件与 DELETE 有关。在(WHERE NOT EXISTS)执行之后但在 INSERT 执行之前,是否有可能由另一个会话删除匹配的行?目前尚不清楚 WHERE NOT EXISTS 在何处与 UPDATE 一起锁定任何内容。

这是原子的吗?我无法找到这将在 SQL Server 文档中记录的位置。

编辑: 我意识到这可以通过事务来完成,但我认为我需要将事务级别设置为 SERIALIZABLE 以避免幻读问题?对于这样一个常见的问题,这肯定是矫枉过正吗?

4

5 回答 5

28
INSERT INTO <table>
SELECT <natural keys>, <other stuff...>
FROM <table>
WHERE NOT EXISTS
   -- race condition risk here?
   ( SELECT 1 FROM <table> WHERE <natural keys> )

UPDATE ...
WHERE <natural keys>
  • 第一个 INSERT 中存在竞争条件。键在内部查询 SELECT 期间可能不存在,但在 INSERT 时确实存在,从而导致键冲突。
  • INSERT 和 UPDATE 之间存在竞争条件。在 INSERT 的内部查询中检查时,该键可能存在,但在 UPDATE 运行时已消失。

对于第二个竞争条件,人们可能会争辩说该密钥无论如何都会被并发线程删除,因此它并不是真正的丢失更新。

最佳解决方案通常是尝试最可能的情况,并在失败时处理错误(当然是在事务内部):

  • 如果密钥可能丢失,请始终先插入。处理唯一约束违规,回退到更新。
  • 如果密钥可能存在,请始终先更新。如果没有找到行,则插入。处理可能的唯一约束违规,回退到更新。

除了正确性之外,这种模式对于速度也是最佳的:尝试插入和处理异常比进行虚假锁定更有效。锁定意味着逻辑页面读取(可能意味着物理页面读取),IO(甚至逻辑)比SEH更昂贵。

更新@彼得

为什么单个语句不是“原子的”?假设我们有一个简单的表:

create table Test (id int primary key);

现在,如果我在一个循环中从两个线程运行此单个语句,它将是“原子的”,正如您所说,不存在竞争条件:

  insert into Test (id)
    select top (1) id
    from Numbers n
    where not exists (select id from Test where id = n.id); 

然而,仅在几秒钟内,就会发生主键违规:

消息 2627,级别 14,状态 1,第 4 行
违反主键约束“PK__Test__24927208”。无法在对象“dbo.Test”中插入重复键。

这是为什么?您是正确的,因为 SQL 查询计划将在DELETE ... FROM ... JOIN、 onWITH cte AS (SELECT...FROM ) DELETE FROM cte和许多其他情况下做“正确的事情”。但是在这些情况下有一个关键的区别:“子查询”是指更新删除操作的目标。对于这种情况,查询计划确实会使用适当的锁,事实上我这种行为在某些情况下很关键,比如在实现队列时使用表作为队列

但是在原始问题以及我的示例中,查询优化器将子查询视为查询中的子查询,而不是某些需要特殊锁定保护的特殊“扫描更新”类型查询。结果是子查询查找的执行可以被并发观察者观察为不同的操作,从而破坏了语句的“原子”行为。除非采取特殊预防措施,否则多个线程可以尝试插入相同的值,都确信它们已经检查过并且该值不存在。只有一个能成功,另一个会打PK违例。QED。

于 2010-03-26T17:29:25.170 回答
7

在测试行是否存在时传递 updlock、rowlock、holdlock 提示。Holdlock 确保所有插入都是序列化的;rowlock 允许对现有行进行并发更新。

如果您的 PK 是 bigint,更新可能仍会阻塞,因为内部散列对于 64 位值是退化的。

begin tran -- default read committed isolation level is fine

if not exists (select * from <table> with (updlock, rowlock, holdlock) where <PK = ...>
    -- insert
else
    -- update

commit
于 2010-04-15T13:03:11.420 回答
3

编辑:Remus 是正确的,带 where 子句的条件插入不能保证相关子查询和表插入之间的状态一致。

也许正确的表格提示可以强制保持一致的状态。INSERT <table> WITH (TABLOCKX, HOLDLOCK)似乎可行,但我不知道这是否是条件插入的最佳锁定级别。

在像 Remus 所描述的那样的一个简单测试中,TABLOCKX, HOLDLOCK显示了大约 5 倍的插入量,没有表格提示,并且没有 PK 错误或课程。

原始答案,不正确:

这是原子的吗?

是的,带 where 子句的条件插入是原子的,您的INSERT ... WHERE NOT EXISTS() ... UPDATE表单是执行 UPSERT 的正确方法。

我会IF @@ROWCOUNT = 0在 INSERT 和 UPDATE 之间添加:

INSERT INTO <table>
SELECT <natural keys>, <other stuff...>
WHERE NOT EXISTS
   -- no race condition here
   ( SELECT 1 FROM <table> WHERE <natural keys> )

IF @@ROWCOUNT = 0 BEGIN
  UPDATE ...
  WHERE <natural keys>
END

单个语句总是在事务中执行,无论是它们自己的(自动提交隐式)还是与其他语句(显式)一起执行。

于 2010-03-27T01:01:09.097 回答
2

您可以使用应用程序锁:(sp_getapplock) http://msdn.microsoft.com/en-us/library/ms189823.aspx

于 2010-03-26T12:45:42.860 回答
2

我见过的一个技巧是尝试 INSERT,如果失败,则执行 UPDATE。

于 2010-03-26T11:57:58.510 回答