下訂單減庫存的方式
現在,連農村的大姐都會用手機上淘寶購物了,相信電商對大家已經非常熟悉了,如果熟悉電商開發的同學,就知道在買家下單購買商品的時候,是需要扣減庫存的,當然有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框架,看你的偏好了。
