如何在 EF6.1 中阻止 table 进行一项没有死锁的简单操作?

How to block a table in EF6.1 for one simple operation without deadlocks?

我对 EF6.1 和 SQL 服务器的行为感到非常沮丧和困惑。

我想要一个 table,其中(目前)一行包含我想用于其他任务的值。每次用户执行此功能时,我都想获取值的内容——为简单起见,只是一个 int——并设置一个新值。因为我还想将此值用作其他 table 的主键,所以它必须是唯一的,并且应该是 gap-less.

我的想法是创建一个事务来获取值,计算一个新值并在数据库中更新它。只要提交了新值,我就想阻止 table。然后下一个可以获取和设置新值。

但是经过5个小时的测试,还是不行。 为了模拟(仅 3 个)同时访问,我想创建一个控制台应用程序并使用 build-in Thread.Sleep(200).

启动它 3 次

这是我正在测试的代码:

[Table("Tests")]
public class Test
{
    public int Id { get; set; }
    public int Value { get; set; }
}

table 已经存在,其中一行包含 { Id = 1, Value = 0 }。

在我的控制台应用程序中我有:

for( int i = 1; i <= 200; i++ )
{
    Thread.Sleep( 200 );
    using( var db = new MyDbContext( connectionString ) )
    {
        using( var trans = db.Database.BeginTransaction( IsolationLevel.RepeatableRead ) )
        {

            var entry = db.Tests.Find( 1 );
            db.Entry( entry ).Entity.Value += 1;

            // Just for testing
            Console.WriteLine( string.Format( "{0,5}", i ) + "   " + string.Format( "{0,7}", db.Entry( entry ).Entity.Value ) );

            db.SaveChanges();
            trans.Commit();
        }
    }
}

我的问题: 有时启动第二个应用程序就足够了,我会陷入僵局。 我试图将隔离级别设置为 IsolationLevel.ReadCommited,但在这样做之后我得到了重复项。 我怎么完全不知道我做错了什么。有人可以帮帮我吗?



更新

当我将 for 带入事务时,第二个启动的应用程序将等待第一个运行的时间(大约 20 秒),然后开始读取和更新价值。这按预期工作,但为什么上面的 "simulation" 不起作用?

using( var db = new MyDbContext( connectionString ) )
{
    using( var trans = db.Database.BeginTransaction( IsolationLevel.Serializable ) )
    {
        for( int i = 1; i <= 2000; i++ )
        {
            var entry = db.Tests.Find( 1 );
            db.Entry( entry ).Entity.Value += 1;

            // Just for testing
            Console.WriteLine( string.Format( "{0,5}", i ) + "   " + string.Format( "{0,7}", db.Entry( entry ).Entity.Value ) );

            db.SaveChanges();
        }
        trans.Commit();
    }
}




解决方案

感谢 Travis J 解决方案:

for( int i = 1; i <= 2000; i++ )
{
    using( var db = new MyDbContext( connectionString ) )
    {
        using( var trans = db.Database.BeginTransaction( IsolationLevel.Serializable ) )
        {

            db.Database.ExecuteSqlCommand( "SELECT TOP 1 *FROM Tests WITH (TABLOCKX, HOLDLOCK)" );

            var entry = db.Tests.Find( 1 );
            db.Entry( entry ).Entity.Value += 1;

            // Just for testing
            Console.WriteLine( string.Format( "{0,5}", i ) + "   " + string.Format( "{0,7}", db.Entry( entry ).Entity.Value ) );

            db.SaveChanges();
            trans.Commit();
        }
    }
}

...让我添加评论:
在我的例子中,它也适用于 IsolationLevel.RepeatableRead.
db.Database.ExecuteSqlCommand( "SELECT TOP 1 *FROM Tests WITH (TABLOCKX, HOLDLOCK)" ); 仅在交易内部有效。事实证明,我在没有任何交易的情况下尝试过此代码,结果与使用 IsolationLevel.ReadCommited.

