最近在园子里看到一篇关于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。
建库脚本:
1 CREATE DATABASE [TransactionTest] 2 GO 3 4 USE [TransactionTest] 5 GO 6 7 CREATE TABLE [dbo].[Member]( 8 [Id] [int] IDENTITY(1,1) NOT NULL, 9 [Name] [nvarchar](32) NOT NULL, 10 [HasMessage] [bit] NOT NULL, 11 CONSTRAINT [PK_Member] PRIMARY KEY CLUSTERED 12 ( 13 [Id] ASC 14 )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] 15 ) ON [PRIMARY] 16 GO 17 SET IDENTITY_INSERT [dbo].[Member] ON 18 INSERT [dbo].[Member] ([Id], [Name], [HasMessage]) VALUES (1, N'Tom', 0) 19 INSERT [dbo].[Member] ([Id], [Name], [HasMessage]) VALUES (2, N'Jerry', 0) 20 SET IDENTITY_INSERT [dbo].[Member] OFF 21 22 CREATE TABLE [dbo].[MemberMessage]( 23 [Id] [int] IDENTITY(1,1) NOT NULL, 24 [Message] [nvarchar](128) NOT NULL, 25 [MemberId] [int] NOT NULL, 26 CONSTRAINT [PK_MemberMessage] PRIMARY KEY CLUSTERED 27 ( 28 [Id] ASC 29 )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] 30 ) ON [PRIMARY] 31 GO 32 33 ALTER TABLE [dbo].[MemberMessage] WITH CHECK ADD CONSTRAINT [FK_MemberMessage_Member] FOREIGN KEY([MemberId]) 34 REFERENCES [dbo].[Member] ([Id]) 35 GO 36 ALTER TABLE [dbo].[MemberMessage] CHECK CONSTRAINT [FK_MemberMessage_Member] 37 GOView Code
方法1:不使用TransactionScope,只依赖Entity各字段的默认并发控制。
Context和Entity定义
1 public class MyDbContext : DbContext 2 { 3 public MyDbContext() : base("TransactionTest") { } 4 5 public MyDbContext(string connectionString) : 6 base(connectionString) 7 { 8 9 } 10 11 public DbSet<Member> Members { get; set; } 12 } 13 14 [Table("Member")] 15 public class Member 16 { 17 [Key] 18 public int Id { get; set; } 19 20 public string Name { get; set; } 21 22 public bool HasMessage { get; set; } 23 24 public virtual ICollection<MemberMessage> Messages { get; set; } 25 } 26 27 [Table("MemberMessage")] 28 public class MemberMessage 29 { 30 [Key] 31 public int Id { get; set; } 32 33 public string Message { get; set; } 34 35 public int MemberId { get; set; } 36 37 [ForeignKey("MemberId")] 38 public virtual Member Member { get; set; } 39 }View Code
测试代码
1 try 2 { 3 using (var context = new MyDbContext()) 4 { 5 var tom = context.Members.FirstOrDefault(m => m.Id == 1); 6 if (tom != null && !tom.HasMessage) 7 { 8 Console.WriteLine("Press Enter to Insert MemberMessage..."); 9 Console.ReadLine(); 10 tom.Messages.Add(new MemberMessage() 11 { 12 Message = "Hi Tom!" 13 }); 14 tom.HasMessage = true; 15 context.SaveChanges(); 16 Console.WriteLine("Insert Completed!"); 17 } 18 } 19 } 20 catch (Exception ex) 21 { 22 Console.WriteLine("Insert Failed: " + ex); 23 }View Code
同时运行两个程序,结果是无法确保不重复插入
通过分析不难发现,该场景的并发控制关键就在于插入前检查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完全一致,测试代码为
1 try 2 { 3 using (var scope = new System.Transactions.TransactionScope()) 4 { 5 using (var context = new MyDbContext()) 6 { 7 var tom = context.Members.FirstOrDefault(m => m.Id == 1); 8 if (tom != null && !tom.HasMessage) 9 { 10 Console.WriteLine("Press Enter to Insert MemberMessage..."); 11 Console.ReadLine(); 12 tom.Messages.Add(new MemberMessage() 13 { 14 Message = "Hi Tom!" 15 }); 16 tom.HasMessage = true; 17 context.SaveChanges(); 18 Console.WriteLine("Insert Completed!"); 19 } 20 } 21 scope.Complete(); 22 } 23 } 24 catch (Exception ex) 25 { 26 Console.WriteLine("Insert Failed: " + ex); 27 }View Code
同样启动两个程序测试,发现其中一次保存操作抛出DbUpdateException,其内部原因是Transaction死锁导致该操作被作为牺牲者。所以看起来也可以达到并发控制的效果,这种方式的优点是不需要去仔细辨别业务中哪些操作会导致字段的并发更新冲突,所有的Entity都可以不加ConcurrencyCheck,缺点则是当冲突不多的时候这种死锁竞争协调与乐观并发控制相比性能会低些。
最后为了完备测试各种组合,我们试一试方法4:既使用TransactionScope,又在HasMessage字段上加入ConcurrencyCheck,Entity代码参考方法1,测试代码则参考方法3,结果仍然是TransactionScope检测到死锁并选择其中一个竞争者抛出异常。
结论:
- TransactionScope用或不用主要取决于是否需要进行分布式事务
- 即使不需要分布式事务,TransactionScope也可以用于没有精力仔细分析哪些Entity的字段需要进行并发检查的时候
- 如果能够细粒度分析并发场景,则推荐使用EF自带的并发控制机制