前段時間工作中,有客戶反應了系統中某類待辦重復出現兩次的情況。我核實了數據之后,分析認為是並發請求下導致的數據不一致性問題,並做了重現。其實這並不是一個需要頻繁調用的功能,但是客戶連續點擊了兩次,導致出現了並發問題。除了前端優化,這里重點探討后台方面代碼層面的處理,最終解決問題。
一、情景分析
Asp.net程序部署Web服務,是多主線程並發執行的,當多個用戶請求進入同一個后台函數時,后進入的請求有可能會獲取到非最新狀態的數據。
結合我遇到的實際情況舉個例子,假設后台函數Func1,先讀取表TableA,TableB的數據,進行處理后,存入TableB中,而數據庫事務執行會在函數結束前才提交。請求Req1執行Func1提交事務之前,Req2又進入Func1並讀取了TableA,TableB的數據,這時Req1執行完成,這就相當於Req2拿到的已經是舊的數據,在舊的數據的基礎上再做數據處理操作,結果自然不正確了。
說到這里,你可能還不能想象具體會出現什么問題,而確實這種並發情況在非冪等功能下才會導致數據錯誤,下面就舉實例說明。
二、實例重現
現在有數據表Info,Info2,Info2的數據就是基於Info表數據產生的,兩個表都有字段-證件號碼IdentNo。
函數SyncWork()的功能為:
1,讀取Info表和Info2表中共同的IdentNo行數據,將Info表中的其它字段同步到Info2表;
2,讀取Info表中有,而Info2表中沒有的IdentNo行數據,將這些數據插入Info2表。
表實體代碼實現如下:
1 /// <summary> 2 /// 信息表 3 /// </summary> 4 public class Info 5 { 6 public int Id { get; set; } 7 /// <summary> 8 /// 證件號碼 9 /// </summary> 10 public string IdentNo { get; set; } 11 /// <summary> 12 /// 姓名 13 /// </summary> 14 public string Name { get; set; } 15 /// <summary> 16 /// 愛好 17 /// </summary> 18 public string Hobby { get; set; } 19 /// <summary> 20 /// 備注信息 21 /// </summary> 22 public string Bz { get; set; } 23 } 24 25 /// <summary> 26 /// 信息表2 27 /// </summary> 28 public class Info2 29 { 30 public int Id { get; set; } 31 /// <summary> 32 /// 證件號碼 33 /// </summary> 34 public string IdentNo { get; set; } 35 /// <summary> 36 /// 姓名 37 /// </summary> 38 public string Name { get; set; } 39 /// <summary> 40 /// 愛好 41 /// </summary> 42 public string Hobby { get; set; } 43 /// <summary> 44 /// 創建時間 45 /// </summary> 46 public DateTime CreateTime { get; set; } 47 /// <summary> 48 /// 最后修改時間 49 /// </summary> 50 public DateTime? UpdateTime { get; set; } 51 /// <summary> 52 /// 評分 53 /// </summary> 54 public int? Score { get; set; } 55 }
SyncWork()代碼實現如下,代碼中加入了輔助的輸出信息:
1 public static string SyncWork() 2 { 3 StringBuilder sb = new StringBuilder(); 4 // 5 int threadId = Thread.CurrentThread.ManagedThreadId; 6 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}.線程Id:{threadId}"); 7 // 8 MyDbContext db = new MyDbContext(); 9 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}.db初始化"); 10 //新增數據 11 var dataToAdd = db.Info.Where(x => !db.Info2.Select(y => y.IdentNo).Contains(x.IdentNo)) 12 .ToList(); 13 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}.獲取待新增數據{dataToAdd.Count}條"); 14 dataToAdd.ForEach(x => 15 { 16 var info2 = new Info2 17 { 18 IdentNo = x.IdentNo, 19 Name = x.Name, 20 Hobby = x.Hobby, 21 CreateTime = DateTime.Now 22 }; 23 db.Info2.Add(info2); 24 }); 25 //更新原有數據 26 var dataToEdit = db.Info.AsQueryable().Join(db.Info2.AsQueryable(), m => m.IdentNo, n => n.IdentNo, 27 (m, n) => new 28 { 29 info = m, 30 info2 = n 31 }) 32 .ToList(); 33 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}獲取待更新數據{dataToEdit.Count}條"); 34 dataToEdit.ForEach(x => 35 { 36 x.info2.Name = x.info.Name; 37 x.info2.Hobby = x.info.Hobby; 38 x.info2.UpdateTime = DateTime.Now; 39 }); 40 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}開始休眠5s"); 41 Thread.Sleep(5000); 42 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}dbSaveBegin"); 43 db.SaveChanges(); 44 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}dbSaveEnd"); 45 return sb.ToString(); 46 }
里邊的這幾行代碼就是問題重現的重點了:
1 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}開始休眠5s"); 2 Thread.Sleep(5000); 3 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}dbSaveBegin"); 4 db.SaveChanges(); 5 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}dbSaveEnd");
在db提交之前,我們讓此線程休眠了5s,以模仿現實中一些耗時的操作。這5s期間可能有后續的N個新請求線程進入此函數,那么這些線程獲取的數據都會是同樣的舊數據了。第一個執行完函數的線程,提交了更改,而后續這些線程再提交的更改,便是基於舊的數據做的更改了。
下面開始重新問題,我們執行一次請求,兩表數據情況分別如下:


