搶購活動的粗略設計和實現

神牛003發表於2017-03-08

本次和大家分享的是一篇關於搶購活動的流程設計,介面設計簡單,不過重點在於商品如何實現搶購的功能(搶購商品線上測試);本次採用的簡單架構是: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爆滿呢(這裡希望有思路的朋友多多指教);這間接導致了的服務職能在我本地電腦上執行或者分散式的方式釋出到我朋友伺服器上,讓人很是鬱悶哈哈;不過能把遇到的問題分享給大家也是挺好的;

下面貼出整體程式碼,共大家參考:神牛-搶購活動設計

相關文章