本篇原文鏈接: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 確認刪除,會提示錯誤: