本次和大家分享的是一篇關于搶購活動的流程設計,界面設計簡單,不過重點在于商品如何實現搶購的功能(搶購商品線上測試);本次采用的簡單架構是: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爆滿呢(這里希望有思路的朋友多多指教);這間接導致了的服務職能在我本地電腦上運行或者分布式的方式發布到我朋友服務器上,讓人很是郁悶哈哈;不過能把遇到的問題分享給大家也是挺好的;
下面貼出整體代碼,共大家參考:神牛-搶購活動設計
文章列表
留言列表