概述
在之前的課程中經歷了查詢的優化技術,將單機查詢效率提升到了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不掛,應用是存活的,那本地隊列的功能就不會失效。因此企業級開發應用還是推薦使用本地隊列,本地隊列的性能以及高可用性對應的應用性和廣泛性。可以使用外部的分佈式集中隊列,當外部集中隊列不可用時或者請求時間超時,可以採用降級的策略,切回本地的內存隊列。