Java電商秒殺系統性能優化(八)——流量削峯技術-削峯填谷之神級操作

概述

在之前的課程中經歷了查詢的優化技術,將單機查詢效率提升到了4000 QPS
對應的交易優化技術使用了緩存校驗+異步扣減庫存的方式,使得秒殺下單的方式有了明顯的提升。

即便查詢優化,交易優化技術用到極致後,只要外部的流量超過了系統可承載的範圍就有拖垮系統的風險。本章通過秒殺令牌秒殺大閘隊列泄洪等流量削峯技術解決全站的流量高性能運行效率。

項目缺陷:

  • 秒殺下單接口會被腳本不停的刷新;
  • 秒殺驗證邏輯和秒殺下單接口強關聯,代碼冗餘度高;
  • 秒殺驗證邏輯複雜,對交易系統產生無關聯負載;

本章目標:

  • 掌握秒殺令牌的原理和使用方式;
  • 掌握秒殺大閘的原理和使用方式;
  • 掌握隊列泄洪的原理和使用方式.

一、秒殺令牌


1.1 原理

  • 秒殺接口需要依靠令牌才能進入,對應的秒殺下單接口需要新增一個入參,表示對應前端用戶獲得傳入的一個令牌,只有令牌處於合法之後,才能進入對應的秒殺下單的邏輯;
  • 秒殺令牌由秒殺活動模塊負責生成,交易系統僅僅驗證令牌的可靠性,以此來判斷對應的秒殺接口是否可以被這次http的request進入;
  • 秒殺活動模塊對秒殺令牌生成全權處理,邏輯收口;
  • 秒殺下單前需要獲得秒殺令牌才能開始秒殺;

1.2 代碼實現

PromoService.java
***
//生成秒殺用的令牌
String generateSecondKillToken(Integer promoId,Integer itemId,Integer userId);
***
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);

        //dataobject->model
        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);
        }
        //判斷活動是否正在進行
        if(promoModel.getStatus().intValue() != 2){
            return null;
        }
        //判斷item信息是否存在
        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;
        }
        //生成token並且存入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;
    }
****
OrderController.java
***
@Autowired
private PromoService promoService;

/生成秒殺令牌
 @RequestMapping(value = "/generatetoken",method = {RequestMethod.POST},consumes={CONTENT_TYPE_FORMED}
 @ResponseBody
public CommonReturnType generatetoken(@RequestParam(name="itemId")Integer itemId,
                                        @RequestParam(name="promoId")Integer promoId) throws BusinessException {
    //根據token獲取用戶信息
     String token = httpServletRequest.getParameterMap().get("token")[0];
     if(StringUtils.isEmpty(token)){
          throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用戶還未登陸,不能下單");
     }
     //獲取用戶的登陸信息
     UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token);
     if(userModel == null){
          throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用戶還未登陸,不能下單");
      }
      //獲取秒殺訪問令牌
      String promoToken = promoService.generateSecondKillToken(promoId,itemId,userModel.getId());
      if(promoToken == null){
          throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"生成令牌失敗");
      }
      //返回對應的結果
      return CommonReturnType.create(promoToken);
} 

OrderServiceImpl.java
***
OrderController.java
***
//校驗秒殺令牌是否正確
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(promoToken,inRedisPromoToken)){
        throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"秒殺令牌校驗失敗");
      }
}  
 //修改前端代碼
 getItem.html
 $.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
               },
{
		alert("獲取令牌失敗,原因爲"+data.data.errMsg);
		if(data.data.errCode == 20003){
    		window.location.href="login.html";
		}
			
		},
		error:function(data){
		lert("獲取令牌失敗,原因爲"+data.responseText);
		}
	}); 

方案缺陷
秒殺令牌活動一開始就無限制生成,影響系統性能;

二、秒殺大閘

爲了解決秒殺令牌在活動一開始無限制生成,影響系統的性能,提出了秒殺大閘的解決方案;

2.1 原理

  • 依靠秒殺令牌的授權原理定製化發牌邏輯,解決用戶對應流量問題,做到大閘功能;
  • 根據秒殺商品初始化庫存頒發對應數量令牌,控制大閘流量;
  • 用戶風控策略前置到秒殺令牌發放中;
  • 庫存售罄判斷前置到秒殺令牌發放中。

2.2 代碼實現:

PromoServiceImpl.java
***
 public void publishPromo(Integer promoId) {
//將大閘的限制數字設到redis內
 redisTemplate.opsForValue().set("promo_door_count_"+promoId,itemModel.getStock().intValue() * 5);
 }
@Override
public String generateSecondKillToken(Integer promoId,Integer itemId,Integer userId) {
         //判斷是否庫存已售罄,若對應的售罄key存在,則直接返回下單失敗
        if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
            return null;
        }
         //獲取秒殺大閘的count數量
        long result = redisTemplate.opsForValue().increment("promo_door_count_"+promoId,-1);
        if(result < 0){
            return null;
        }
 }
 OrderController.java
 ***

方案缺陷

  • 浪涌流量涌入後系統無法應對
  • 多庫存多商品等令牌限制能力弱;

三、隊列泄洪

採用秒殺大閘之後,還是無法解決浪涌流量涌入後臺系統,並且多庫存多商品等令牌限制能力較弱;

3.1 原理

  • 排隊有些時候比並發更高效(例如redis單線程模型,innodb mutex key等);
  • 依靠排隊去限制併發流量;
  • 依靠排隊和下游阻塞窗口程度調整隊列釋放流量大小;

以支付寶銀行網關隊列爲例,支付寶需要對接許多銀行網關,當你的支付寶綁定多張銀行卡,那麼支付寶對於這些銀行都有不同的支付渠道。在大促活動時,支付寶的網關會有上億級別的流量,銀行的網關扛不住,支付寶就會將支付請求隊列放到自己的消息隊列中,依靠銀行網關承諾可以處理的TPS流量去泄洪;

消息隊列就像“水庫”一樣,攔蓄上游的洪水,削減進入下游河道的洪峯流量,從而達到減免洪水災害的目的;

3.2 代碼實現

OrderController.java
***
private ExecutorService executorService;

 @PostConstruct
public void init(){
    //定義一個只有20個可工作線程的線程池
   executorService = Executors.newFixedThreadPool(20);
}
 //同步調用線程池的submit方法
//擁塞窗口爲20的等待隊列,用來隊列化泄洪
 Future<Object> future = executorService.submit(new Callable<Object>() {
   @Override
   public Object call() throws Exception {
      //加入庫存流水init狀態
       String stockLogId = itemService.initStockLog(itemId,amount);
       //再去完成對應的下單事務型消息機制
       if(!mqProducer.transactionAsyncReduceStock(userModel.getId(),itemId,promoId,amount,stockLogId)){
           throw new BusinessException(EmBusinessError.UNKNOWN_ERROR,"下單失敗");
          }
          return null;
        }
        });

        try {
            future.get();
        } catch (InterruptedException e) {
            throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
        } catch (ExecutionException e) {
            throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
        }
        return CommonReturnType.create(null);
}

四、本地OR分佈式

本地:將隊列維護在本地內存中;
分佈式:將隊列設置到外部redis中

比如說我們有100臺機器,假設每臺機器設置20個隊列,那我們的擁塞窗口就是2000,但是由於負載均衡的關係,很難保證每臺機器都能夠平均收到對應的createOrder的請求,那如果將這2000個排隊請求放入redis中,每次讓redis去實現以及去獲取對應擁塞窗口設置的大小,這種就是分佈式隊列;

本地和分佈式有利有弊:

分佈式隊列最嚴重的就是性能問題,發送任何一次請求都會引起call網絡的消耗,並且要對Redis產生對應的負載,Redis本身也是集中式的,雖然有擴展的餘地。單點問題就是若Redis掛了,整個隊列機制就失效了。

本地隊列的好處就是完全維護在內存當中的,因此其對應的沒有網絡請求的消耗,只要JVM不掛,應用是存活的,那本地隊列的功能就不會失效。因此企業級開發應用還是推薦使用本地隊列,本地隊列的性能以及高可用性對應的應用性和廣泛性。可以使用外部的分佈式集中隊列,當外部集中隊列不可用時或者請求時間超時,可以採用降級的策略,切回本地的內存隊列。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章