的交易相同

特拉维斯,谢谢你!

我会改用 TransactionScope。人们会警告你,它会提升使用分布式事务,但那只是在它需要的时候。任何内部事务(如使用 SaveChanges() 创建的事务)都应将自身纳入外部环境范围。

TransactionScope 创建代码

public static TransactionScope CreateTransactionScope(TransactionScopeOption transactionScopeOption = TransactionScopeOption.Required)
        {
            var transactionOptions = new TransactionOptions
            {
                IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted,
                Timeout = TransactionManager.MaximumTimeout
            };

            return new TransactionScope(transactionScopeOption, transactionOptions);
        }

用法

using(var transaction = CreateTransactionScope()){
//do stuff

transaction.Complete();  //don't forget this...and it's 'complete' not 'commit'
}

您遇到的现象可能是由以下两个因素之一引起的。如果不在 MSSQL 中看到 table 设计本身,就很难分辨。这些因素要么是脏读,要么是幻读。正在更改的值在更改生效之前被读取。这导致值增加两次,但两次都是从 x -> y,x-> y 而不是从 x -> y,y -> z(这就是为什么你观察到的数字小于预期总数的原因。

RepeatableRead:
Locks are placed on all data that is used in a query, preventing other users from updating the data. Prevents non-repeatable reads but phantom rows are still possible. IsolationLevel Enumeration MDN (emphasis mine)

如果主键发生变化,锁将被释放,其行为类似于新行。然后可以读取该行,并且该值将是更新之前的值。这可能会发生,尽管我认为这很可能是脏读。

这里可能会出现脏读,因为该行仅在查询执行时被锁定,也就是说在获取和保存期间执行查询。因此,在修改实际值的过程中,可能会读取数据库中的值并导致 10 两次变为 11。

为了防止这种情况,必须使用更严格的锁。 Serializable 似乎是这里的自然选择,因为它声明在事务完成之前一直持有锁,并且不颁发共享锁。但是,在使用可序列化范围时,脏读仍然是可能的。这里唯一真正的选择是锁定 整个 table.

也同意,并给出示例代码:

entities.Database.ExecuteSqlCommand(
    "SELECT TOP 1 KeyColumn FROM MyTable WITH (TABLOCKX, HOLDLOCK)"
);

转化成你这样的情况(注意,我也把隔离改成了serializable,希望进一步加强锁)

for( int i = 1; i <= 200; i++ )
{
    Thread.Sleep( 200 );
    using( var db = new MyDbContext( connectionString ) )
    {
        using( var trans = db.Database.BeginTransaction( IsolationLevel.Serializable ) )
        {

            db.Database.ExecuteSqlCommand(
                "SELECT TOP 1 FROM Test WITH (TABLOCKX, HOLDLOCK)"
            );

            var entry = db.Tests.Find( 1 );
            db.Entry( entry ).Entity.Value += 1;

            // Just for testing
            Console.WriteLine( string.Format( "{0,5}", i ) + "   " + string.Format( "{0,7}", db.Entry( entry ).Entity.Value ) );

            db.SaveChanges();
            trans.Commit();
        }
    }
}

锁定...取 2.

锁定整个表是件麻烦事。所以我会通过以下两种方式之一来解决这个问题:

1.) 在SQL语句中执行操作

您可以简单地增加更新语句中的值。

UPDATE dbo.Tests
SET Value = Value + 1
OUTPUT inserted.Id, inserted.Value
WHERE ...

这将保证 'Value' 只会递增一次。请注意保证干净读取的 OUTPUT 子句。该行已锁定以供操作和读取。

2.) 使用 RowVersions

为什么不让他们也尝试在 WHERE 子句中识别 ID 为 AND ROWVERSION 的行。 ROWVERSION 数据类型将始终在更新触及行时发生变化(即使值没有变化)。

尽管您获得的更新可能不再是该行的最新版本,恕我直言,这是一种比等待某些东西陷入僵局更简洁的方法。