原创不易,求分享、求一键三连
之前在一家直播团队做过一段时间的营收部门负责人,榜单是直播平台最通用的一种玩法,可以彰显用户的身份,刺激用户之间的pk,从而增加平台的营收,下面介绍几种榜单常见的玩法。
玩法规则大致是每30分钟,对主播收到打赏值进行排行,其中有2类排行榜,限时热门总榜和限时热门分区榜,这里使用自然30分钟代表每个周期,每天有48个30分钟,分别有1、2、3代表每天第1、2、3个30分钟。
玩法规则大致是主播房间内用户抽到的冰晶城堡数量的排行,页面上有3个榜单,昨日榜、今日榜、总榜。
直播重营收,营收看活动,活动看打榜,所以这种榜单每个月都会以各种形式出现,我们需要设计一套通用的榜单系统,减轻后续工作量,这是背景。
首先我们对业务进行抽象:
我们抽象出一些关键词:
时间有今日、昨日、自然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为榜单积分。
此处可能会有人会有疑惑,为啥会需要需要榜单积分缓存?
_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服务容量进行配置,可以在榜单更新时间内失效。
最后给一个流程图:
当用户在直播间消费时,增加榜单数据,参数入下:
当用户在直播间抽奖抽到指定道具时,增加榜单数据,参数如下:
主播近七日收到用户打赏之和的排行,这里近七日是一个滑动窗口概念,例如20200420代表的是20200414 ~20200420这7日。
榜单维度,可以用日期来标识,例如20200420代表的是20200414 ~20200420这7日 榜单对象,主播 榜单积分,主播近7日收到的积分之和
存在两种榜单数据,一个是七日的榜单数据(实际使用),一个是每日的榜单数据(辅助使用)。
每日凌晨启动定时任务将前6日的日榜数据加到近7日的榜单数据中,数据是从数据库中获取,获取的是全量数据,当凌晨用户投喂时,会实时更新七日榜单的数据,也就是说脚本积分数据和实时积分数据是同时在跑的,理论上,当脚本跑完时,数据会是正确的。
这种方案好处是简单,可以快速实现,坏处需要定时任务,且数据不是平滑更新的,定时任务执行期间数据不准确。
方案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天的场景,且业务调用较为复杂。
更新数据时更新今日的七日榜数据,同时更新明天的七日榜数据(如果没有脚本相当于是今日的日榜数据),并且记录每日的数据,每日中午会将前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。
好了,今天的分享就到这,喜欢的同学可以四连支持:
想要更多交流可以加群讨论: