原文:Handling Concurrency with the Entity Framework 6 in an ASP.NET MVC 5 Application
1.并发冲突:
当一个用户编辑一个实体数据时,另一个用户在第一个用户的改变提交到数据库之前同时也在编辑这个实体数据,这时就会发生冲突。如果不处理这种冲突,最后更新数据库的用户的更改将覆盖其他用户的修改。在许多程序中,这种风险是可以接受的:如果程序具有较少的用户和较少的更新操作,或者不是覆盖关键的变化,这种情况下处理并发的成本可能大于好处。在这种情况下我们不必配置程序处理并发。
1.1.保守式并发(Pessimistic Concurrency)(加锁):
如果我们的程序不需要避免在并发时意外丢失数据,一种方式是使用数据库锁,这种方式叫做保守式并发。例如,我们在从数据库读取一行数据之前,可以请求只读或者更新访问锁。如果我们对一行进行更新访问锁定,其他用户就不能再该该行请求只读或者更新访问锁,因为他们得到的数据副本在程序中被改变了。如果对一行加只读锁,其他用户也可以对其加只读锁,但是不能加更新锁。
管理锁是有缺点的,它会使程序变复杂。它需要大量的数据库管理资源,并且随着用户数量的增加可能会导致性能问题。因此,不是所有的数据库管理系统都支持保守式并发。EF没有对其提供内置支持,本教程也不会展示如何实现它。
1.2.开放式并发(Optimistic Concurrency):
开放式并发意味着允许并发冲突发生,然后在并发冲突发生时做适当的反应。例如,John在Department的Edit页面,把名为English的department的Budget数量从$350,000.00修改为$0.00:
在John点击保存之前,Jane把English的department的Start Date从9/1/2007修改为8/8/2013:
John首先点击Save,然后Jane点击Save。接下来会发生什么取决于我们如何处理并发冲突。下面是一些选择:
- 我们跟踪哪些属性被用户修改,并且只更新修改的列。在示例场景中,不会造成数据丢失,因为两个用户修改的是不同的属性。这种方法可以减少丢失数据的冲突的数量,但是当对相同的属性作出修改时,它不能避免数据丢失。EF是否采用这种方法取决于我们如何实现更新代码。这在web程序中通常是不实际的,因为为了跟踪实体所有属性的原始值以及新值,它要求我们保持大量的状态。保持大量的状态会影响程序的性能,因为它需要服务器资源或必须包含在web页面本身(比如隐藏域)或者cookie中。
- 让Jane的更改覆盖John的更改。这被称作Client Wins或者Last in Wins场景(所有从客户端获取的值优先于数据存储的值)。如果我们不编码做并发处理,这种情况将会自动发生。
- 阻止Jane的变化更新到数据库中。通常情况下,我们显示一条错误信息,告诉她数据现在的状态,如果他依然想要修改允许她重试修改。这被称作Store Wins场景(数据存储的值优先于从客户端获取的值)。本教程将实现Store Wins场景。这种方法确保在用户没有意识到发生了什么时,没有修改会被覆盖。
1.3.检测并发冲突:
我们可以通过处理EF抛出的OptimisticConcurrencyException异常来解决冲突。为了知道什么时候抛出这些异常,必须启用EF检测冲突。因此,我们必须适当地配置数据库和数据模型。启用冲突检测的方法如下:
- 在数据表中包含一个跟踪列,被用来判定该行什么时候被修改。我们可以在SQL的Update和Delete命令的Where子句中配置EF包含该列。跟踪列的类型通常是时间戳(rowversion)。时间戳的值是一个连续的数字,在每次更新行时增加。在Update或者Delete命令中,Where子句包含跟踪列的原始值(原来的行版本)。如果该行已经被其他用户修改,时间戳列的值将会与原始值不同,因此Update或者Delete语句将无法找到要更新的行,因为Where子句中包含的是原始值。当EF发现Update或者Delete命令没有更新列(也就是说,当受影响的行为0),它就认为发生了并发冲突。
- 配置EF,在Update和Delete命令的Where子句中包含每列的原始值。
如果选择配置EF,如果该行被第一次读取时做了任何改变,Where子句将不会返回一行被更新,EF将认为发生了并发冲突。因为数据表中有许多列,这种做法将导致庞大的Where子句,并且要求我们保持大量的状态。如前所述,保持大量的状态会出现性能问题。因此这种做法通常是不推荐的,因此本教程也不使用此种方法。
如果我们要采用这种方法处理并发,我们必须为所有想要跟踪并发的非主键属性添加ConcurrencyCheck属性。这种改变将启用EF在Update的Where子句中包含所有的列。
本教程将采用添加时间戳来跟踪Department实体的属性,创建一个控制器和视图,并且添加测试以确保一切工作正常。
2.为Department实体添加开放式并发属性:
修改Models\Department.cs,添加名为RowVersion
的跟踪列:
public class Department { public int DepartmentID { get; set; } [StringLength(50, MinimumLength = 3)] public string Name { get; set; } [DataType(DataType.Currency)] [Column(TypeName = "money")] public decimal Budget { get; set; } [DataType(DataType.Date)] [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)] [Display(Name = "Start Date")] public DateTime StartDate { get; set; } [Display(Name = "Administrator")] public int? InstructorID { get; set; } [Timestamp] public byte[] RowVersion { get; set; } public virtual Instructor Administrator { get; set; } public virtual ICollection<Course> Courses { get; set; } }
Timestamp属性指定该列将会被包含在发送到数据库的Update和删除命令的Where子句中。该属性被叫做Timestamp是因为在使用SQL rowversion之前,SQL Server的早期版本中使用一个SQL timestamp数据类型。.NET中rowversion是字节数组。
如果我们选择使用fluent API,则使用IsConcurrencyToken方法指定跟踪属性:
modelBuilder.Entity<Department>() .Property(p => p.RowVersion).IsConcurrencyToken();
在Package Manager Console输入命令:
Add-Migration RowVersion Update-Database
3.修改Department控制器:
在DepartmentController.cs中,
ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "LastName");
使用下面语句替换:
ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName");
修改Edit的POST:
[HttpPost] [ValidateAntiForgeryToken] public async Task<ActionResult> Edit(int? id, byte[] rowVersion) { string[] fieldsToBind = new string[] { "Name", "Budget", "StartDate", "InstructorID", "RowVersion" }; if (id == null) { return new HttpStatusCodeResult(HttpStatusCode.BadRequest); } var departmentToUpdate = await db.Departments.FindAsync(id); if (departmentToUpdate == null) { Department deletedDepartment = new Department(); TryUpdateModel(deletedDepartment, fieldsToBind); ModelState.AddModelError(string.Empty, "Unable to save changes. The department was deleted by another user."); ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", deletedDepartment.InstructorID); return View(deletedDepartment); } if (TryUpdateModel(departmentToUpdate, fieldsToBind)) { try { db.Entry(departmentToUpdate).OriginalValues["RowVersion"] = rowVersion; await db.SaveChangesAsync(); return RedirectToAction("Index"); } catch (DbUpdateConcurrencyException ex) { var entry = ex.Entries.Single(); var clientValues = (Department)entry.Entity; var databaseEntry = entry.GetDatabaseValues(); if (databaseEntry == null) { ModelState.AddModelError(string.Empty, "Unable to save changes. The department was deleted by another user."); } else { var databaseValues = (Department)databaseEntry.ToObject(); if (databaseValues.Name != clientValues.Name) ModelState.AddModelError("Name", "Current value: " + databaseValues.Name); if (databaseValues.Budget != clientValues.Budget) ModelState.AddModelError("Budget", "Current value: " + String.Format("{0:c}", databaseValues.Budget)); if (databaseValues.StartDate != clientValues.StartDate) ModelState.AddModelError("StartDate", "Current value: " + String.Format("{0:d}", databaseValues.StartDate)); if (databaseValues.InstructorID != clientValues.InstructorID) ModelState.AddModelError("InstructorID", "Current value: " + db.Instructors.Find(databaseValues.InstructorID).FullName); ModelState.AddModelError(string.Empty, "The record you attempted to edit " + "was modified by another user after you got the original value. The " + "edit operation was canceled and the current values in the database " + "have been displayed. If you still want to edit this record, click " + "the Save button again. Otherwise click the Back to List hyperlink."); departmentToUpdate.RowVersion = databaseValues.RowVersion; } } catch (RetryLimitExceededException /* dex */) { //Log the error (uncomment dex variable name and add a line here to write a log. ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator."); } } ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", departmentToUpdate.InstructorID); return View(departmentToUpdate); }
在Views\Department\Edit.cshtml添加一个隐藏域存储RowVersion
属性的值。
@model ContosoUniversity.Models.Department @{ ViewBag.Title = "Edit"; } <h2>Edit</h2> @using (Html.BeginForm()) { @Html.AntiForgeryToken() <div class="form-horizontal"> <h4>Department</h4> <hr /> @Html.ValidationSummary(true) @Html.HiddenFor(model => model.DepartmentID) @Html.HiddenFor(model => model.RowVersion)
4.测试开放式并发处理:
在English department的Edit右键Open in new tab,然后点击English department的Edit链接:
在第一个浏览器标签修改,并保存:
在浏览器的第二个标签修改并保存:
再次点击保存:
5.更新Delete页面:
修改DepartmentController.cs的Delete方法:
public async Task<ActionResult> Delete(int? id, bool? concurrencyError) { if (id == null) { return new HttpStatusCodeResult(HttpStatusCode.BadRequest); } Department department = await db.Departments.FindAsync(id); if (department == null) { if (concurrencyError.GetValueOrDefault()) { return RedirectToAction("Index"); } return HttpNotFound(); } if (concurrencyError.GetValueOrDefault()) { ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete " + "was modified by another user after you got the original values. " + "The delete operation was canceled and the current values in the " + "database have been displayed. If you still want to delete this " + "record, click the Delete button again. Otherwise " + "click the Back to List hyperlink."; } return View(department); } [HttpPost] [ValidateAntiForgeryToken] public async Task<ActionResult> Delete(Department department) { try { db.Entry(department).State = EntityState.Deleted; await db.SaveChangesAsync(); return RedirectToAction("Index"); } catch (DbUpdateConcurrencyException) { return RedirectToAction("Delete", new { concurrencyError = true, id = department.DepartmentID }); } catch (DataException /* dex */) { //Log the error (uncomment dex variable name after DataException and add a line here to write a log. ModelState.AddModelError(string.Empty, "Unable to delete. Try again, and if the problem persists contact your system administrator."); return View(department); } }
替换之前的代码,POST只接收ID:
public async Task<ActionResult> DeleteConfirmed(int id)
替换之后的代码,POST参数变为模型绑定的Department实体实例。这样除了访问主键外,还访问RowVersion属性:
public async Task<ActionResult> Delete(Department department)
修改Views\Department\Delete.cshtml:
@model ContosoUniversity.Models.Department @{ ViewBag.Title = "Delete"; } <h2>Delete</h2> <p class="error">@ViewBag.ConcurrencyErrorMessage</p> <h3>Are you sure you want to delete this?</h3> <div> <h4>Department</h4> <hr /> <dl class="dl-horizontal"> <dt> Administrator </dt> <dd> @Html.DisplayFor(model => model.Administrator.FullName) </dd> <dt> @Html.DisplayNameFor(model => model.Name) </dt> <dd> @Html.DisplayFor(model => model.Name) </dd> <dt> @Html.DisplayNameFor(model => model.Budget) </dt> <dd> @Html.DisplayFor(model => model.Budget) </dd> <dt> @Html.DisplayNameFor(model => model.StartDate) </dt> <dd> @Html.DisplayFor(model => model.StartDate) </dd> </dl> @using (Html.BeginForm()) { @Html.AntiForgeryToken() @Html.HiddenFor(model => model.DepartmentID) @Html.HiddenFor(model => model.RowVersion) <div class="form-actions no-color"> <input type="submit" value="Delete" class="btn btn-default" /> | @Html.ActionLink("Back to List", "Index") </div> } </div>
运行,在English department的Delete右键Open in new tab,然后点击English department的Edit链接.
在浏览器的第一个标签修改并保存:
在浏览器的第二个标签页,点击Delete:
再次点击Delete,将会删除该department,然后导航到Index页面。
处理各种并发场景的其他方法,请查看:Optimistic Concurrency Patterns和Working with Property Values。