微信小程式搶紅包高併發設計

陳國利發表於2023-03-06

1、背景

某次促銷活動採用微信炒群,紅包雨的方式進行引流,面向廣大C端使用者,活動期間面向大規模使用者,系統設計需要承載三高(高可用、高併發、高效能)要求。

系統設計首先我們要考慮幾個問題:

1、業務場景面向高併發,怎麼設計一個高效能搶紅包程式以解決在高併發條件下能正常執行?

2、系統高併發瓶頸會出現在哪裡?

3、如何攔截無效請求(重複的)?

4、如何應對羊毛黨縟羊毛問題?

帶著以上問題,嘗試設計一個小巧紅包發放程式。
系統現有基座層面採用SpringCloud微服務技術框架,SLB負載,應用層面可以加多個應用節點,實現水平擴充套件,應用服務壓力可以有效分解(應用服務基礎架構方面基本固化,可改造空間有限);

而資料庫集中式儲存(目前使用阿里雲Polardb一主多從),資料庫暫無法實現水平擴充套件,所以資料庫可能成為整個系統一個關鍵瓶頸,因為高併發秒殺搶紅包場景存在短時間內有大量請求去運算元據庫時會出現資料的錯亂,超發,系統崩潰,mysql死鎖等情況。

 

2、解決思路

既然資料庫可能會成系統效能瓶頸,那麼就要對症下藥,系統設計就盡最大量的減少對資料庫的高併發訪問。搶紅包場景與秒殺場景類似。解決秒殺場景的關鍵措施在於:上游限流

上游限流成為系統設計的關鍵策略。這裡提供一下解決高併發設計幾個關鍵措施。

1、頁面靜態化,就是將整個頁面靜態化放到OSS或CDN節點中(前端是小程式,整體頁面不好做靜態化,只能在圖片、JS、CSS等方面做點工作)。

2、防止前端操作頻繁或重複提交,可以將短時間內同一個使用者多次請求合併,也可以分批暫停機制或加數學驗證碼:使用者在計算驗證碼結果時可以減少大量請求同時進入,減少redis, mysql,伺服器的壓力。

3、採用多級快取機制,有一些不常變化字典可以快取在前端;後端應用程式做多級快取機制,

  a、第一級快取:記憶體快取標記;

  b、第二級快取:應用伺服器本地快取Ehcache本地磁碟化快取一些關鍵資料;

  c、第三級快取:分散式快取redis。

 

加上多級快取機制,這是一個巨大最佳化,透過標識來判斷redis的庫存是否足夠,如不足就中斷去讀取redis庫存

例:

 boolean over = map.get(goodsId);

 if(over) { return Result.error(‘庫存不足’); }

當我們map透過key讀取到value值為true的時候,就返回錯誤提示給使用者,這樣不管以後有多個請求進入都只執行兩行程式碼,後面的操作無法進入。

4、防刷規則限定

主要是防止惡意使用者透過介面方式,或者機器自動去請求系統,造成瞬間大量壓力,可以引入之前搶紅包通用策略規則,建立有效的防刷機制。

5、redis預減庫存

在使用者搶紅包或秒殺商品前去redis獲取當前的庫存數量,然後在秒殺時候直接減去redis儲存的庫存(這裡Redis和MySQL資料是同步的,只要進入MQ佇列操作完成下單,MySQL資料庫會-1數量),從而避開去MySQL讀取庫存資料。

6、加入MQ訊息佇列

它是一個訊息中介軟體,透過生產者傳送訊息給消費者,進行業務操作,而生產者無需知道執行結果,也就是使用者點選搖一搖之後等待處理結果,之後再去輪詢查詢處理結果(非同步操作),這樣就避開了不斷請求去運算元據庫。(這裡的輪詢查詢也是直接從redis裡面去查詢,因為秒殺成功之後會將秒殺的結果放到redis中,輪詢時候透過key去查詢)。

7、採用負載均衡Nginx

解決高併發的好方法,使用前端負載均衡SLB/Nginx等,後端服務也就是多增加幾個tomcat伺服器。當使用者訪問的時候,請求可以提交到空閒的tomcat伺服器上。

3、高併發設計

1、前端做好頻繁重複提交策略(如合併提交),這是限流的第一步;

2、閘道器層面或負載均衡層面限流,把一些通用規則限流策略透過指令碼方式動態加上去;

3、介面層面做好一些業務限流,比如同一個使用者多次提交去重合並,抽過的不給再抽,可以有效防止程式機器刷單情況;

4、利用redis分散式快取,把庫存預載入到redis裡面,庫存在redis中預扣;

