5

EF+MySQL乐观锁控制电商并发下单扣减库存,在高并发下的问题

 2 years ago
source link: https://www.cnblogs.com/bluedoctor/p/4294655.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

下订单减库存的方式

现在,连农村的大姐都会用手机上淘宝购物了,相信电商对大家已经非常熟悉了,如果熟悉电商开发的同学,就知道在买家下单购买商品的时候,是需要扣减库存的,当然有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框架,看你的偏好了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK