本篇目录
本系列的源码本人已托管于Coding上:点击查看,想要注册Coding的可以点击该连接注册。
先附上codeplex上EF的源码:entityframework.codeplex.com,此外,本人的实验环境是VS 2013 Update 5,windows 10,MSSQL Server 2008/2012。
理解并发
并发管理解决的是允许多个实体同时更新,实际上这意味着允许同时在相同的数据上执行多个数据库操作。并发是在一个数据库上管理多个操作的一种方式,同时遵守了数据库操作的ACID属性(原子性,一致性,隔离性和持久性)。
想象一下下面几种可能发生并发的场景:
- 用户甲和乙都尝试修改相同的实体
- 用户甲和乙都尝试删除相同的实体
- 用户甲正在尝试修改一个实体时,用户乙已经删除了该实体
- 用户甲已经请求读取一个实体,用户乙读完该实体之后更新了它
这些场景可能会潜在地产生错误的数据,试想,成百上千的用户同时尝试操作一个相同的实体,这种并发问题将会对系统带来更大的影响。
在处理与并发相关的问题时,一般有两种方法:
- 积极并发:无论何时从数据库请求数据,数据都会被读取并保存到应用内存中。数据库级别没有放置任何显式锁。数据操作会按照数据层接收到的顺序执行。
- 消极并发:无论何时从数据库请求数据,数据都会被读取,然后该数据上就会加锁,因此没有人能访问该数据。这会降低并发相关问题的机会,缺点是加锁是一个昂贵的操作,会降低整个应用程序的性能。EF默认支持积极并发,这样,一旦所有的数据被读取,就会呈现在内存中。当然,也可以配置EF使用消极并发,但是EF不直接支持。
理解积极并发
前面提到,在积极并发中,无论何时从数据库请求数据,数据都会被读取并保存到应用内存中。数据库级别没有放置任何显式锁。因为这种方法没有添加显式锁,所以比消极并发更具扩展性和灵活性。使用积极并发,重点是如果发生了任何冲突,应用程序要亲自处理它们。最重要的是,使用积极并发控制时,在应用中要有一个冲突处理策略,要让应用程序的用户知道他们的修改是否因为冲突的缘故没有持久化。积极并发本质上是允许冲突发生,然后以一种适当的方式解决该冲突。
下面是处理冲突的策略例子。
忽略冲突/强制更新
这种策略是让所有的用户更改相同的数据集,然后所有的修改都会经过数据库,这就意味着数据库会显示最后一次更新的值。这种策略会导致潜在的数据丢失,因为许多用户的更改都丢失了,只有最后一个用户的更改是可见的。
部分更新
在这种情况中,我们也允许所有的更改,但是不会更新完整的行,只有特定用户拥有的列更新了。这就意味着,如果两个用户更新相同的记录但却不同的列,那么这两个更新都会成功,而且来自这两个用户的更改都是可见的。
警告/询问用户
当一个用户尝试更新一个记录时,但是该记录自从他读取之后已经被别人修改了,这时应用程序就会警告该用户该数据已经被某人更改了,然后询问他是否仍然要重写该数据还是首先检查已经更新的数据。
拒绝更改
当一个用户尝试更新一个记录时,但是该记录自从他读取之后已经被别人修改了,此时告诉该用户不允许更新该数据,因为数据已经被某人更新了。
理解消极并发
和积极并发相反,消极并发的目标是永远不让任何冲突发生。这是通过在使用记录之前就在记录上放置显式锁实现的。数据库记录上可以得到两种类型的锁:
- 只读锁
- 更新锁
当把只读锁放到记录上时,应用程序只能读取该记录。如果应用程序要更新该记录,它必须获取到该记录上的更新锁。如果记录上加了只读锁,那么该记录仍然能够被想要只读锁的请求使用。然而,如果需要更新锁,该请求必须等到所有的只读锁释放。同样,如果记录上加了更新锁,那么其他的请求不能再在这个记录上加锁,该请求必须等到已存在的更新锁释放才能加锁。
从前面的描述中,似乎消极并发能解决所有跟并发相关的问题,因为我们不必在应用中处理这些问题。然而,事实上并不是这样的。在使用消极并发管理之前,我们需要记住,使用消极并发有很多问题和开销。下面是使用消极并发面临的一些问题:
- 应用程序必须管理每个操作正在获取的所有锁;
- 加锁机制的内存需求会降低应用性能
多个请求互相等待需要的锁,会增加死锁的可能性。由于这些原因,EF不直接支持消极并发。如果想使用消极并发的话,我们可以自定义数据库访问代码。此外,当使用消极并发时,LINQ to Entities不会正确工作。
我们尽可能不要尝试使用消极并发。并发相关的冲突可以使用
TimeStamp
字段或者RowVersion
类型处理。后面会做介绍。
使用EF实现积极并发
使用EF实现积极并发有很多方法,接下来我们就会看一下这些方法。我这里仍然使用打赏者的例子。
新建一个控制台项目,取名ConcurrencyAndTransactionManagement,这次只创建打赏者实体类如下:
public class Donator
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Amount { get; set; }
public DateTime DonateDate { get; set; }
}
EF的默认并发
先看一下EF默认是如何处理并发的。现在假设我们的应用程序要更新一个Donator的Amount值,那么我们首先需要实现这两个函数GetDonator() 和 UpdateDonator(),前者用于获取指定Donator,后者用于更新指定Donator。
static Donator GetDonator(int id)
{
using (var db=new Context())
{
return db.Donators.Find(id);
}
}
static void UpdateDonator(Donator donator)
{
using (var db=new Context())
{
db.Entry(donator).State=EntityState.Modified;
db.SaveChanges();
}
}
下面我们实现这么一种场景:有两个用户甲和乙都读取了同一个Donator实体,然后这两个用户都尝试更新这个实体的不同字段,比如甲更新Name字段,乙更新Amount字段,代码如下:
//1.用户甲获取id=1的打赏者
var donator1 = GetDonator(1);
//2.用户乙也获取id=1的打赏者
var donator2 = GetDonator(1);
//3.用户甲只更新这个实体的Name字段
donator1.Name = "用户甲";
UpdateDonator(donator1);
//4.用户乙只更新这个实体的Amount字段
donator2.Amount = 100m;
UpdateDonator(donator2);
上面的代码尝试模拟了一种并发问题。现在,两个用户都有相同的数据副本,然后尝试更新相同的记录。执行代码前,先看下数据库中的数据:
为了测试,在执行第四步时打一个断点:
在断点之后的代码执行之前,去数据库看一下数据,可以看到用户甲的更新已经产生作用了:
继续执行代码,再看一下数据库中的数据发生了什么变化:
从上面的截图可以看出,用户乙的请求成功了,而用户甲的更新丢失了。因此,从上面的代码不难看出,如果我们使用EF更新整条记录,那么最后一个请求总会获取胜利,也就是说,最后一次请求的更新会覆盖之前所有请求的更新。
设计处理字段级别并发的应用
接下来,我们会看到如何编写处理字段级并发问题的应用代码。这种方式设计应用的思想是,只有更新的字段会在数据库中更改。这个就保证了如果多个用户正在更新不同的字段,所有的更改都会持久化到数据库。
实现这个的关键是让该应用识别用户正在请求更新的所有列,然后为该用户有选择地更新那些字段。通过以下两个东西来实现:
- 一个方法:该方法会给我们一个原始模型的克隆,只有用户请求的属性会更新为新值
- 更新方法:它会检查原始请求模型的哪个属性值已经更改,然后在数据库中只更新那些值。
因此,首先需要创建一个简单的方法,该方法需要模型属性的值,然后会返回一个新的模型,该模型除了用户尝试更新的属性之外,其他的属性值都和原来的模型属性值相同。
static Donator GetUpdatedDonator(int id,string name,decimal amount,DateTime donateDate)
{
return new Donator
{
Id = id,
Name = name,
Amount = amount,
DonateDate = donateDate
};
}
如果用户只想更新Amount
字段,方法的调用就像下面这样:
var donator1 = GetDonator(1);
var donator2 = GetDonator(1);
var newDonator = GetUpdatedDonator(donator2.Id, donator1.Name,100m, donator1.DonateDate);
在上面的代码中,donator1是用户请求模型的原始对象,100m是打赏金额的新值。
上面的方法超级简单,它只显示了如何获得具有更新属性值的克隆对象。现实生活中,很少会看到这样的代码。为了更简洁,我们还可以使用映射模块将领域模型映射到数据模型。
下一步,需要更改更新方法。该更新方法会实现下面更新数据的算法:
- 从数据库中检索最新的模型值
- 检查原始模型和要更新的模型来找出更改属性的列表
- 只更新步骤1中检索到的模型发生变化的属性
- 保存更改
该算法的代码大概像下面这个样子:
static void UpdateDonatorEnhanced(Donator originalDonator,Donator newDonator)
{
using (var db=new Context())
{
//从数据库中检索最新的模型
var donator = db.Donators.Find(originalDonator.Id);
//接下来检查用户修改的每个属性
if (originalDonator.Name!=newDonator.Name )
{
//将新值更新到数据库
donator.Name = newDonator.Name;
}
if (originalDonator.Amount != newDonator.Amount)
{
//将新值更新到数据库
donator.Amount = newDonator.Amount;
}
//这里省略其他属性...
db.SaveChanges();
}
}
接下来,使用这两个方法来更新应用程序代码,并检查结果:
#region 2.0 设计处理字段级别的并发应用
//1.用户甲读取id=1的打赏者
var donator1 = GetDonator(1);
//2.用户乙同样读取id=1的打赏者
var donator2 = GetDonator(1);
//3.用户甲通过创建一个新的对象来更新打赏金额为100m
var newDonator1 = GetUpdatedDonator(donator2.Id, donator1.Name,100m, donator1.DonateDate);
UpdateDonatorEnhanced(donator1,newDonator1);
//4.用户乙通过创建一个新的对象来更新打赏者姓名为“并发测试”
var newDonator2 = GetUpdatedDonator(donator2.Id, "并发测试", donator2.Amount, donator2.DonateDate);
UpdateDonatorEnhanced(donator1, newDonator2);
#endregion
运行代码之前,先看下数据库中的数据:
在执行第四步时打个断点,运行程序:
再次查看数据库中的数据,发现用户甲的操作已经执行了:
继续运行程序,再次查看数据库的数据,发现用户乙的操作也执行了:
从上面的截图看到,两个用户的请求同一个实体的更新值都持久化到数据库中了。因此,如果用户更新不同的字段,该程序可以有效地处理并发更新了。但是如果多个用户同时更新相同的字段,那么这种方法仍然显示的是最后一次请求的值。虽然这种方式减少了一些并发相关的问题,但是这种方法意味着我们必须写大量代码来处理并发问题。后面我们会看到如何使用EF提供的机制来处理并发问题。
为并发实现RowVersion
前面,我们看到了EF默认如何处理并发(最后一次请求获胜),然后看了如果多个用户尝试更新不同的字段时,如何设计应用处理这些问题。接下来,我们看一下当多个用户更新相同的字段时,使用EF如何处理字段级并发。
EF让我们指定字段级并发,这样如果一个用户更新一个字段的同时,该字段已经被其他人更新过了,就会抛出一个并发相关的异常。使用这种方法,当多个用户尝试更新相同的字段时,我们就可以更有效地处理并发相关的问题。
如果我们为多个字段使用了特定字段的并发,那么会降低应用性能,因为生成的Sql会更大,更加有效的方式就是使用RowVersion机制。RowVersion机制使用了一种数据库功能,每当更新行的时候,就会创建一个新的行值。
给Donator实体添加一个属性:
[Timestamp]
public byte[] RowVersion { get; set; }
//修改上下文
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
modelBuilder.Entity<Donator>().Property(d => d.RowVersion).IsRowVersion();
base.OnModelCreating(modelBuilder);
}
添加一个数据库初始化器,重新生成数据库,数据库模式变为:
插入两条数据,RowVersion列显示的是二进制数据:
现在,EF就会为并发控制追踪RowVersion列值。接下来尝试更新不同的列:
//1.用户甲获取id=1的打赏者
var donator1 = GetDonator(1);
//2.用户乙也获取id=1的打赏者
var donator2 = GetDonator(1);
//3.用户甲只更新这个实体的Name字段
donator1.Name = "用户甲";
UpdateDonator(donator1);
//4.用户乙只更新这个实体的Amount字段
donator2.Amount = 100m;
UpdateDonator(donator2);
运行程序,会抛出下面的异常:
其他信息:Entities may have been modified or deleted since entities were loaded.
从抛出的异常信息来看,很明显是抛出了和并发相关的异常DbUpdateConcurrencyException
,其他信息说明了自从实体加载以来,可能已经被修改或删除了
。
无论何时一个用户尝试更新一条已经被其他用户更新的记录,都会获得异常DbUpdateConcurrencyException
。
当实现并发时,我们总要编写异常处理的代码,给用户展示一个更友好的描述信息。比如:
//4.用户乙只更新这个实体的Amount字段
try
{
donator2.Amount = 100m;
UpdateDonator(donator2);
Console.WriteLine("应该发生并发异常!");
}
catch (DbUpdateConcurrencyException ex)
{
Console.WriteLine("异常如愿发生!");
}
此时,我们应该使用当前的数据库值更新数据,然后重新更改。作为开发者,如果我们想要协助用户的话,可以使用EF的DbEntityEntry
类获取当前的数据库值。
理解事务
处理以数据为中心的应用时,另一个重要的话题是事务管理。ADO.NET为事务管理提供了一个非常干净和有效的API。因为EF运行在ADO.NET之上,所以EF可以使用ADO.NET的事务管理功能。
当从数据库角度谈论事务时,它意味着一系列操作被当作一个不可分割的操作。所有的操作要么全部成功,要么全部失败。事务的概念是一个可靠的工作单元,事务中的所有数据库操作应该应该被看作一个工作单元。
从应用程序的角度来看,如果我们有多个数据库操作被当作一个工作单元,那么应该将这些操作包裹在一个事务中。为了能够使用事务,应用程序需要执行下面的步骤:
- 开始事务;
- 执行所有的查询,执行所有的数据库操作,这些操作被视为一个工作单元;
- 如果所有的事务成功了,那么提交事务;
- 如果任何一个操作失败,就回滚事务。
创建测试环境
提到事务,最经典的例子莫过于银行转账了。我们这里也使用这个例子来理解一下和事务相关的概念。为了简单模拟银行转账的情景,假设银行为不同的账户类型使用了不同的表,对应地,我创建了两个实体OutputAccount
和InputAccount
,实体类代码如下:
[Table("OutputAccounts")]
public class OutputAccount
{
public int Id { get; set; }
[StringLength(8)]
public string Name { get; set; }
public decimal Balance { get; set; }
}
[Table("InputAccounts")]
public class InputAccount
{
public int Id { get; set; }
[StringLength(8)]
public string Name { get; set; }
public decimal Balance { get; set; }
}
从应用程序的角度看,无论何时用户将钱从OutputAccount
转入InputAccount
,这个操作应该被视为一个工作单元。永远不应该发生OutputAccount的金额扣除了,而InputAccount的金额没有增加!接下来我们就看一下使用EF如何管理事务。
现在给数据库插入数据,让它们的初始金额如下所示:
现在,我们尝试使用EF的事务从OutputAccount
的甲转入1000给InputAccount
的乙。
这只是个例子,只为了以一种简单的方式描述涉及到的概念。现实生活中,这个场景的数据库会远比这个更复杂和更优化。
EF的默认事务处理
EF的默认行为是,无论何时执行任何涉及Create,Update或Delete的查询,都会默认创建事务。当DbContext类上的SaveChanges()
方法被调用时,事务就会提交。
要实现我们的场景,代码应该是下面这样的:
#region 4.0 EF默认的事务处理
int outputId = 2,inputId=1;
decimal transferAmount = 1000m;
using (var db=new Context())
{
//1 检索事务中涉及的账户
var outputAccount = db.OutputAccounts.Find(outputId);
var inputAccount = db.InputAccounts.Find(inputId);
//2 从输出账户上扣除1000
outputAccount.Balance -= transferAmount;
//3 从输入账户上增加1000
inputAccount.Balance += transferAmount;
//4 提交事务
db.SaveChanges();
}
#endregion
运行程序,结果如下:
可以看到,甲账户上少了1000,而乙账户上多了1000。因此,这两个操作有效地被包裹在了一个事务中,并作为一个工作单元执行。如果任何一个操作失败,数据就不会发生变化。
因为把读操作放到事务中没有好处,但是却降低了整个应用程序的性能,因此,EF不会对涉及数据库的
Select
查询使用事务。
使用TransactionScope处理事务
如果有一个场景具有多个DbContext对象,那么我们想将涉及多个DbContext对象的操作关联为一个工作单元,这时,我们需要在TransactionScope
对象内部包裹SaveChanges
方法的调用。为了描述这个场景,我们使用DbContext类的两个不同实例来执行扣款和收款:
#region 5.0 使用TransactionScope处理事务
int outputId = 2, inputId = 1;
decimal transferAmount = 1000m;
using (var ts=new TransactionScope(TransactionScopeOption.Required))
{
var db1=new Context();
var db2=new Context();
//1 检索事务中涉及的账户
var outputAccount = db1.OutputAccounts.Find(outputId);
var inputAccount = db2.InputAccounts.Find(inputId);
//2 从输出账户上扣除1000
outputAccount.Balance -= transferAmount;
//3 从输入账户上增加1000
inputAccount.Balance += transferAmount;
db1.SaveChanges();
db2.SaveChanges();
ts.Complete();
}
#endregion
上面的代码中,我们使用了两个不同的DbContext实例执行扣款和收款操作。因此,默认的EF行为不会工作。在调用各自的SaveChanges()
方法时,和上下文相关的各个事务不会提交。相反,因为它们都在TransactionScope
对象的内部,所以,当TransactionScope
对象的Complete()
方法调用时,事务才会提交。如果任何一个操作失败,就会发生异常,TransactionScope就不会调用Complete()
方法,从而回滚更改。
使用EF6管理事务
从EF 6起,EF在DbContext对象上提供了Database.BeginTransaction()
方法,当使用上下文类在事务中执行原生SQL命令时,这个方法特别有用。
接下来看一下如何使用这个新方法管理事务。这里我们使用原生SQL从OutputAccounts
中扣款,使用模型类给InputAccounts
收款:
#region 6.0 使用EF6管理事务
int outputId = 2, inputId = 1;
decimal transferAmount = 1000m;
using (var db=new Context())
{
using (var trans=db.Database.BeginTransaction())
{
try
{
var sql = "Update OutputAccounts set Balance=Balance-@amountToDebit where id=@outputId";
db.Database.ExecuteSqlCommand(sql, new SqlParameter("@amountToDebit", transferAmount), new SqlParameter("@outputId",outputId));
var inputAccount = db.InputAccounts.Find(inputId);
inputAccount.Balance += transferAmount;
db.SaveChanges();
trans.Commit();
}
catch (Exception ex)
{
trans.Rollback();
}
}
}
#endregion
稍作解释,首先创建了一个DbContext类的实例,然后使用这个实例通过调用Database.BeginTransaction()
方法开始了一个事务。该方法给我们返回了一个DbContextTransaction
对象的句柄,使用该句柄可以提交或者回滚事务。然后使用原生SQL从OutputAccounts中扣款,使用模型类为 InputAccounts收款。调用SaveChanges()
方法只会影响第二个操作(在事务提交之后影响),但不会提交事务。如果两个操作都成功了,那么就调用DbContextTransaction
对象的Commit()
方法,否则,我们就处理异常并调用Rollback()
方法回滚事务。
这种方式只用于EF6,如果是EF6之前的版本,必须依赖
TransactionScope
管理事务。
使用已存在的事务
有时,我们想在EF的DbContext类中使用一个已存在的事务。原因可能有这么几个:
- 一些操作可能在应用的不同部分完成。
- 对老项目使用了EF,并且这个老项目使用了一个类库,这个类库给我们提供了事务或数据库连接的句柄。
对于这些场景,EF允许我们在DbContext类中使用一个和事务相关联的已存在连接。接下来,写一个简单的函数来模拟老项目的类库提供句柄,该函数使用纯粹的ADO.NET执行扣款操作:
//模拟老项目的类库
static bool DebitOutputAccount(SqlConnection conn, SqlTransaction trans, int accountId, decimal amountToDebit)
{
int affectedRows = 0;
var command = conn.CreateCommand();
command.Transaction = trans;
command.CommandType=CommandType.Text;
command.CommandText = "Update OutputAccounts set Balance=Balance-@amountToDebit where id=@accountId";
command.Parameters.AddRange(new SqlParameter[]
{
new SqlParameter("@amountToDebit",amountToDebit),
new SqlParameter("@accountId",accountId)
});
try
{
affectedRows= command.ExecuteNonQuery();
}
catch (Exception ex)
{
throw ex;
}
return affectedRows == 1;
}
该函数需要四个参数,来自调用者的数据库连接对象和事务对象,以及扣款账户id和扣款金额,知道了这些参数之后就可以执行扣款操作的更新查询。
现在,假设这个函数以类库的行为提供给我们,很显然,我们什么都不能更改。这种情况,我们不能使用Database.BeginTransaction
方法,因为我们需要将SqlConnection
和SqlTransaction
对象传给该函数,并把该函数放到我们的事务里。这样,我们就需要首先创建一个SqlConnection
,然后开始SqlTransaction
。代码如下:
#region 7.0 使用已存在的事务
int outputId = 2, inputId = 1;
decimal transferAmount = 1000m;
var connectionString = ConfigurationManager.ConnectionStrings["ConcurrencyAndTransactionManagementConn"].ConnectionString;
using (var conn=new SqlConnection(connectionString))
{
conn.Open();
using (var trans=conn.BeginTransaction())
{
try
{
var result = DebitOutputAccount(conn, trans, outputId, transferAmount);
if (!result)
{
throw new Exception("不能正常扣款!");
}
using (var db=new Context(conn,contextOwnsConnection:false))
{
db.Database.UseTransaction(trans);
var inputAccount=db.InputAccounts.Find(inputId);
inputAccount.Balance += transferAmount;
db.SaveChanges();
}
trans.Commit();
}
catch (Exception ex)
{
trans.Rollback();
}
}
}
#endregion
稍作解释,首先创建了一个SqlConnection
,然后使用该连接关联了一个SqlTransaction
。事务开始后,我们就使用连接和事务对象调用老项目中的方法,然后检查了一下调用老项目中的方法是否执行成功!如果失败,我们直接抛出异常,捕获异常后会回滚该事务。如果成功了,我们使用了DbContext类来为InputAccounts
用户添加收款,并提交事务。这里有一句代码值得注意db.Database.UseTransaction(trans);
,这句话的意思是,EF执行的操作都在外部传入的事务中执行。还有,contextOwnsConnection
的值为false,表示上下文和数据库连接没有关系,上下文释放了,数据库连接还没释放;反之为true的话,上下文释放了,数据库连接也就释放了。
选择合适的事务管理
目前,我们已经知道了好几种使用EF处理事务的方法,下面一一对号入座:
- 如果只有一个DbContext类,那么应该尽力使用EF的默认事务管理。我们总应该将所有的操作组成一个在相同的DbContext对象的作用域中执行的工作单元,
SaveChanges()
方法会处理提交事务。 - 如果使用了多个DbContext对象,那么管理事务的最佳方法可能就是把调用放到
TransactionScope
对象的作用域中了。 - 如果要执行原生SQL,并想把这些操作和事务关联起来,那么应该使用EF提供的
Database.BeginTransaction()
方法。然而这种方法只支持EF6,不支持之前的版本。 - 如果想为要求
SqlTransaction
的老项目使用EF,那么可以使用Database.UseTransaction()
方法,在EF6中可用。
本章小结
首先,我们看了下如何管理EF中并发相关的问题,然后讨论了如何使用EF实现积极并发。我们也看了消极并发的一些基本概念以及为什么EF不支持和不推荐使用消极并发。
然后,我们看了如何使用EF管理事务。先是看了EF管理事务的默认实现,然后看了使用EF控制事务管理,最后看到了使用EF实现应用程序需要的大多数信息。
自我测试
- 在
EntityTypeConfiguration
类中,需要调用什么方法将一个属性标记为并发属性? - 哪一种异常类型表示并发错误?
如果您觉得这篇文章对您有价值或者有所收获,请点击右下方的店长推荐,然后查看答案,谢谢!
参考书籍:
《Mastering Entity Framework》
《Code-First Development with Entity Framework》
《Programming Entity Framework Code First》