zoukankan      html  css  js  c++  java
  • EF+MySQL乐观锁控制电商并发下单扣减库存,在高并发下的问题

    下订单减库存的方式

    现在,连农村的大姐都会用手机上淘宝购物了,相信电商对大家已经非常熟悉了,如果熟悉电商开发的同学,就知道在买家下单购买商品的时候,是需要扣减库存的,当然有2种扣减库存的方式,

    一种是预扣库存,相当于锁定库存,

    一种是直接扣减库存。

    我们采用的是预扣库存的方式,预扣库存的时候,在SalesInfo表中,将最大可售数量MaxSalesNum减去购买数量,用一条SQL语句来表示这个业务,就是下面这个样子的:

    update salesinfo set MaxSalesNum=MaxSalesNum-@BuyNum where Id=@ID

     这是SqlServer的SQL语句格式,其它数据库大同小异。

    下面讨论如何在高并发下实现这个扣减库存的问题。

    初试:EF手工版乐观锁

    我们用的EF(Entity Framework)+MySQL,很不幸,在 EF 中没法直接实现这个效果,它的DbContext数据上下文决定了要完成这种情况下的修改,得先查询到指定的数据到EF缓存,然后修改数据,最后保存数据, 更新可售库存的程序看起来是下面这个样子的(第一版的代码):

    protected override int ChangeStock(SalesInfo salesInfo, OrderDetail detail)
    {
        using (var productdbContext = new UnitContextProducts())
        {
            using (var c = productdbContext.BeginTransaction(System.Data.IsolationLevel.ReadCommitted))
            {
                int retry = 10;//如果出现更新的并发冲突,尝试一定次数
                do
                {
                            //查询最新的商品可售数量,由于EF 没法使用更新锁 forupdate,所以需要取时间戳用乐观锁
                            var currSalesInfo = (from p in productdbContext.Repository<dalProductModel.SalesInfo>().Entities
                                                 where p.Id == salesInfo.Id
                                                 select new
                                         {
                                                     p.ModifiedTime,
                                                     p.SkuId,
                                                     p.MaxSalesNum,
                                                     p.Id
                                         }).FirstOrDefault();
                    if (currSalesInfo != null)
                    {
                       //重新计算扣减后的库存,但是由于整个订单的处理不在当前事务内,还是有可能出现超买
                       int currStock = currSalesInfo.MaxSalesNum - detail.Quantity;
                       //加上时间戳进行更新判断,乐观锁,处理扣减库存的并发问题
                       productdbContext.Repository<dalProductModel.SalesInfo>().Update(p =>
                                    p.Id == currSalesInfo.Id &&
                                    p.MaxSalesNum == currSalesInfo.MaxSalesNum &&
                                    p.ModifiedTime == currSalesInfo.ModifiedTime,
                       p => new dalProductModel.SalesInfo
                       {
                                   MaxSalesNum = currStock,
                                   ModifiedTime = DateTime.Now,
                       });
                       c.Commit();
                       int count = productdbContext.Commit();
                        if (count > 0)
                        {
                                    salesInfo.MaxSalesNum = currStock;
                                    return count;
                        }
                        System.Threading.Thread.Sleep(1000);
                    }
                }
                while (--retry > 0);
                        
            }
            return 0;
        }
    }

     上面的程序中,detail.Quantity 表示本次要购买的某个商品数量,currSalesInfo 是当前根据商品ID查询出来的数据,

    int currStock = currSalesInfo.MaxSalesNum - detail.Quantity;


    这个语句表示计算得到的预扣库存后的新库存,Update 方法是我们对EF进行的一个封装,第一个参数是要更新的条件,第二个参数是要更新的数据。

    这里采用商品表的 ModifiedTime 字段来表示自上一次查询以后,看本次修改的时候有没有另外一个人先修改了,所以这里用 ModifiedTime 作修改的附加条件,相当于是一个“乐观锁”。

    但是,经过简单压力测试,上面这个程序会出现“超买”,没有控制到并发修改库存的问题,于是尝试用“EF乐观锁”来解决这个扣减库存的问题,

    进阶:EF乐观锁


    参考了2篇文章《EF在MySQL中对记录的乐观并发控制(原创)》,《MySQL 实现 EF Code First TimeStamp/RowVersion 并发控制》,由于我们也是EF CodeFirst,所以着重参考了第二篇文章的做法,并且将ModifiedTime 字段改造成Timespan 类型,并添加触发器以便每次修改数据的时候自动更新该字段值,与支持EF的乐观锁,具体做法过程请参考第二篇文章内容。

    下面是改写的代码(改写第二版):

    //using (var trans = productdbContext.BeginTransaction(System.Data.IsolationLevel.ReadCommitted))
                //{
                    //如果出现更新的并发冲突,尝试一定次数
                    bool retry = false;
                    int retrycount = 0;
                    do
                    {
                        var currSalesInfo = (from p in productdbContext.DbContext.Set<dalProductModel.SalesInfo>()
                                             where p.Id == salesInfo.Id
                                             select p).FirstOrDefault();
                        if (currSalesInfo == null)
                            throw new Exception("没有找到指定的SalesInfo 记录: " + salesInfo.Id);
                        if(currSalesInfo.MaxSalesNum<=0) //必须判断,否则可能出现超卖
    return 0;
    //重新计算扣减后的库存,但是由于整个订单的处理不在当前事务内,还是有可能出现超买 int currStock = currSalesInfo.MaxSalesNum - detail.Quantity; currSalesInfo.MaxSalesNum = currStock; try { int count = productdbContext.DbContext.SaveChanges(); if (count > 0) { //trans.Commit(); //salesInfo.MaxSalesNum = currStock; //网友 Ivan 提示要注释这个 retry = false; return count; } } catch (DbUpdateConcurrencyException ex) { retry = true; ex.Entries.Single().Reload(); } retrycount++; if (retrycount > 100) break; } while (retry); // }//end using

    注:为了避免我们对EF封装可能代码的问题,这里完全使用了EF最原始的方式来编写代码。

    满怀希望的开始了测试,在每秒5次并发的时候,就出现了多扣减库存的问题。

    结果不令人满意,还是会出现多扣减库存的问题。

    进而反复改进事务的隔离级别,结果发现没有改善。
    将代码仔细对比了原来博客文章,还有MSDN关于检测EF并发的文章,确认代码是正确的!

    无奈:EF的ESQL

    最后,又去国外技术论坛找了很久,无果,没有看到有这方面的说明,例子大部分都是SqlServer的,莫非这个并发功能对MySQL支持不好?

    无赖之下,只有手写SQL上了,于是用ESQL,改写成下面的代码(第三版):

     protected override int ChangeStock(SalesInfo salesInfo, OrderDetail detail)
            {
                var productdbContext = new UnitContextProducts();
                string sql = string.Format("update salesinfo set MaxSalesNum=MaxSalesNum-{0} where Id={1}", detail.Quantity, salesInfo.Id);
                int count1 = productdbContext.DbContext.Database.ExecuteSqlCommand(sql);
                return count1;
    }

    OK,成功解决问题,原来问题解决起来如此简单,就是一条SQL语句:

    update salesinfo set MaxSalesNum=MaxSalesNum-{0} where Id={1}

    但是EF没有这种更新的时候,字段自增自减的功能。

    问题虽然解决了,发现前面几个版本的代码好臃肿,但这样写,可能会引起新的问题,SQL语句的移植性降低了,不同数据库对表名字段名的格式要求可能会不同,比如Linux上的MySQL严格区分表名大小写,而Windows上的MySQL没有这个要求。

    品尝 “SOD框架”的小菜

    如果是SOD 框架,这个问题其实很好解决,用OQL的字段自更新语句即可:

    SalesinfoEntity salesinfo=new SalesinfoEntity()
    {
      ID=99,
      MaxSalesNum=1 //要预扣的库存数
    };
    var q=OQL.From(salesinfo)
      .UpdateSelf('-',salesinfo.MaxSalesNum)
      .Where(salesinfo.ID)
    .END;
    EntityQuery<SalesinfoEntity>.Instance.ExecuteOql(q);//假设只有一个连接字符串配置

    SOD框架式PDF.NET框架的数据开发框架,它简化了各种数据操作,其中的OQL是框架的ORM查询语言,这个字段自更新功能的更多信息,可以查看这篇文章《ORM查询语言(OQL)简介--实例篇》  2.1.2,UpdateSelf 字段自更新

    如果你觉得EF在某些方面束缚了你的拳脚,可以选择SOD框架试试看,相信你选择它没错,尤其在金融和电商领域,目前框架已经有很多成功案例,请点击链接

    SOD框架已经全面开源,参见《[置顶]一年之计在于春,2015开篇:PDF.NET SOD Ver 5.1完全开源》。

    补充:

    在网友 上海-Ival的帮助下,他告诉我主要是 默认情况下MySQL DateTime 数据精度不够,需要使用精度更高的 timestamp 类型,并指定数据更新的时候地默认值,采用下面类似的SQL语句修改当前列的类型:

    ALTER TABLE `test2`.`salesinfo` 
    CHANGE COLUMN `ModifiedTime` `ModifiedTime` 
    timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) ;

    注意要指定精度为6。
    实体类属性 ModifiedTime不用修改,仍然使用DateTime 类型。

    但是需要指定属性为并发标记,代码如下:

     public class ProductdbContext : DbContext
        {
            public DbSet<SalesInfo> SalesInfoes{get;set;}
    
            protected override void OnModelCreating(DbModelBuilder modelBuilder)
            {
                base.OnModelCreating(modelBuilder);
    
                modelBuilder.Entity<SalesInfo>()
                    .Property(p => p.ModifiedTime)
                    .IsConcurrencyToken();
            }
        }

    经过这样改进后,EF+MySQL终于可以处理并发更新了,非常感谢网友 上海-Ival 的帮助!

    PS:虽然解决了本文的问题,但是EF这种并发处理方案,在代码编写上还是略显麻烦,是否使用ESQL或者其它ORM框架,看你的偏好了。

  • 相关阅读:
    Selenium自动化测试-unittest单元测试框架
    Python 面向对象
    【新手总结】在.Net项目中使用Redis作为缓存服务
    asp.net性能优化之使用Redis缓存(入门)
    浅谈MVC、MVP、MVVM架构模式的区别和联系
    jquery uploadify在谷歌浏和火狐下无法上传的解决方案(.Net版)
    [翻译]NUnit---Action Attributes(八)
    [翻译]NUnit---String && Collection && File && Directory Assert (七)
    [翻译]NUnit---Exception && Utility Methods (六)
    [翻译]NUnit---Condition Asserts && Comparisons Asserts && Type Asserts (五)
  • 原文地址:https://www.cnblogs.com/bluedoctor/p/4294655.html
Copyright © 2011-2022 走看看