大流量、業務效率?從一個榜單開始

原創不易,求分享、求一鍵三連

業務場景

之前在一家直播團隊做過一段時間的營收部門負責人,榜單是直播平臺最通用的一種玩法,可以彰顯用戶的身份,刺激用戶之間的pk,從而增加平臺的營收,下面介紹幾種榜單常見的玩法。

限時熱門榜

玩法規則大致是每30分鐘,對主播收到打賞值進行排行,其中有2類排行榜,限時熱門總榜和限時熱門分區榜,這裏使用自然30分鐘代表每個週期,每天有48個30分鐘,分別有1、2、3代表每天第1、2、3個30分鐘。

歐皇主播榜

玩法規則大致是主播房間內用戶抽到的冰晶城堡數量的排行,頁面上有3個榜單,昨日榜、今日榜、總榜。

直播重營收,營收看活動,活動看打榜,所以這種榜單每個月都會以各種形式出現,我們需要設計一套通用的榜單系統,減輕後續工作量,這是背景。

榜單分析

首先我們對業務進行抽象:

我們抽象出一些關鍵詞:

  • 用戶id(user_id)
  • 主播id(master_id)
  • 投餵(coin)
  • 時間
  • 分區

時間有今日、昨日、自然30分鐘。從這些榜單中我們可以抽象出統一的一套規則,榜單類型、榜單維度、榜單對象、榜單積分。

榜單規則

  • 榜單類型

同一種榜單類型代表的是一類榜單,這一類榜單具備同一套邏輯規則,例如限時熱門榜,雖然每30分鐘會有一個榜單,但是這些榜單數據的規則是一致的。

限時熱門分區榜和限時熱門榜的規則是不一樣的,熱門分區限時榜統計的是分區的主播,限時熱門分區榜統計的是全區的主播。

需要注意的是,限時熱門分區榜和限時熱門榜也可抽象成一類榜單。

  • 榜單維度

同一類榜單可能會有多個榜單,例如限時熱門榜,每個自然30分鐘內都會有一個榜單,每個的榜單都是不同的,或者說是互不影響的。

限時熱門分區榜,每個自然30分鐘內都會有一個榜單,這裏自然30分鐘就是一個維度。

限時熱門分區榜,每個自然30分鐘內*所有分區都會有一個榜單,這裏自然30分鐘和分區就是一個維度。

歐皇主播日榜,活動時間內主播房間內每日用戶抽到的冰晶城堡數量的排行,這裏日就是一個維度。

歐皇主播日榜,活動時間內主播房間內用戶抽到的冰晶城堡數量的排行,這裏只有一個榜單數據,維度爲空。

  • 榜單對象

榜單對象指的是我們給誰進行排行,這個誰可以是用戶,也可以是主播,也可以是其他,例如限時熱門榜,這個榜單對象就是主播,我們需要給主播進行排行。

  • 榜單對象積分

榜單對象積分比較簡單,就是一個進行排序的值,例如限時熱門榜,用戶消費就是積分。

榜單實現

  • 榜單配置

配置可以放在配置文件裏面,或者可以通過後臺管理系統進行管理,配置如下:

[[rank]]
rankname = "master_luck_day"  // 榜單類型
title = "歐皇主播日榜" // 榜單名稱,實際業務中沒有使用到,這裏只做一個名稱區分
top = 100 // 榜單最多展示n條,和業務有關
set = 86400 * 2 // redis set的過期時間,見下方說明
string_expire = 86400 // redis item的過期時間,見下方說明
customsort = 1 // 自定義排序規則,代表相同積分,先到的在前,見下方說明
[[rank]]
rankname = "master_luck_total"
title = "歐皇主播總榜" 
top = 100  
set = 86400 * 30 // 假設活動過期時間是30天
string_expire = 86400 
customsort = 2
  • 榜單接口

這裏只展示最常見的3個接口,其它接口請在具體業務場景中添加。

incrScore:增加榜單積分,類似於redis的incr;

請求參數

返回結果

{
    "code": 0,
    "errcode": 0,
    "message": "ok",
    "errmsg": "ok",
    "data": {
        //  成功或失敗,失敗可以重試
        "status": true
    }
}

getScore:獲取榜單分數及榜單排名;

請求參數

返回結果

{
    "code": 0,
    "errcode": 0,
    "message": "ok",
    "errmsg": "ok",
    "data": {
        //  分數
        "score": 0,
        //  排名
        "rank": 0
    }
}

topScore:獲取榜單排名

請求參數

返回結果

{
    "code": 0,
    "errcode": 0,
    "message": "ok",
    "errmsg": "ok",
    "data": {
        //  排名數據
        "data": [
            {
                //  rank_item
                "rank_item": 0,
                //  排名
                "rank": 0,
                //  積分
                "score": 0
            }
        ]
    }
}

