秒殺專案系列之九: 流量削峰(秒殺令牌+秒殺大閘限定令牌數+佇列洩洪)
阿新 • • 發佈:2021-02-05
- 當前秒殺系統存在的問題
- 秒殺下單介面會被指令碼不停的刷
使用者只需要知道秒殺url,使用者token、itemId、promoId,很容易通過http請求的方式不斷重新整理搶商品.雖然有秒殺開始時間的驗證,但是還是會對伺服器產生壓力. - 秒殺驗證邏輯和秒殺下單介面強關聯,程式碼冗餘度高
- 秒殺驗證邏輯複雜,對交易系統產生無關聯負載
對使用者身份的驗證和對秒殺活動資訊的驗證應該與交易系統無強耦合.
- 秒殺令牌的原理和使用方式
-
秒殺令牌的原理
- 秒殺介面需要依靠令牌才能進入
- 秒殺的令牌由秒殺活動模組負責生成
- 秒殺活動模組對秒殺令牌生成全權處理,邏輯收口
- 秒殺下單前需要先獲得秒殺令牌
-
秒殺令牌的作用
- 秒殺令牌只有在秒殺開始後才會生成,在有秒殺令牌後才能搶商品,所以就避免了提前通過token、itemId、promoId和url的指令碼搶商品.
- 驗證程式碼放在生成令牌中,與下單程式碼隔離開,實現低耦合.
-
秒殺令牌實現程式碼
-
OrderController.java
// 生成秒殺令牌 @PostMapping(value = "/generatetoken", consumes = {CONTENT_TYPE_FORMED}) public CommonReturnType generatetoken(@RequestParam("itemId"
-
PromoServiceImpl.java
@Override public String generateSecondKillToken(Integer promoId, Integer itemId, Integer userId) { // 校驗是否有商品秒殺活動 PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId); PromoModel promoModel = convertFromDataObject(promoDO); if(promoModel == null){ return null; } // 校驗秒殺活動是否開始 if(promoModel.getStartDate().isAfterNow()){ promoModel.setStatus(1); }else if(promoModel.getEndDate().isBeforeNow()){ promoModel.setStatus(3); }else{ promoModel.setStatus(2); } // 1表示秒殺未開始,2表示進行中,3表示已結束。如果秒殺活動不正在進行中,則不生成秒殺令牌 if(promoModel.getStatus() != 2){ return null; } // 校驗商品資訊是否存在 ItemModel itemModel = itemService.getItemByIdInCache(itemId); if(itemModel == null){ return null; } // 校驗使用者資訊是否存在 UserModel userModel = userService.getUserByIdInCache(userId); if(userModel == null){ return null; } // 生成秒殺令牌並存入redis快取中,設定一個5分鐘的有效期 String token = UUID.randomUUID().toString().replace("-",""); redisTemplate.opsForValue().set("promo_token_" + promoId + "_userid_" + userId + "_itemid_" + itemId, token); redisTemplate.expire("promo_token_" + promoId + "_userid_" + userId + "_itemid_" + itemId, 5, TimeUnit.MINUTES); return token; }
-
OrderServiceImpl.java
@Override @Transactional public OrderModel createOrder(Integer userId, Integer itemId, Integer promoId, Integer amount, String stockLogId) throws BusinessException { // 使用者資訊、秒殺活動資訊、商品資訊等放在生成令牌處校驗 ItemModel itemModel = itemService.getItemByIdInCache(itemId); if(itemModel == null){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "商品資訊不存在"); } if(amount <= 0 || amount > 99){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "數量資訊不正確"); } // 2.落單減庫存 boolean result = itemService.decreaseStock(itemId, amount); if(!result){ throw new BusinessException((EmBusinessError.STOCK_NOT_ENOUGH)); } // 3.訂單入庫 OrderModel orderModel = new OrderModel(); orderModel.setItemId(itemId); orderModel.setUserId(userId); orderModel.setAmount(amount); if(promoId != null){ orderModel.setItemPrice(itemModel.getPromoModel().getPromoItemPrice()); }else{ orderModel.setItemPrice(itemModel.getPrice()); } orderModel.setPromoId(promoId); orderModel.setOrderPrice(orderModel.getItemPrice().multiply(BigDecimal.valueOf(amount))); // 生成交易流水號(訂單號) orderModel.setId(generateOrderNo()); OrderDO orderDO = convertFromOrderModel(orderModel); orderDOMapper.insertSelective(orderDO); // 4. 商品銷量增加,先增加到快取中,然後通過rocketmq事務訊息機制傳送訊息 itemService.increaseSales(itemId, amount); // 設定庫存流水狀態為成功 StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId); if(stockLogDO == null){ throw new BusinessException(EmBusinessError.UNKNOWN_ERROR); } // status為2表示扣減庫存成功 stockLogDO.setStatus(2); stockLogDOMapper.updateByPrimaryKeySelective(stockLogDO); // 5. 返回前端 return orderModel; }
-
前端getitem.html下單介面ajax程式碼
// jQuery(document).ready()這個方法在dom載入就緒時對其進行操縱並呼叫執行它所繫結的函式。 jQuery(document).ready(function(){ $("#createorder").on("click", function () { var token = window.localStorage["token"]; if(token == null){ alert("沒有登陸,不能下單"); window.location.href="login.html"; return false; } $.ajax({ type:"POST", contentType: "application/x-www-form-urlencoded", url:"http://" + g_host + "/order/generatetoken?token=" + token, data:{ "itemId":g_itemVO.id, "promoId":g_itemVO.promoId }, xhrFields:{withCredentials:true}, success:function (data) { if(data.status == "success"){ var promoToken = data.data; $.ajax({ type:"POST", contentType: "application/x-www-form-urlencoded", url:"http://" + g_host + "/order/createorder?token=" + token, data:{ "itemId":g_itemVO.id, "promoId":g_itemVO.promoId, "amount":1, "promoToken":promoToken }, xhrFields:{withCredentials:true}, success:function (data) { if(data.status == "success"){ alert("下單成功"); window.location.reload(); }else{ alert("下單失敗,原因為"+data.data.errMsg); if(data.data.errCode == 20003){ window.location.href="login.html"; } } }, error:function (data) { alert("下單失敗,原因為"+data.responseText); } }); }else{ alert("獲取令牌失敗,原因為"+data.data.errMsg); if(data.data.errCode == 20003){ window.location.href="login.html"; } } }, error:function (data) { alert("獲取令牌失敗,原因為"+data.responseText); } }); }); initView(); });
-
-
目前存在的問題
秒殺令牌只要活動一開始就可以無限制生成,影響系統性能.
比如有100件商品,十萬使用者搶,每個使用者點一下就生成一個秒殺令牌,只有100件商品,生成海量的令牌只會影響系統性能.
- 秒殺大閘的原理和使用方式
-
秒殺大閘原理
- 依靠秒殺令牌的授權原理定製化發牌邏輯,做到大閘功能
- 根據秒殺商品初始化庫存頒發對應數量令牌,控制大閘流量
- 使用者風控策略前置到秒殺令牌發放中(秒殺令牌已完成)
- 庫存售罄判斷前置到秒殺令牌傳送中
-
秒殺大閘程式碼實現
-
PromoServiceImpl.java
// 釋出促銷活動 public void publishpromo(Integer promoId) { // 通過活動id獲取活動 PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId); if(promoDO.getItemId() == null || promoDO.getItemId() == 0){ return; } ItemModel itemModel = itemService.getItemById(promoDO.getItemId()); // 將庫存同步到redis中 redisTemplate.opsForValue().set("promo_item_stock_" + itemModel.getId(), itemModel.getStock()); // 將銷量同步到redis中 redisTemplate.opsForValue().set("promo_item_sales_" + itemModel.getId(), itemModel.getSales()); // 將秒殺大閘的限制數字設定到redis中,並設定大閘的限制數量為庫存的5倍 redisTemplate.opsForValue().set("promo_door_count_" + promoId, itemModel.getStock() * 5); }
-
PromoServiceImpl.java
@Override public String generateSecondKillToken(Integer promoId, Integer itemId, Integer userId){ // 判斷庫存是否已售罄,若對應的售罄key存在,則直接返回下單失敗,之前在下訂單方法中,現在前置到獲取令牌方法中 if(redisTemplate.hasKey("promo_item_stock_invalid_" + itemId)){ return null; } // 校驗是否有商品秒殺活動 PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId); PromoModel promoModel = convertFromDataObject(promoDO); if(promoModel == null){ return null; } // 校驗秒殺活動是否開始 if(promoModel.getStartDate().isAfterNow()){ promoModel.setStatus(1); }else if(promoModel.getEndDate().isBeforeNow()){ promoModel.setStatus(3); }else{ promoModel.setStatus(2); } // 1表示秒殺未開始,2表示進行中,3表示已結束。如果秒殺活動不正在進行中,則不生成秒殺令牌 if(promoModel.getStatus() != 2){ return null; } // 校驗商品資訊是否存在 ItemModel itemModel = itemService.getItemByIdInCache(itemId); if(itemModel == null){ return null; } // 校驗使用者資訊是否存在 UserModel userModel = userService.getUserByIdInCache(userId); if(userModel == null){ return null; } // 獲取秒殺大閘的count數量 long result = redisTemplate.opsForValue().increment("promo_door_count_" + promoId, -1); if(result <= 0){ return null; } // 生成秒殺令牌並存入redis快取中,設定一個5分鐘的有效期 String token = UUID.randomUUID().toString().replace("-",""); redisTemplate.opsForValue().set("promo_token_" + promoId + "_userid_" + userId + "_itemid_" + itemId, token); redisTemplate.expire("promo_token_" + promoId + "_userid_" + userId + "_itemid_" + itemId, 5, TimeUnit.MINUTES); return token; }
-
-
目前存在的問題
- 浪湧流量湧入後系統無法應對
當庫存比較多的時候,以5倍或數倍的方式發放令牌還是會造成瞬間大量的請求湧入. - 多庫存、多商品等令牌限制能力弱
當前是針對少量庫存和商品的處理,當多庫存、多商品時,瞬間請求量仍然很大.
- 浪湧流量湧入後系統無法應對
- 佇列洩洪的原理和使用方式
-
佇列洩洪原理
- 排隊有時候比並發更高效
例如redis單執行緒模型,innodb mutex key等. - 依靠排隊去限制併發流量
- 依靠排隊和下游擁塞視窗程度調整佇列釋放流量大小
比如支付寶銀行閘道器佇列.支付寶支援的併發數很多,但是支付操作是在對接的銀行上實現的,銀行支援不了那麼高的併發操作,所以由支付寶調整釋放流量的大小給銀行處理,保證在銀行支援的能力範圍內.
- 排隊有時候比並發更高效
-
佇列洩洪程式碼實現
-
OrderController.java
private ExecutorService executorService; @PostConstruct public void init(){ // newFixedThreadPool(): 建立一個定長執行緒池,可控制執行緒最大併發數,超出的執行緒會在佇列中等待。 // 開闢20個執行緒數的執行緒池,同一時間只能處理20個請求,其他的請求放在佇列中等待,用來佇列化洩洪 executorService = Executors.newFixedThreadPool(20); } @PostMapping(value = "/createorder", consumes = {CONTENT_TYPE_FORMED}) public CommonReturnType createOrder(@RequestParam("itemId") Integer itemId, @RequestParam(value = "promoId", required = false) Integer promoId, @RequestParam("amount") Integer amount, @RequestParam(value = "promoToken", required = false) String promoToken) throws BusinessException { // 使用token的方法獲取使用者資訊 String token = httpServletRequest.getParameterMap().get("token")[0]; if(StringUtils.isEmpty(token)){ throw new BusinessException(EmBusinessError.USER_NOT_EXIST, "使用者未登陸,不能下單"); } UserModel userModel = (UserModel)redisTemplate.opsForValue().get(token); if(userModel == null){ throw new BusinessException(EmBusinessError.USER_NOT_EXIST, "使用者未登陸,不能下單"); } // 校驗秒殺令牌是否正確 if(promoId != null){ String inRedisPromoToken = (String) redisTemplate.opsForValue().get("promo_token_" + promoId + "_userid_" + userModel.getId() + "_itemid_" + itemId); if(inRedisPromoToken == null){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "秒殺令牌校驗失敗"); } if(!org.apache.commons.lang3.StringUtils.equals(inRedisPromoToken, promoToken)){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "秒殺令牌校驗失敗"); } } // 同步呼叫執行緒池的submit方法 // 當將一個Callable的物件傳遞給ExecutorService的submit方法,則該call方法自動在一個執行緒上執行,並且會返回執行結果Future物件。 // 即每一個初始化庫存流水操作、rocketmq事務型訊息、下訂單操作放在一個執行緒中執行,一共20個執行緒,則可以同時有20個這一系列操作,其他的放在佇列中 Future<Object> future = executorService.submit(new Callable<Object>() { @Override public Object call() throws Exception { // 初始化庫存流水(id、itemid、amount、status存入資料庫流水錶) String stockLogId = itemService.initStockLog(itemId, amount); // 完成對應的下單事務型訊息機制 boolean orderState = mqProducer.transactionAsyncReduceStockAndAddSales(userModel.getId(), itemId, promoId, amount, stockLogId); // 下單失敗 if(!orderState){ throw new BusinessException(EmBusinessError.UNKNOWN_ERROR, "下單失敗"); } return null; } }); try { // 返回null future.get(); } catch (InterruptedException | ExecutionException e) { throw new BusinessException(EmBusinessError.UNKNOWN_ERROR); } return CommonReturnType.create(null); }
-
- 本地和分散式洩洪
- 本地: 將佇列維護在本地記憶體中
- 優勢:
- 高效能: 沒有到redis網路請求的消耗
- 高可用性: 只要機器不宕機就能用
- 缺點: 不能實現很好的負載均衡
- 優勢:
- 分散式: 將佇列設定到外部redis中
- 優勢: 叢集統一管理,能夠實現很好的負載均衡
- 缺點: 效能較低: 有到redis網路請求的消耗,效能比本地記憶體的方式低
- 單點故障: 可能會造成整個redis佇列中的請求都無法處理
- 更好的方式: 使用外部集中式的分散式佇列(比如redis分散式佇列中),當該佇列效能出現問題時,採用降級的方式切回到本地記憶體佇列.