业务场景

之前在一家直播团队做过一段时间的营收部门负责人,榜单是直播平台最通用的一种玩法,可以彰显用户的身份,刺激用户之间的pk,从而增加平台的营收,下面介绍几种榜单常见的玩法。

限时热门榜

玩法规则大致是每30分钟,对主播收到打赏值进行排行,其中有2类排行榜,限时热门总榜和限时热门分区榜,这里使用自然30分钟代表每个周期,每天有48个30分钟,分别有1、2、3代表每天第1、2、3个30分钟。

大流量、业务效率?从一个榜单开始-LMLPHP

欧皇主播榜

玩法规则大致是主播房间内用户抽到的冰晶城堡数量的排行,页面上有3个榜单,昨日榜、今日榜、总榜。

大流量、业务效率?从一个榜单开始-LMLPHP

直播重营收,营收看活动,活动看打榜,所以这种榜单每个月都会以各种形式出现,我们需要设计一套通用的榜单系统,减轻后续工作量,这是背景。

榜单分析

首先我们对业务进行抽象:

大流量、业务效率?从一个榜单开始-LMLPHP

我们抽象出一些关键词:

  • 用户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;

请求参数

大流量、业务效率?从一个榜单开始-LMLPHP

返回结果

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

getScore:获取榜单分数及榜单排名;

请求参数

大流量、业务效率?从一个榜单开始-LMLPHP

返回结果

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

topScore:获取榜单排名

请求参数

大流量、业务效率?从一个榜单开始-LMLPHP

返回结果

{
    "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的过期时间大于榜单更新最大时间,如下所示:

大流量、业务效率?从一个榜单开始-LMLPHP

需要注意的是,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服务容量进行配置,可以在榜单更新时间内失效。

最后给一个流程图:

榜单更新流程

大流量、业务效率?从一个榜单开始-LMLPHP

榜单实现案例

  1. 限时热门榜/限时热门分区榜实现

当用户在直播间消费时,增加榜单数据,参数入下:

大流量、业务效率?从一个榜单开始-LMLPHP
  1. 欧皇主播日榜/欧皇主播总榜实现

当用户在直播间抽奖抽到指定道具时,增加榜单数据,参数如下:

大流量、业务效率?从一个榜单开始-LMLPHP

进阶场景

近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七日榜。

大流量、业务效率?从一个榜单开始-LMLPHP

当到了26日时,主播1的20220426七日榜的积分会为3;当到了27日时,主播1的20220427七日榜的积分会为2;当到了28日时,主播1的20220428七日榜的积分会为1;当到了29日时,主播1的20220429七日榜的积分会为0。

这种方案好处是没有定时任务,数据是平滑更新的,坏处是接口请求会放大,同时会更新很多条数据,基本无法支持近30天的场景,且业务调用较为复杂。

  • 方案3

更新数据时更新今日的七日榜数据,同时更新明天的七日榜数据(如果没有脚本相当于是今日的日榜数据),并且记录每日的数据,每日中午会将前5日每日的数据加到明日的7日榜中。

大流量、业务效率?从一个榜单开始-LMLPHP

我们一起看一下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之中,从而实现相同积分排序。

如何实现排名变化趋势?

有些榜单场景会有主播今日的排名会和逐日昨日的排名进行比较,看是上升、下降还是不变?

大流量、业务效率?从一个榜单开始-LMLPHP

例如主播今日投喂榜需要实现排名变化趋势,可以每天零点执行脚本,获取榜单上一个周期的排行数据,也就是昨日的topn排名的主播排行信息,写到今日的榜单数据中,并且将昨日排名数据,写到今日的排行数据中,字段使用extra_data,当获取榜单排行时,可以获取到extra_data数据,当前排名和昨日排名数据进行比较即可得到变化趋势,若没有获取到extra_data数据,即昨日没有上排行榜,变化趋势为向上。

这个方案有个小问题,就是不够平滑,但该功能实时性要求较小,可以忽略。extra_data怎么使用缓存、怎么平滑展示数据留个大家去思考。

以上就是一个实际业务场景,以及面对这个业务场景时候如何提升开发效率的case。

好了,今天的分享就到这,喜欢的同学可以四连支持:

大流量、业务效率?从一个榜单开始-LMLPHP

想要更多交流可以加我微信:

大流量、业务效率?从一个榜单开始-LMLPHP
05-05 09:07