5、利用本地伺服器記憶體標記當用作二級快取,當redis庫存<0時把一些無效請求過濾,減少redis訪問壓力。

6、利用MQ訊息佇列,排隊抽獎,非同步處理訂單業務和零錢發放等操作時間較長作業。

 

搶紅包處理流程圖

微信小程式搶紅包高併發設計

 

前端和後端介面互動邏輯

 

1、前端搖一搖發起介面請求,後端介面做一些規則校驗之後,快速返回並附帶非同步任務查詢ID即:jobId。

2、前端等搖一搖動作停止之後,發起任務(帶上引數jobId)查詢介面,如果查詢有結果(抽中或抽不中),對於當前使用者來說業務算是完成一輪,讓使用者等待抽下一輪,例如可以暫停3/5分鐘(時間間隔業務定義)再次搖一搖。如果查詢結果還在處理中,前端可設定每5秒輪詢查詢一次(直到任務返回處理完成為止)。

3、暫停3/5分鐘後再搖如果上次已經抽中,這次再搖,根據業務要求,後端不入抽獎佇列,即不給再抽中的機會,直接返回提示給前端。如果上次沒有抽中,可以再次入抽獎佇列排隊再抽。

4、當然前端請求過程中間還有各種防刷驗證和各種業務返回提示,如紅包已經抽完等待下一輪抽獎。

 

搶紅包處理流程時序圖

微信小程式搶紅包高併發設計

 

4、程式碼實現

