EntityFramework與TransactionScope事務和並發控制
最近在園子里看到一篇關於TransactionScope的文章,發現事務和並發控制是新接觸Entity Framework和Transaction Scope的園友們不易理解的問題,遂組織此文跟大家共同探討。
首先事務的ACID特性作為最基礎的知識我想大家都應該知道了。ADO.NET的SQLTransaction就是.NET框架下訪問SqlServer時最底層的數據庫事務對象,它可以用來將多次的數據庫訪問封裝為“原子操作”,也可以通過修改隔離級別來控制並發時的行為。TransactionScope則是為了在分布式數據節點上完成事務的工具,它經常被用在業務邏輯層將多個數據庫操作組織成業務事務的場景,可以做到透明的可分布式事務控制和隱式失敗回滾。但與此同時也經常有人提到TransactionScope有性能和部署方面的問題,關於這一點,根據MSDN的 Using the TransactionScope Class 的說法,當一個TransactionScope包含的操作是同一個數據庫連接時,它的行為與SqlTransaction是類似的。當它在多個數據庫連接上進行數據操作時,則會將本地數據庫事務提升為分布式事務,而這種提升要求各個節點均安裝並啟動DTC服務來支持分布式事務的協調工作,它的性能與本地數據庫事務相比會低很多,這也是CAP定律說的分布式系統的Consistency和Availability不可兼得的典型例子。所以當我們選擇是否使用TransactionScope時,一定要確認它會不會導致不想發生的分布式事務,也應該確保事務盡快做完它該做的事情,為了確認事務是否被提升我們可以用SQL Profiler去跟蹤相關的事件。
然后再來看一看Entity Framework,其實EF也跟事務有關系。它的Context概念來源於Unit of Work模式,Context記錄提交前的所有Entity變化,並在SaveChanges方法調用時發起真正的數據庫操作,SaveChanges方法在默認情況下隱含一個事務,並且試圖使用樂觀並發控制來提交數據,但是為了進行並發控制我們需要將Entity Property的ConcurrencyMode設置為Fixed才行,否則EF不理會在此Entity上面發生的並發修改,這一點可以參考MSDN Saving Changes and Managing Concurrency。微軟推薦大家使用以下方法來捕獲沖突的並發操作,並使用RefreshMode來選擇覆蓋或丟棄失敗的操作:
1 try 2 { 3 // Try to save changes, which may cause a conflict. 4 int num = context.SaveChanges(); 5 Console.WriteLine("No conflicts. " + 6 num.ToString() + " updates saved."); 7 } 8 catch (OptimisticConcurrencyException) 9 { 10 // Resolve the concurrency conflict by refreshing the 11 // object context before re-saving changes. 12 context.Refresh(RefreshMode.ClientWins, orders); 13 14 // Save changes. 15 context.SaveChanges(); 16 Console.WriteLine("OptimisticConcurrencyException " 17 + "handled and changes saved"); 18 }
當然除了樂觀並發控制我們還可以對沖突特別頻繁、沖突解決代價很大的用例進行悲觀並發控制。悲觀並發基本思想是不讓數據被同時離線修改,也就是像源碼管理里面“加鎖”功能一樣,碼農甲鎖上了這個文件,乙就不能再修改了,這樣一來這個文件就不可能發生沖突,悲觀並發控制實現的方式比如數據行加IsLocked字段等。
最后為了進行多Context事務,當然還可以混合使用TransactionScope和EF。
好了理論簡單介紹完,下面的例子是幾種不同的方法對並發控制的效果,需求是每個Member都有個HasMessage字段,初始為False,我們需要給其中一個Member加入唯一一條MemberMessage,並將Member.HasMessage置為True。
建庫腳本:

CREATE DATABASE [TransactionTest]
GO
USE [TransactionTest]
GO
CREATE TABLE [dbo].[Member](
[Id] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](32) NOT NULL,
[HasMessage] [bit] NOT NULL,
CONSTRAINT [PK_Member] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
SET IDENTITY_INSERT [dbo].[Member] ON
INSERT [dbo].[Member] ([Id], [Name], [HasMessage]) VALUES (1, N'Tom', 0)
INSERT [dbo].[Member] ([Id], [Name], [HasMessage]) VALUES (2, N'Jerry', 0)
SET IDENTITY_INSERT [dbo].[Member] OFF
CREATE TABLE [dbo].[MemberMessage](
[Id] [int] IDENTITY(1,1) NOT NULL,
[Message] [nvarchar](128) NOT NULL,
[MemberId] [int] NOT NULL,
CONSTRAINT [PK_MemberMessage] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[MemberMessage] WITH CHECK ADD CONSTRAINT [FK_MemberMessage_Member] FOREIGN KEY([MemberId])
REFERENCES [dbo].[Member] ([Id])
GO
ALTER TABLE [dbo].[MemberMessage] CHECK CONSTRAINT [FK_MemberMessage_Member]
GO
方法1:不使用TransactionScope,只依賴Entity各字段的默認並發控制。
Context和Entity定義

public class MyDbContext : DbContext
{
public MyDbContext() : base("TransactionTest") { }
public MyDbContext(string connectionString) :
base(connectionString)
{
}
public DbSet<Member> Members { get; set; }
}
[Table("Member")]
public class Member
{
[Key]
public int Id { get; set; }
public string Name { get; set; }
public bool HasMessage { get; set; }
public virtual ICollection<MemberMessage> Messages { get; set; }
}
[Table("MemberMessage")]
public class MemberMessage
{
[Key]
public int Id { get; set; }
public string Message { get; set; }
public int MemberId { get; set; }
[ForeignKey("M
try
{
using (var context = new MyDbContext())
{
var tom = context.Members.FirstOrDefault(m => m.Id == 1);
if (tom != null && !tom.HasMessage)
{
Console.WriteLine("Press Enter to Insert MemberMessage...");
Console.ReadLine();
tom.Messages.Add(new MemberMessage()
{
Message = "Hi Tom!"
});
tom.HasMessage = true;
context.SaveChanges();
Console.WriteLine("Insert Completed!");
}
}
}
catch (Exception ex)
{
Console.WriteLine("Insert Failed: " + ex);
}
同時運行兩個程序,結果是無法確保不重復插入
通過分析不難發現,該場景的並發控制關鍵就在於插入前檢查HasMessage如果是False,則插入MemberMessage后更新Member.HasMessage字段時需要再次檢查數據庫中HasMessage字段是否為False,如果為True就是有其他人並發的更改了該字段,本次保存應該回滾或做其他處理。所以為此需要有針對性的加入並發控制。
方法2:給HasMessage字段加上並發檢查
1 [Table("Member")] 2 public class Member 3 { 4 [Key] 5 public int Id { get; set; } 6 7 public string Name { get; set; } 8 9 [ConcurrencyCheck] 10 public bool HasMessage { get; set; } 11 12 public virtual ICollection<MemberMessage> Messages { get; set; } 13 }
仍然使用方法1的測試代碼,結果則是其中一次數據插入會拋出OptimisticConcurrencyException,也就是說防止重復插入數據的目的已經達到了。
那回過頭來看看是否可以使用TransactionScope對EF進行並發控制,於是有方法3:使用TransactionScope但不給Entity加入並發檢查
Context和Entity的定義與方法1完全一致,測試代碼為

try
{
using (var scope = new System.Transactions.TransactionScope())
{
using (var context = new MyDbContext())
{
var tom = context.Members.FirstOrDefault(m => m.Id == 1);
if (tom != null && !tom.HasMessage)
{
Console.WriteLine("Press Enter to Insert MemberMessage...");
Console.ReadLine();
tom.Messages.Add(new MemberMessage()
{
Message = "Hi Tom!"
});
tom.HasMessage = true;
context.SaveChanges();
Console.WriteLine("Insert Completed!");
}
}
scope.Complete();
}
}
catch (Exception ex)
{
Console.WriteLine("Insert Failed: " + ex);
}
同樣啟動兩個程序測試,發現其中一次保存操作拋出DbUpdateException,其內部原因是Transaction死鎖導致該操作被作為犧牲者。所以看起來也可以達到並發控制的效果,這種方式的優點是不需要去仔細辨別業務中哪些操作會導致字段的並發更新沖突,所有的Entity都可以不加ConcurrencyCheck,缺點則是當沖突不多的時候這種死鎖競爭協調與樂觀並發控制相比性能會低些。
最后為了完備測試各種組合,我們試一試方法4:既使用TransactionScope,又在HasMessage字段上加入ConcurrencyCheck,Entity代碼參考方法1,測試代碼則參考方法3,結果仍然是TransactionScope檢測到死鎖並選擇其中一個競爭者拋出異常。
結論:
- TransactionScope用或不用主要取決於是否需要進行分布式事務
- 即使不需要分布式事務,TransactionScope也可以用於沒有精力仔細分析哪些Entity的字段需要進行並發檢查的時候
- 如果能夠細粒度分析並發場景,則推薦使用EF自帶的並發控制機制