教程:在 ASP.NET MVC 5 应用中处理 EF 的并发

在前面的教程中,你学习了如何更新数据。 本教程介绍如何在多个用户同时更新同一实体时使用乐观并发来处理冲突。 更改处理实体的 Department 网页,以便处理并发错误。 下图显示了“编辑”和“删除”页面,包括发生并发冲突时显示的一些消息。

屏幕截图显示了“编辑”页,其中突出显示了“部门名称”、“预算”、“开始日期”和“管理员”的值。

屏幕截图显示了记录的“删除”页,其中包含有关删除操作和“删除”按钮的消息。

在本教程中,你将了解:

  • 了解并发冲突
  • 添加乐观并发
  • 修改部门控制器
  • 测试并发处理
  • 更新“删除”页

先决条件

并发冲突

当某用户显示实体数据以对其进行编辑,而另一用户在上一用户的更改写入数据库之前更新同一实体的数据时,会发生并发冲突。 如果不启用此类冲突的检测,则最后更新数据库的人员将覆盖其他用户的更改。 在许多应用程序中,此风险是可接受的:如果用户很少或更新很少,或者一些更改被覆盖并不重要,则并发编程可能弊大于利。 在此情况下,不必配置应用程序来处理并发冲突。

悲观并发 (锁定)

如果应用程序确实需要防止并发情况下出现意外数据丢失,一种方法是使用数据库锁定。 这称为 悲观并发。 例如,在从数据库读取一行内容之前,请求锁定为只读或更新访问。 如果将一行锁定为更新访问,则其他用户无法将该行锁定为只读或更新访问,因为他们得到的是正在更改的数据的副本。 如果将一行锁定为只读访问,则其他人也可将其锁定为只读访问,但不能进行更新。

管理锁定有缺点。 编程可能很复杂。 它需要大量的数据库管理资源,且随着应用程序用户数量的增加,可能会导致性能问题。 由于这些原因,并不是所有的数据库管理系统都支持悲观并发。 Entity Framework 不提供对它的内置支持,本教程不介绍如何实现它。

开放式并发

悲观并发的替代方法是 乐观并发。 悲观并发是指允许发生并发冲突,并在并发冲突发生时作出正确反应。 例如,John 运行“部门编辑”页面,将 英语部门的预算 金额从 350,000.00 美元更改为 0.00 美元。

在 John 单击“保存之前,Jane 会运行同一页,并将开始日期字段从 2007 年 9 月 1 日更改为 2013 年 8 月 8 日。

John 先单击“保存”,当浏览器返回到“索引”页时,会看到他的更改,然后 Jane 单击“保存”。 接下来的情况取决于并发冲突的处理方式。 其中一些选项包括:

  • 可以跟踪用户已修改的属性,并仅更新数据库中相应的列。 在示例方案中,不会有数据丢失,因为是由两个用户更新不同的属性。 下次有人浏览英国部门时,他们将看到约翰和简的更改 -- 2013 年 8 月 8 日开始,预算为零美元。

    这种更新方法可减少可能导致数据丢失的冲突次数,但是如果对实体的同一属性进行竞争性更改,则数据难免会丢失。 Entity Framework 是否以这种方式工作取决于更新代码的实现方式。 通常不适合在 Web 应用程序中使用,因为它要求保持大量的状态,以便跟踪实体的所有原始属性值以及新值。 维护大量的状态可能会影响应用程序的性能,因为它需要服务器资源或必须包含在网页本身(例如隐藏字段)或 Cookie 中。

  • 你可以让 Jane 的更改覆盖 John 的更改。 下次有人浏览英语部门时,他们将看到 2013 年 8 月 8 日,还原的值为 350,000.00 美元。 这称为“客户端优先”或“最后一个优先” 。 (客户端的所有值优先于数据存储的值。)正如本部分的介绍所述,如果不为并发处理编写任何代码,则自动执行此操作。

  • 可以阻止 Jane 更改在数据库中更新。 通常,将显示一条错误消息,显示她的当前数据状态,并允许她重新应用更改(如果她仍想进行更改)。 这称为“存储优先”方案。 (数据存储值优先于客户端提交的值。)本教程将执行“存储优先”方案。 此方法可确保用户在未收到具体发生内容的警报时,不会覆盖任何更改。

检测并发冲突

可以通过处理 Entity Framework 引发的 OptimisticConcurrencyException 异常来解决冲突。 为了知道何时引发这些异常,Entity Framework 必须能够检测到冲突。 因此,你必须正确配置数据库和数据模型。 启用冲突检测的某些选项包括:

  • 数据库表中包含一个可用于确定某行更改时间的跟踪列。 然后,可以将 Entity Framework 配置为在 SQL UpdateDelete命令的子句中包含Where该列。

    跟踪列的数据类型通常是 rowversionrowversion 值是每次更新行时递增的序列号。 在或UpdateDelete命令中,子Where句包括跟踪列的原始值(原始行版本)。 如果正在更新的行已被其他用户更改,则 rowversion 列中的值与原始值不同,因此 UpdateDelete 语句找不到因子句而更新的 Where 行。 当 Entity Framework 发现没有由 UpdateDelete 命令更新任何行(即受影响行数为零时),它会将其解释为并发冲突。

  • 配置 Entity Framework,以在子句和Delete命令的子句Update中包含表中Where每个列的原始值。

    与在第一个选项中一样,如果自第一次读取行以来行中的任何内容发生更改,则 Where 子句不会返回要更新的行,实体框架将其解释为并发冲突。 对于包含多个列的数据库表,此方法可能会导致非常大 Where 的子句,并且可能需要保持大量的状态。 如前所述,维持大量的状态会影响应用程序的性能。 因此通常不建议使用此方法,并且它也不是本教程中使用的方法。

    如果确实要实现此方法以实现并发,则必须通过在实体中添加 ConcurrencyCheck 属性来标记要跟踪其并发的所有非主键属性。 此更改使 Entity Framework 能够在语句的 UPDATE SQL WHERE 子句中包含所有列。

在本教程的其余部分中,你将向实体添加 行version 跟踪属性 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 属性指定此列将包含在Where发送到数据库的子句UpdateDelete命令中。 该属性称为 Timestamp,因为以前版本的 SQL Server 在 SQL rowversion 替换它之前使用了 SQL 时间戳数据类型。 rowversion.Net 类型是一个字节数组。

如果想要使用 fluent API,可以使用 IsConcurrencyToken 方法指定跟踪属性,如以下示例所示:

modelBuilder.Entity<Department>()
    .Property(p => p.RowVersion).IsConcurrencyToken();

通过添加属性,更改了数据库模型,因此需要再执行一次迁移。 在包管理器控制台 (PMC) 中输入以下命令:

Add-Migration RowVersion
Update-Database

修改部门控制器

Controllers\DepartmentController.cs 中,添加语句 using

using System.Data.Entity.Infrastructure;

DepartmentController.cs 文件中,将“LastName”的所有四个匹配项更改为“FullName”,以便部门管理员下拉列表将包含讲师的全名,而不仅仅是姓氏。

ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName");

将方法的现有代码HttpPostEdit替换为以下代码:

[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);
}

如果 FindAsync 方法返回 NULL,则该院系已被另一用户删除。 显示的代码使用已发布的表单值来创建部门实体,以便可以使用错误消息重新显示“编辑”页。 或者,如果仅显示错误消息而未重新显示院系字段,则不必重新创建 Department 实体。

该视图将原始 RowVersion 值存储在隐藏字段中,该方法在参数中 rowVersion 接收该值。 在调用 SaveChanges 之前,必须将该原始 RowVersion 属性值置于实体的 OriginalValues 集合中。 然后,当 Entity Framework 创建 SQL UPDATE 命令时,该命令将包含一个 WHERE 子句,该子句查找具有原始 RowVersion 值的行。

如果命令(没有行 UPDATE 具有原始 RowVersion 值),则 Entity Framework 将引发异常 DbUpdateConcurrencyException ,并且块中的 catch 代码会从异常对象获取受影响的 Department 实体。

var entry = ex.Entries.Single();

此对象具有用户在其 Entity 属性中输入的新值,可以通过调用 GetDatabaseValues 该方法从数据库中读取值。

var clientValues = (Department)entry.Entity;
var databaseEntry = entry.GetDatabaseValues();

如果有人从数据库中删除了该行,此方法 GetDatabaseValues 将返回 null;否则,您必须将返回的对象 Department 强制转换为类才能访问 Department 属性。 (由于已检查删除,databaseEntry因此只有在执行后和执行之前SaveChanges删除部门时FindAsync,才会为 null。

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 != currentValues.Name)
    ModelState.AddModelError("Name", "Current value: " + databaseValues.Name);
    // ...

较长的错误消息说明了所发生的事情及其用途:

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.");

最后,代码将 RowVersion 对象的值 Department 设置为从数据库检索到的新值。 重新显示“编辑”页时,这个新的 RowVersion 值将存储在隐藏字段中,当用户下次单击“保存”时,将只捕获自“编辑”页重新显示起发生的并发错误。

Views\Department\Edit.cshtml 中,添加隐藏字段以保存 RowVersion 属性值,紧跟属性的 DepartmentID 隐藏字段:

@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)

测试并发处理

运行站点并单击“ 部门”。

右键单击 英语部门的“编辑 超链接”,然后在新选项卡中选择“ 打开”, 然后单击 英语部门的“编辑 超链接”。 这两个选项卡显示相同的信息。

在第一个浏览器选项卡中更改一个字段,然后单击“保存”。

浏览器显示具有更改值的索引页。

更改第二个浏览器选项卡中的字段,然后单击“ 保存”。 看见一条错误消息:

屏幕截图显示了“编辑”页,其中显示一条消息,说明操作已取消,因为其他用户更改了该值。

再次单击“保存”。 在第二个浏览器选项卡中输入的值与在第一个浏览器中更改的数据的原始值一起保存。 在索引页中出现时,可以看到已保存的值。

更新“删除”页

对于“删除”页,Entity Framework 以类似方式检测其他人编辑院系所引起的并发冲突。 HttpGet Delete当该方法显示确认视图时,该视图在隐藏字段中包括原始RowVersion值。 然后,该值对HttpPostDelete用户确认删除时调用的方法可用。 实体框架创建 SQL DELETE 命令时,它包含 WHERE 具有原始 RowVersion 值的子句。 如果命令导致零行受到影响(这意味着在显示“删除确认”页后更改了该行),则会引发并发异常,并且 HttpGet Delete 调用方法时会设置错误标志 true ,以便重新显示带有错误消息的确认页。 此外,零行也可能受到影响,因为该行已被其他用户删除,因此在这种情况下会显示不同的错误消息。

DepartmentController.cs中,将HttpGetDelete该方法替换为以下代码:

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);
}

该方法接受可选参数,该参数指示是否在并发错误之后重新显示页面。 如果此标志是 true,则会使用 ViewBag 属性将错误消息发送到视图。

将方法(namedDeleteConfirmed)中的HttpPostDelete代码替换为以下代码:

[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);
    }
}

在刚替换的基架代码中,此方法仅接受记录 ID:

public async Task<ActionResult> DeleteConfirmed(int id)

已将此参数更改为由模型绑定器创建的 Department 实体实例。 这样,除了记录键之外,还可以访问 RowVersion 属性值。

public async Task<ActionResult> Delete(Department department)

你还将操作方法名称从 DeleteConfirmed 更改为了 Delete。 命名HttpPostDelete方法DeleteConfirmed的基架代码,为方法提供HttpPost唯一签名。 (CLR 要求重载的方法具有不同的方法参数。现在,签名是唯一的,可以坚持 MVC 约定,对和HttpGet删除方法使用相同的名称HttpPost

如果捕获到并发错误,代码将重新显示“删除”确认页,并提供一个指示它应显示并发错误消息的标志。

Views\Department\Delete.cshtml 中,将基架代码替换为以下代码,为 DepartmentID 和 RowVersion 属性添加错误消息字段和隐藏字段。 突出显示所作更改。

@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>

此代码在标题之间h2h3添加错误消息:

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

它将替换为LastName字段中Administrator的以下项FullName

<dt>
  Administrator
</dt>
<dd>
  @Html.DisplayFor(model => model.Administrator.FullName)
</dd>

最后,它会在Html.BeginForm语句后面添加隐藏字段DepartmentIDRowVersion属性:

@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)

运行“部门索引”页。 右键单击 英语系的“删除 ”超链接,然后在新选项卡中选择“ 打开”, 然后在第一个选项卡中单击 英语系的“编辑 超链接”。

在第一个窗口中,更改其中一个值,然后单击“ 保存”。

“索引”页确认更改。

在第二个选项卡中,单击“删除”。

你将看到并发错误消息,且已使用数据库中的当前内容刷新了“院系”值。

Department_Delete_confirmation_page_with_concurrency_error

如果再次单击“删除”,会重定向到已删除显示院系的索引页。

获取代码

下载已完成的项目

其他资源

可以在 ASP.NET 数据访问 - 建议的资源中找到 指向其他 Entity Framework 资源的链接。

有关处理各种并发方案的其他方法的信息,请参阅 乐观并发模式处理 MSDN 上的属性值 。 下一教程演示如何为 InstructorStudent 实体实现按层次结构表继承。

后续步骤

在本教程中,你将了解:

  • 已了解并发冲突
  • 添加了乐观并发
  • 修改后的部门控制器
  • 测试的并发处理
  • 已更新“删除”页

请继续学习下一篇文章,了解如何在数据模型中实现继承。