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框架,看你的偏好了。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM