基於Java算法支持4萬qps的抽獎代碼實戰項目案例分享


筆者16年剛入新公司不久時,曾接到一個需求要搞一個從來沒搞過的抽獎項目。做搖一搖、大轉盤等抽獎業務。和兩個小伙伴一起,我負責服務端抽獎的所有接口,他們負責后台抽獎數據管理,一周時間搞定。當時由於剛進公司,對公司產品流量沒什么經驗數據,某個同事給的方案是抽獎過程查數據、存數據走Mysql數據庫。剛上線時還算順利,流量確實不是很高,但也吃緊吧。不久恰逢公司想做大力度活動,籌划了一個百萬紅包雨,幾乎是沒有開發時間的。當時產品找過來要求前端直接對接我們接口。我給的評估是流量太高,走數據庫肯定撐不住的,但是大家都是新人,都不知道會有多少流量會進來,這個活動也是第一次做,時間上似乎確實是來不及了。最后強行對接了我們的抽獎接口。后果是,系統大面積癱瘓,3分鍾寫入幾十萬數據,嚴重線上事故。。。

事故之后,便是反思以及做系統改造,使其能夠支撐現有業務。於是,我這邊負責抽獎服務的改造工作。經過一系列改造和壓測后,抽獎服務的性能達到了4萬qps,基本滿足了業務的要求。下面簡單分享下我的項目改造的一些實戰經驗吧。

一、抽獎算法模型

以下是省略了相關業務、額外算法的單純根據配置計算中獎概率的算法代碼,方便讀者理解算法

圖示,闡述了算法原理,計算出抽獎活動一組數字,根據抽獎獎品的概率計算出每個獎品所在的數字區間。Random隨機數落在了哪個獎品的數字區間,則用戶中這個區間對應的獎品。

基於Java算法支持4萬qps的抽獎代碼實戰項目案例分享-圖片-1

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();
}


四、前端的空包策略

在預估流量過高的情況下,可以前端采用空包的策略。即用戶發起的抽獎一定概率下不調用后端接口服務,直接返回未中獎。防止過多的請求流向后端服務。

五、數據的存儲策略,壓測支持4qps

如果數據查詢直接走數據庫,在不可預計的高頻流量下,極有可能拖垮數據庫,從而導致整個服務崩潰。所以,要支持高並發、高流量,需采用高效的緩存策略以及耐壓的數據存儲服務。

1)  本地緩存策略,抽獎的基礎數據因為數據量不大,可以放入到本地緩存中。從而進行高效讀取。

2)  Redis緩存策略,數據查詢先走本地緩存,再走Redis緩存,最后走MySql,也就是說幾乎徹底隔離了抽獎過程中與數據庫的直接打交道。

 

六、高並發下抽獎如何防止獎品因為並發超量發獎?


采用Redis的自增策略,可在高效抽獎的同時並保證類似數據庫樂觀鎖的方式,來實現抽獎的獎品不會被超量抽中獎。實現方式如下:

參考Redis的封裝:http://www.ityuan.com/coding/385.html

封裝一個Redis的工具類:RedisUtils以及方法incRedisUtils.inc(“key”) 每執行一次,返回值自增+1。那么:

RedisUtils.inc(“Prefix”+lotteryItemId) 自增值大於獎品lotteryItem的最大可發獎品數num時,則返回謝謝參與或者未中獎即可。

 七、中獎記錄的保存、抽取、發獎

1)              用戶中獎后,將中獎記錄保存Redis中。為方便將數據取出,需要通過Redis構造一個自增主鍵(incKey)與抽獎活動ID構建緩存的Key。我們暫且將它命名為:lotteryAwardKey

lotteryAwardKey = "prefix"+lotteryId+"_"+incValue

incValue1開始自增。nowIncValue=RedisUtils.inc(lotteryAwardKey);

2)              將中獎記錄抽取並批量insert進入Mysql數據庫,類似代碼如下:

for (int start = awardPageNo;start < nowIncValue;start++) {
   awardList.add(RedisUtils.get("prefix”+lotteryId+"_"+start));
}

(這里awardPageNo為尚未抽取數據的自增值的起點)

3)發獎操作,只需要定時器將Mysql中未發獎的中獎數據撈取,采用多線程發獎即可。


作者采用 IT猿同步助手一鍵多平台發布, 查看原文


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM