MERGE 死锁预防

i-o*_*one 15 sql-server deadlock execution-plan merge

在我们的一个数据库中,我们有一个由多个线程密集并发访问的表。线程确实通过MERGE. 还有一些线程偶尔会删除行,因此表数据非常不稳定。执行 upsert 的线程有时会陷入死锁。该问题看起来与问题中描述的问题相似。不过,不同之处在于,在我们的例子中,每个线程都只更新或插入一行

简化设置如下。该表是堆,上面有两个唯一的非聚集索引

CREATE TABLE [Cache]
(
    [UID] uniqueidentifier NOT NULL CONSTRAINT DF_Cache_UID DEFAULT (newid()),
    [ItemKey] varchar(200) NOT NULL,
    [FileName] nvarchar(255) NOT NULL,
    [Expires] datetime2(2) NOT NULL,
    CONSTRAINT [PK_Cache] PRIMARY KEY NONCLUSTERED ([UID])
)
GO
CREATE UNIQUE INDEX IX_Cache ON [Cache] ([ItemKey]);
GO
Run Code Online (Sandbox Code Playgroud)

典型的查询是

DECLARE
    @itemKey varchar(200) = 'Item_0F3C43A6A6A14255B2EA977EA730EDF2',
    @fileName nvarchar(255) = 'File_0F3C43A6A6A14255B2EA977EA730EDF2.dat';

MERGE INTO [Cache] WITH (HOLDLOCK) T
USING (
    VALUES (@itemKey, @fileName, dateadd(minute, 10, sysdatetime()))
) S(ItemKey, FileName, Expires)
ON T.ItemKey = S.ItemKey
WHEN MATCHED THEN
    UPDATE
    SET
        T.FileName = S.FileName,
        T.Expires = S.Expires
WHEN NOT MATCHED THEN
    INSERT (ItemKey, FileName, Expires)
    VALUES (S.ItemKey, S.FileName, S.Expires)
OUTPUT deleted.FileName;
Run Code Online (Sandbox Code Playgroud)

即,匹配通过唯一索引键发生。提示HOLDLOCK在这里,因为并发性(如建议here)。

我做了一些小调查,以下是我发现的。

在大多数情况下,查询执行计划是

索引查找执行计划

使用以下锁定模式

索引查找锁定模式

IX锁定对象,然后是更细粒度的锁定。

然而,有时查询执行计划是不同的

表扫描执行计划

(这个计划形状可以通过添加INDEX(0)提示来强制),其锁定模式为

表扫描锁定模式

通知X锁定已放置在对象上IX

由于两个IX兼容,但两个X不兼容,并发下发生的事情是

僵局

死锁图

僵局

这里出现了问题的第一部分。符合条件X后是否锁定对象IX?不是bug吗?

文档说明:

意向锁之所以被称为意向锁,是因为它们是在较低级别的锁之前获取的,因此表示意图将锁放置在较低级别

并且

IX 表示只更新部分行而不是所有行的意图

所以,在我看来非常可疑X之后锁定对象IX

首先,我尝试通过添加表锁定提示来防止死锁

MERGE INTO [Cache] WITH (HOLDLOCK, TABLOCK) T
Run Code Online (Sandbox Code Playgroud)

MERGE INTO [Cache] WITH (HOLDLOCK, TABLOCKX) T
Run Code Online (Sandbox Code Playgroud)

TABLOCK到位锁定模式变

合并holdlock tabblock锁定模式

TABLOCKX锁定模式是

合并holdlock tabblockx 锁定模式

由于两个SIX(以及两个X)不兼容,因此可以有效地防止死锁,但不幸的是,也防止了并发(这是不希望的)。

我的下一个尝试是添加PAGLOCKROWLOCK使锁更细化并减少争用。两者都没有影响(X在 之后仍然立即观察到对象IX)。

我的最后一次尝试是通过添加FORCESEEK提示来强制使用具有良好粒度锁定的“良好”执行计划形状

MERGE INTO [Cache] WITH (HOLDLOCK, FORCESEEK(IX_Cache(ItemKey))) T
Run Code Online (Sandbox Code Playgroud)

它奏效了。

这里出现了问题的第二部分。会不会发生这种情况FORCESEEK会被忽略并使用错误的锁定模式?(正如我所提到的,PAGLOCKROWLOCK被看似忽略)。


添加UPDLOCK没有效果(X在对象之后仍然可以观察到IX)。

IX_Cache正如预期的那样,使索引聚集工作。它导致了聚集索引搜索和粒度锁定的计划。此外,我还尝试强制显示粒度锁定的聚集索引扫描

然而。额外的观察。在原始设置中,即使FORCESEEK(IX_Cache(ItemKey)))在适当的位置,如果将@itemKey变量声明从varchar(200)更改为nvarchar(200),则执行计划变为

使用 nvarchar 索引查找执行计划

看到使用了seek,但是在这种情况下的锁定模式再次显示X锁定后放置在对象上IX

因此,似乎强制查找不一定保证粒度锁(因此不存在死锁)。我不相信聚集索引能保证粒度锁定。或者是吗?

我的理解(如果我错了请纠正我)是锁定在很大程度上是情境性的,并且某些执行计划形状并不意味着某些锁定模式。

关于仍然打开X后锁定对象的资格问题IX。如果它符合条件,是否可以采取一些措施来防止对象锁定?

Pau*_*ite 13

在对象上放置IX后跟是否X符合条件?是不是bug?

看起来有点奇怪,但它是有效的。在IX获取时,意图很可能是X在较低级别获取锁。没有什么可以说这种锁实际上必须被采取。毕竟,在较低级别可能没有任何东西可以锁定;引擎无法提前知道。此外,可能会有一些优化,以便可以跳过较低级别的锁(可以在此处查看ISS锁的示例)。

更具体地说,对于当前场景,确实可序列化的键范围锁不可用于堆,因此唯一的选择是X对象级别的锁。从这个意义上说,X如果访问方法是堆扫描,引擎可能能够及早检测到锁将不可避免地需要,因此避免获取IX锁。

另一方面,锁定是复杂的,有时可能出于内部原因而采用意图锁,而与采用较低级别锁的意图不一定相关。采取IX可能是为一些模糊的边缘情况提供所需保护的侵入性最小的方式。对于类似的考虑,请参阅在 IsolationLevel.ReadUncommitted 上发布的共享锁

因此,当前的情况对于您的死锁场景来说是不幸的,并且原则上可能是可以避免的,但这不一定与“错误”相同。如果您需要明确的答案,您可以通过您的正常支持渠道或 Microsoft Connect 报告问题。

会不会发生这种情况FORCESEEK会被忽略并使用错误的锁定模式?

FORCESEEK不是一个提示,而是一个指令。如果优化器找不到符合“提示”的计划,它将产生错误。

强制索引是确保可以采取键范围锁定的一种方式。连同在处理要更改的行的访问方法时自然采用的更新锁,这提供了足够的保证来避免您的场景中的并发问题。

如果表的模式没有改变(例如添加新索引),提示也足以避免此查询与自身发生死锁。仍然有可能与其他可能在非聚集索引之前访问堆的查询(例如对非聚集索引的键的更新)发生循环死锁。

...变量声明从varchar(200)nvarchar(200)...

这打破了单行会受到影响的保证,因此引入了 Eager Table Spool 以用于万圣节保护。作为对此的进一步解决方法,请使用MERGE TOP (1) INTO [Cache]....

我的理解 [...] 是锁定在很大程度上是情境性的,某些执行计划形状并不意味着某些锁定模式。

在执行计划中可以看到的当然还有更多。您可以使用计划指南等强制某个计划形状,但引擎可能仍会决定在运行时采用不同的锁定。如果您包含上述TOP (1)元素,则机会相当低。

一般说明

以这种方式使用堆表有点不寻常。您应该考虑将其转换为聚簇表的优点,也许使用 Dan Guzman 在评论中建议的索引:

CREATE UNIQUE CLUSTERED INDEX IX_Cache ON [Cache] ([ItemKey]);
Run Code Online (Sandbox Code Playgroud)

这可能具有重要的空间重用优势,并为当前的死锁问题提供了一个很好的解决方法。

MERGE在高并发环境中也有点不寻常。有点违反直觉,执行单独的INSERTandUPDATE语句通常更有效,例如:

DECLARE
    @itemKey varchar(200) = 'Item_0F3C43A6A6A14255B2EA977EA730EDF2',
    @fileName nvarchar(255) = 'File_0F3C43A6A6A14255B2EA977EA730EDF2.dat';

BEGIN TRANSACTION;

    DECLARE @expires datetime2(2) = DATEADD(MINUTE, 10, SYSDATETIME());

    UPDATE TOP (1) dbo.Cache WITH (SERIALIZABLE, UPDLOCK)
    SET [FileName] = @fileName,
        Expires = @expires
    OUTPUT Deleted.[FileName]
    WHERE
        ItemKey = @itemKey;

    IF @@ROWCOUNT = 0
        INSERT dbo.Cache
            (ItemKey, [FileName], Expires)
        VALUES
            (@itemKey, @fileName, @expires);

COMMIT TRANSACTION;
Run Code Online (Sandbox Code Playgroud)

请注意不再需要 RID 查找:

执行计划

如果您可以保证存在唯一索引ItemKey(如问题中所述)TOP (1),则UPDATE可以删除 中的冗余,给出更简单的计划:

简化更新

双方INSERTUPDATE计划资格在任何情况下微不足道的计划。MERGE总是需要完全基于成本的优化。

请参阅相关的问答SQL Server 2014 并发输入问题以了解要使用的正确模式,以及有关MERGE.

死锁并不总是可以避免的。通过仔细的编码和设计,它们可以减少到最小,但应用程序应该始终准备好优雅地处理奇怪的死锁(例如,重新检查条件然后重试)。

如果您可以完全控制访问相关对象的进程,您还可以考虑使用应用程序锁来序列化对单个元素的访问,如SQL Server 并发插入和删除中所述