然后,Info表中新增了一條數據:

這時,我們再執行請求,預期結果應該將行003老虎的數據添加到Info2中,但是我們現在模擬並發,連續調用2次請求看看結果:

可以看到,居然將003老虎的數據插入了兩次。這就是並發帶來的副作用了。
附上兩次請求的輔助輸出信息:
1 //Request1 2 22:50:05:953.線程Id:44 3 22:50:05:953.db初始化 4 22:50:05:982.獲取待新增數據1條 5 22:50:06:000.獲取待更新數據2條 6 22:50:06:001.開始休眠5s 7 22:50:11:001.dbSaveBegin 8 22:50:11:084.dbSaveEnd 9 //Request2 10 22:50:07:240.線程Id:48 11 22:50:07:240.db初始化 12 22:50:07:270.獲取待新增數據1條 13 22:50:07:287.獲取待更新數據2條 14 22:50:07:287.開始休眠5s 15 22:50:12:287.dbSaveBegin 16 22:50:12:339.dbSaveEnd
三、問題解決
既然問題是並發請求導致的,而這個功能不是需要頻繁調用的功能,最簡便的解決方法就是,我們可以設置此功能同一時間只能由一個線程來訪問,即通過lock()的方式。
最終實現代碼如下:
1 public class InfoSync 2 { 3 private static object syncObject = new object(); 4 public static string Sync() 5 { 6 lock (syncObject) 7 { 8 return SyncWork(); 9 } 10 } 11 private static string SyncWork() 12 { 13 //... 14 } 15 }
同時貼出示例控制器的簡單實現:
1 public class DataController : Controller 2 { 3 // GET: Data 4 public ActionResult Index() 5 { 6 try 7 { 8 var str = InfoSync.Sync(); 9 return Content(str); 10 } 11 catch (Exception ex) 12 { 13 return Content($"程序發生錯誤:{ex.Message}\n內部錯誤:{ex.InnerException.Message}"); 14 } 15 } 16 }
四、總結
類似文中數據同步並發情況的實際應用情況還有很多,比如系統有時會需要產生編號,我們會訪問數據庫中這類編號的最新值,然后計算出下一個編號值,如果不處理並發情況,業務量大時可能就會出現重復編號了。
本文中,針對這類請求並發問題,通過代碼鎖的方式,將特定功能的並發請求執行轉化為隊列請求執行,從而避免了問題的發生。
當然,處理並發還有其它途徑,如通過數據庫鎖的方式,再如分布式部署情況下,我們用代碼鎖的方式也會失效了,實際工作中還需要根據具體情況采用最小代價成本的處理方式。
--End
