程序員如何解決並發衝突的難題?

尋夢新聞LINE@每日推播熱門推薦文章,趣聞不漏接❤️

加入LINE好友

摘要:上述代碼中發生並發異常時,將會將數據庫的值提交到內存中,然後重新提交更新數據。這里需要注意的是這個方法並不是萬能的,只是將當前客戶端的值成功存入數據庫中,這種情況被稱為客戶端獲勝,當然了還有數據庫獲勝,以及數據庫和客戶端合併獲勝(這三個概念解決並發衝突的方式將在下一小節講解)。

程序員如何解決並發衝突的難題? 科技 第1張

作者 | 羽生結弦

責編 | 胡雪蕊

在大多數的應用中都會出現客戶端同時發送多個請求對同一條數據就行修改,這個時候就會出現並發衝突。我們一般的做法會有如下兩種:

1. 樂觀並發所謂的樂觀並發就是多個請求同時對同一條數據的更新,只有最後一個更新請求會被保存,其他更新請求將會被拋棄。

2. 悲觀並發所謂悲觀並發就是多個請求同時對同一條數據的更新,只有當前更新請求完成或者被拋棄,才會執行下一個更新請求,如果當前更新請求未完成或者未被拋棄,那麼後面所有的更新請求將會被阻塞。

通過上面的簡單講解我們簡單的了解了如何處理並發請求,那麼下面我們來看一下上面兩種做法的具體講解和做到。

方法一

在 Entity Framework 中,默認的解決方案是樂觀並發,原因是當出現並發情況的時候,內部沒有任何對其他客戶端訪問同一行數據的限制。我們來看一下例子,我們在數據庫中存有一條數據,數據如下圖所示:

程序員如何解決並發衝突的難題? 科技 第2張

下面我們來修改一下 Name 字段的值:

csharpclassProgram{staticvoidMain(string[] args){int userId = 1;using (var db = new EfContext()) {using (var ef = new EfContext()) { User user1 = db.Users.FirstOrDefault(p => p.Id == userId); User user2= ef.Users.FirstOrDefault(p => p.Id == userId); user1.Name = “李四”; db.SaveChanges(); user2.Name = “王五”; ef.SaveChanges(); } } }}

在上面的代碼中我們利用嵌套 using 的形式做到了並發訪問。首先我們同時查詢出 id 等於1的人員,然後將 user1 中的 Name 修改為李四並提交,接著再把 user2 中的 Name 修改為王五並提交。這個時候我們再查詢數據庫就會發現 Name 列被更新為了最後一次提交值王五,如下圖所示:

程序員如何解決並發衝突的難題? 科技 第3張

上述操作發生了什麼呢?我們來看一下,首先我們利用 db 從數據庫中讀取了 id 等於1的人員信息,此時該人員信息為張三,然後我們將 Name 值改為李四,並且提交到了數據庫,在這個時候,數據庫中的Name值將不再是張三,而是李四。接著我們再將 user2 的 Name 值修改為王五,並提交的數據庫,這個時候數據庫的 Name 列的值變為了王五。上述情況下,Entity Framework 將修改轉換為 update 語句時是利用主鍵來定位指定行,因此上面兩次操作都會成功,只不過最後一次修改的數據會最終持久化到數據庫中。但是這種方式存在一個巨大的隱患,例如在門票預售系統中,門票的數量是有限制的,購票人數超過門票數量限制將會禁止購買。如果利用 Entity Framework 默認的樂觀並發模式,每次有並發請求購票時,每個請求都會減去門票數量,並且向數據庫中插入一條購票信息,這樣一來永遠是最後一個請求的數據會持久化到數據庫中,這樣就造成了門票預約人數超過了門票的限制數量。

針對上面所說的問題,我麼可以利用如下兩種方式來解決:

1. 並發 Token

利用這個方法我們只需在實體類對應的 Map 文件的構造函數中加讓類似下面的代碼即可:

csharpProperty(p => p.Name).IsConcurrencyToken();

2. 行版本

通過行版本設置,我們需要為實體添加一個行版本子字節數組,代碼如下:

csharppublicbyte[] RowVersion { get; set; }

然後將行版本字段映射進數據庫,這樣每次更新數據的時候都行版本字段也會跟著更新。最後我們在實體類對應的 Map 文件的構造函數中添加如下代碼即可:

csharpProperty(p => p.RowVersion).IsRowVersion();

這樣在每次提交修改請求時 Entity Framework 都會檢查數據庫中的行版本和當前提交數據的行版本是否一致,如果一直就更新數據和行版本信息。

上述兩種方法都將會引發並發異常,那麼我們該如何解決這個異常呢?我們需要用到並發異常類( DbUpdateConcurrencyException )中的 Entries 屬性,該屬性是一個集合。我們需要調用集合中每個對象的 Reload 方法將數據庫中最新的值放在內存中。這樣後續的實體值將和數據庫保持一致。完成這一步後,我們可以重新向數據庫提交更新數據。具體做到代碼如下:

