如何在 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
数据类型将始终在更新触及行时发生变化(即使值没有变化)。
尽管您获得的更新可能不再是该行的最新版本,恕我直言,这是一种比等待某些东西陷入僵局更简洁的方法。
我对 EF6.1 和 SQL 服务器的行为感到非常沮丧和困惑。
我想要一个 table,其中(目前)一行包含我想用于其他任务的值。每次用户执行此功能时,我都想获取值的内容——为简单起见,只是一个 int——并设置一个新值。因为我还想将此值用作其他 table 的主键,所以它必须是唯一的,并且应该是 gap-less.
我的想法是创建一个事务来获取值,计算一个新值并在数据库中更新它。只要提交了新值,我就想阻止 table。然后下一个可以获取和设置新值。
但是经过5个小时的测试,还是不行。
为了模拟(仅 3 个)同时访问,我想创建一个控制台应用程序并使用 build-in Thread.Sleep(200)
.
这是我正在测试的代码:
[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
数据类型将始终在更新触及行时发生变化(即使值没有变化)。
尽管您获得的更新可能不再是该行的最新版本,恕我直言,这是一种比等待某些东西陷入僵局更简洁的方法。