.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

 
posted @ 2021-12-14 10:07  GDOUJKZZ  阅读(1218)  评论(2编辑  收藏  举报