总目录:ASP.NET MVC5 及 EF6 学习笔记 - (目录整理)

上一篇:EF学习笔记(九):异步处理和存储过程

本篇原文链接:Handling Concurrency

Concurrency Conflicts 并发冲突

发生并发冲突很简单,一个用户点开一条数据进行编辑,另外一个用户同时也点开这条数据进行编辑,那么如果不处理并发的话,谁后提交修改保存的,谁的数据就会被记录,而前一个就被覆盖了;

如果在一些特定的应用中,这种并发冲突可以被接受的话,那么就不用花力气去特意处理并发;毕竟处理并发肯定会对性能有所影响。

Pessimistic Concurrency (Locking) 保守并发处理(锁)

如果应用需要预防在并发过程中数据丢失,那么一种方式就是采用数据库锁;这种方式称为保守并发处理。

这种就是原有的处理方式,要修改数据前,先给数据库表或者行加上锁,然后在这个事务处理完之前,不会释放这个锁,等处理完了再释放这个锁。

但这种方式应该是对一些特殊数据登记才会使用的,比如取流水号,多个用户都在取流水号,用一个表来登记当前流水号,那么取流水号过程肯定要锁住表,不然同时两个用户取到一样的流水号就出异常了。

而且有的数据库都没有提供这种处理机制。EF并没有提供这种方式的处理,所以本篇就不会讲这种处理方式。

Optimistic Concurrency 开放式并发处理

替代保守并发处理的方式就是开放式并发处理,开放式并发处理运行并发冲突发生,但是由用户选择适当的方式来继续;(是继续保存数据还是取消)

比如在出现以下情况:John打开网页编辑一个Department,修改预算为0, 而在点保存之前,Jone也打开网页编辑这个Department,把开始日期做了调整,然后John先点了保存,Jone之后点了保存;

在这种情况下,有以下几种选择:

1、跟踪用户具体修改了哪个属性,只对属性进行更新;当时也会出现,两个用户同时修改一个属性的问题;EF是否实现这种,需要看自己怎么写更新部分的代码;在Web应用中,这种方式不是很合适,需要保持大量状态数据,维护大量状态数据会影响程序性能,因为状态数据要么需要服务器资源,要么需要包含在页面本身(隐藏字段)或Cookie中;

2、如果不做任何并发处理,那么后保存的就直接覆盖前一个保存的数据,叫做: Client Wins or Last in Wins

3、最后一种就是,在后一个人点保存的时候,提示相应错误,告知其当前数据的状态,由其确认是否继续进行数据更新,这叫做:Store Wins(数据存储值优先于客户端提交的值),此方法确保没有在没有通知用户正在发生的更改的情况下覆盖任何更改。

Detecting Concurrency Conflicts 检测并发冲突

要想通过解决EF抛出的OptimisticConcurrencyException来处理并发冲突,必须先知道什么时候会抛出这个异常,EF必须能够检测到冲突。因此必须对数据模型进行适当的调整。

有两种选择:

1、在数据库表中增加一列用来记录什么时候这行记录被更新的,然后就可以配置EF的Update或者Delete命令中的Where部分把这列加上;

一般这个跟踪记录列的类型为 rowversion ,一般是一个连续增长的值。在Update或者Delete命令中的Where部分包含上该列的原本值;

如果原有记录被其他人更新,那么这个值就会变化,那么Update或者Delete命令就会找不到原本数据行;这个时候,EF就会认为出现了并发冲突。

2、通过配置EF,在所有的Update或者Delete命令中的Where部分把所有数据列都包含上;和第1种方式一样,如果其中有一列数据被其他人改变了,那么Update或者Delete命令就不会找到原本数据行,这个时候,EF就会认为出现了并发冲突。

这个方式唯一问题就是where后面要拖很长很长的尾巴,而且以前版本中,如果where后面太长会引发性能问题,所以这个方式不被推荐,后面也不会再讲。

 如果确定要采用这个方案,则必须为每一个非主键的Properites都加上ConcurrencyCheck属性定义,这个会让EF的update的WHERE加上所有的列;

