Entity Framework管理并发
作者:.NET开发菜鸟
理解并发
并发管理解决的是允许多个实体同时更新,实际上这意味着允许多个用户同时在相同的数据上执行多个数据库操作。并发是在一个数据库上管理多个操作的一种方式,同时遵守了数据库操作的ACID属性(原子性、一致性、隔离性和持久性)。
想象一下下面几种可能发生并发的场景:
1、用户甲和乙都尝试修改相同的实体。
2、用户甲和乙都尝试删除相同的实体。
3、用户甲正在尝试修改一个实体时,用户乙已经删除了该实体。
4、用户甲已经请求读取一个实体,用户乙读完该实体之后更新了它。
这些场景可能会潜在地产生错误的数据,试想,成百上千的用户同时尝试操作一个相同的实体,这种并发问题将会对系统带来更大的影响。
在处理与并发相关的问题时,一般有以下两种方法:
1、乐观并发:无论何时从数据库请求数据,数据都会被读取并保存到应用内存中。数据库级别没有放置任何显示锁。数据操作会按照数据层接收到的顺序执行。
2、悲观并发:无论何时从数据库请求数据,数据都会被读取,然后该数据上就会加锁,因此没有人能访问该数据。这会降低并发相关问题的机率,缺点是加锁是一个昂贵的操作,会降低整个应用程序的性能。
一、理解乐观并发
前面提到,在乐观并发中,无论何时从数据库请求数据,数据都会被读取并保存到应用内存中。数据库级别没有放置任何显式锁。因为这种方法没有添加显式锁,所以比悲观并发更具扩展性和灵活性。使用乐观并发,重点是如果发生了任何冲突,应用程序要亲自处理它们。最重要的是:使用乐观并发控制时,在应用中要有一个冲突解决策略,要让应用程序的用户知道他们的修改是否因为冲突的缘故没有持久化。乐观并发本质上是允许冲突发生,然后以一种适当的方式解决该冲突。
下面是处理冲突的策略例子。
1、忽略冲突/强制更新
这种策略是让所有的用户更改相同的数据集,然后所有的修改都会经过数据库,这就意味着数据库会显示最后一次更新的值。这种策略会导致潜在的数据丢失,因为许多用户的更改数据都丢失了,只有最后一个用户的更改是可见的。
2、部分更新
在这种情况中,我们也允许所有的更改,但是不会更新完整的行,只有特定用户拥有的列更新了。这就意味着,如果两个用户更新相同的记录但却不同的列,那么这两个更新都会成功,而且来自这两个用户的更改都是可见的。
3、警告/询问用户
当一个用户尝试更新一个记录时,但是该记录自从他读取之后已经被其他用户更改了,这时应用程序就会警告该用户该数据已经被其他用户更改了,然后询问他是否仍然要重写该数据还是首先检查已经更新的数据。
4、拒绝更改
当一个用户尝试更新一个记录时,但是该记录自从他读取之后已经被其他用户更改了,此时告诉该用户不允许更新该数据,因为数据已经被其他用户更新了。
二、理解悲观并发
悲观并发正好和乐观并发相反,悲观并发的目标是永远不让任何冲突发生。这是通过在使用记录之前就在记录上放置显式锁实现的。数据库记录上可以得到两种类型的锁:
只读锁
更新锁。
当把只读锁放到记录上时,应用程序只能读取该记录。如果应用程序要更新该记录,它必须要获取到该记录上的更新锁。如果记录上加了只读锁,那么该记录仍然能够被想要只读锁的请求使用。然而,如果需要更新锁,该请求必须等到所有的只读锁释放。同样,如果记录上加了更新锁,那么其他的请求不能再在这个记录上加锁,该请求必须等到已存在的更新锁释放才能加锁。
从前面的描述中,似乎悲观并发能解决所有跟并发相关的问题,因为我们不必在应用中处理这些问题。然而,事实上并不是这样的。在使用悲观并发管理之前,我们需要记住,使用悲观并发有很多问题和开销。下面是使用悲观并发面临的一些问题:
应用程序必须管理每个操作正在获取的所有锁。
加锁机制的内存需求会降低应用性能。
多个请求互相等待需要的锁,会增加死锁的可能性。由于这些原因,EF不直接支持悲观并发。如果想使用悲观并发的话,我们可以自定义数据库访问代码。此外,当使用悲观并发时,LINQ to Entities不会正确工作。
三、使用EF实现乐观并发
使用EF实现乐观并发有很多方法,接下来我们就看一下这些方法。
1、新建控制台项目,项目名:EFConcurrencyApp,新闻实体类定义如下:
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text; using System.Threading.Tasks; namespace EFConcurrencyApp.Model { public class News { public int Id { get; set; } [MaxLength(100)] public string Title { get; set; } [MaxLength(30)] public string Author { get; set; } public string Content { get; set; } public DateTime CreateTime { get; set; } public decimal Amount { get; set; } } }
2、使用数据迁移的方式生成数据库,并填充种子数据。
namespace EFConcurrencyApp.Migrations { using EFConcurrencyApp.Model; using System; using System.Data.Entity; using System.Data.Entity.Migrations; using System.Linq; internal sealed class Configuration : DbMigrationsConfiguration<EFConcurrencyApp.EF.EFDbContext> { public Configuration() { AutomaticMigrationsEnabled = false; } protected override void Seed(EFConcurrencyApp.EF.EFDbContext context) { // This method will be called after migrating to the latest version. // You can use the DbSet<T>.AddOrUpdate() helper extension method // to avoid creating duplicate seed data. context.News.AddOrUpdate( new Model.News() { Title = "美国大城市房价太贵 年轻人靠“众筹”买房", Author = "佚名", Content = "美国大城市房价太贵 年轻人靠“众筹”买房", CreateTime = DateTime.Now, Amount = 0, }, new Model.News() { Title = "血腥扑杀流浪狗太残忍?那提高成本就是必须的代价", Author = "佚名", Content = "血腥扑杀流浪狗太残忍?那提高成本就是必须的代价", CreateTime = DateTime.Now, Amount = 0, }, new Model.News() { Title = "iPhone 8或9月6日发布 售价或1100美元起", Author = "网络", Content = "iPhone 8或9月6日发布 售价或1100美元起", CreateTime = DateTime.Now, Amount = 0, } ); } } }
3、数据库上下文定义如下
using EFConcurrencyApp.Model; using System; using System.Collections.Generic; using System.Data.Entity; using System.Linq; using System.Text; using System.Threading.Tasks; namespace EFConcurrencyApp.EF { public class EFDbContext:DbContext { public EFDbContext() : base("name=AppConnection") { } public DbSet<News> News { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { // 设置表名和主键 modelBuilder.Entity<News>().ToTable("News").HasKey(p => p.Id); base.OnModelCreating(modelBuilder); } } }
4、实现EF的默认并发
先看一下EF默认是如何处理并发的,现在假设我们的应用程序要更新一个News的Amount值,那么我们首先需要实现这两个函数FindNews()和UpdateNews(),前者用于获取指定的News,后者用于更新指定News。
Program类里面定义的两个方法如下:
static News FindNews(int id) { using (var db = new EFDbContext()) { return db.News.Find(id); } } static void UpdateNews(News news) { using (var db = new EFDbContext()) { db.Entry(news).State = EntityState.Modified; db.SaveChanges(); } }
下面我们实现这样一个场景:有两个用户甲和乙都读取了同一个News实体,然后这两个用户都尝试更新这个实体的不同字段,比如甲更新Title字段,乙更新Author字段,代码如下:
//1.用户甲获取id=1的新闻 var news1 = FindNews(1); //2.用户乙获取id=1的新闻 var news2 = FindNews(1); //3.用户甲更新这个实体的新闻标题 news1.Title = news1.Title + "(更新)"; UpdateNews(news1); //4.用户乙更新这个实体的Amount news2.Amount = 10m; UpdateNews(news2);
上面的代码尝试模拟了一种并发问题。现在,甲和乙两个用户都有相同的数据副本,然后尝试更新相同的记录。执行代码前,先看一下数据库中的数据:
为了测试,在执行第四步时打一个断点:
在断点之后的代码执行之前,去数据库看一下数据,可以看到用户甲的更新已经产生作用了:
继续执行代码,在看一下数据库中的数据发生了什么变化:
从上面的截图可以看出,用户乙的请求成功了,而用户甲的更新丢失了。因此,从上面的代码不难看出,如果我们使用EF更新整条数据,那么最后一个请求总会获得胜利,也就是说:最后一次请求的更新会覆盖之前所有请求的更新。
四、设计处理字段级别并发的应用
接下来,我们会看到如何编写处理字段级别并发问题的应用代码。这是设计方式的应用思想是:只有更新的字段才会在数据库中进行更改。这样就保证了如果多个用户正在更新不同的字段,所有的更改都可以持久化到数据库。
实现这个的关键是让该应用识别用户正在请求更新的所有列,然后为该用户有选择地更新那些字段。通过以下两个方法来实现:
取数据的方法:该方法会给我们一个原始模型的克隆,只有用户请求的属性会更新为新值。
更新的方法:它会检查原始请求模型的哪个属性值已经发生更改,然后在数据库中只更新那些值。
因此,首先需要创建一个简单的方法,该方法需要模型属性的值,然后会返回一个新的模型,该模型除了用户尝试更新的属性以外,其他的属性值都和原来的模型属性值相同。方法定义如下:
static News GetUpdatedNews(int id, string title, string author, decimal amount, string content, DateTime createTime) { return new News { Id = id, Title = title, Amount = amount, Author = author, Content = content, CreateTime = createTime, }; }
下一步,需要更改更新的方法。该更新方法会实现下面更新数据的算法:
1、根据Id从数据库中检索最新的模型值。
2、检查原始模型和要更新的模型来找出更改属性的列表。
3、只更新步骤2中检索到的模型发生变化的属性。
4、保存更改。
更新方法定义如下:
static void UpdateNewsEnhanced(News originalNews, News newNews) { using (var db = new EFDbContext()) { //从数据库中检索最新的模型 var news = db.News.Find(originalNews.Id); //接下来检查用户修改的每个属性 if (originalNews.Title != newNews.Title) { //将新值更新到数据库 news.Title = newNews.Title; } if (originalNews.Content != newNews.Content) { //将新值更新到数据库 news.Content = newNews.Content; } if (originalNews.CreateTime != newNews.CreateTime) { //将新值更新到数据库 news.CreateTime = newNews.CreateTime; } if (originalNews.Amount != newNews.Amount) { //将新值更新到数据库 news.Amount = newNews.Amount; } if (originalNews.Author != newNews.Author) { //将新值更新到数据库 news.Author = newNews.Author; } // 持久化到数据库 db.SaveChanges(); } }
运行代码前,先查看数据库中的数据:
然后执行主程序代码,在执行第四步时打个断点:
再次查看数据库的数据,发现用户甲的操作已经执行了:
继续运行程序,再次查看数据库的数据,发现用户乙的操作也执行了:
从上面的截图看到,两个用户请求同一个实体的更新值都持久化到了数据库中。因此,如果用户更新不同的字段,该程序可以有效地处理并发更新了。但是如果多个用户同时更新相同的字段,那么这种方法仍然显示的是最后一次请求的值。虽然这种方式减少了一些并发相关的问题,但是这种方法意味着我们必须写大量代码来处理并发问题。后面我们会看到如何使用EF提供的机制来处理并发问题。
五、使用RowVersion实现并发
前面我们看到了EF默认如何处理并发(最后一次请求的数据更新成功),然后看到如果多个用户尝试更新不同的字段时,如何设计应用处理这些问题。接下来,我们看一下当多个用户更新相同的字段时,使用EF如何处理字段级更新。
EF让我们指定字段级并发,这样如果一个用户更新一个字段的同时,该字段已经被其他用户更新过了,就会抛出一个并发相关的异常。使用这种方法,当多个用户尝试更新相同的字段时,我们就可以更有效地处理并发相关的问题。
如果我们为多个字段使用了特定字段的并发,那么会降低应用性能,因为生成的SQL会更大,更加有效的方式就是使用RowVersion机制。RowVersion机制使用了一种数据库功能,每当更新行的时候,就会创建一个新的行值。
给News实体类添加一个属性:
[Timestamp] public byte[] RowVersion { get; set; }
在数据库上下文中配置属性:
protected override void OnModelCreating(DbModelBuilder modelBuilder) { // 设置表名和主键 modelBuilder.Entity<News>().ToTable("News").HasKey(p => p.Id); // 设置属性 modelBuilder.Entity<News>().Property(d => d.RowVersion).IsRowVersion(); base.OnModelCreating(modelBuilder); }
删除原先的数据库,然后重新生成数据库,数据库模式变为:
查看数据,RowVersion列显示的是二进制数据:
现在EF就会为并发控制追踪RowVersion列值。接下来尝试更新不同的列:
using (var context = new EFDbContext()) { var news = context.News.SingleOrDefault(p => p.Id == 1); Console.WriteLine(string.Format("标题:{0} 打赏金额:{1} ", news.Title, news.Amount.ToString("C"))); context.Database.ExecuteSqlCommand(@"update news set amount = 229.95 where Id = @p0", news.Id); news.Amount = 239.95M; Console.WriteLine(string.Format("标题:{0} 打赏金额:{1} ", news.Title, news.Amount.ToString("C"))); context.SaveChanges(); }
运行程序,会抛出下面的异常:
从抛出的异常信息来看,很明显是抛出了和并发相关的异常DbUpdateConcurrencyException,其他信息说明了自从实体加载以来,可能已经被修改或删除了。
无论何时一个用户尝试更新一条已经被其他用户更新的记录,都会获得异常DbUpdateConcurrencyException。
当实现并发时,我们总要编写异常处理的代码,给用户展示一个更友好的描述信息。上面的代码加上异常处理机制后修改如下:
using (var context = new EFDbContext()) { var news = context.News.SingleOrDefault(p => p.Id == 1); Console.WriteLine(string.Format("标题:{0} 打赏金额:{1} ", news.Title, news.Amount.ToString("C"))); context.Database.ExecuteSqlCommand(string.Format(@"update News set Amount = 229.95 where Id = {0}", news.Id)); news.Amount = 239.95M; Console.WriteLine(string.Format("标题:{0} 打赏金额:{1} ", news.Title, news.Amount.ToString("C"))); try { context.SaveChanges(); } catch (DbUpdateConcurrencyException ex) { Console.WriteLine(string.Format("并发异常:{0}", ex.Message)); } catch (Exception ex) { Console.WriteLine(string.Format("普通异常:{0}", ex.Message)); } }
此时,我们应该使用当前的数据库值更新数据,然后重新更改。作为开发者,如果我们想要协助用户的话,我们可以使用EF的DbEntityEntry类获取当前的数据库值。
using (var context = new EFDbContext()) { var news = context.News.SingleOrDefault(p => p.Id == 1); Console.WriteLine(string.Format("标题:{0} 打赏金额:{1} ", news.Title, news.Amount.ToString("C"))); context.Database.ExecuteSqlCommand(string.Format(@"update News set Amount = 229.95 where Id = {0}", news.Id)); news.Amount = 239.95M; Console.WriteLine(string.Format("标题:{0} 打赏金额:{1} ", news.Title, news.Amount.ToString("C"))); try { context.SaveChanges(); } catch (DbUpdateConcurrencyException ex) { // 使用这段代码会将Amount更新为239.95 var postEntry = context.Entry(news); postEntry.OriginalValues.SetValues(postEntry.GetDatabaseValues()); context.SaveChanges(); } catch (Exception ex) { Console.WriteLine(string.Format("普通异常:{0}", ex.Message)); } }
示例代码下载地址:点此下载
到此这篇关于Entity Framework管理并发的文章就介绍到这了。希望对大家的学习有所帮助,也希望大家多多支持脚本之家。