大廠員工,手把手教你開發一個高併發、高可用的營銷活動

程序员博博發表於2024-08-27

前言

這幾年工作中做過不少營銷活動,無論是電商業務、支付業務、還是信貸業務,營銷在整個業務發展過程中都是必不可少的。如果前期營銷宣傳到位,會給業務帶來一波不小的流量。那麼作為技術,如何接住這波流量,而不是服務被打掛。今天大廠員工,手把手教你開發出一個高併發、高可用的營銷活動。

體驗

點我 - 體檢地址

點我 - github原始碼



業務

任何脫離業務的技術都是無用功,所以我們先簡單介紹一下業務。

業務希望我們的使用者在比如購買商品,下單支付等場景,轉化率儘可能的高。那麼為了獎勵和刺激使用者,我們希望透過一些優惠券的方式,來激勵符合我們規則的使用者,進行下單,進行支付,進行借錢,進行購物等等後續操作。

比如某個使用者符合我們活動的規則,第一步我們會給他展示優惠資訊,激勵他來進行下一步、完成這個任務,然後在給他發獎、核銷等後續動作

校驗抽獎資格

那麼根據以上我們業務的分析,我們第一步就是,使用者進來,我們查詢活動,並且校驗使用者是否有資格參加活動。如果有多個活動,我們根據業務規則選擇一個活動讓使用者進行參與。

那麼這也是我們營銷活動的起點,第一步。如果成千上百萬的使用者一下子湧進來,我們去查詢資料庫活動資訊,並且校驗規則,我們的資料庫瞬間就會崩掉。所以我們的核心思路是:逐級分流,逐步分散流量。透過備份、限流、降級、熔斷等手段提升可用性。

首先就是加快取,對於一些靜態頁面,css,js等檔案,可以放在客戶端快取或者CDN裡面。對於活動資訊以及規則,在活動上線之前,將這些資訊快取到redis裡面。使用者進來時,我們直接取redis裡面查詢活動資訊,並且計算活動規則,全程不需要和資料庫進行互動。最後,評估活動qps,進行降級限流,如果流量過大,直接進行攔截,防止系統雪崩。

public MktActivityInfo checkActivityRule(String phone) {
    // 從redis快取中取
    MktActivityInfo activityInfo = activityCacheService.getActivityInfo();
    if (activityInfo == null || StringUtils.isEmpty(activityInfo.getActivityId())) {
        return null;
    }

    ActivityRuleContext context = new ActivityRuleContext();
    context.setPhone(phone);
    // redis快取中取
    List<MktActivityRule> mktActivityRules = activityCacheService.listActivityRule(activityInfo.getActivityId());
    for (MktActivityRule mktActivityRule : mktActivityRules) {
        BaseRuleService baseRuleService = BaseRuleFactory.getBaseRuleService(mktActivityRule.getRuleKey());
        if (baseRuleService == null || !baseRuleService.check(context)) {
            return null;
        }
    }

    return activityInfo;
}

抽獎

一般到達抽獎,基本都是完成了前面的任務,比如支付,下單等等,最終獲得抽獎資格

  1. 減庫存。將獎品的庫存資訊提前快取到redis裡面,比如獎品100個快取到redis裡面。如果有100W人來搶100個獎品,最終也只有100個人透過redis的校驗
Long num = RedisUtils.decr(CACHE_MKT_ACTIVITY_PRIZE_NUM, stringRedisTemplate);
if (num == null || num < 0) {

    // 將redis庫存加回,可做可不做,看業務需求
    RedisUtils.incr(CACHE_MKT_ACTIVITY_PRIZE_NUM, stringRedisTemplate);
    throw new RuntimeException("redis庫存不足 - " + ERROR_MSG);
}
  1. 根據業務場景,如果不是必中獎。在減庫存之前,做一個隨機數。如果在隨機數之外,直接返回”獎品被搶完“,限制大部分流量進入到redis減庫存
int seed = ThreadLocalRandom.current().nextInt(0, 100) + 1; // 1-100
int random = NumberUtils.toInt(RedisUtils.get(CACHE_MKT_ACTIVITY_PRIZE_RANDOM, stringRedisTemplate));
if (seed > random) {
    //log.warn("隨機比例被攔截 seed = {}, random = {}", seed, random);
    throw new RuntimeException("隨機比例攔截 - " + ERROR_MSG);
}
  1. 放棄重試
    失敗重試會影響系統效能,重試次數越多,對系統效能的影響越大。
    抽獎過程中,從抽獎資訊驗證到扣庫存、中獎資訊入庫的整個過程中,任何一個環節異常或失敗,我們都不會進行重試,全部當做未中獎處理

  2. 防止獎品超發
    一般我們會透過樂觀鎖,悲觀鎖,分散式鎖來解決。其中樂觀鎖的效率是最高的。
    下面sql不是標準的樂觀鎖,標準的樂觀鎖使用一個version欄位來判斷。不過下面的sql能很好的解決樂觀鎖容易失敗的弊端

update mkt_activity_prize set num = num - 1 where num  >= 1
// 4. 真正資料庫減庫存,並且插入發獎記錄
// 如果redis預減庫存成功,這裡大機率會成功,基本不會失敗,如果失敗,放棄重試,失敗重試會影響系統效能,重試次數越多,對系統效能的影響越大。
Boolean execute = transactionTemplate.execute(status -> {
    // 4.1 扣減庫存
    Integer update = mktActivityPrizeDao.occupyActivityPrize(activityPrize.getActivityId(), activityPrize.getPrizeId());
    if (update == null || update <= 0) {
        //log.warn("mysql 扣減庫存失敗 update = {}", update);
        throw new RuntimeException("mysql庫存扣減失敗 - " + ERROR_MSG);
    }

    // 4.2 插入發獎記錄
    MktActivityPrizeGrant grant = buildMktActivityPrizeGrant(phone, activityPrize);
    Integer insert = mktActivityPrizeGrantDao.insert(grant);
    if (insert == null || insert <= 0) {
        //log.warn("mysql 插入發獎記錄失敗 insert = {}", insert);
        throw new RuntimeException("mysql 插入發獎記錄失敗 - " + ERROR_MSG);
    }

    return true;
});

那麼從以上幾個步驟我們可以看出,在真正的資料庫減少庫存的時候,隨機攔截 + redis減庫存已經幫我們攔截了大部分流量了,也就只有少部分流量會進入到我們真正的減庫存環節。如果減庫存的流量還是特別的大,我們還可以調整隨機比列,同時減庫存可以放到mq中,直接非同步化發放獎品,基本少整個流程不會與資料庫進行互動,瓶頸點幾乎可以說是沒有。這種架構,支撐百萬,千萬qps一點問題都沒有。

最後

本文根據真實的業務場景,詳細的剖析了一場營銷活動從技術的角度如何設計規劃,做到真正的高併發,高可用,支撐業務穩定的執行。其中涉及到的技術點還是比較多的,很多細節沒有一一列舉,包括如何保證redis庫存和mysql一致,如果業務在活動中想修改庫存怎麼辦,怎麼保證不重複領取等等問題。
強烈建議大家有空可以自己實現一版,其中的一些細節還是非常考驗技術的,實現下來,一定會有不少的收穫,謝謝大家。

相關文章