首页 > 解决方案 > EF Core 多线程死锁 + BeginTransaction + Commit

问题描述

SaveChangesAsync()我对如何和BeginTransaction()+transaction.Commit()工作 有一些疑问。

我的团队有一个 .NET Core 工作者,它从 Microsoft EventHub 接收事件,并通过 EF Core 3 将数据保存到 SQL 服务器。
其中一种事件类型有很多数据,所以我们创建了几个表,单独的数据,然后将其保存到这些表。子表引用父表的id列 (FK_Key)。
在某些条件下保存新数据之前必须删除数据库中的某些数据,因此我们删除 -> 更新数据。

要将数据保存到数据库中,我们调用dbContext.Database.BeginTransaction()and transaction.Commit()。当我们运行 worker 时,我们会遇到死锁异常,例如Transaction (Process ID 71) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.

我发现其中之一 .BatchDeleteAsync()PurgeDataInChildTables()其中之一BulkInsertOrUpdateAsync()引发Upsert()了死锁异常(每次运行工作程序时它都会改变)。

这是代码:

public async Task DeleteAndUpsert(List<MyEntity> entitiesToDelete, List<MyEntity> entitiesToUpsert)
{
    if (entitiesToDelete.Any())
        await myRepository.Delete(entitiesToDelete);

    if (entitiesToUpsert.Any())
        await myRepository.Upsert(entitiesToUpsert);
}


public override async Task Upsert(IList<MyEntity> entities)
{
    using (var dbContext = new MyDbContext(DbContextOptions, DbOptions))
    {
        using (var transaction = dbContext.Database.BeginTransaction())
        {
            await PurgeDataInChildTables(entities, dbContext);
            await dbContext.BulkInsertOrUpdateAsync(entities);
            // tables that depends on the parent table (FK_Key)
            await dbContext.BulkInsertOrUpdateAsync(entities.SelectMany<Child1>(x => x.Id).ToList());
            await dbContext.BulkInsertOrUpdateAsync(entities.SelectMany<Child2>(x => x.Id).ToList());
            await dbContext.BulkInsertOrUpdateAsync(entities.SelectMany<Child3>(x => x.Id).ToList());
            transaction.Commit();
        }
    }
}

public override async Task Delete(IList<MyEntity> entities)
{
    using (var dbContext = new MyDbContext(DbContextOptions, DbOptions))
    {
        using (var transaction = dbContext.Database.BeginTransaction())
        {
            await PurgeDataInChildTables(entities, dbContext);
            await dbContext.BulkDeleteAsync(entities);
            transaction.Commit();
        }
    }
}

private async Task PurgeDataInChildTables(IList<MyEntity> entities, MyDbContext dbContext)
{
    var ids = entities.Select(x => x.Id).ToList();

    await dbContext.Child1.Where(x => ids.Contains(x.Id)).BatchDeleteAsync();
    await dbContext.Child2.Where(x => ids.Contains(x.Id)).BatchDeleteAsync();
    await dbContext.Child3.Where(x => ids.Contains(x.Id)).BatchDeleteAsync();
}

当 worker 启动时,它会创建四个线程,并且它们都更新插入到同一个表中(并且也删除了)。因此,我假设当一个线程启动一个事务而另一个线程启动另一个事务(或类似的东西..)然后尝试更新插入(或从中删除)子表时会发生死锁。我尝试了一些东西来解决这个问题,并注意到当我删除并使用
时,死锁似乎得到了解决。 BeginTransaction()SaveChangesAsync()

这是修改后的代码:

public override async Task Upsert(IList<MyEntity> entities)
{
    using (var dbContext = new MyDbContext(DbContextOptions, DbOptions))
    {
        await PurgeDataInChildTables(entities, dbContext);
        await dbContext.BulkInsertOrUpdateAsync(entities);
        // tables that depends on the parent table (FK_Key)
        await dbContext.BulkInsertOrUpdateAsync(entities.SelectMany(x => x.Child1).ToList());
        await dbContext.BulkInsertOrUpdateAsync(entities.SelectMany(x => x.Child2).ToList());
        await dbContext.BulkInsertOrUpdateAsync(entities.SelectMany(x => x.Child3).ToList());
        await dbContext.SaveChangesAsync();
    }
}

public override async Task Delete(IList<MyEntity> entities)
{
    using (var dbContext = new MyDbContext(DbContextOptions, DbOptions))
    {
        await PurgeDataInChildTables(entities, dbContext);
        await dbContext.BulkDeleteAsync(entities);
        await dbContext.SaveChangesAsync();
    }
}

死锁发生在工人启动后约 30 秒,但当我修改代码时,它在 2 到 3 分钟内没有发生,所以我认为问题已解决,如果我运行工人更长时间,它可能仍然会发生。

最后,这是我的问题:

标签: c#entity-framework-coredatabase-deadlocks

解决方案


如果不查看数据库的分析会话,很难准确地说。需要查找的是使用哪种锁(它在shared哪里,它在哪里exclusive或)以及何时实际打开update事务。我将描述一个需要通过实际数据库分析来证明的理论行为。

当您使用 Database.BeginTransaction() 包装所有内容时
EF 未设置隔离级别,它使用数据库默认隔离级别。万一发生Microsoft SQL ServerRead committed。此隔离级别表示并发事务可以读取数据,但如果正在进行修改,其他事务将等待它完成,即使他们只想读取。交易将在Commit() 被调用之前进行。

当您没有明确指定事务时
选择语句SaveChangesAsync并将导致具有相同隔离级别的单独事务默认为数据库。事务的持有时间不会超过它需要的时间:例如,在 的情况下,SaveChangesAsync它将在写入所有更改的同时存在,从调用方法的那一刻开始。

事务(进程 ID 71)与另一个进程在锁资源上死锁,并已被选为死锁牺牲品。重新运行事务。

当有多个事务试图访问某些资源,其中一个正在尝试读取数据而另一个正在尝试修改时,会出现此消息。在这种情况下,为避免死锁,数据库将尝试终止需要较少资源回滚的事务。在您的情况下 - 这是一个尝试读取的事务。就回滚的权重而言,读取是轻量级的。

总结:
当您拥有一个巨大的锁来长时间持有一个资源时,它会阻止其他工作人员访问该资源,因为数据库可能会在其他工作人员尝试读取时杀死其他工作人员的事务var ids = entities.Select(x => x.Id).ToList();。当你重写你的代码时,你摆脱了长锁。更重要的是,正如我从BulkInsertOrUpdateAsync的文档中看到的那样,此扩展在每次调用时都使用内部事务,不影响也不涉及 EF 上下文。SaveChangesAsync如果是这样,那么这意味着当数据不是通过扩展而是以通常的 EF 方式更改时,实际事务的存活时间甚至少于一次调用。


推荐阅读