在大多数的应用中都会出现客户端同时发送多个请求对同一条数据就行修改,这个时候就会出现并发冲突。我们一般的做法会有如下两种:
1.乐观并发
所谓的乐观并发就是多个请求同时对同一条数据的更新,只有最后一个更新请求会被保存,其他更新请求将会被抛弃。
2.悲观并发
所谓悲观并发就是多个请求同时对同一条数据的更新,只有当前更新请求完成或者被抛弃,才会执行下一个更新请求,如果当前更新请求未完成或者未被抛弃,那么后面所有的更新请求将会被阻塞。
通过上面的简单讲解我们简单的了解了如何处理并发请求,那么下面我们来看一下上面两种做法的具体讲解和实现。
零、方法一
在 Entity Framework 中,默认的解决方案是乐观并发,原因是当出现并发情况的时候,内部没有任何对其他客户端访问同一行数据的限制。我们来看一下例子,我们在数据库中存有一条数据,数据如下图所示:
下面我们来修改一下 Name 字段的值:
class Program { static void Main(string[] args) { int userId = 1; using (var db = new EfContext()) { using (var ef = new EfContext()) { User user1 = db.Users.FirstOrDefault(p => p.Id == userId); User user2= ef.Users.FirstOrDefault(p => p.Id == userId); user1.Name = "李四"; db.SaveChanges(); user2.Name = "王五"; ef.SaveChanges(); } } } }
在上面的代码中我们利用嵌套 using 的形式实现了并发访问。首先我们同时查询出 id 等于1的人员,然后将 user1 中的 Name 修改为李四并提交,接着再把 user2 中的 Name 修改为王五并提交。这个时候我们再查询数据库就会发现 Name 列被更新为了最后一次提交值王五,如下图所示:
上述操作发生了什么呢?我们来看一下,首先我们利用 db 从数据库中读取了 id 等于1的人员信息,此时该人员信息为张三,然后我们将 Name 值改为李四,并且提交到了数据库,在这个时候,数据库中的Name值将不再是张三,而是李四。接着我们再将 user2 的 Name 值修改为王五,并提交的数据库,这个时候数据库的 Name 列的值变为了王五。上述情况下,Entity Framework 将修改转换为 update 语句时是利用主键来定位指定行,因此上面两次操作都会成功,只不过最后一次修改的数据会最终持久化到数据库中。但是这种方式存在一个巨大的隐患,例如在门票预售系统中,门票的数量是有限制的,购票人数超过门票数量限制将会禁止购买。如果利用 Entity Framework 默认的乐观并发模式,每次有并发请求购票时,每个请求都会减去门票数量,并且向数据库中插入一条购票信息,这样一来永远是最后一个请求的数据会持久化到数据库中,这样就造成了门票预约人数超过了门票的限制数量。
针对上面所说的问题,我么可以利用如下两种方式来解决:
1.并发 Token
利用这个方法我们只需在实体类对应的 Map 文件的构造函数中加让类似下面的代码即可:
Property(p => p.Name).IsConcurrencyToken();
2. 行版本
通过行版本设置,我们需要为实体添加一个行版本子字节数组,代码如下:
public byte[] RowVersion { get; set; }
然后将行版本字段映射进数据库,这样每次更新数据的时候都行版本字段也会跟着更新。最后我们在实体类对应的 Map 文件的构造函数中添加如下代码即可:
Property(p => p.RowVersion).IsRowVersion();
这样在每次提交修改请求时 Entity Framework 都会检查数据库中的行版本和当前提交数据的行版本是否一致,如果一直就更新数据和行版本信息。
上述两种方法都将会引发并发异常,那么我们该如何解决这个异常呢?我们需要用到并发异常类( DbUpdateConcurrencyException )中的 Entries 属性,该属性是一个集合。我们需要调用集合中每个对象的 Reload 方法将数据库中最新的值放在内存中。这样后续的实体值将和数据库保持一致。完成这一步后,我们可以重新向数据库提交更新数据。具体实现代码如下:
class Program { static void Main(string[] args) { int userId = 1; using (var db = new EfContext()) { using (var ef = new EfContext()) { User user1 = db.Users.FirstOrDefault(p => p.Id == userId); User user2= ef.Users.FirstOrDefault(p => p.Id == userId); user1.Name = "李四"; db.SaveChanges(); try { user2.Name = "王五"; ef.SaveChanges(); } catch (DbUpdateConcurrencyException e) { foreach (var item in e.Entries) { item.Reload(); ef.SaveChanges(); } } } } } }
这里需要注意的是这个方法并不是万能的,只是将当前客户端的值成功存入数据库中,这种情况被称为客户端获胜,当然了还有数据库获胜,以及数据库和客户端合并获胜(这三个概念解决并发冲突的方式将在下一小节讲解)。在讲解这个问题前我们先来了解一下 Entity Framework 的原始值和更新后的数据库值以及当前值从哪里获得。代码如下:
try { //more code } catch (DbUpdateConcurrencyException e) { foreach (var item in e.Entries) { //原始值 var ov = item.OriginalValues.ToObject(); //更新后数据库值 var dv = item.GetDatabaseValues().ToObject(); // 当前值 var nv = item.CurrentValues.ToObject(); } }
从上面的代码中我们可以看到获取这三种值我们依然是从并发异常类的 Entries 属性中获得。看到这里一定会有人想到不利用 Reload 方法来更新内存中的最新值,而是直接利数据库值更新当前内存中的值,如果你想到这里说明你已经掌握了解决并发冲突最简单的方法。那么我们就来看一下代码:
try { //more code } catch (DbUpdateConcurrencyException e) { foreach (var item in e.Entries) { Object dv = item.GetDatabaseValues().ToObject(); item.OriginalValues.SetValues(dv); ef.SaveChanges(); } }
一、方法二
上一小节中我们提到了客户端获胜、数据库获胜以及数据库和客户端合并获胜,并且讲解了原始值和更新后的数据库值以及当前值从哪里获得的。在这一节将利用客户端获胜、数据库获胜以及客户端和数据库合并获胜处理并发的方法。
1.客户端获胜
当调用 SaveChanges 方法时,如果存在并发冲突将会引发 DbUpdateConcurrencyException 异常,那么这个时候我们将调用 handleDbUpdateConcurrencyException 函数来处理异常并正确解决冲突,最后在调用 SaveChanges 方法重试提交数据。如果依然排除 DbUpdateConcurrencyException 异常,将不在进行处理。我们来看以下代码:
class Program { static void Main(string[] args) { int userId = 1; using (var db = new EfContext()) { using (var ef = new EfContext()) { User user1 = db.Users.FirstOrDefault(p => p.Id == userId); User user2 = ef.Users.FirstOrDefault(p => p.Id == userId); user1.Name = "李四"; db.SaveChanges(); try { user2.Name = "王五"; ef.SaveChanges(); } catch (DbUpdateConcurrencyException e) { Retry(ef, handleDbUpdateConcurrencyException: exception => { exception = (e as DbUpdateConcurrencyException).Entries; foreach (var item in exception) { item.OriginalValues. SetValues(item.GetDatabaseValues()); } }); } } } } }
上述代码中发生并发异常时,将会将数据库的值提交到内存中,然后重新提交更新数据。
2. 数据库获胜
如果你想让数据库获胜,那就简单了。再发生异常时不需做任何处理,只返回方法的返回值即可。我们将上一个例子的代码更新一下:
class Program { static void Main(string[] args) { int userId = 1; using (var db = new EfContext()) { using (var ef = new EfContext()) { User user1 = db.Users.FirstOrDefault(p => p.Id == userId); User user2 = ef.Users.FirstOrDefault(p => p.Id == userId); user1.Name = "李四"; db.SaveChanges(); try { user2.Name = "王五"; ef.SaveChanges(); } catch (DbUpdateConcurrencyException e) { return; } } } } }