.NET 6 在小并发下如何生成唯一单据号
一、场景介绍
小并发下要解决生成单据号的问题,会碰到哪些问题呢?,接下来让我们一探究竟【这是小并发的解决方案,大家有更好的做好可以一起讨论分享】。
之所以叫小并发:是因为确实是小并发场景的应用模式,一般针对企业的内部系统,比如工厂里面的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