前言
對應(yīng)的交易優(yōu)化技術(shù)使用了緩存校驗+異步扣減庫存的方式湿颅,使得秒殺下單的方式有了明顯的提升讯赏。
即便查詢優(yōu)化,交易優(yōu)化技術(shù)用到極致后族檬,只要外部的流量超過了系統(tǒng)可承載的范圍就有拖垮系統(tǒng)的風險茬腿。本章通過秒殺令牌呼奢,秒殺大閘,隊列泄洪等流量削峰技術(shù)解決全站的流量高性能運行效率滓彰。
項目缺陷:
- 秒殺下單接口會被腳本不停的刷新控妻;
- 秒殺驗證邏輯和秒殺下單接口強關(guān)聯(lián),代碼冗余度高揭绑;
- 秒殺驗證邏輯復雜弓候,對交易系統(tǒng)產(chǎn)生無關(guān)聯(lián)負載郎哭;
1. 秒殺令牌
1.1 原理
- 秒殺接口需要依靠令牌才能進入,對應(yīng)的秒殺下單接口需要新增一個入?yún)⒐酱妫硎緦?yīng)前端用戶獲得傳入的一個令牌夸研,只有令牌處于合法之后,才能進入對應(yīng)的秒殺下單的邏輯依鸥;
- 秒殺令牌由秒殺活動模塊負責生成亥至,交易系統(tǒng)僅僅驗證令牌的可靠性,以此來判斷對應(yīng)的秒殺接口是否可以被這次http的request進入贱迟;
- 秒殺活動模塊對秒殺令牌生成全權(quán)處理姐扮,邏輯收口;
- 秒殺下單前需要獲得秒殺令牌才能開始秒殺衣吠;
1.2 代碼實現(xiàn)
/**
* 每次下單生成秒殺令牌 替代原來令牌
*/
@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 {
//根據(jù)token獲取用戶信息
String[] tokenArray = httpServletRequest.getParameterMap().get("token");
if (ArrayUtils.isEmpty(tokenArray) || StringUtils.isEmpty(tokenArray[0]) || tokenArray[0].equals("null")) {
throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用戶還未登陸茶敏,不能下單");
}
//獲取用戶的登陸信息
UserModel userModel = (UserModel) redisTemplate.opsForValue().get(tokenArray[0]);
if (userModel == null) {
throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用戶還未登陸,不能下單");
}
/**
* 以userid為維度防止token重復
*/
//獲取秒殺訪問令牌
String promoToken = promoService.generateSecondKillToken(promoId, itemId, userModel.getId());
if (promoToken == null) {
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "生成令牌失敗");
}
//返回對應(yīng)的結(jié)果
return CommonReturnType.create(promoToken);
}
//封裝下單請求
@RequestMapping(value = "/createorder", method = {RequestMethod.POST}, consumes = {CONTENT_TYPE_FORMED})
@ResponseBody
public CommonReturnType createOrder(@RequestParam(name = "itemId") Integer itemId,
@RequestParam(name = "amount") Integer amount,
@RequestParam(name = "promoId", required = false) Integer promoId,
@RequestParam(name = "promoToken", required = false) String promoToken) throws BusinessException {
//根據(jù)token獲取用戶信息
String[] tokenArray = httpServletRequest.getParameterMap().get("token");
if (ArrayUtils.isEmpty(tokenArray) || StringUtils.isEmpty(tokenArray[0]) || tokenArray[0].equals("null")) {
throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用戶還未登陸缚俏,不能下單");
}
//獲取用戶的登陸信息
UserModel userModel = (UserModel) redisTemplate.opsForValue().get(tokenArray[0]);
if (userModel == null) {
throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用戶還未登陸惊搏,不能下單");
}
//校驗秒殺令牌是否正確
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, "秒殺令牌校驗失敗");
}
}
....
}
生成秒殺令牌
/**
* 生成秒殺用的令牌
*
* @param promoId
* @param itemId
* @param userId
*/
@Override
public String generateSecondKillToken(Integer promoId, Integer itemId, Integer userId) {
//判斷是否庫存已售罄,若對應(yīng)的售罄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;
}
/**
* 如果已發(fā)放令牌數(shù)量大于活動商品的數(shù)量 * 系數(shù)恬惯,就不在發(fā)放秒殺令牌
*
* 獲取秒殺大閘的count數(shù)量
*/
long result = redisTemplate.opsForValue().increment(PROMO_DOOR_COUNT + promoId, -1);
if (result < 0) {
return null;
}
//生成token并且存入redis內(nèi)并給一個5分鐘的有效期
String token = UUID.randomUUID().toString().replace("-", "");
/**
* 以USERid為維度防止token重復
*/
redisTemplate.opsForValue().set("promo_token_" + promoId + "_userid_" + userId + "_itemid_" + itemId, token, 5, TimeUnit.MINUTES);
return token;
}
點擊下單按鈕,前端兩次接口請求:
- 1.創(chuàng)建與用戶id亚茬、商品id酪耳、促銷id關(guān)聯(lián)的時效性token( 非用戶token)&
- 2.創(chuàng)建訂單createOrder
秒殺令牌的缺陷
- 在活動剛開始的時候,比如有 1000w個用戶下單才写,就會生成 1000w個秒殺令牌葡兑;
秒殺令牌的生成是耗資源與性能的奖蔓; - 如果1000w個用戶都得到秒殺令牌赞草,那么一直關(guān)注活動的用戶,在秒殺的用戶沒有搶占先機吆鹤;
2. 秒殺大閘
為了解決秒殺令牌在活動一開始無限制生成厨疙,影響系統(tǒng)的性能,提出了秒殺大閘的解決方案疑务;
2.1 原理
依靠秒殺令牌的授權(quán)原理定制化發(fā)牌邏輯沾凄,解決用戶對應(yīng)流量問題,做到大閘功能知允;
根據(jù)秒殺商品初始化庫存頒發(fā)對應(yīng)數(shù)量令牌撒蟀,控制大閘流量;
用戶風控策略前置到秒殺令牌發(fā)放中温鸽;
庫存售罄判斷前置到秒殺令牌發(fā)放中保屯。
2.2 代碼實現(xiàn):
/**
* 將大閘的限制數(shù)字設(shè)到redis內(nèi)
*/
redisTemplate.opsForValue().set(PROMO_ITEM_STOCK +itemModel.getId(), itemModel.getStock());
String promo_itemid = "promoid_" + promoId + "_itemid_" + itemModel.getId();
redisTemplate.opsForValue().set(promo_itemid, 1, 10, TimeUnit.MINUTES);
/**
* 每隔一段時間重新設(shè)置令牌最大數(shù)量
*/
String promo_itemid = "promoid_" + promoId + "_itemid_" + itemId;
if (!redisTemplate.hasKey(promo_itemid)) {
/**
* 將大閘的限制數(shù)字設(shè)到redis內(nèi)
*/
redisTemplate.opsForValue().set(PROMO_DOOR_COUNT + promoId, itemModel.getStock().intValue() * 5);
redisTemplate.opsForValue().set(promo_itemid, 1, 10, TimeUnit.MINUTES);
}
long result = redisTemplate.opsForValue().increment(PROMO_DOOR_COUNT + promoId, -1);
if (result < 0) {
return null;
}
方案缺陷
浪涌流量涌入后系統(tǒng)無法應(yīng)對
多庫存多商品等令牌限制能力弱手负;
3. 隊列泄洪
采用秒殺大閘之后,還是無法解決浪涌流量涌入后臺系統(tǒng)姑尺,并且多庫存多商品等令牌限制能力較弱竟终;
3.1 技術(shù)來源
- 排隊有些時候比并發(fā)更高效(例如redis單線程模型,innodb mutex key等)切蟋;
- 依靠排隊去限制并發(fā)流量统捶;
- 依靠排隊和下游阻塞窗口程度調(diào)整隊列釋放流量大小柄粹;
- 以支付寶銀行網(wǎng)關(guān)隊列為例喘鸟,支付寶需要對接許多銀行網(wǎng)關(guān),當你的支付寶綁定多張銀行卡驻右,那么支付寶對于這些銀行都有不同的支付渠道迷守。在大促活動時,支付寶的網(wǎng)關(guān)會有上億級別的流量旺入,銀行的網(wǎng)關(guān)扛不住兑凿,支付寶就會將支付請求隊列放到自己的消息隊列中,依靠銀行網(wǎng)關(guān)承諾可以處理的TPS流量去泄洪茵瘾;
阻塞隊列就像“水庫”一樣礼华,攔蓄上游的洪水,削減進入下游河道的洪峰流量拗秘,從而達到減免洪水災(zāi)害的目的圣絮;
3.2 代碼實現(xiàn)
private ExecutorService executorService;
@PostConstruct
public void init() {
executorService = new ThreadPoolExecutor(20, 20,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(1024),new ThreadPoolExecutor.AbortPolicy());
}
/**
* 同步調(diào)用線程池的submit方法
*/
/**
* 擁塞窗口為20的等待隊列,用來隊列化泄洪
*/
Future<Object> future = executorService.submit(new Callable<Object>() {
@Override
public Object call() throws Exception {
//加入庫存流水init狀態(tài)
String stockLogId = itemService.initStockLog(itemId, amount);
//再去完成對應(yīng)的下單事務(wù)型消息機制
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);
}
3.3 本地雕旨、分布式實現(xiàn)隊列方案比較
本地:將隊列維護在本地內(nèi)存中扮匠;
分布式:將隊列設(shè)置到外部redis中
比如說我們有100臺機器,假設(shè)每臺機器設(shè)置20個隊列凡涩,那我們的擁塞窗口就是2000棒搜,但是由于負載均衡的關(guān)系,很難保證每臺機器都能夠平均收到對應(yīng)的createOrder的請求活箕,那如果將這2000個排隊請求放入redis中力麸,每次讓redis去實現(xiàn)以及去獲取對應(yīng)擁塞窗口設(shè)置的大小,這種就是分布式隊列育韩;
本地和分布式有利有弊:
分布式隊列最嚴重的就是性能問題克蚂,發(fā)送任何一次請求都會引起call網(wǎng)絡(luò)的消耗,并且要對Redis產(chǎn)生對應(yīng)的負載筋讨,Redis本身也是集中式的埃叭,雖然有擴展的余地。單點問題就是若Redis掛了悉罕,整個隊列機制就失效了赤屋。
本地隊列的好處就是完全維護在內(nèi)存當中的误墓,因此其對應(yīng)的沒有網(wǎng)絡(luò)請求的消耗 ,只要JVM不掛益缎,應(yīng)用是存活的谜慌,那本地隊列的功能就不會失效。因此企業(yè)級開發(fā)應(yīng)用還是推薦使用本地隊列莺奔,本地隊列的性能以及高可用性對應(yīng)的應(yīng)用性和廣泛性欣范。可以使用外部的分布式集中隊列令哟,當外部集中隊列不可用時或者請求時間超時恼琼,可以采用降級的策略,切回本地的內(nèi)存隊列屏富。