如何在 EF6.1 中为一个简单的操作阻塞一个表而没有死锁?
Posted
技术标签:
【中文标题】如何在 EF6.1 中为一个简单的操作阻塞一个表而没有死锁?【英文标题】:How to block a table in EF6.1 for one simple operation without deadlocks? 【发布时间】:2015-11-15 20:59:36 【问题描述】:我对 EF6.1 和 SQL Server 的行为感到非常沮丧和困惑。
我想要一张表,其中包含(目前)一行,其中包含我想用于其他任务的值。每次用户执行此功能时,我都想获取值的内容 - 为简单起见只是一个 int - 并设置一个新值。因为我想将此值也用作其他表的主键,所以它必须是唯一的并且应该是无间隙的。
我的想法是创建一个交易来获取价值,计算一个新的并在数据库中更新它。只要提交了新值,我就想阻止该表。然后下一个可以获取并设置新的值。
但经过 5 小时的测试后,它仍然无法正常工作。
为了模拟(仅 3 次)同时访问,我想创建一个控制台应用程序并使用内置 Thread.Sleep(200)
启动它 3 次。
这是我正在测试的代码:
[Table("Tests")]
public class Test
public int Id get; set;
public int Value get; set;
该表已经存在,并且其中有一行 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 秒),然后开始读取和更新值。这按预期工作,但为什么上面的“模拟”不起作用?
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
的事务相同。
特拉维斯,谢谢!
【问题讨论】:
如何为您的测试表定义键?它是 id 和 value 的组合吗? @TravisJ 不,如上所示。我在上面的代码中使用 Code First,之后没有对数据库进行任何更改。有问题吗? 【参考方案1】:您遇到的现象可能是由以下两个因素之一引起的。如果不查看 MSSQL 中的表设计本身,就很难判断。这些因素要么是脏读,要么是幻读。正在更改的值在更改生效之前被读取。这会导致值增加两次,但两次都是从 x -> y, x-> y 开始,而不是从 x -> y, y -> z 开始(这就是为什么您观察到的数字小于预期总数的原因。
可重复读取: 对查询中使用的所有数据进行锁定,以防止其他用户更新数据。防止不可重复读取,但仍然可能出现幻行。 IsolationLevel Enumeration MDN(强调我的)
如果主键发生变化,锁将被释放,并且会像新行一样起作用。然后可以读取该行,并且该值将是更新之前的值。这可能正在发生,尽管我认为这很可能是脏读。
这里可能会发生脏读,因为该行仅在执行查询时才被锁定,也就是说,在获取和保存期间,会执行查询。所以在这之间,当实际值被修改时,可能会读取数据库中的值并导致 10 变成 11 两次。
为了防止这种情况,必须使用更严格的锁。可序列化在这里似乎是自然的选择,因为它声明锁会一直保持到事务完成,并且不会发出共享锁。但是,在使用可序列化作用域时,仍然可能出现脏读。这里唯一真正的选择是锁定整个表。
https://***.com/a/22721849/1026459也同意,并给出示例代码:
entities.Database.ExecuteSqlCommand(
"SELECT TOP 1 KeyColumn FROM MyTable WITH (TABLOCKX, HOLDLOCK)"
);
这会转化为您的这种情况(请注意,我还将隔离更改为可序列化以希望进一步加强锁定)
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();
【讨论】:
对下一位读者稍作修正:在我的例子中,sql 命令是"SELECT TOP 1 * FROM Tests WITH (TABLOCKX, HOLDLOCK)"
。 非常感谢! 对我来说坏消息:我已经测试过这条线,但它没有工作......显然当时我还有另一个问题。最后但并非最不重要的一点:你能告诉我,为什么我的 Update 中的代码在没有 tablelock 的情况下工作?
我在想rollback
在哪里?!由于DbContextTransaction
被using
块包围,所以不需要调用trans.Rollback()
?对吗?
@AliBagheriShakib 因为他使用的是using
,所以不需要在try...catch-phrase 中调用trans.Rollback();
。它在using
的Dispose
中自动调用。有关更详细的说明,请参阅this answer。【参考方案2】:
我会改用 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'
【讨论】:
我试过你的代码,但它产生的结果与.BeginTransaction()
相同。我以 3 秒的延迟启动了该应用程序 5 次。当值为 0 时,预期结果为 1000,因为增量为 5*200。但结果是:855。这意味着 145 个值被使用了两次。
我想我明白你在说什么。事物的“读取”方面没有锁定。如果您将其设为单个 sql 语句,您将得到事务保证,甚至不必搞乱包装事务或任何东西。更新 dbo.Tests 设置值 = 值 + 1【参考方案3】:
锁定...采取 2。
锁定整个表是一件麻烦事。所以我会通过以下两种方式之一来解决这个问题:
1.) 在SQL语句中执行操作
您可以简单地增加更新语句中的值。
UPDATE dbo.Tests
SET Value = Value + 1
OUTPUT inserted.Id, inserted.Value
WHERE ...
这将保证“值”只增加一次。请注意OUTPUT
子句,它保证了干净的读取。该行被锁定以供操作和读取。
2.) 使用 RowVersions
为什么不让他们也尝试在WHERE
子句中识别具有 ID AND ROWVERSION
的行。每当更新触及行时,ROWVERSION
数据类型总是会改变(即使值没有改变)。
虽然您可能会获得不再是该行最新版本的更新,但恕我直言,这是一种比等待某些东西死锁更干净的方法。
【讨论】:
嗯,好主意...我会在接下来的几天里尝试这个,并提供反馈。以上是关于如何在 EF6.1 中为一个简单的操作阻塞一个表而没有死锁?的主要内容,如果未能解决你的问题,请参考以下文章