一、場景介紹
小並發下要解決生成單據號的問題,會碰到哪些問題呢?,接下來讓我們一探究竟【這是小並發的解決方案,大家有更好的做好可以一起討論分享】。
之所以叫小並發:是因為確實是小並發場景的應用模式,一般針對企業的內部系統,比如工廠里面的WMS,MES,QMS需要單據號生成的系統。
單據號的一般組成:業務類型+YYYYMMDD+流水號【五位】,每天重新從1開始。
根據單據號的組成規則,一般數據庫表設計如下:
1、業務類型和YYYYMMDD 統一稱為前綴 prefix,存到我們數據庫中。
2、另外一個當前值表達的是當前序號已經到多少了。
並且一般會根據Prefix和一些其他業務字段組成,建立一個唯一索引,避免插入重復數據。
大概表的設計如下(部分邏輯):
create table SFC_BARCODE_SEQUENCE ( id VARCHAR2(36 CHAR) default sys_guid() not null, datetime_created DATE default sysdate not null, user_created VARCHAR2(80 CHAR) default 'SYS' not null, datetime_modified DATE, user_modified VARCHAR2(80 CHAR), state CHAR(1) default 'A' not null, enterprise_id VARCHAR2(36 CHAR) default '*' not null, org_id VARCHAR2(36 CHAR) not null, barcode_category VARCHAR2(80 CHAR) not null, prefix VARCHAR2(80 CHAR) not null, --前綴 current_value NUMBER(22) not null, --當前序號 barcode_rule VARCHAR2(80 CHAR) not null ) --創建一個唯一索引,建這個唯一索引是避免在高並發場景下,插入重復數據。 create unique index IX_SFC_BARCODE_SEQUENCE on SFC_BARCODE_SEQUENCE (ENTERPRISE_ID, ORG_ID, BARCODE_CATEGORY, PREFIX) tablespace WMSD pctfree 10 initrans 2 maxtrans 255 storage ( initial 64K next 1M minextents 1 maxextents unlimited );
接着我們就開始根據業務邏輯寫一個生成單號的邏輯,如下所示:
public static string GenerateBillNo(string billTypeCode, string OrgId, string EnterpriseId, string CurrentUserName) { using (var db = DbContext.GetInstance()) { DateTime dbTime = DateTime.Now; var prefix = billTypeCode + DateTime.Now.ToString("yyyyMMdd"); string barcodeCategory = billTypeCode + "TEST_BILL_CATEGORY"; string newBillNo = prefix + "0001"; //當前序號 var currentSeq = db.Queryable<SFC_BARCODE_SEQUENCE>() .Where(x => x.BARCODE_CATEGORY == barcodeCategory && x.PREFIX == prefix) .Where(x => x.STATE == "A" && x.ORG_ID == OrgId && x.ENTERPRISE_ID == EnterpriseId) .ToList() .FirstOrDefault(); if (currentSeq == null) { SFC_BARCODE_SEQUENCE model = new SFC_BARCODE_SEQUENCE(); model.ID = Guid.NewGuid().ToString("N").ToUpper(); model.DATETIME_CREATED = dbTime; model.USER_CREATED = CurrentUserName; model.STATE = "A"; model.ORG_ID = OrgId; model.ENTERPRISE_ID = EnterpriseId; model.BARCODE_CATEGORY = barcodeCategory; model.PREFIX = prefix; model.CURRENT_VALUE = 1; model.BARCODE_RULE = $"檢驗單據類型({billTypeCode}) + 年月日(yyyyMMdd) + 4位流水"; db.Insertable(model).ExecuteCommand(); } else { db.Updateable<SFC_BARCODE_SEQUENCE>() .Where(x => x.ID == currentSeq.ID) .SetColumns(t => new SFC_BARCODE_SEQUENCE() { USER_MODIFIED = CurrentUserName, DATETIME_MODIFIED = dbTime, CURRENT_VALUE = (currentSeq.CURRENT_VALUE + 1) }).ExecuteCommand(); newBillNo = prefix + (currentSeq.CURRENT_VALUE + 1).ToString().PadLeft(4, '0'); } return newBillNo; } }
上面這種方式,存在的問題:高並發下,容易產生重復單號等問題,如下分析
這是我們認為模擬50個並發進行操作,會導致重復單據數據產生。
現在代碼存了重復數據產生,也有以下的問題點:
問題一、多個並發同時過來的時候,開啟數據庫連接池很慢,這一步需要對數據庫連接池進行調優設置,並且還要配上連接預熱的功能【這里不展開講】
問題二、高並發插入的時候,在數據庫層面設置唯一索引,但是也不能讓報異常了就把當前線程給拒絕了【某個線程插入報異常,說明有線程插入成功了,這個時候,應該要接着走下面的更新單號的邏輯】
問題三、更新單號的時候,重復覆蓋的問題,導致獲取到重復單號。
下面說說具體的解決方案
二、各種實現方式
基礎工作
1、數據庫連接池預熱
之所以要建立數據庫連接池預熱是因為在高並發的情況下,很多建立連接這個操作都會非常耗時,所以先預熱數據庫連接池,在高並發情況下,只需要去數據庫連接池獲取連接即可,而不需要重新連接,連接池里的連接也不是越多好,連接越多就要頻繁的進行線程切換,對性能也不好。
連接池預熱的簡單代碼【獲取一下數據庫的最新時間等方式,可用開啟獨立的定時任務來干這事情,數據庫連接池要多少連接,要根據服務器的CPU核數等有關】
//這個連接池里面的連接數量,可以通過數據庫連接字符串的連接參數進行設置。 //Console.WriteLine("連接池預熱開始"); //for (var i = 0; i < ThreadCount; i++) //{ // BarcodeProvider.GetDbNow(); //} //Console.WriteLine("連接池預熱結束");
2、高並發插入的時候【因為我們單號是按天開始,每天都要重新從1開始,而不是序列號一直累積的那種,所以每天剛剛開始的時候,都要進行一次插入操作】,也有一個比較巧的設計思路,如下所示:
紅色部分:休息300毫秒,拋出異常,不一定是違法了數據庫唯一健的異常。
黃色部分:即使你是插入失敗,你也要考慮是不是其他線程會插入成功,因為你已經等了300毫秒。
整個邏輯只執行三次的原因:如果我們數據庫出問題了,這個邏輯不能一直無限循環下次。
1、悲觀鎖
優點:
1、實現簡單
2、百分百能保證成功
缺點:
1、悲觀鎖的效率不高,扛不住高並發的場景,不過一般的場景也夠用了。
其實有些場景,推薦使用悲觀鎖,一般企業內部的系統都可以用這種方式
實現方式
事務一,先開啟事務,執行到update 語句時候,事務2,也開始事務,但是會在紅色部分update語句卡住。
只有等事務一提交(綠色部分)了,這樣事務2才能繼續執行下去。
2、樂觀鎖(版本號機制)
優點:能夠高並發,其實代碼也相對簡單
缺點:可能會有失敗的情況
實現方式:采用版本號類似的字段,剛剛好序號表的順序序號就是這種類似於版本號的,自增字段加上即可。
如果多個線程同時讀取,那么更新的時候,就只會有一個線程更新成功,其他返回失敗。
樂觀鎖還有一種實現方式是CAS
兩種方式的比較
樂觀鎖:不需要直接去給鎖定某一塊,這樣相對來說並發會更好,但是不能保證每次都成功。
悲觀鎖:開啟事務,先update 再select 方式,其實也可以接受,並且沒有返回失敗,在並發情況不大下,悲觀鎖也是OK的。
選擇:根據業務場景來定,如果用戶不接受返回失敗,那直接就悲觀鎖:事務里面 update 再select 方式在一般的系統也足夠用了,如果你要上分布式鎖這些東東,也是等業務發展到一定程度再來考慮,畢竟大部分系統都到不了那個時候,尤其是企業內部應用系統。
樂觀鎖:如果用戶能夠接受偶爾返回失敗,並且並發量也比較大的話,可以考慮使用這種方式。
三、案例程序
涉及技術:.NET 6 控制台+Sqlsguar+Oracle;
代碼演示效果:我未來演示效果【這個效果是我加了Thread.Sleep,所以耗時不用太關注】
代碼地址:https://github.com/gdoujkzz/NET6GenerateBillNoDemo/tree/master