秒殺或搶紅包實現程式碼片斷,標註說明,程式碼中有很多業務操作(寫入、查詢等),當時寫的程式碼優雅性較差,不要看程式碼優雅性,讀者可以不管它,只需要理解高併發處理思路即可。

 /**
     * 限時紅包雨,紅包抽獎
     *
     * @param con     前端提交的業務引數
     * @param request 用於獲取請求頭資訊
     */
    public ResponseData onRedPackageRain(ActivityPrizeRainCondition con, HttpServletRequest request) {
        //各種校驗, 校驗必填引數
        this.validateParam(con);
        //校驗簽名
        this.checkAsign(con, request);

        //活動輪數id
        String liveActivityRoundsId = con.getLiveActivityRoundsId();
        String ip = NetworkUtils.getClientIp(request);

        //同openID或ip限流
        this.rateLimit(con.getOpenid(), ip);
        //校驗是否在活動時間執行
        this.checkedActivityTime(liveActivityRoundsId);


        //所有獎品抽完,記憶體標識,減少Redis訪問
        Assert.isTrue(!localFlag, "獎品已抽完了,請等下一輪");
        //校驗當天抽獎次數
        this.checkedLotteryNum(liveActivityRoundsId, con.getOpenid(), 86400L);

        //取快取有獎品數量的獎品 隨機抽,某一輪紅包輪數id字首
        String prefix_key = GlobalConstant.WECHAT_LIVE_ACTIVITY_ROUNDS + liveActivityRoundsId + ":";
        Set<String> keys = stringRedisTemplate.keys(prefix_key + "*");
        Assert.isTrue(!CollectionUtils.isEmpty(keys), "沒有獎品啦");

        //可抽獎的獎品id
        List<String> newKeys = new ArrayList<>();
        for (String key : keys) {
            String mapKey = key.substring(key.lastIndexOf(":") + 1);
            String mapValue = stringRedisTemplate.opsForValue().get(key);
            if (StringUtils.isNotEmpty(mapValue) && Integer.parseInt(mapValue) > 0) {
                newKeys.add(mapKey);
            }
        }
        if (CollectionUtils.isEmpty(newKeys)) {
            localFlag = true;
            Assert.isTrue(false, "獎品已抽完了,請等下一輪");
        }

        /*隨機抽一個獎品id*/
        Integer rand = RandomUtils.nextInt(newKeys.size());
        String prizeLotteryId = newKeys.get(rand);
        con.setPrizeNumber(prizeLotteryId);
        con.setIp(ip);

        //符合條件的使用者請求放入MQ佇列
        rabbitTemplate.convertAndSend(MIAOSHA_QUEUE, con);
        LOG.info("-----------紅包雨抽到獎品,加入佇列:{}", con.toString());
        return renderSuccess("具備秒殺資格");
    }
  /**
     * 非同步消費抽中紅包佇列
     * @param con 活動請求業務引數
     * @param message MQ訊息物件
     * @param channel MQ通道物件
     */
    @RabbitListener(queues = MIAOSHA_QUEUE)
    public void consumeMessage(ActivityPrizeRainCondition con, Message message, Channel channel) {
        
        try {
            LOG.info("rabbitmq message consume======={}", con.toString());
            //設定最大服務訊息數量,避免訊息處理不過來,全部堆積在本地快取裡
            // 會告訴RabbitMQ不要同時給一個消費者推送多於N個訊息,即一旦有N個訊息還沒有ack,則該consumer將block掉,直到有訊息ack
            // channel.basicQos(0,5,false);
            //確認應答資訊
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            
            //執行紅包發放流程
            this.doRedPackageRain(con);
            
        }catch (Exception e){
            LOG.error("Message consume ERROR!",e);
        }
    }

    /**
     * 紅包發放關鍵執行程式
     * @param con 業務請求引數
     */
    private void doRedPackageRain(ActivityPrizeRainCondition con){
        //初始中獎金額
        double randomMoney = 0;
        String liveActivityRoundsId= con.getLiveActivityRoundsId();
        String prizeId = con.getPrizeNumber();
        String prefix_key = GlobalConstant.WECHAT_LIVE_ACTIVITY_ROUNDS + liveActivityRoundsId+ ":"+prizeId;
        String prizeNum = stringRedisTemplate.opsForValue().get(prefix_key);
        //入隊後再次校驗Redis是否有庫存
        if(StringUtils.isEmpty(prizeNum)||Integer.parseInt(prizeNum)<=0){
            //再次 檢查快取所有獎品是否還有庫存  有的隨機抽
            // 某一輪輪數id 字首
            String prefix_key2 = GlobalConstant.WECHAT_LIVE_ACTIVITY_ROUNDS + liveActivityRoundsId+ ":";
            Set<String> keys = stringRedisTemplate.keys(prefix_key2+"*");
            if(CollectionUtils.isEmpty(keys)){
                this.setRedisResult(null,randomMoney,"已搶光了,請下次再來",con.getOpenid(),con.getTimeStamp());
                return ;
            }
            //可抽獎的獎品id
            List<String> newKeys = new ArrayList<>();
            for(String key : keys){
                String mapKey = key.substring(key.lastIndexOf(":")+1);
                String mapValue = stringRedisTemplate.opsForValue().get(key);
                if(StringUtils.isNotEmpty(mapValue)&&Integer.parseInt(mapValue)>0){
                    newKeys.add(mapKey);
                }
            }
            if(CollectionUtils.isEmpty(newKeys)){
                this.setRedisResult(null,randomMoney,"獎品已抽完了,請等下一輪",con.getOpenid(),con.getTimeStamp());
                return ;
            }else {
                  /*再次隨機抽一個獎品id*/
                Integer rand = RandomUtils.nextInt(newKeys.size());
                prizeId = newKeys.get(rand);
                con.setPrizeNumber(prizeId);
            }
        }

        // 扣減獎品資料庫庫存
        int cou = liveActivityRoundsDetailMapper.updatePrizeNum(liveActivityRoundsId,prizeId);
        if(cou>0){
            int prizeNumNew = Integer.parseInt(prizeNum)-1;
            //扣減Redis庫存
            stringRedisTemplate.opsForValue().set(prefix_key,String.valueOf(prizeNumNew));
        }else {
            this.setRedisResult(null,randomMoney,"獎品已抽完了,請等下一輪",con.getOpenid(),con.getTimeStamp());
            return ;
        }

        //查詢中獎獎品詳情
        LiveActivityPrizeRoundsVo prizeIdVo = liveActivityRoundsDetailMapper.getLiveActivityPrizeRoundsVo(liveActivityRoundsId,prizeId);
        //中獎獎品標題
        String prizeTitle = prizeIdVo.getTitle();
        ActivityPrizeLog activityPrizeLog = new ActivityPrizeLog();
        BeanUtils.copyProperties(con, activityPrizeLog);
        //插入中獎記錄
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");
        activityPrizeLog.setId(uuid);
        activityPrizeLog.setCreateDt(new Date());
        activityPrizeLog.setPrizeNumber(prizeId);
        activityPrizeLog.setSourceFlag(2);
        activityPrizeLog.setActivityId(prizeIdVo.getActivityId());
        //輪數id
        activityPrizeLog.setSourceBusinessEntityId(liveActivityRoundsId);
        //風格:0券1報名卡2特權卡3紅包4獎品
        if (3 == prizeIdVo.getStyle()) {
            //有獎品參與抽獎的:小紅包
            randomMoney = MoneyPackageUtil.getRandomMoneyByMinMax(prizeIdVo.getPriceMin().doubleValue(), prizeIdVo.getPriceMax().doubleValue());
            activityPrizeLog.setIsPay(0);
            activityPrizeLog.setTotalMoney(randomMoney);
            //插入中獎記錄
            activityPrizeLogMapper.insert(activityPrizeLog);
            LOG.info("插入中獎記錄,抽中紅包隨機生成紅包金額==={}", randomMoney);
            PayTransferRecord payTransferRecord = this.insertPayTransferWechat(activityPrizeLog, con.getIp(),con.getWechatId());
            try {
                //非同步調起微信紅包入賬介面
                CompletableFuture.supplyAsync(() -> this.payTransferWechat(payTransferRecord.getId(), activityPrizeLog.getId()));
            }catch (Exception e){
                LOG.error(e.getMessage(),e);
            }
        } else {
            //插入中獎記錄
            activityPrizeLogMapper.insert(activityPrizeLog);
            LOG.info("插入中獎記錄,抽中非紅包獎品==={}",prizeTitle);
        }
        this.setRedisResult(prizeId,randomMoney,prizeTitle,con.getOpenid(),con.getTimeStamp());
    }

    /**
     * 把秒殺結果放進Redis
     * @param prizeId 紅包金額記錄ID
     * @param randomMoney 隨機發放金額
     * @param prizeTitle 紅包標題
     * @param openid  使用者openid
     */
    private void setRedisResult(String prizeId, Double randomMoney, String prizeTitle, String openid,
                                String timeStamp) {
        Map<String, Object> map = new HashMap<>();
        map.put("prizeId", prizeId);
        map.put("randomMoney", randomMoney);
        map.put("prizeTitle", prizeTitle);
        stringRedisTemplate.opsForValue().set(GlobalConstant.WECHAT_LIVE_ACTIVITY_PRIZE_PREFIX + openid + timeStamp,
                JSONObject.toJSONString(map), 180, TimeUnit.SECONDS);
    }

    /**
     * 執行儲存紅包發放記錄
     * @param activityPrizeLog 活動物件資訊
     * @param ip 客戶端IP
     * @param wechatId 使用者ID
     * @return 返回交易結果記錄物件
     */
    private PayTransferRecord insertPayTransferWechat(final ActivityPrizeLog activityPrizeLog,
                                                        final String ip, final Integer wechatId) {
        LOG.info("插入紅包發放記錄: {}", activityPrizeLog);
        PayTransferRecord payTransferRecord = new PayTransferRecord();
        payTransferRecord.setWechatId(wechatId);
        //狀態:0未支付 1已支付
        payTransferRecord.setState(0);
        payTransferRecord.setCreateDt(new Date());
        payTransferRecord.setCustomerId(activityPrizeLog.getCustomerId());
        payTransferRecord.setOpenId(activityPrizeLog.getOpenid());
        payTransferRecord.setAmount(activityPrizeLog.getTotalMoney());
        payTransferRecord.setDesc("福利紅包");
        payTransferRecord.setBusinessEntityId(activityPrizeLog.getId());
        payTransferRecord.setCreateIp(ip);
        //插入 輪數id
        payTransferRecord.setDeviceInfo(activityPrizeLog.getSourceBusinessEntityId());
        payTransferRecord = wxPayService.createPayTransferRecord(payTransferRecord);
        return payTransferRecord;
    }

    /**
     * 紅包零錢插入支付記錄,並且呼叫遠端服務發放介面
     *
     * @return
     */
    private Integer payTransferWechat(String payTransferRecordId, String activityPrizeLogId) {
        int count = 0;
        if (StringUtils.isNotEmpty(payTransferRecordId)) {
            LOG.info("商戶開始呼叫發放福利紅包介面,企業預付款payTransferRecordId={}",payTransferRecordId);
            PayTransferRecord returnInfo = wxPayService.startPayTransfer(payTransferRecordId);
            if (returnInfo != null && 1 == returnInfo.getState()) {
                ActivityPrizeLog newActivityPrizeLog = new ActivityPrizeLog();
                newActivityPrizeLog.setId(activityPrizeLogId);
                newActivityPrizeLog.setIsPay(1);
                count = activityPrizeLogMapper.updateById(newActivityPrizeLog);
                LOG.info("商戶成功發放福利紅包,併成功更改支付狀態的條數=={}", count);
            }
        }
        return count;
    }


import java.util.Random;

/**
 * 隨機產生紅包金額工具類
 * @author cgli, E-mail:cgli@qq.com
 * @version created on :Dec 24, 2019 8:31:27 PM
 */
public final class MoneyPackageUtil {

    /**
     *
     */
    private MoneyPackageUtil() {
    }

    /**
     * 獲取每次搶紅包的錢。
     *
     * @param remainer
     * @return
     */
    public static double getRandomMoney(MoneyPackage remainer) {
        // remainSize 剩餘的紅包數量
        // remainMoney 剩餘的錢
        double money = 0;
       //金額剩餘0時直接返回0
        if (remainer.getRemainMoney() == 0) {
            return money;
        }
        if (remainer.getRemainSize() <= 1) {
            //remainer.remainSize--;
            money = remainer.getRemainMoney();
            remainer.setRemainSize(0);
            remainer.setRemainMoney(0);
            return money;
        }
        Random r = new Random();
        //最小初始紅包
        double min = 0.01;
        double max = (remainer.getRemainMoney() / remainer.getRemainSize()) * 2;
        money = r.nextDouble() * max;
        money = money <= min ? 0.01 : money;
        money = Math.floor(money * 100) / 100;
        remainer.setRemainSize(remainer.getRemainSize() - 1);
        remainer.setRemainMoney(remainer.getRemainMoney() - money);
        //remainer.remainMoney -= money;
        return money;
    }

    /**
     * 取某個最小值min,最大值max之間的隨機數(min,max) double型別
     *
     * @param min
     * @param max
     * @return
     */
    public static double getRandomMoneyByMinMax(double min, double max) {
        double money = 0;
       //  money = min + Math.random() * max % (max - min + 1);
        money = min + ((max - min) * new Random().nextDouble());
        money = Math.floor(money * 100) / 100;
        return money;
    }

}

 

 

5、壓力測試

 

使用者搶紅包獲取金額場景壓測

伺服器配置,應用2臺,閘道器服務整個平臺(還有很多其他服務)共享4臺

  • CPU: 4核
  • 記憶體:8G
  • SSD硬碟:60G
  • 前端阿里雲SLB負載均衡。
  • 資料庫:使用阿里的Polardb;
  • 作業系統:Linux 7.4
  • JDK8版本;
  • RabbitMQ
  • 阿里雲購買redis服務4HZ/16GB

 

場景

併發使用者數

每秒事務數TPS

平均響應時間(秒)

90%響應時間(秒)

平均網路吞吐(Bytes/s

成功事務數

失敗事務數

平均每秒點選數

持續時長(分鐘)

獲取使用者實時金額

1

20

0.035

0.038

18434.2

100

0

20

-

50

909.73

0.052

0.075

1080858.6

181946

0

909.73

3

100

1098.273

0.082

0.094

1033785.077

241620

0

1098.273

3

200

1152.715

0.146

0.177

1084904.538

299706

0

1152.715

3

500

1139.852

0.262

0.394

1072909.096

437703

190

1139.852

3

600

1132.715

0.304

0.45

1066107.639

342080

266

1132.715

3

800

1055.268

0.336

0.476

993213.388

361957

635

1055.268

3

1000

1148.911

0.455

0.823

1083026.667

441182

1707

1151.583

3

 

場景說明:基準場景為1個併發迭代100次,取平均值;併發場景為規定併發數(每秒上5個)迭代1次在3分鐘內持續發起請求。

結果分析: 獲取使用者實時餘額介面響應較快,伺服器壓力不大,隨著併發數的增加失敗的事務也有所增加,但事務成功率都在99%以上。

備註:併發場景壓測過程中出現的報錯如下:

Error -26366:Action.c(19) Error -26366 "Text="status"true" not found for web_reg_find 

Error -26374:Action.c(19) Error -26374 The above "not found" error(s) may be explained by header and body byte counts being 0 and 0, respectively. 

Error -27728:Action.c(19) Error -27728 Step download timeout (120 seconds) has expired

 

到了1000併發,再往上就壓不上去了,主要是壓力測試機器自身出口頻寬已經打滿的情況。2臺低配置機器,在綜合場景下1000併發響應0.45秒,也已經符合業務場景的要求。實際上,在後面業務投入情況來看也是OK的。

應用本身機器和資料庫壓力並不高。

應用伺服器資源使用情況:

微信小程式搶紅包高併發設計

 

資料庫資源使用情況:

微信小程式搶紅包高併發設計

6、未完問題

防刷在IP檢查,前後端驗籤等處理下已經可以有效攔住80%以上無效請求,但不能做到100%攔截。

1、後端合併請求處理,在這裡沒有體現,還可以繼續完善最佳化。

2、防羊毛黨處理機制,篇幅較長,打算另外一篇中詳細介紹。

相關文章