原創不易,求分享、求一鍵三連
業務場景
之前在一家直播團隊做過一段時間的營收部門負責人,榜單是直播平臺最通用的一種玩法,可以彰顯使用者的身份,刺激使用者之間的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為榜單積分。
此處可能會有人會有疑惑,為啥會需要需要榜單積分快取?
- zset限制member數量大小;
- 業務場景需要展示超過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服務容量進行配置,可以在榜單更新時間內失效。
最後給一個流程圖:
榜單更新流程
榜單實現案例
- 限時熱門榜/限時熱門分割槽榜實現
當使用者在直播間消費時,增加榜單資料,引數入下:
- 歐皇主播日榜/歐皇主播總榜實現
當使用者在直播間抽獎抽到指定道具時,增加榜單資料,引數如下:
進階場景
近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。
好了,今天的分享就到這,喜歡的同學可以四連支援:
想要更多交流可以加群討論: