Entity Framework 和 Transactionscope 在 Transactionscope 释放后没有恢复隔离级别。

14

我在处理事务范围和Entity Framework方面有些困难。

最初,我们希望应用程序中的所有连接在读取数据时都使用快照隔离级别,但在某些情况下,我们希望使用已提交或未提交的隔离级别来读取数据,为此,我们将使用事务范围临时更改查询的隔离级别(正如在几篇帖子和不同的博客中指出的那样)。

然而,问题在于当事务范围被处理后,隔离级别仍然保留在连接上,这会导致相当多的问题。

我尝试了各种变化,但结果都一样; 隔离级别在事务范围之外被保留。

是否有人可以为我解释这种行为,或者可以解释我做错了什么?

我已经找到了一个解决方法,通过将事务范围封装在一个可处置类中,该类为我还原隔离级别,但我希望能够对这种行为进行良好的解释,我认为这种行为不仅影响我的代码,而且也会影响其他人。

以下是一个示例代码,说明了问题:

using (var context = new MyContext())
{
    context.Database.Connection.Open();

    //Sets the connection to default read snapshot
    using (var command = context.Database.Connection.CreateCommand())
    {
        command.CommandText = "SET TRANSACTION ISOLATION LEVEL SNAPSHOT";
        command.ExecuteNonQuery();
    }

    //Executes a DBCC USEROPTIONS to print the current connection information and this shows snapshot
    PrintDBCCoptions(context.Database.Connection);

    //Executes a query
    var result = context.MatchTypes.ToArray();

    //Executes a DBCC USEROPTIONS to print the current connection information and this still shows snapshot
    PrintDBCCoptions(context.Database.Connection);

    using (var scope = new TransactionScope(TransactionScopeOption.Required,
        new TransactionOptions()
        {
            IsolationLevel = IsolationLevel.ReadCommitted //Also tried ReadUncommitted with the same result
        }))
    {
        //Executes a DBCC USEROPTIONS to print the current connection information and this still shows snapshot
        //(This is ok, since the actual new query with the transactionscope isn't executed yet)
        PrintDBCCoptions(context.Database.Connection);
        result = context.MatchTypes.ToArray();
        //Executes a DBCC USEROPTIONS to print the current connection information and this has now changed to read committed as expected                    
        PrintDBCCoptions(context.Database.Connection);
        scope.Complete(); //tested both with and without
    }

    //Executes a DBCC USEROPTIONS to print the current connection information and this is still read committed
    //(I can find this ok too, since no command has been executed outside the transaction scope)
    PrintDBCCoptions(context.Database.Connection);
    result = context.MatchTypes.ToArray();

    //Executes a DBCC USEROPTIONS to print the current connection information and this is still read committed
    //THIS ONE is the one I don't expect! I expected that the islation level of my connection should revert here
    PrintDBCCoptions(context.Database.Connection);
}

1
还有一些答案在这里中提到,并且可能与Connect Bug相关。 - crokusek
1个回答

24

今天我进行了一些调查,发现在不同环境下,出现问题的原因有多种。

数据库服务器版本:

首先,操作的结果取决于你正在运行哪个SQL Server版本(已测试的版本为SQL Server 2012和SQL Server 2014)。

SQL Server 2012

在SQL Server 2012上,即使将最后设置的隔离级别释放回连接池并从其他线程/操作中检索回来,它仍将遵循连接在随后的操作中。实际上,这意味着如果你通过事务将隔离级别设置为未提交读取,连接会保留此设置,直到另一个事务范围将其设置为其他的隔离级别(或通过对连接执行SET TRANSACTION ISOLATION LEVEL命令)。这是不好的,你可能会在不知情的情况下突然遇到脏读。

例如:

Console.WriteLine(context.MatchTypes.Where(mt => mt.Id == 2).Select(mt => mt.LastUpdated).First());

using (var scope = new TransactionScope(TransactionScopeOption.Required, 
                                        new TransactionOptions 
                                        { 
                                            IsolationLevel = IsolationLevel.ReadUncommitted 
                                        }))
{
    Console.WriteLine(context.MatchTypes.Where(mt => mt.Id == 2)
                                        .Select(mt => mt.LastUpdated).First());
    scope.Complete(); //tested both with and without
}

Console.WriteLine(context.MatchTypes.Where(mt => mt.Id == 2).Select(mt => mt.LastUpdated).First());

在这个示例中,第一个 EF 命令将以数据库默认方式运行,在事务范围内的命令将以 ReadUncommitted 方式运行,第三个命令也将以 ReadUncommitted 方式运行。

SQL Server 2014

而在 SQL Server 2014 上,每当从连接池获取连接时,sp_reset_connection 过程 (看起来就是这个过程) 都会将隔离级别重新设置为数据库默认值,即使连接是从同一事务范围内重新获取的。实际上,这意味着如果您有一个事务范围,在其中执行两个连续的命令,只有第一个命令才会获得事务范围的隔离级别。同时也不好的是,您将基于数据库默认隔离级别(锁定或快照读取)。

例如:

Console.WriteLine(context.MatchTypes.Where(mt => mt.Id == 2).Select(mt => mt.LastUpdated).First());

using (var scope = new TransactionScope(TransactionScopeOption.Required, 
                                        new TransactionOptions 
                                        { 
                                            IsolationLevel = IsolationLevel.ReadUncommitted 
                                        }))
{
    Console.WriteLine(context.MatchTypes.Where(mt => mt.Id == 2)
                             .Select(mt => mt.LastUpdated).First());
    Console.WriteLine(context.MatchTypes.Where(mt => mt.Id == 2)
                             .Select(mt => mt.LastUpdated).First());
    scope.Complete(); 
}

在这个示例中,第一个 EF 命令将使用数据库默认设置运行,事务内的第一个命令将使用 ReadUncommitted 运行,但是范围内的第二个命令将突然再次以数据库默认设置运行。

手动打开连接问题:

不同的 SQL Server 版本存在其他问题,其中涉及手动打开连接,但是我们严格来说不需要这样做,因此我现在不会深入探讨这个问题。

使用 Database.BeginTransaction:

由于某些原因,Entity Framework 的 Database.BeginTransaction 逻辑似乎在两个数据库中都可以工作,这是可以接受的,但在我们的代码中,我们针对两个不同的数据库进行操作,因此我们需要事务范围。

结论:

在 SQL Server 中处理隔离级别与事务范围相结合时,我认为存在很多 bug,这样使用是不安全的,并且可能会导致任何应用程序的严重问题。使用时要非常小心。

但事实仍然存在:我们需要让代码正常工作。最近我已经处理了微软繁琐的支持,结果并不太好,所以我首先会找到一个适合我们的解决方案。然后我将使用 Connect 报告我的发现,并希望 Microsoft 在事务范围处理和连接方面采取一些行动。

解决方案:

解决方案(截至目前)如下所示。

此解决方案的要求如下: 1. 由于其他应用程序需要在同一数据库上运行,因此数据库必须以 READ COMMITTED 隔离级别运行,我们不能使用数据库上的默认 READ COMMITTED SNAPSHOT 设置。 2. 我们的应用程序必须具有 SNAPSHOT 隔离级别的默认设置 - 这是通过使用 SET TRANSACTION ISOLATION LEVEL SNAPSHOT 解决的。 3. 如果存在事务范围,则需要遵守此隔离级别。

因此,根据这些标准,解决方案将如下所示:

在上下文构造函数中,我注册 StateChange 事件,在状态更改为打开并且没有活动事务时,使用经典的 ADO.NET 默认将隔离级别设置为快照。如果使用了事务范围,我们需要根据此处的设置运行 SET TRANSACTION ISOLATION LEVEL(为了限制我们自己的代码,我们只允许 ReadCommitted、ReadUncommitted 和 Snapshot 的隔离级别)。至于由上下文上的 Database.BeginTransaction 创建的事务,似乎已经按照预期进行了处理,所以我们不会对这些类型的事务采取任何特殊行动。

以下是上下文中的代码:

public MyContext()
{
    Database.Connection.StateChange += OnStateChange;
}

protected override void Dispose(bool disposing)
{
    if(!_disposed)
    {
        Database.Connection.StateChange -= OnStateChange;
    }

    base.Dispose(disposing);
}

private void OnStateChange(object sender, StateChangeEventArgs args)
{
    if (args.CurrentState == ConnectionState.Open && args.OriginalState != ConnectionState.Open)
    {
        using (var command = Database.Connection.CreateCommand())
        {
            if (Transaction.Current == null)
            {
                command.CommandText = "SET TRANSACTION ISOLATION LEVEL SNAPSHOT";
            }
            else
            {
                switch (Transaction.Current.IsolationLevel)
                {
                    case IsolationLevel.ReadCommitted:
                        command.CommandText = "SET TRANSACTION ISOLATION LEVEL READ COMMITTED";
                        break;
                    case IsolationLevel.ReadUncommitted:
                        command.CommandText = "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED";
                        break;
                    case IsolationLevel.Snapshot:
                        command.CommandText = "SET TRANSACTION ISOLATION LEVEL SNAPSHOT";
                        break;
                    default:
                        throw new ArgumentOutOfRangeException();
                }
            }

            command.ExecuteNonQuery();
        }
    }
}

我在SQL Server 2012和2014中测试了这段代码,似乎可以运行。它不是最好的代码,并且有它的限制(例如,每次EF执行都会对数据库执行SET TRANSACTION ISOLATIONLEVEL,因此会增加额外的网络流量。)


你真的深入研究了它!非常有趣,对我的工作也很有帮助。然而,我不明白为什么目前“READ COMMITTED SNAPSHOT”被认为是最佳实践,以至于EF从EF 6开始将其更改为生成数据库的默认设置。此外,您似乎在所有代码示例中都使用相同的上下文。当使用不同的上下文实例时,您是否看到相同的效果? - Gert Arnold
我个人并不认为这是最佳实践,但我们的应用程序执行一些长时间运行的事务(30秒),必须保证ACID,在此期间,其他连接对相同数据执行读提交查询必须等待30秒才能获得结果,这对用户来说是不可接受的。他们宁愿将最后提交的数据作为结果。然而,在某些情况下,逻辑确实需要最新的数据,但不想进行脏读取。在这种情况下,等待是可以接受的,我们使用读提交的事务来强制等待。 - Rune G
1
就不同的上下文实例而言,是的。对于 SQL Server 2012,如果您使用连接池,据我所知,先前设置的隔离级别将保留在同一连接上,我认为这是灾难性的。只要您不手动打开连接并让 EF 为您打开它,我发布的解决方案将防止此行为发生。 - Rune G
我不知道SQL2014的sp_reset_connection会将隔离级别更改回默认级别。 - hazimdikenli
4
这太疯狂了!EF6.1.3仍然保持当前状态吗? - Jaans
这已经有一段时间了,自从我进行了这项调查。然而,这个问题与EF本身无关,而是与数据库引擎有关。我不确定在SQL Server 2016中会出现什么情况。 - Rune G

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接