榜單表設計

表設計如下,在實際使用中,需要注意分庫分表,索引也根據實際使用到的場景進行添加,這裏只展示唯一索引:

CREATE TABLE `rank` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `rank_name` varchar(30) NOT NULL DEFAULT '0' COMMENT '榜單類型',
  `rank_type` varchar(50) NOT NULL DEFAULT '' COMMENT '榜單維度',
  `rank_item` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '榜單對象',
  `score` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '積分',
  `extra_data` varchar(50) NOT NULL DEFAULT '擴展數據',
  `rank` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '排名',
  `custom_sort` varchar(200) NOT NULL DEFAULT '' COMMENT '自定義排序',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_rank_id_rank_type_rank_item` (`rank_id`,`rank_type`,`rank_item`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通用榜單表'
CREATE TABLE `rank_log` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `rank_name` varchar(30) NOT NULL DEFAULT '0' COMMENT '榜單id',
  `rank_type` varchar(50) NOT NULL DEFAULT '' COMMENT '子榜id',
  `rank_item` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '對象id',
  `msg_id` varchar(150) NOT NULL DEFAULT '' COMMENT '消息',
  `change_score` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '變化的積分',
  `after_score` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '變化後的積分',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_rank_name_rank_type_rank_item_msg_id` (`rank_name`,`rank_type`,`rank_item`,`msg_id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='榜單更新日誌表'

更新榜單積分時,同時會更新榜單日誌表,通過事務更新,保持數據一致性,通過msg_id保證冪等,接口如果調用失敗,可以重試,類似於用戶花錢時,會更新錢包數據同時會記錄流水數據。調用incr接口時,會執行下面的sql,這2條sql在同一事務中執行。

insert into rank(rank_name,rank_type,rank_item,score) values(params.rank_name,params.rank_type,params.rank_item,params.score) insert on dumplicate update score = params.score;
insert into rank_log(rank_name,rank_type,rank_item,score,msg_id) values(params.rank_name,params.rank_type,params.rank_item,params.score,params.msg_id);

需要注意的是數據庫會保存全量排行榜數據。

事務說明

使用事務更新是否有必要?能否直接通過緩存做冪等?

確實在一般情況下使用緩存做冪等(set key ... nx px),然後輔以日誌查詢就足夠了,使用流水日誌對一致性更好,同時查詢問題更加方便,但是對數據庫的壓力更大,可以根據實際業務場景選用合適的技術方案。

榜單緩存設計

在一般業務中,榜單隻需要展示topn的排名數據,例如top10,top100等,並且在有一定體量的公司中,數據庫都不能直接對外,必須在數據庫上層加一層緩存。

  • 榜單排名數據

榜單排名數據使用的是zset實現,zset的key爲榜單名稱+子榜id, zset的member爲對象id,score爲榜單積分。更新榜單時,做如下操作:

_rankListKey = "rank:list:%d:%s"
rankListKey := fmt.Sprintf(_rankListKey, params.RankName, params.RankType)
// 下面的redis操作可以使用一些優化手段,例如pipline,此處爲示例
redis.zAdd(_rankListKey, score, rank_item) // score代表的是該榜單對象當前的積分
redis.Expire(_rankListKey,  config.set_expire) // config.set_expire爲配置set的的過期時間
redis.zrembyscore(_rankListKey,0,last_rank_score - 1) //last_rank_score代表的是第top名的積分,刪除0到最後一名之間的數據,保證數據只有top個

zset的過期時間大於榜單更新最大時間,如下所示:

需要注意的是,zset的member數量是需要限制的,不然可能會有大key和熱key的問題。

  • 榜單積分數據

業務場景中需要展示某個主播具體的有多少積分。榜單排名數據使用的是string實現, key爲榜單類型+榜單維度+榜單對象,value爲榜單積分。

此處可能會有人會有疑惑,爲啥會需要需要榜單積分緩存?

  1. zset限制member數量大小;
  2. 業務場景需要展示超過topn的積分,如上第2張圖;
_rankItemKey = "rank:item:%d:%s:%d"
rankItemKey := fmt.Sprintf(_rankItemKey, params.RankName, params.RankType, params.RankItem)
score, err := redis.get(rankItemKey)
if err == redis.ErrNil {
    // 回源數據庫,查詢積分,得到rscore
    redis.set(rankItemKey, rank_item, rscore + params.score, config.string_expire)    // config.string_expire爲配置的的過期時間
    err = nil
    return nil
} else if err != nil {
    // 返回錯誤,業務可以重試
    return err
}
redis.incr(rankItemKey, params.rank_item,params.score) 

榜單積分緩存數據量會比榜單排名緩存多很多,過期時間可以根據redis服務容量進行配置,可以在榜單更新時間內失效。

最後給一個流程圖:

榜單更新流程

榜單實現案例

  1. 限時熱門榜/限時熱門分區榜實現

當用戶在直播間消費時,增加榜單數據,參數入下:

  1. 歐皇主播日榜/歐皇主播總榜實現

當用戶在直播間抽獎抽到指定道具時,增加榜單數據,參數如下:

進階場景

近7日榜的實現

主播近七日收到用戶打賞之和的排行,這裏近七日是一個滑動窗口概念,例如20200420代表的是20200414 ~20200420這7日。

  • 業務分析

榜單維度,可以用日期來標識,例如20200420代表的是20200414 ~20200420這7日 榜單對象,主播 榜單積分,主播近7日收到的積分之和

  • 方案1

存在兩種榜單數據,一個是七日的榜單數據(實際使用),一個是每日的榜單數據(輔助使用)。

每日凌晨啓動定時任務將前6日的日榜數據加到近7日的榜單數據中,數據是從數據庫中獲取,獲取的是全量數據,當凌晨用戶投餵時,會實時更新七日榜單的數據,也就是說腳本積分數據和實時積分數據是同時在跑的,理論上,當腳本跑完時,數據會是正確的。

這種方案好處是簡單,可以快速實現,壞處需要定時任務,且數據不是平滑更新的,定時任務執行期間數據不準確。

  • 方案2

方案2沒有使用每日的輔助榜單數據,每次更新數據時會同步更新今日的七日榜和後6天的七日榜,例如今天是2022-04-20,如果增加1積分,會同時更新20220420七日榜、20220422七日榜、20220423七日榜、20220424七日榜、20220425七日榜、20220426七日榜。

當到了26日時,主播1的20220426七日榜的積分會爲3;當到了27日時,主播1的20220427七日榜的積分會爲2;當到了28日時,主播1的20220428七日榜的積分會爲1;當到了29日時,主播1的20220429七日榜的積分會爲0。

這種方案好處是沒有定時任務,數據是平滑更新的,壞處是接口請求會放大,同時會更新很多條數據,基本無法支持近30天的場景,且業務調用較爲複雜。

  • 方案3

更新數據時更新今日的七日榜數據,同時更新明天的七日榜數據(如果沒有腳本相當於是今日的日榜數據),並且記錄每日的數據,每日中午會將前5日每日的數據加到明日的7日榜中。

我們一起看一下20220423七日榜的數據的正確性,20220423七日榜在2022-04-22增加積分1,在2022-04-22中午,將2022-04-17 ~ 2022-04-21這5天日榜的數據共2分加到了20220423七日榜中,在2022-04-23主播1增加1積分增加了積分1,主播積分爲4。

這個方案的好處是數據是平滑更新的,可以實現任意時間階段的連續榜單,且調用簡單,連續榜的邏輯已是在服務內部實現,壞處是實現較爲複雜。

榜單積分相同如何排序?

zset存在一個問題,就是相同積分時,zset會按照member的字典序進行排序,有些業務場景,可能會對相同積分的也需要進行排序,例如相同積分,先到在前。榜單配置中增加有customsort字段, 1代表按時間正序排序, 2代表按時間倒序排序。

數據庫存在custom_sort字段,如果按照時間正序排序,爲負數的時間戳,如果按照時間倒序排序,爲正數的時間戳。

每次更新積分數據後,搜索數據庫與該對象積分相同的數據(最多top條,根據配置,下面用1000來說明),sql語句爲:

select item_id from rank where rank_name = params.rank_name and rank_type = params.rank_type and score = cur_score order by custom_sort desc limit 1000 

然後將score積分加上一個小數,從0.999至0,將相同的數據添加至zset之中,從而實現相同積分排序。

如何實現排名變化趨勢?

有些榜單場景會有主播今日的排名會和逐日昨日的排名進行比較,看是上升、下降還是不變?

例如主播今日投餵榜需要實現排名變化趨勢,可以每天零點執行腳本,獲取榜單上一個週期的排行數據,也就是昨日的topn排名的主播排行信息,寫到今日的榜單數據中,並且將昨日排名數據,寫到今日的排行數據中,字段使用extra_data,當獲取榜單排行時,可以獲取到extra_data數據,當前排名和昨日排名數據進行比較即可得到變化趨勢,若沒有獲取到extra_data數據,即昨日沒有上排行榜,變化趨勢爲向上。

這個方案有個小問題,就是不夠平滑,但該功能實時性要求較小,可以忽略。extra_data怎麼使用緩存、怎麼平滑展示數據留個大家去思考。

以上就是一個實際業務場景,以及面對這個業務場景時候如何提升開發效率的case。

好了,今天的分享就到這,喜歡的同學可以四連支持:

想要更多交流可以加羣討論:

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章