csharpclassProgram{staticvoidMain(string[] args){int userId = 1;using (var db = new EfContext()) {using (var ef = new EfContext()) { User user1 = db.Users.FirstOrDefault(p => p.Id == userId); User user2= ef.Users.FirstOrDefault(p => p.Id == userId); user1.Name = “李四”; db.SaveChanges();try { user2.Name = “王五”; ef.SaveChanges(); }catch (DbUpdateConcurrencyException e) {foreach (var item in e.Entries) { item.Reload(); ef.SaveChanges(); } } } } }}

這里需要注意的是這個方法並不是萬能的,只是將當前客戶端的值成功存入數據庫中,這種情況被稱為客戶端獲勝,當然了還有數據庫獲勝,以及數據庫和客戶端合併獲勝(這三個概念解決並發衝突的方式將在下一小節講解)。在講解這個問題前我們先來了解一下 Entity Framework 的原始值和更新後的數據庫值以及當前值從哪里獲得。代碼如下:

csharptry{//more code}catch (DbUpdateConcurrencyException e){foreach (var item in e.Entries) {//原始值var ov = item.OriginalValues.ToObject();//更新後數據庫值var dv = item.GetDatabaseValues().ToObject();// 當前值var nv = item.CurrentValues.ToObject(); }}

從上面的代碼中我們可以看到獲取這三種值我們依然是從並發異常類的 Entries 屬性中獲得。看到這里一定會有人想到不利用 Reload 方法來更新內存中的最新值,而是直接利數據庫值更新當前內存中的值,如果你想到這里說明你已經掌握了解決並發衝突最簡單的方法。那麼我們就來看一下代碼:

csharptry{//more code}catch (DbUpdateConcurrencyException e){foreach (var item in e.Entries) { Object dv = item.GetDatabaseValues().ToObject(); item.OriginalValues.SetValues(dv); ef.SaveChanges(); }}

方法二

上一小節中我們提到了客戶端獲勝、數據庫獲勝以及數據庫和客戶端合併獲勝,並且講解了原始值和更新後的數據庫值以及當前值從哪里獲得的。在這一節將利用客戶端獲勝、數據庫獲勝以及客戶端和數據庫合併獲勝處理並發的方法。

1. 客戶端獲勝

當調用 SaveChanges 方法時,如果存在並發衝突將會引發 DbUpdateConcurrencyException 異常,那麼這個時候我們將調用 handleDbUpdateConcurrencyException 函數來處理異常並正確解決衝突,最後在調用 SaveChanges 方法重試提交數據。如果依然排除 DbUpdateConcurrencyException 異常,將不在進行處理。我們來看以下代碼:

csharpclassProgram{staticvoidMain(string[] args){int userId = 1;using (var db = new EfContext()) {using (var ef = new EfContext()) { User user1 = db.Users.FirstOrDefault(p => p.Id == userId); User user2 = ef.Users.FirstOrDefault(p => p.Id == userId); user1.Name = “李四”; db.SaveChanges();try { user2.Name = “王五”; ef.SaveChanges(); }catch (DbUpdateConcurrencyException e) { Retry(ef, handleDbUpdateConcurrencyException: exception => { exception = (e as DbUpdateConcurrencyException).Entries;foreach (var item in exception) { item.OriginalValues. SetValues(item.GetDatabaseValues()); } }); } } } }}

上述代碼中發生並發異常時,將會將數據庫的值提交到內存中,然後重新提交更新數據。

2. 數據庫獲勝

如果你想讓數據庫獲勝,那就簡單了。再發生異常時不需做任何處理,只返回方法的返回值即可。我們將上一個例子的代碼更新一下:

csharpclassProgram{staticvoidMain(string[] args){int userId = 1;using (var db = new EfContext()) {using (var ef = new EfContext()) { User user1 = db.Users.FirstOrDefault(p => p.Id == userId); User user2 = ef.Users.FirstOrDefault(p => p.Id == userId); user1.Name = “李四”; db.SaveChanges();try { user2.Name = “王五”; ef.SaveChanges(); }catch (DbUpdateConcurrencyException e) {return; } } } }}

上面代碼運行後,只有李四會被更新到數據庫中,王五因為並發衝突且異常捕獲後沒有進行任何處理而不會存入數據庫。

3. 數據庫和客戶端合併獲勝

這種方式是最複雜的,需要合併數據庫和客戶端的數據,如果用到此方法我們需要謹記如下兩點:

如果原始值與數據庫中的值不通,就說明數據庫中的值已經被其他客戶端更新,這時必須放棄當前的更新,保留數據庫的更新;如果原始值與數據庫的值相同,代表不會發生並發衝突,按照正常處理流程處理即可。同樣,我們將上面的例子按照上面兩點進行修改:

csharpclassProgram{staticvoid Main(string[] args) { int userId = 1; using (var db = new EfContext()) { using (var ef = new EfContext()) { User user1 = db.Users.FirstOrDefault(p => p.Id == userId); User user2 = ef.Users.FirstOrDefault(p => p.Id == userId); user1.Name = “李四”; db.SaveChanges();try { user2.Name = “王五”; ef.SaveChanges(); }catch (DbUpdateConcurrencyException e) { Retry(ef, handleDbUpdateConcurrencyException: exception => { exception = (e as DbUpdateConcurrencyException).Entries; foreach (var item in exception) {Object dv = item.GetDatabaseValues();Object ov = item.OriginalValues(); item.OriginalValues.SetValues(dv); dv.PropertyNames.Where(property => !object.Equals(ov[property], dv[property])).ToList().ForEach(property => item.Property(property).IsModified = false); } }); } } } }}

方法三

前面兩種方法都是利用 SaveChanges 捕獲並發異常,其實我們也可以自定義 SaveChanges 的擴展方法來處理並發異常。下面我們就來看一下具體的兩種策略。

1. 普通策略

這個策略非常簡單,就是利用循環來做到重試機制,代碼如下:

csharppublicstaticpartialclassDbContextExtensions{publicstaticintSaveChanges(this DbContext dbContext, Action> action,int retryCount = 3){if (retryCount <= 0) {thrownew ArgumentOutOfRangeException(nameof(retryCount), $”{retryCount}必須大於0″); }for (int retry=1;retry {try { }catch (DbUpdateConcurrencyException e) when (retry < retryCount) { resolveConficts(e.Entries); } }return dbContext.SaveChanges(); }}

2. 高級策略

在 .NET 中已經有開發人員幫我們開發出了強大的工具 Polly ,Polly 是一個 .NET 彈性和瞬態故障處理庫,允許開發人員以 Fluent 和線程安全的方式來做到重試、斷路、超時、隔離和回退策略。

首先我們需要定義一個枚舉類型csharppublicenum RefreshConflict{ StoreWins, ClientWins, MergeClientAndStore}

然後根據不同的獲勝模式來刷新數據庫的值csharppublicstaticclassRefreshEFStateExtensions{publicstatic EntityEntry Refresh(this EntityEntry tracking, RefreshConflict refreshMode){switch (refreshMode) {case RefreshConflict.StoreWins: { tracking.Reload();break; }case RefreshConflict.ClientWins: { PropertyValues databaseValues = tracking.GetDatabaseValues();if (databaseValues == null) { tracking.State = EntityState.Detached; }else { tracking.OriginalValues.SetValues(databaseValues); }break; }case RefreshConflict.MergeClientAndStore: { PropertyValues databaseValues = tracking.GetDatabaseValues();if (databaseValues == null) { tracking.State = EntityState.Detached; }else {//當實體被更新時,刷新數據庫原始值 PropertyValues originalValues = tracking.OriginalValues.Clone(); tracking.OriginalValues.SetValues(databaseValues);//如果數據庫中對於屬性有不同的值保留數據庫中的值#if SelfDefine databaseValues.PropertyNames // Navigation properties are not included. .Where(property => !object.Equals(originalValues[property], databaseValues[property])) .ForEach(property => tracking.Property(property).IsModified = false);#else databaseValues.Properties .Where(property => !object.Equals(originalValues[property.Name], databaseValues[property.Name])) .ToList() .ForEach(property => tracking.Property(property.Name).IsModified = false);#endif }break; } }return tracking; }}

最後定義刷新狀態的方法csharppublicstaticpartialclassDbContextExtensions{publicstaticintSaveChanges(this DbContext context, RefreshConflict refreshMode, int retryCount = 3){if (retryCount <= 0) {thrownew ArgumentOutOfRangeException(nameof(retryCount), $”{retryCount}必須大於0″); }return context.SaveChanges( conflicts => conflicts.ToList().ForEach(tracking => tracking.Refresh(refreshMode)), retryCount); }publicstaticintSaveChanges(this DbContext context, RefreshConflict refreshMode, RetryStrategy retryStrategy) => context.SaveChanges( conflicts => conflicts.ToList().ForEach(tracking => tracking.Refresh(refreshMode)), retryStrategy);}

到這里 Entity Framework 解決並發衝突的方案已經講完了,上面這幾種方案都是固定的寫法,大家可以直接將上面的代碼復制進項目中使用。

作者簡介:朱鋼,筆名羽生結弦,CSDN博客專家,.NET高級開發工程師,7年一線開發經驗,參與過電子政務系統和AI客服系統的開發,以及互聯網招聘網站的架構設計,目前就職於北京恒創融慧科技發展有限公司,從事企業級安全監控系統的開發。

>工程師如何解決並發衝突的難題?

About 尋夢園
尋夢園是台灣最大的聊天室及交友社群網站。 致力於發展能夠讓會員們彼此互動、盡情分享自我的平台。 擁有數百間不同的聊天室 ,讓您隨時隨地都能找到志同道合的好友!