如何避免检查约束中的 IX 死锁

Ird*_*dis 3 sql-server deadlock

我有以下情况,我有一个带有主键的表和一个约束,我不能有具有特定要求的行。出于演示目的,这里我有一个不允许在 N 列中插入重复值的约束。在实际情况下,它使用其他表的外键和附加过滤器检查多个列,因此我不能放置简单的唯一约束。所以这里是例子

create table dbo.T1 (
    Id int not null identity (1,1),
    N int not null
)

alter table dbo.T1
add primary key (Id);

go

create function [dbo].[fn_CheckN](@id int, @n int)
returns int
as 
begin
    if exists (select * from dbo.T1 t where t.n = @n and t.Id != @id)
        return 0

    return 1
end

go

alter table [dbo].T1 with nocheck add  constraint [CK_T1_Valid] check  (([dbo].[fn_CheckN]([Id],[N]) = 1))
go

alter table [dbo].T1 check constraint [CK_T1_Valid]
go
Run Code Online (Sandbox Code Playgroud)

当我同时运行时

insert into dbo.T1 (N) 
values (@i)
Run Code Online (Sandbox Code Playgroud)

我在主键上遇到了这个死锁 S -> X, X -> S。我有点理解为什么。死锁xml:https : //pastebin.com/hceR3sum

我第一次尝试解决这个问题是先抓住 S 锁

begin tran 
declare @lock int = (select top(1) 1 from dbo.T1 with (tablock, holdlock))

insert into dbo.T1 (N) 
values (@i)

commit
Run Code Online (Sandbox Code Playgroud)

但它因这个僵局而失败了 S -> IX, IX -> S. 有人能解释一下这是怎么回事吗?死锁 xml:https : //pastebin.com/mLXJb59C

我用 X 锁锁定整个表来修复它。可以吗?有没有更好的方法?

begin tran 
declare @lock int = (select top(1) 1 from dbo.T1 with (tablockx, holdlock))

insert into dbo.T1 (N) 
values (@i)

commit
Run Code Online (Sandbox Code Playgroud)

如果我将索引放在 N 列上,则会出现此死锁https://pastebin.com/KJGmLDhH。真正的要求几乎相同,最简单的情况是有 4 列带有 accountIds 和 enabled 标志,当记录发生问题时,我必须检查帐户 ID 在所有启用的记录中是否唯一。或者如果它被禁用,我没有什么可检查的。类似的东西。

我使用 C# 同时运行查询

class Program
{
    private const string connectionString = "Server=.;Database=Performance;Trusted_Connection=True;MultipleActiveResultSets=True; Max Pool Size=3000";

    static async Task Main(string[] args)
    {
        await ClearAsync();
        await Task.WhenAll(Enumerable.Range(0, 100000).Select(async i => await InsertAsync(i)).ToArray()); 
        Console.WriteLine("Done");
        Console.ReadKey();
    }

    public static async Task InsertAsync(int i)
    {
        using var connection = new SqlConnection(connectionString);
        using var cmd = new SqlCommand(@"
insert into dbo.T1 (N) 
values (@i)
", connection);
        await connection.OpenAsync();
        cmd.Parameters.Add("@i", SqlDbType.Int).Value = i;
        await cmd.ExecuteNonQueryAsync();
    }

    public static async Task ClearAsync()
    {
        using var connection = new SqlConnection(connectionString);
        using var cmd = new SqlCommand("delete from dbo.T1", connection);
        await connection.OpenAsync();
        await cmd.ExecuteNonQueryAsync();
    }
}
Run Code Online (Sandbox Code Playgroud)

我使用 Microsoft SQL Server Express(64 位)13.0.1601.5

Pau*_*ite 10

如果不提供 column 上的索引N,SQL Server 将无法有效检查目标值是否存在,必须扫描表。扫描一直持续到找到匹配项,或者如果不存在匹配项(您的目标案例),则扫描整个表。这是非常低效的,并且每行都会发生。

在默认锁定读提交隔离级别,扫描通常还意味着在测试匹配时获取和释放每一行上的共享锁。

根据在不同连接上插入行(因此排他地锁定)的顺序,相互竞争的活动将导致广泛的共享-排他性阻塞或死锁。

所以你需要一个非聚集索引N,例如:

CREATE NONCLUSTERED INDEX [IX dbo.T1 N]
ON dbo.T1 (N);
Run Code Online (Sandbox Code Playgroud)

有索引

当索引存在时,您可能仍会遇到死锁(如您所见)。这是因为当表较小时,SQL Server 可能会选择扫描非聚集索引,而不是寻找所需的 N 值,然后寻找 id > @id OR id < @id。

这可能会以与原始情况类似的方式导致阻塞或死锁。确切的交互有点复杂,因为 SQL Server 通常会在使用标量函数检查约束之前选择在聚集索引和非聚集索引中插入新行。(在演示场景中可以通过在检查约束后强制 SQL Server 插入非聚集索引来避免这种情况,但我不想进入。)

解决方法

我只想说,除了最专业的从业者之外,所有人都应该避免使用标量函数强制执行检查约束。有太多的怪癖和隐藏的陷阱。当约束不能按预期工作时,您可能最终会得到无效数据,甚至根本不会触发。

请记住,大多数人认为他们比实际情况更专业。也就是说,仅出于教育价值,可以通过强制对非聚集索引进行搜索访问来避免演示中的死锁场景。

我还添加了一个READCOMMITTEDLOCK提示,因为必须使用最近提交的值而不是行版本来验证约束。该demo在使用读提交快照隔离或快照隔离时无法保证唯一性,因为该函数可能会读取过期数据。

CREATE FUNCTION dbo.fn_CheckN
(   
    @id integer, 
    @n integer
)
RETURNS integer
WITH SCHEMABINDING
AS
BEGIN
    RETURN
        CASE WHEN EXISTS 
            (
                SELECT 1
                FROM dbo.T1 AS T 
                    WITH (READCOMMITTEDLOCK, FORCESEEK)
                WHERE T.N = @n
                AND T.Id != @id
            )
            THEN 0
            ELSE 1
        END;
END;
Run Code Online (Sandbox Code Playgroud)

这仍然不是健壮的代码,不适合生产使用。它确实解决了问题中显示的问题。

根据实际需求,您可能可以使用触发器非规范化约束索引视图

其他备注

  • HOLDLOCK并不意味着持有锁。SQL Server 始终持有足够长的锁以保证给定查询规范和配置设置(包括隔离级别)的正确性。HOLDLOCK是 的同义词SERIALIZABLE。对于专家用户来说,它是一个高级选项,可以为特定对象指定可序列化的隔离语义,同时在同一事务中的其他对象上使用不同的级别。
  • TABLOCK确保只使用对象级锁。TABLOCKX确保只使用独占对象级锁。两者都倾向于完全序列化对指定对象的访问,这对于避免争用非常有用——但只是因为您现在根本没有并发性。
  • 聚集索引标识模式将在基表的末尾创建一个非常热门的页面。并发插入都将针对该页面,导致闩锁争用,并降低吞吐量。
  • 您的演示代码指定了 MARS,但您没有使用它。相反,您为每个插入创建一个新连接。我意识到这只是一个快速的测试设备,但仍然如此。

标量函数检查约束未按预期运行的示例(一些链接很旧,但所描述的行为仍然是最新的):

另请注意,检查约束中使用的标量函数不适用于SQL Server 2019+ 上的内联