本次和大家分享的是一篇关于抢购活动的流程设计,界面设计简单,不过重点在于商品如何实现抢购的功能(抢购商品线上测试);本次采用的简单架构是: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="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} ", 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} ", 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爆满呢(这里希望有思路的朋友多多指教);这间接导致了的服务职能在我本地电脑上运行或者分布式的方式发布到我朋友服务器上,让人很是郁闷哈哈;不过能把遇到的问题分享给大家也是挺好的;
下面贴出整体代码,共大家参考:神牛-抢购活动设计