【问题标题】:SQL Server deadlock when only inserting new rows without performing any selects仅插入新行而不执行任何选择时的 SQL Server 死锁
【发布时间】:2023-04-03 01:57:01
【问题描述】:

我的应用程序中经常出现死锁,即使它没有执行选择语句、删除语句和更新语句。它只是插入全新的数据。

TL;DR:好像和外键有关。如果我删除它,那么我根本不会遇到任何死锁。但出于显而易见的原因,这不是一个可接受的解决方案。

给定以下表结构

CREATE TABLE [dbo].[IncomingFile]
(
    [Id] UNIQUEIDENTIFIER NOT NULL, 
    [ConcurrencyVersion] RowVersion NOT NULL,
    CONSTRAINT [PK_IncomingFile] PRIMARY KEY CLUSTERED([Id])
)
GO

CREATE TABLE [dbo].[IncomingFileEvent]
(
    [Id] UNIQUEIDENTIFIER NOT NULL, 
    [ConcurrencyVersion] RowVersion NOT NULL,
    [IncomingFileId] UNIQUEIDENTIFIER NOT NULL,
    CONSTRAINT [PK_IncomingFileEvent] PRIMARY KEY CLUSTERED([Id]),
    CONSTRAINT [FK_IncomingFileEvent_IncomingFileId]
        FOREIGN KEY ([IncomingFileId])
        REFERENCES [dbo].[IncomingFile] ([Id])
)
GO

当我遇到多个插入数据的并发任务时,我总是会看到死锁。 READ_COMMITTED_SNAPSHOT 在我的数据库选项中启用(即使我没有在阅读)。

这里是重现问题的代码。如果您没有遇到问题,请增加程序顶部的NumberOfTasksPerCpu 常量。

using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Diagnostics;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace SqlServerDeadlockRepro
{
    class Program
    {
        private const int NumberOfTasksPerCpu = 8; // Keep increasing this by one if you do not get a deadlock!
        private const int NumberOfChildRows = 1_000;
        private const string MSSqlConnectionString = "Server=DESKTOP-G05BF1U;Database=EFCoreConcurrencyTest;Trusted_Connection=True;";

        private static int NumberOfConcurrentTasks;

        static async Task Main(string[] args)
        {
            NumberOfConcurrentTasks = Environment.ProcessorCount * NumberOfTasksPerCpu;

            var readySignals = new Queue<ManualResetEvent>();
            var trigger = new ManualResetEvent(false);
            var processingTasks = new List<Task>();
            for (int index = 0; index < NumberOfConcurrentTasks; index++)
            {
                var readySignal = new ManualResetEvent(false);
                readySignals.Enqueue(readySignal);
                var task = CreateDataWithSqlCommand(trigger, readySignal);
                processingTasks.Add(task);
            }
            Console.WriteLine("Waiting for tasks to become ready");
            while (readySignals.Count > 0)
            {
                var readySignalBatch = new List<WaitHandle>();
                for(int readySignalCount = 0; readySignals.Count > 0 && readySignalCount < 64; readySignalCount++)
                {
                    readySignalBatch.Add(readySignals.Dequeue());
                }
                WaitHandle.WaitAll(readySignalBatch.ToArray());
            }
            Console.WriteLine("Saving data");
            var sw = Stopwatch.StartNew();
            trigger.Set();
            await Task.WhenAll(processingTasks.ToArray());
            sw.Stop();
            Console.WriteLine("Finished - " + sw.ElapsedMilliseconds);
        }

        private static int TaskNumber = 0;
        private static async Task CreateDataWithSqlCommand(ManualResetEvent trigger, ManualResetEvent readySignal)
        {
            await Task.Yield();
            using var connection = new SqlConnection(MSSqlConnectionString);
            await connection.OpenAsync().ConfigureAwait(false);
            var transaction = (SqlTransaction)await connection.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted).ConfigureAwait(false);

            Console.WriteLine("Task " + Interlocked.Increment(ref TaskNumber) + $" of {NumberOfConcurrentTasks}  ready ");
            readySignal.Set();
            trigger.WaitOne();
            Guid parentId = Guid.NewGuid();
            string fileCommandSql = "insert into IncomingFile (Id) values (@Id)";
            using var fileCommand = new SqlCommand(fileCommandSql, connection, transaction);
            fileCommand.Parameters.Add("@Id", System.Data.SqlDbType.UniqueIdentifier).Value = parentId;
            await fileCommand.ExecuteNonQueryAsync().ConfigureAwait(false);

            using var fileEventCommand = new SqlCommand
            {
                Connection = connection,
                Transaction = transaction
            };
            var commandTextBulder = new StringBuilder("INSERT INTO [IncomingFileEvent] ([Id], [IncomingFileId]) VALUES ");
            for (var i = 1; i <= NumberOfChildRows * 2; i += 2)
            {
                commandTextBulder.Append($"(@p{i}, @p{i + 1})");
                if (i < NumberOfChildRows * 2 - 1)
                    commandTextBulder.Append(',');

                fileEventCommand.Parameters.AddWithValue($"@p{i}", Guid.NewGuid());
                fileEventCommand.Parameters.AddWithValue($"@p{i + 1}", parentId);
            }

            fileEventCommand.CommandText = commandTextBulder.ToString();
            await fileEventCommand.ExecuteNonQueryAsync().ConfigureAwait(false);
            await transaction.CommitAsync().ConfigureAwait(false);
        }
    }
}

更新

还尝试制作主键NONCLUSTERED 并根据当前日期和时间添加CLUSTERED 索引。

CREATE TABLE [dbo].[IncomingFile]
(
    [Id] UNIQUEIDENTIFIER NOT NULL, 
    [ConcurrencyVersion] RowVersion NOT NULL,
    [CreatedUtc] DateTime2 DEFAULT GETDATE(),
    CONSTRAINT [PK_IncomingFile] PRIMARY KEY NONCLUSTERED([Id])
)
GO
CREATE CLUSTERED INDEX [IX_IncomingFile_CreatedUtc] on [dbo].[IncomingFile]([CreatedUtc])
GO

CREATE TABLE [dbo].[IncomingFileEvent]
(
    [Id] UNIQUEIDENTIFIER NOT NULL, 
    [ConcurrencyVersion] RowVersion NOT NULL,
    [IncomingFileId] UNIQUEIDENTIFIER NOT NULL,
    [CreatedUtc] DateTime2 DEFAULT GETDATE(),
    CONSTRAINT [PK_IncomingFileEvent] PRIMARY KEY NONCLUSTERED([Id]),
    CONSTRAINT [FK_IncomingFileEvent_IncomingFileId]
        FOREIGN KEY ([IncomingFileId])
        REFERENCES [dbo].[IncomingFile] ([Id])
)
GO
CREATE CLUSTERED INDEX [IX_IncomingFileEvent_CreatedUtc] on [dbo].[IncomingFileEvent]([CreatedUtc])
GO

更新 2

我尝试了从here 获取的顺序 guid,但没有任何区别。

更新 3

好像和外键有关。如果我删除它,那么我根本不会遇到任何死锁。

更新 4

Sql Server 产品组的回复以及一些建议已发布在我原来的 github 问题上。

https://github.com/dotnet/efcore/issues/21899#issuecomment-683404734​​​​​​​

【问题讨论】:

  • 选项,将主键设为 `PRIMARY KEY NONCLUSTERED` - 这将使表堆成堆,您可能仍想添加人工聚集索引(身份,...)。第二种选择 - 如果不需要随机的 guid,您可以从代码中生成顺序 guid。
  • 只需按照接受的答案中的建议添加索引或重做主键。
  • 添加了索引,没有区别。我也尝试了复合PK,但也没有解决它。使 PK 索引为 NON CLUSTERED,仍然没有区别。

标签: sql-server sqlconnection database-deadlocks sqltransaction


【解决方案1】:

死锁是由于检查参照完整性所需的执行计划造成的。将大量 (1K) 行插入相关的 IncomingFileEvent 表时,将执行 IncomingFile 表的全表扫描。扫描获取一个在事务期间持有的共享表锁,当不同的会话各自对刚刚插入的IncomingFile 行持有独占行锁并被另一个会话独占行锁阻塞时,会导致死锁。

下面是显示这一点的执行计划:

避免死锁的一种方法是在 IncomingFileEvent 插入查询上使用 OPTION (LOOP JOIN) 查询提示:

    var commandTextBulder = new StringBuilder("INSERT INTO [IncomingFileEvent] ([Id], [IncomingFileId]) VALUES ");
    for (var i = 1; i <= NumberOfChildRows * 2; i += 2)
    {
        commandTextBulder.Append($"(@p{i}, @p{i + 1})");
        if (i < NumberOfChildRows * 2 - 1)
            commandTextBulder.Append(',');

        fileEventCommand.Parameters.AddWithValue($"@p{i}", Guid.NewGuid());
        fileEventCommand.Parameters.AddWithValue($"@p{i + 1}", parentId);
    }
    commandTextBulder.Append(" OPTION (LOOP JOIN);");

这是带有提示的计划:

另一方面,考虑将现有主键更改为以下主键。从数据建模的角度(识别关系)来看,这更正确,并且会提高插入和选择的性能,因为相关的行在物理上聚集在一起。

CONSTRAINT [PK_IncomingFileEvent] PRIMARY KEY CLUSTERED(IncomingFileId, Id)

【讨论】:

  • 谢谢!这是唯一的方法(除了删除 FK)吗?我问是因为我在任何地方都建议不要将连接提示传递给优化器。
  • @PeterMorris,虽然并不理想,但documentation 声明“我们建议仅将提示作为有经验的开发人员和数据库管理员的最后手段”。当IncomingFile 表只有几行时需要提示以避免死锁,但当表较大时不需要提示,因为 SQL Server 将创建与提示版本相同的计划。
  • 为什么需要告诉 sql server 呢?我测试的其他数据库没有这个问题。
  • @PeterMorris,我很少需要求助于 SQL Server 中的提示,但这是一个更糟糕的情况,数十个(在我的工作站上)同步线程都命中计划优化的空/小表为了性能而不是最大化并发性。还有其他方法可以在没有死锁的情况下提高性能。例如,我运行了 1K 次迭代的单线程测试,每次通过表值参数插入 IncomingFile 行和 1K 相关 IncomingFileEvent 行。测试在大约 10 秒内运行,在 FK 就位的情况下插入了 1M 行。
  • 有争议的是这是一个错误,但请随时在feedback forum 上报告它。一些应用程序/架构设计容易出现死锁。
【解决方案2】:

我编写了以下扩展来解决 EF Core 的问题。

protected override void OnConfiguring(DbContextOptionsBuilder options)
{
    base.OnConfiguring(options);
    options.UseLoopJoinQueries();
}

使用此代码...

    public static class UseLoopJoinQueriesExtension
    {
        public static DbContextOptionsBuilder UseLoopJoinQueries(this DbContextOptionsBuilder builder)
        {
            if (builder is null)
                throw new ArgumentNullException(nameof(builder));

            builder.AddInterceptors(new OptionLoopJoinCommandInterceptor());
            return builder;
        }
    }

    internal class OptionLoopJoinCommandInterceptor : DbCommandInterceptor
    {
        public override Task<InterceptionResult<DbDataReader>> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default)
        {
            AppendOptionToSql(command);
            return Task.FromResult(result);
        }

        public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
        {
            AppendOptionToSql(command);
            return result;
        }

        private static void AppendOptionToSql(DbCommand command)
        {
            const string OPTION_TEXT = " OPTION (LOOP JOIN)";
            string[] commands = command.CommandText.Split(";");

            for (int index = 0; index < commands.Length; index++)
            {
                string sql = commands[index].Trim();
                if (sql.StartsWith("insert into ", StringComparison.InvariantCultureIgnoreCase)
                    || sql.StartsWith("select ", StringComparison.InvariantCultureIgnoreCase)
                    || sql.StartsWith("delete ", StringComparison.InvariantCultureIgnoreCase)
                    || sql.StartsWith("merge ", StringComparison.InvariantCultureIgnoreCase))
                {
                    commands[index] += OPTION_TEXT;
                }
            }

#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
            command.CommandText = string.Join(";\r\n", commands);
#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
        }
    }

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2015-10-28
    • 2019-01-14
    • 2022-01-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多