本次和大家分享的是一篇關於搶購活動的流程設計,介面設計簡單,不過重點在於商品如何實現搶購的功能(搶購商品線上測試);本次採用的簡單架構是:MVC+Redis(儲存,佇列)+Task.MainForm(神牛工作管理員),由於精力有限這裡沒有涉及到資料庫方面的操作,全程利用redis來儲存釋出的商品和搶購佇列,Task.MainForm是自己再之前開源的服務框架,目前這個服務有兩種開源版本:netcore版本(TaskCore.MainForm)和winform版本(Task.MainForm);馬上就3.8節日了,雖然我不過,但是各位朋友的另一半或者就是您可能會過節日吧,為了預祝您節日快樂,這裡推薦一下媳婦開的服裝店:神牛衣櫃3,新款上市多多優惠哦;本章內容希望大家能夠喜歡,也希望各位多多"掃碼支援"和"推薦"謝謝!
» 搶購活動手繪流程圖
» 分析搶購按鈕做的事情和程式碼
» 怎麼用Task.MainForm在後臺處理佇列搶購訂單
» 釋出時遇到的問題
下面一步一個腳印的來分享:
» 搶購活動手繪流程圖
首先,要明確的是對於一個搶購活動來說,使用者在搶購的時候,需要嚴格控制搶購成功的商品數量,這裡因此採用了佇列的方式來處理,由於本次測試用例是針對釋出多個商品都可以進行搶購活動,所以在後臺處理採用了多工的方式來處理(一種搶購商品一個任務處理搶購佇列);其次需要在搶購成功時候通知使用者,通常在頁面中提示搶購成功或者訂單號之類的(這裡由於最初設計使用websocket實現,由於精力有限才有最直接在前端setInterval的查詢方式,即如果查到了成功或者失敗狀態就不用再查詢通知資訊了);其他...,下面直接來看下我經常用畫板手繪的圖:
看圖說話感覺挺簡單的,整個流程用程式碼寫下來其實關鍵點還是比較多的,比如:搶購數量上限,數量的減少,訊息的通知,使用者介面的提示訊息等,花費了我兩個晚上寫程式碼才粗略完成的線上效果:搶購商品線上測試,不用你登入,預設採用訪問電腦的ip作為登入使用者的UserId,如果有信你所在公司多個同事都測試了,那訂單都會展示在一起哈哈,因為這裡直接是通過ip繫結對應的搶購成功的訂單;
» 分析搶購按鈕做的事情和程式碼
整個搶購系統入口搶購按鈕應該算比較繁雜的功能了,既要簡單判斷搶購時商品庫存剩餘量,又要把搶購使用者加入佇列,各種非空或者已經搶購過的判斷邏輯;其實也不復雜,可能我測試用例太簡單了沒有涉及到太多東西吧,下面先上段搶購按鈕的程式碼和註釋:
1 /// <summary> 2 /// 商品搶購提交 3 /// </summary> 4 /// <param name="shopping"></param> 5 /// <returns></returns> 6 [HttpPost] 7 public async Task<ActionResult> QiangYiFu(MoShopping shopping) 8 { 9 var msg = "刷的太快了,可能搶購成功了哦"; 10 var result = new RedirectResult(string.Format("/home/QiangResult/{1}?msg={0}", msg, shopping.ShopId)); 11 try 12 { 13 14 #region 非空驗證 15 if (shopping == null || shopping.ShopId <= 0) { return result; } 16 var shopIdStr = shopping.ShopId.ToString(); 17 var shop = cache.GetHashValue<MoShopping>(ShoppingHashId, shopIdStr); 18 if (shop == null || shop.ShopId <= 0) { msg = "該商品已不存在"; return result; } 19 if (shop.Total <= 0) 20 { 21 msg = "該商品已被搶空"; return result; 22 } 23 #endregion 24 25 #region 加入搶單佇列 26 //獲取Ip,充當登入使用者Id 27 var myIp = Request.UserHostAddress; 28 //判斷之前是否搶過該商品 29 var myShopping = cache.GetHashValue<MoMyShopping>(MyShoppingKey + shopIdStr, myIp); 30 if (myShopping != null) { msg = "正在排隊中,請稍後"; return result; } 31 32 myShopping = new MoMyShopping 33 { 34 ShopId = shop.ShopId, 35 UserId = myIp, 36 Name = shop.Name 37 }; 38 //加入搶單佇列 39 if (cache.SetQueueOnList(shopIdStr, JsonConvert.SerializeObject(myShopping))) 40 { 41 //增加 42 //模擬增加登入人與佇列之間的關係 43 var addRelation = cache.SetHashCache<MoMyShopping>(MyShoppingKey + shopIdStr, myIp, myShopping, 1 * 60 * 24); 44 //獲取排隊人數 45 var qiangCount = cache.GetListCount(shopIdStr); 46 msg = qiangCount <= 0 ? "排隊中,請稍後..." : string.Format("當前面有:{0}人搶單,排隊中請稍後...", qiangCount); 47 } 48 #endregion 49 } 50 catch (Exception ex) 51 { 52 } 53 finally { result = new RedirectResult(string.Format("/home/QiangResult/{1}?msg={0}", HttpUtility.UrlEncode(msg), shopping.ShopId)); } 54 return result; 55 }
程式碼中有一個當前多少人搶單的提示,其實這就是統計了下佇列的總量,這也體現出了佇列的一定好處;在12306搶過火車票的朋友能經常看到,"當前有多少人在排隊搶購車票"的類似提示資訊,差不多應該就是直接讀取的佇列總數吧哈哈;這裡提交執行完各種邏輯後,是跳轉到其他試圖中來提示訊息的,因為這樣能一定量的避免使用者重複提交和介面的複雜度;說道使用者重複提交,這裡我採用3種方式:
1. 使用者點選提交按鈕後,影藏按鈕
2. 在action中查詢使用者是否已經有相同商品的訂單
3. 最關鍵的一步:在處理佇列訂單的服務中,判斷使用者是否有相同商品的訂單(這裡類似於第二部,但是位置不同)
這裡再貼出資訊提示和後臺處理的佇列訂單通知的程式碼:
1 /// <summary> 2 /// 資訊提示 3 /// </summary> 4 /// <returns></returns> 5 public ActionResult QiangResult(Int64? quId) 6 { 7 if (quId == null) { return RedirectToAction("Shops"); } 8 9 var msg = HttpUtility.UrlDecode(Request.Params["msg"]); 10 var shopIdStr = quId.ToString(); 11 var shop = cache.GetHashValue<MoShopping>(ShoppingHashId, shopIdStr); 12 if (shop == null || shop.ShopId <= 0) { return RedirectToAction("Shops"); } 13 14 //獲取Ip,充當登入使用者Id 15 var myIp = Request.UserHostAddress; 16 //獲取搶單通知訊息 17 var mynotify = cache.GetHashValue<MoQiangNotify>(this.QiangMsgEqueue + shopIdStr, myIp); 18 if (mynotify != null) 19 { 20 msg = mynotify.Status == (int)EnStatus.成功 ? "已經搶購成功,訂單號:" + mynotify.OrderId : (Enum.Parse(typeof(EnStatus), mynotify.Status.ToString()).ToString()); 21 } 22 23 ViewBag.Msg = msg; 24 return View(shop); 25 }
後臺處理的佇列訂單通知:
1 /// <summary> 2 /// 後臺處理的佇列訂單通知 3 /// </summary> 4 /// <returns></returns> 5 public JsonResult GetNotify(Int64? quId) 6 { 7 var notify = new MoQiangNotify { ShopId = 0, Status = (int)EnStatus.搶購中 }; 8 var shopIdStr = quId.ToString(); 9 10 //獲取Ip,充當登入使用者Id 11 var myIp = Request.UserHostAddress; 12 //獲取登入人與搶單佇列之間的關係 13 var getRelation = cache.GetHashValue<MoMyShopping>(MyShoppingKey + shopIdStr, myIp); 14 if (getRelation == null) { notify.Status = (int)EnStatus.失敗; return Json(notify); } 15 16 //獲取搶單通知訊息 17 var mynotify = cache.GetHashValue<MoQiangNotify>(this.QiangMsgEqueue + shopIdStr, myIp); 18 if (mynotify == null) { return Json(notify); } 19 notify.Status = mynotify.Status; 20 notify.OrderId = mynotify.OrderId; 21 22 return Json(notify); 23 }
這裡也放出訊息介面的佈局和簡單的狀態查詢方式(可以考慮websocket,由於精力有限直接使用setinterval查詢哈哈):
1 @model Stage.Api.Controllers.HomeController.MoShopping 2 @{ 3 ViewBag.Title = "搶單資訊"; 4 } 5 6 <h2>搶單資訊</h2> 7 <hr /> 8 <div class="form-horizontal"> 9 <div class="form-group"> 10 <label class="control-label col-md-2">商品:</label> 11 <div class="col-md-10 text-left"> 12 <label class="control-labe">@Model.Name</label> 13 </div> 14 </div> 15 <div class="form-group"> 16 <label class="control-label col-md-2">圖片:</label> 17 <div class="col-md-10 text-left"> 18 <a href="https://shenniu003.taobao.com/" title="神牛衣櫃淘寶服裝店" target="_blank"> 19 <img src="//gd1.alicdn.com/imgextra/i1/1598378015/TB2vRxfg7qvpuFjSZFhXXaOgXXa_!!1598378015.jpg_50x50.jpg_.webp" style="width:150px;height:150px;border:1px solid #ccc"> 20 </a> 21 </div> 22 </div> 23 <div class="form-group"> 24 <label class="control-label col-md-2">訊息:</label> 25 <div class="col-md-10 text-left" id="divMsg" style="color:red"> 26 @ViewBag.Msg 27 </div> 28 </div> 29 </div> 30 <br /> 31 @Html.ActionLink("我的訂單", "MyShopping", "", new { }, new { @class = "btn btn-default" }) @Html.ActionLink("返回列表", "Shops", new { }, new { @class = "btn btn-default" }) 32 <input type="hidden" id="hidStatus" value="0" /> 33 <script type="text/javascript"> 34 $(function () { 35 36 //查詢狀態方法 37 //測試使用setinterval來查詢訊息,這裡建議使用socket 38 function search() { 39 40 var hidStatus = $("#hidStatus"); 41 var status = hidStatus.val(); 42 if (status != 0) { clearInterval(myShoppingInterval); } //清除計時器 43 44 var msg = $("#divMsg"); 45 $.post("/home/GetNotify/@Model.ShopId", function (data) { 46 console.log(data); 47 if (data) { 48 if (data.Status == 0) { 49 //msg.html("搶單中,請稍後..."); 50 } else { 51 hidStatus.val(data.Status); 52 if (data.Status == 2) { 53 //成功 54 msg.html("已經搶購成功,訂單號:" + data.OrderId); 55 } else if (data.Status == 3) { 56 msg.html("重複搶購失敗"); 57 } else { 58 msg.html("搶購失敗"); 59 } 60 } 61 } 62 }) 63 } 64 //載入頁面先執行一次 65 search(); 66 var myShoppingInterval = setInterval(search, 1000 * 5); 67 }) 68 </script>
這裡是搶購的主要程式碼了,效果圖如:
上面裡面操作的資料來源都來源於redis中,因此這裡分裝了一個Redis的操作類,裡面主要用到了:key-value,佇列Queue,hash列表的操作,所有的測試用例web程式會在文章結尾發放,下面來關注下處理佇列訂單的服務;
» 怎麼用Task.MainForm在後臺處理佇列搶購訂單
首先,如果你也想了解或使用我這個服務框架Task.MainForm,可以參考定時管理器框架-Task.MainForm文章;在這裡我用這個來充當後臺處理訂單的服務,主要功能:處理搶購資料,生成客戶訂單,把處理的結果加入訊息佇列;由於我這裡實現的是多個活動的商品都能使用的服務,所以這裡每個商品都會建立一個Task任務來處理上面說的幾個功能,因此有了以下程式碼和效果圖:
程式碼:
1 public class MyShopping : TPlugin 2 { 3 private readonly RedisCache _cache = new RedisCache(); 4 //商品資訊hash 5 private readonly string ShoppingHashId = "Shopping"; 6 //搶購商品資訊佇列 7 private readonly string QiangShopping = "QiangShopping"; 8 //1天 9 private int timeOut = 1 * 60 * 24; 10 //搶單通知佇列 11 private readonly string QiangMsgEqueue = "QiangNotifyEqueue"; 12 //我搶的商品 13 private readonly string MyShoppingKey = "My"; 14 //儲存已經開啟活動的商品 15 private Dictionary<string, MoShopping> Dic_StartShop = new Dictionary<string, MoShopping>(); 16 17 public override void _Load(Action<StringBuilder> action) 18 { 19 var sbLog = new StringBuilder(string.Empty); 20 try 21 { 22 sbLog.AppendFormat("{0}:", this.XmlConfig.Name); 23 action(sbLog); 24 25 //搶購商品處理 26 while (true) 27 { 28 var sbQiangShopping = new StringBuilder(string.Empty); 29 try 30 { 31 //獲取搶購商品的佇列 32 var qiangShoppingStr = _cache.GetQueueOnList(QiangShopping); 33 if (string.IsNullOrWhiteSpace(qiangShoppingStr)) { continue; } 34 var qiangShopping = JsonConvert.DeserializeObject<MoShopping>(qiangShoppingStr); 35 if (qiangShopping == null) { continue; } 36 sbQiangShopping.AppendFormat("已經開啟搶購任務商品:{0}個,獲取搶購【{1}】任務=》", Dic_StartShop.Count, qiangShopping.Name); 37 38 var shopId = qiangShopping.ShopId.ToString(); 39 if (Dic_StartShop.ContainsKey(shopId)) { sbQiangShopping.Append("重複任務,不新增=》"); continue; } //重複任務不新增 40 41 //獲取商品 42 var shoppingOne = _cache.GetHashValue<MoShopping>(ShoppingHashId, shopId); 43 if (shoppingOne == null) { sbQiangShopping.Append("獲取商品資訊失敗=》"); continue; } 44 if (shoppingOne.Total > 0) { Dic_StartShop.Add(shopId, shoppingOne); } //加入開啟活動監控池 45 else { sbQiangShopping.Append("商品庫存不足=》"); continue; } 46 47 #region 每個搶購活動開一個任務 48 Task.Factory.StartNew(b => 49 { 50 var item = b as MoShopping; 51 var id = item.ShopId.ToString(); 52 var isEnd = false; 53 // RedisCache _cache = new RedisCache(); 54 while (!isEnd) 55 { 56 //初始化訊息通知 57 var notify = new MoQiangNotify { Status = (int)EnStatus.失敗 }; 58 var sbLogQiangGou = new StringBuilder(string.Empty); 59 try 60 { 61 #region 獲取資訊 62 63 //獲取商品 64 var shopping = _cache.GetHashValue<MoShopping>(ShoppingHashId, id); 65 66 //獲取搶購佇列資訊 67 var qiangStr = _cache.GetQueueOnList(id); 68 if (string.IsNullOrWhiteSpace(qiangStr)) 69 { 70 //如果沒有搶購資訊並且商品庫存為0,直接關閉搶購任務 71 if (shopping == null || shopping.Total <= 0) { isEnd = true; continue; } 72 else { continue; } 73 } 74 var myShopping = JsonConvert.DeserializeObject<MoMyShopping>(qiangStr); 75 notify.ShopId = myShopping.ShopId; 76 notify.UserId = myShopping.UserId; 77 78 sbLogQiangGou.AppendFormat("開始搶購【{0}】,實際剩餘:{1}個=>", shopping.Name, shopping.Total); 79 80 #endregion 81 82 #region 邏輯處理 83 84 //判斷快取庫存數量,如果沒有庫存,把剩餘佇列訂單變成搶單失敗 85 if (shopping.Total <= 0) { continue; } 86 87 //減少快取中的庫存 88 shopping.Total--; 89 if (shopping.Total >= 0) 90 { 91 //驗證是否重複搶購 92 var myOrderList = _cache.GetHashValue<List<MoMyShopping>>(MyShoppingKey, notify.UserId); 93 myOrderList = myOrderList ?? new List<MoMyShopping>(); 94 if (myOrderList.Any(bb => bb.UserId == notify.UserId && bb.ShopId == notify.ShopId)) 95 { 96 sbLogQiangGou.Append("重複搶購=>"); 97 notify.Status = (int)EnStatus.重複搶購失敗; 98 continue; 99 } 100 101 //減少快取中的庫存 102 _cache.SetHashCache<MoShopping>(ShoppingHashId, id, shopping, timeOut); 103 104 //todo 模擬資料庫生成個訂單號 105 notify.OrderId = DateTime.Now.ToString("yyyyMMddHHmmssfff"); 106 notify.Status = (int)EnStatus.成功; 107 108 //增加資料庫中客人預定訂單資料 (由於此測試用例沒有涉及資料庫儲存的庫存) 109 myShopping.Status = (int)EnStatus.成功; 110 myShopping.Name = shopping.Name; 111 myShopping.OrderId = notify.OrderId; 112 myOrderList.Add(myShopping); 113 _cache.SetHashCache<List<MoMyShopping>>(MyShoppingKey, notify.UserId, myOrderList, 1 * 60 * 24); 114 115 } 116 #endregion 117 } 118 catch (Exception ex) 119 { 120 sbLogQiangGou.AppendFormat("異常資訊2:{0}=>", ex.Message + ex.StackTrace + ex.Source); 121 } 122 finally 123 { 124 if (!string.IsNullOrWhiteSpace(notify.UserId)) 125 { 126 sbLogQiangGou.AppendFormat("{0}搶購商品【{1}】{2}{3}=>", notify.UserId, notify.ShopId, Enum.Parse(typeof(EnStatus), notify.Status.ToString()), ",訂單號碼:" + notify.OrderId); 127 128 //加入搶單狀態訊息通知佇列,在通過任務讀取到分散式訊息通道上,等待查詢 129 //cache.SetQueueOnList(QiangMsgEqueue + id, JsonConvert.SerializeObject(notify)); 130 131 _cache.SetHashCache<MoQiangNotify>(QiangMsgEqueue + id, notify.UserId, notify, 10); 132 } 133 action(sbLogQiangGou); 134 } 135 } 136 }, shoppingOne); 137 #endregion 138 } 139 catch (Exception ex) 140 { 141 sbQiangShopping.AppendFormat("異常資訊1:{0}\r\n", ex.Message); 142 } 143 finally 144 { 145 action(sbQiangShopping); 146 } 147 148 //System.Threading.Thread.Sleep(1000 * 10); 149 } 150 } 151 catch (Exception ex) 152 { 153 sbLog.AppendFormat("異常資訊0:{0}\r\n", ex.Message); 154 } 155 finally 156 { 157 action(sbLog); 158 } 159 } 160 }
關鍵程式碼是通過while迴圈讀取參加活動的商品佇列: var qiangShoppingStr = _cache.GetQueueOnList(QiangShopping); ,每個商品佇列再通過 Task.Factory.StartNew 來建立自己的任務,然後任務裡面再迴圈讀取使用者搶購佇列: var qiangStr = _cache.GetQueueOnList(id); 使用了兩種佇列,一個任務建立了適用於多種商品處理自己的搶購任務的服務;當處理搶購佇列時候,這裡主要處理了以下幾個邏輯:
1. 驗證是否重複搶購
2. 減少快取中的庫存
3. 模擬資料庫生成個訂單
4. 加入搶單狀態訊息通知佇列
服務主要做的就是上面的幾個步驟的邏輯,也就是如上的程式碼;下面給出具體的實體類:
1 /// <summary> 2 /// 搶單狀態訊息 3 /// </summary> 4 public class MoQiangNotify : MoMyShopping 5 { 6 /// <summary> 7 /// EnStatus : 搶單中 = 0,失敗 = 1,成功 = 2 8 /// </summary> 9 public int Status { get; set; } 10 11 /// <summary> 12 /// 訂單號(只有成功才有編號) 13 /// </summary> 14 public string OrderId { get; set; } 15 } 16 17 /// <summary> 18 /// 搶單狀態 19 /// </summary> 20 public enum EnStatus 21 { 22 搶單中 = 0, 23 失敗 = 1, 24 成功 = 2, 25 重複搶購失敗 = 3 26 } 27 28 /// <summary> 29 /// 搶單人與商品管理資訊 30 /// </summary> 31 public class MoMyShopping 32 { 33 /// <summary> 34 /// 商品編號 35 /// </summary> 36 public Int64 ShopId { get; set; } 37 38 /// <summary> 39 /// 商品名稱 40 /// </summary> 41 public string Name { get; set; } 42 43 /// <summary> 44 /// 搶單人Id 45 /// </summary> 46 public string UserId { get; set; } 47 48 /// <summary> 49 /// EnStatus : 搶單中 = 0,失敗 = 1,成功 = 2 50 /// </summary> 51 public int Status { get; set; } 52 53 /// <summary> 54 /// 訂單號(只有成功才有編號) 55 /// </summary> 56 public string OrderId { get; set; } 57 } 58 59 /// <summary> 60 /// 商品資訊 61 /// </summary> 62 public class MoShopping 63 { 64 /// <summary> 65 /// 商品編號 66 /// </summary> 67 public Int64 ShopId { get; set; } 68 69 /// <summary> 70 /// 商品名稱 71 /// </summary> 72 public string Name { get; set; } 73 74 /// <summary> 75 /// 庫存數 76 /// </summary> 77 public int Total { get; set; } 78 79 /// <summary> 80 /// 描述 81 /// </summary> 82 public string Des { get; set; } 83 84 }
» 釋出時遇到的問題
通過上面的說明,感覺這次分享的搶購活動設計還是可以的(如果您覺得行可以多多點贊),本來在本地電腦配置(4核,6G記憶體)執行處理訂單的服務沒什麼異裝,處理挺快的,但是釋出到租用的伺服器(單核,2G記憶體)的時候就悲劇了,開啟服務後Cpu直接100%,分析了下服務程式碼,當執行到迴圈獲取佇列的時候cpu才會突然暴漲(我redis也再這伺服器上),頓時我就不好了,除了這種迴圈讀取佇列的方式外,還能用什麼代替讓(單核,2G記憶體)配置的伺服器跑起來,不讓cpu爆滿呢(這裡希望有思路的朋友多多指教);這間接導致了的服務職能在我本地電腦上執行或者分散式的方式釋出到我朋友伺服器上,讓人很是鬱悶哈哈;不過能把遇到的問題分享給大家也是挺好的;
下面貼出整體程式碼,共大家參考:神牛-搶購活動設計