Add an Optimistic Concurrency Property to the Department Entity

给Modles/Department 加上一个跟踪属性: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或者Delete的时候一定要加在Where语句里;

叫做Timestamp的原因是SQL Server以前的版本使用timestamp 数据类型,后来用SQL rowversion取代了 timestamp 。

在.NET里 rowversion 类型为byte数组。

当然,如果喜欢用fluent API,你可以用IsConcurrencyToken方法来定义一个跟踪列:

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

记得变更属性后,要更新数据库,在PMC中进行数据库更新:

Add-Migration RowVersion
Update-Database

 修改Department 控制器

先增加一个声明:

using System.Data.Entity.Infrastructure;

然后把控制器里4个事件里的SelectList里的 LastName 改为 FullName ,这样下拉选择框里就看到的是全名;显示全名比仅仅显示Last Name要友好一些。

下面就是对Edit做大的调整:

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

可以看到,修改主要分为以下几个部分:
1、先通过ID查询一下数据库,如果不存在了,则直接提示错误,已经被其他用户删除了;

2、通过 db.Entry(departmentToUpdate).OriginalValues["RowVersion"] = rowVersion; 这个语句把原版本号给赋值进来;

3、EF在执行SaveChange的时候自动生成的Update语句会在where后面加上版本号的部分,如果语句执行结果没有影响到任何数据行,则说明出现了并发冲突;EF会自动抛出DbUpdateConcurrencyException异常,在这个异常里进行处理显示已被更新过的数据,比如告知用户那个属性字段被其他用户变更了,变更后的值是多少;

    var clientValues = (Department)entry.Entity;    //取的是客户端传进来的值
            var databaseEntry = entry.GetDatabaseValues();  //取的是数据库里现有的值 ,如果取来又是null,则表示已被其他用户删除

这里有人会觉得,不是已经在前面处理过被删除的情况,这里又加上出现null的情况处理,是不是多余,应该是考虑其他异步操作的问题,就是在第1次异步查询到最后SaveChange之间也可能被删除。。。(个人觉得第1次异步查询有点多余。。也许是为了性能考虑吧)

最后就是写一堆提示信息给用户,告诉用户哪个值已经给其他用户更新了,是否还继续确认本次操作等等。

对于Edit的视图也需要更新一下,加上版本号这个隐藏字段:

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

最后测试一下效果:

打开2个网页,同时编辑一个Department:

第一个网页先改预算为 0 ,然后点保存;

第2个网页改日期为新的日期,然后点保存,就出现以下情况:

这个时候如果继续点Save ,则会用最后一次数据更新到数据库:

忽然又有个想法,如果在第2次点Save之前,又有人更新了这个数据呢?会怎么样?

打开2个网页,分别都编辑一个Department ;

然后第1个网页把预算变更为 0 ;点保存;

第2个网页把时间调整下,点保存,这时候提示错误,不点Save ;

在第1个网页里,再编辑该Department ,把预算变更为 1 ,点保存;

回到第2个网页,点Save , 这时 EF会自动再次提示错误

 

下面对Delete 处理进行调整,要求一样,就是删除的时候要检查是不是原数据,有没有被其他用户变更过,如果变更过,则提示用户,并等待用户是否确认继续删除;

把Delete Get请求修改一下,适应两种情况,一种就是有错误的情况:

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

把Delete Post请求修改下,在删除过程中,处理并发冲突异常:

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

最后要修改下Delete的视图,把错误信息显示给用户,并且在视图里加上DepartmentID和当前数据版本号的隐藏字段:

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

最后看看效果:

打开2个网页进入Department Index页面,第1个页面点击一个Department的Edit ,第2个页面点击该 Department的Delete;

然后第一个页面把预算改为100,点击Save.

第2个页面点击Delete 确认删除,会提示错误: