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

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

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

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

在本教程中,你将了解:

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

先决条件

并发冲突

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

悲观并发 (锁定)

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

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

开放式并发

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

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

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

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

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

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

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

检测并发冲突

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

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

    跟踪列的数据类型通常为 rowversionrowversion 值是每次更新行时递增的序列数。 Update在 或 Delete 命令中Where,子句包括跟踪列的原始值 (原始行版本) 。 如果另一个用户更改了要更新的行,则列中的值 rowversion 不同于原始值,因此 UpdateDelete 语句无法找到要更新的行, Where 因为 子句。 如果实体框架发现或 Delete 命令 (没有更新Update任何行,也就是说,当受影响的行数为零) 时,它会将其解释为并发冲突。

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

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

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

在本教程的其余部分,你将向实体添加 rowversion 跟踪属性 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 发送到数据库的 和 Delete 命令的 子句Update中。 属性称为 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 集合中。 然后,当实体框架创建 SQL UPDATE 命令时,该命令将包含一个 WHERE 子句,用于查找具有原始 RowVersion 值的行。

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

var entry = ex.Entries.Single();

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

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

如果有人从数据库中删除了该行,则 GetDatabaseValues 方法返回 null;否则,您必须将返回的对象 Department 强制转换为 类才能访问 Department 属性。 (由于已检查删除,databaseEntry因此仅当执行之后和 executes 之前FindAsyncSaveChanges删除部门时,才会为 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 以类似方式检测其他人编辑院系所引起的并发冲突。 HttpGetDelete当 方法显示确认视图时,视图将在隐藏字段中包含原始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 属性将错误消息发送到视图。

HttpPostDelete 名为 DeleteConfirmed) 的方法 (的代码替换为以下代码:

[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。 名为 方法DeleteConfirmed的基架代码,HttpPostDelete为方法提供HttpPost唯一签名。 ( CLR 要求重载的方法具有不同的方法参数。) 现在签名是唯一的,可以坚持 MVC 约定,对 HttpPostHttpGet delete 方法使用相同的名称。

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

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>

此代码在 和 h3 标题之间h2添加错误消息:

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

它将 字段中的 LastNameFullNameAdministrator 替换为:

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

最后,它在 语句后面添加 和 RowVersion 属性的Html.BeginForm隐藏字段DepartmentID

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

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

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

“索引”页确认更改。

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

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

Department_Delete_confirmation_page_with_concurrency_error

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

获取代码

下载已完成项目

其他资源

可以在 ASP.NET 数据访问 - 推荐资源中找到指向其他实体框架资源的链接。

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

后续步骤

在本教程中,你将了解:

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

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