筆者16年剛入新公司不久時,曾接到一個需求要搞一個從來沒搞過的抽獎項目。做搖一搖、大轉盤等抽獎業務。和兩個小伙伴一起,我負責服務端抽獎的所有接口,他們負責后台抽獎數據管理,一周時間搞定。當時由於剛進公司,對公司產品流量沒什么經驗數據,某個同事給的方案是抽獎過程查數據、存數據走Mysql數據庫。剛上線時還算順利,流量確實不是很高,但也吃緊吧。不久恰逢公司想做大力度活動,籌划了一個百萬紅包雨,幾乎是沒有開發時間的。當時產品找過來要求前端直接對接我們接口。我給的評估是流量太高,走數據庫肯定撐不住的,但是大家都是新人,都不知道會有多少流量會進來,這個活動也是第一次做,時間上似乎確實是來不及了。最后強行對接了我們的抽獎接口。后果是,系統大面積癱瘓,3分鍾寫入幾十萬數據,嚴重線上事故。。。
事故之后,便是反思以及做系統改造,使其能夠支撐現有業務。於是,我這邊負責抽獎服務的改造工作。經過一系列改造和壓測后,抽獎服務的性能達到了4萬qps,基本滿足了業務的要求。下面簡單分享下我的項目改造的一些實戰經驗吧。
一、抽獎算法模型
以下是省略了相關業務、額外算法的單純根據配置計算中獎概率的算法代碼,方便讀者理解算法
圖示,闡述了算法原理,計算出抽獎活動一組數字,根據抽獎獎品的概率計算出每個獎品所在的數字區間。Random隨機數落在了哪個獎品的數字區間,則用戶中這個區間對應的獎品。
1)抽獎獎品對象
public class LotteryItem { /** * 獎品名稱 */ private String awardName; /** * 中獎幾率 */ private Double awardProbability; /** * 獎品中獎數字范圍起點 */ private Integer awardStartCode; /** * 獎品中獎數字范圍終點 */ private Integer awardEndCode; /** * 中獎數字,實際應用可不定義。 * 此處定義是為了方便讀者理解 */ private Integer awardCode; public String getAwardName() { return awardName; } public void setAwardName(String awardName) { this.awardName = awardName; } public Double getAwardProbability() { return awardProbability; } public void setAwardProbability(Double awardProbability) { this.awardProbability = awardProbability; } public Integer getAwardStartCode() { return awardStartCode; } public void setAwardStartCode(Integer awardStartCode) { this.awardStartCode = awardStartCode; } public Integer getAwardEndCode() { return awardEndCode; } public void setAwardEndCode(Integer awardEndCode) { this.awardEndCode = awardEndCode; } public Integer getAwardCode() { return awardCode; } public void setAwardCode(Integer awardCode) { this.awardCode = awardCode; }; }
2) 抽獎信息對象
/** * @description: 抽獎活動中,中獎概率計算模型 * @author www.ityuan.com * @date 2017年12月28日 上午11:48:02 */ public class Lottery { /** * 中獎數字范圍起點(通常0作為起點) */ private Integer winningStartCode; /** * 當前概率計算出的中獎數字范圍終點 */ private Integer winningEndCode; /** * 中獎的數字范圍 */ private Integer codeScope; public Integer getWinningStartCode() { return winningStartCode; } public void setWinningStartCode(Integer winningStartCode) { this.winningStartCode = winningStartCode; } public Integer getWinningEndCode() { return winningEndCode; } public void setWinningEndCode(Integer winningEndCode) { this.winningEndCode = winningEndCode; } public Integer getCodeScope() { return codeScope; } public void setCodeScope(Integer codeScope) { this.codeScope = codeScope; } }
3)抽獎算法代碼
import java.util.ArrayList; import java.util.List; import java.util.Random; /** * @description: TODO(這里用一句話描述這個類的作用) * @author www.ityuan.com * @date 2017年12月28日 下午9:24:40 */ public class LotteryUtils { private static final Random random = new Random(); private static final Integer MAXSOPE = 100000000; public static void calAwardProbability(Lottery lottery, List<LotteryItem> lotteryItemList) { Integer codeScope = 1; for (LotteryItem item : lotteryItemList) { Integer nowScope = 1; Double awardProbability = item.getAwardProbability(); while (true) { Double test = awardProbability * nowScope; // 概率的精確度,調整到小數點后10位,概率太小等於不中獎,跳出 if (test < 0.0000000001) { break; } if ((test >= 1L && (test - test.longValue()) < 0.0001D) || nowScope >= MAXSOPE) { if (nowScope > codeScope) { // 設置中獎范圍 codeScope = nowScope; } break; } else { // 中獎數字范圍以10倍進行增長 nowScope = nowScope * 10; } } } Integer winningStartCode = 0; Integer winningEndCode = winningStartCode; for (LotteryItem item : lotteryItemList) { Integer codeNum = (int) (item.getAwardProbability() * codeScope); // 獲得其四舍五入的整數值 // 無人中獎時,將中獎的起始范圍設置在隨機數的范圍之外 if (codeNum == 0) { item.setAwardStartCode(codeScope + 1); item.setAwardEndCode(codeScope + 1); } else { item.setAwardStartCode(winningEndCode); item.setAwardEndCode(winningEndCode + codeNum - 1); winningEndCode = winningEndCode + codeNum; } } // 設置用戶的中獎隨機碼信息 lottery.setWinningStartCode(winningStartCode); lottery.setWinningEndCode(winningEndCode); lottery.setCodeScope(codeScope); } public static LotteryItem beginLottery(Lottery lottery, List<LotteryItem> lotteryItemList) { // 確定活動是否有效,如果活動無效則,直接抽獎失敗 Integer randomCode = random.nextInt(lottery.getCodeScope()); if (randomCode >= lottery.getWinningStartCode() && randomCode <= lottery.getWinningEndCode()) { for (LotteryItem item : lotteryItemList) { if (randomCode >= item.getAwardStartCode() && randomCode <= item.getAwardEndCode()) { item.setAwardCode(randomCode); return item; } } } return null; } public static void main(String[] args) { List<LotteryItem> lotteryItemList = new ArrayList<LotteryItem>(); LotteryItem awardItem1 = new LotteryItem(); awardItem1.setAwardName("紅包10元"); awardItem1.setAwardProbability(0.25D); lotteryItemList.add(awardItem1); LotteryItem awardItem2 = new LotteryItem(); awardItem2.setAwardName("紅包20元"); awardItem2.setAwardProbability(0.25D); lotteryItemList.add(awardItem2); LotteryItem awardItem3 = new LotteryItem(); awardItem3.setAwardName("謝謝參與"); awardItem3.setAwardProbability(0.5D); lotteryItemList.add(awardItem3); Lottery lottery = new Lottery(); LotteryUtils.calAwardProbability(lottery, lotteryItemList); System.out.println("抽獎活動中獎數字范圍:["+lottery.getWinningStartCode()+","+lottery.getWinningEndCode()+")"); LotteryUtils.beginLottery(lottery, lotteryItemList); for (LotteryItem item : lotteryItemList) { System.out.println(item.getAwardName()+" 中獎數字范圍:["+item.getAwardStartCode()+","+item.getAwardEndCode()+"]"); } System.out.println("以下是模擬的抽獎中獎結果:"); LotteryItem award1 = LotteryUtils.beginLottery(lottery, lotteryItemList); System.out.println("抽中的數字是:"+award1.getAwardCode()+",恭喜中獎:"+award1.getAwardName()+",數字落點["+award1.getAwardStartCode()+","+award1.getAwardEndCode()+"]"); LotteryItem award2 = LotteryUtils.beginLottery(lottery, lotteryItemList); System.out.println("抽中的數字是:"+award2.getAwardCode()+",恭喜中獎:"+award2.getAwardName()+",數字落點["+award2.getAwardStartCode()+","+award2.getAwardEndCode()+"]"); LotteryItem award3 = LotteryUtils.beginLottery(lottery, lotteryItemList); System.out.println("抽中的數字是:"+award3.getAwardCode()+",恭喜中獎:"+award3.getAwardName()+",數字落點["+award3.getAwardStartCode()+","+award3.getAwardEndCode()+"]"); LotteryItem award4 = LotteryUtils.beginLottery(lottery, lotteryItemList); System.out.println("抽中的數字是:"+award4.getAwardCode()+",恭喜中獎:"+award4.getAwardName()+",數字落點["+award4.getAwardStartCode()+","+award4.getAwardEndCode()+"]"); } }
抽獎Demo代碼執行結果
抽獎活動中獎數字范圍:[0,100) 紅包10元 中獎數字范圍:[0,24] 紅包20元 中獎數字范圍:[25,49] 謝謝參與 中獎數字范圍:[50,99] 以下是模擬的抽獎中獎結果: 抽中的數字是:47,恭喜中獎:紅包20元,數字落點[25,49] 抽中的數字是:69,恭喜中獎:謝謝參與,數字落點[50,99] 抽中的數字是:22,恭喜中獎:紅包10元,數字落點[0,24] 抽中的數字是:83,恭喜中獎:謝謝參與,數字落點[50,99]
二、對核心電商系統的保護
如果因為成本控制原因,當電商系統的硬件耐壓能力有限時,抽獎活動帶來的瞬間高頻流量可能會將防火牆擊潰,從而導致整個電商或者其他正常業務受影響。這時候就需要考慮將抽獎系統與正常業務系統的環境進行隔離。例如,將抽獎系統遷移到阿里雲上部署或者其他次要機房。
三、系統的過載保護
系統的過載保護目的是當流量超出預期時,自動過濾一部分流量,防止系統被拖垮。
常用的過載保護思路,大多是基於漏桶算法思想或者信號量控制。
例如:java自帶的Semaphore 或者Google Guava
Semaphore semaphore = new Semaphore(10); if (semaphore.tryAcquire()) {// (非阻塞式) // 獲得許可證才可進行下一步操作 // semaphore.acquire();(阻塞式) // dos somethine // 釋放許可證 semaphore.release(); }
四、前端的空包策略
在預估流量過高的情況下,可以前端采用空包的策略。即用戶發起的抽獎一定概率下不調用后端接口服務,直接返回未中獎。防止過多的請求流向后端服務。
五、數據的存儲策略,壓測支持4萬qps
如果數據查詢直接走數據庫,在不可預計的高頻流量下,極有可能拖垮數據庫,從而導致整個服務崩潰。所以,要支持高並發、高流量,需采用高效的緩存策略以及耐壓的數據存儲服務。
1) 本地緩存策略,抽獎的基礎數據因為數據量不大,可以放入到本地緩存中。從而進行高效讀取。
2) Redis緩存策略,數據查詢先走本地緩存,再走Redis緩存,最后走MySql,也就是說幾乎徹底隔離了抽獎過程中與數據庫的直接打交道。
六、高並發下抽獎如何防止獎品因為並發超量發獎?
采用Redis的自增策略,可在高效抽獎的同時並保證類似數據庫樂觀鎖的方式,來實現抽獎的獎品不會被超量抽中獎。實現方式如下:
參考Redis的封裝:http://www.ityuan.com/coding/385.html
封裝一個Redis的工具類:RedisUtils以及方法inc。RedisUtils.inc(“key”) 每執行一次,返回值自增+1。那么:
RedisUtils.inc(“Prefix”+lotteryItemId) 自增值大於獎品lotteryItem的最大可發獎品數num時,則返回謝謝參與或者未中獎即可。
七、中獎記錄的保存、抽取、發獎
1) 用戶中獎后,將中獎記錄保存Redis中。為方便將數據取出,需要通過Redis構造一個自增主鍵(incKey)與抽獎活動ID構建緩存的Key。我們暫且將它命名為:lotteryAwardKey。
lotteryAwardKey = "prefix"+lotteryId+"_"+incValue。
incValue從1開始自增。nowIncValue=RedisUtils.inc(lotteryAwardKey);
2) 將中獎記錄抽取並批量insert進入Mysql數據庫,類似代碼如下:
for (int start = awardPageNo;start < nowIncValue;start++) { awardList.add(RedisUtils.get("prefix”+lotteryId+"_"+start)); }
(這里awardPageNo為尚未抽取數據的自增值的起點)
3)發獎操作,只需要定時器將Mysql中未發獎的中獎數據撈取,采用多線程發獎即可。