一、排行榜业务简介

从直播的业务角度看,排行榜是激励用户的一个机制。现在互联网的内容平台都会有充斥个各种排行榜。以和钱关系最强的直播行业【B站、斗鱼、网易、快手、YY】来看,每个平台都有所建树。重要的地方玩法就比较多

排行对比

只要涉及到人的平台,就会有竞争、有竞争就会有排行榜单。本人在排行榜业务做过比较久了,简单讲一期中的业务。

  • 内容
  • 竞争
  • 排行榜
  • 核心目的
    • 让用户多点几下、多化划几下、多用一会App
    • 多从用户钱包里淘几个钱到咱口袋
  • 生态循环
    • 榜单认知
    • 上榜荣誉
    • 上榜欲望
    • 实际行动

二、排行榜技术落地

现在互联网音视频网站,基本都是根据用户花钱、互动建立一个排行榜。让用户进行一种静态的竞争。这些排行榜都有以下特点:

  • 头部效应:
    • 只需要暂时前 N 名用户
    • 前 N 名之外展示距离上榜海沧多少分来激励用户
  • 实时性要求较高:
    • 用户花了钱,必须要在 T 秒内看见分数、排名变动
    • 前N名用户动态调整
      • 比如,某个用户从999+突然进入到1一名、那么原来排名1~999用户名次都+1

技术选型

  • 缓存
    • Redis ZSET 数据结构:最好的动态构建排名数据结构
    • Redis STRING 数据结构:用了ZSET,KV 就无脑先用 STRING
  • 持久化
    • MySQL:满足基本需求 + 公司内部大规模使用。

排行基础实现

Redis 缓存操作命令比较丰富,加分场景涉及到写 Redis 缓存有 UPDATE、 INCR 2 种方式,各有优缺点。

三、技术挑战:缓存、DB 数据一致性

下面是一个普通的缓存使用场景,互联网的最优解法基本都是先更新DB,后面再多次删除缓存。 但是排行榜有一个特殊的ZSET缓存,回源需要计算出前N名,这个操作在 MySQL 成本是非常高,甚至做不了,所以是不能每次更新都删除缓存的。我们会更具对应的业务,制定特殊的加分处理方式。

案例1:榜单列表分数和主播自己分数对不上

一些榜单的写入比较高,比如按照全站主播收礼物的流水高低做一个排行榜,一个主播同时会有多个用户送礼,也就会有并发加分的情况。具体现象和原因可以参考下面的

排行基础实现

从图可以看出某个主播排名列表的分数和自己信息卡分数不一致,原因是因为并发写Redis ZSET 、STRING 2个 KEY 的顺序反了。

这种种并发问题可以这些解决思路

方案1:

  • 思路:让加分【并发写】 ⇒ 【顺序写】
  • 操作:用一个消息队列将加分消息转一下,其中按照主播维度进行分组
  • 优点:实现简单
  • 缺点:顺序写 TPS 上限低,大主播数据延迟比较严重

方案2:

  • 思路:加分 = 分数永远自增 = 用 LUA 写加分逻辑
  • 操作:使用 LUA 脚本操作 Redis,加分场景如果要改的分数比原来小则跳过
  • 优点:支持并发写,单主播写入TPS 很容达到 1K+
  • 缺点:无
// RcRankPushInSortedSetWithIncr .
/*
解决问题
    1.删除榜单上多余人数时使用分数 OR 排名不能保证绝对原子
    2.并发在 ZADD 无法保证顺序性
*/
func (d *Dao) RcRankPushInSortedSetWithIncr(ctx context.Context, redisKey *RedisKeyResp, itemId, numLimit, expireTime int64, incrScore float64) (int64, error) {
    scriptStr := `
local item_id = tonumber(ARGV[1])        -- 加分用户、主播
local incr_score = tonumber(ARGV[2])     -- 分数
local num_limit = tonumber(ARGV[3])      -- ZSET 总数限制
local expire_time = tonumber(ARGV[4])    -- 过期时间
 
-- 分数比原来的小,不加分
-- https://redis.io/commands/zscore/  O(1)
local old_score = tonumber(redis.call("ZSCORE", KEYS[1], item_id))
if old_score and incr_score < old_score then
    return 0
end
---- 写ZSET
---- https://redis.io/commands/zadd/  O(log(N))
local incr_resp = redis.call("ZADD", KEYS[1], incr_score, item_id)
---- 设置过期时间
---- https://redis.io/commands/expire/  O(1)
if expire_time > 0 then
    redis.call("EXPIRE", KEYS[1], expire_time)
end
-- 检查Zset item 数量
if num_limit > 1 then
    local elem_num = tonumber(redis.call("ZCARD", KEYS[1]))
    if elem_num > num_limit then
        -- https://redis.io/commands/zremrangebyrank/   O(log(N)+M)
        redis.call("ZREMRANGEBYRANK", KEYS[1], 0, elem_num - num_limit-1)
    end
end
 
return incr_score
`
    incrResp := d.XRedisBySource(redisKey.RankSource).Eval(ctx, scriptStr, []string{redisKey.RedisKey}, itemId, incrScore, numLimit, expireTime)
    if incrResp.Err() != nil {
        return 0, incrResp.Err()
    }
    return incrResp.Int64()
}

五、技术挑战:极热单点流量:写

在研究系统性能上限之前,得先研究使用的各种工具的上限,各种MQ、Redis、MySQL

Redis

使用 memtier_benchmark 进行测试,测试结果为单实例理论上限值。企业大规模使用都是集群模式,测试的结果可以认为是某些热 KEY 的上限。在 B站 单Redis 集群可以支持 100+实例, 单实例最高10GB, 可以就是单集群数据规模最高1TB。

https://help.aliyun.com/document_detail/609727.html?spm=a2c4g.188009.0.0.4f19148aC989fI https://www.digitalocean.com/community/tutorials/how-to-perform-redis-benchmark-tests

命令 单核心上限 建议日常峰值【上限 * 0.3】
SET 1W 3K
GET 10W 3W
ZADD
ZRANGE

MySQL

直接借鉴阿里云的测试结果,基本上已经做过优化了 https://help.aliyun.com/document_detail/150351.html?spm=a2c4g.150352.0.0.23293c67alwna2

命令 上限 建议日常峰值【上限 * 0.3】
Read 8W 2.4W
Write 2.5W 8K

排行基础实现

四、技术挑战:极热单点流量:读

排行对比

就算是Redis 这种存储 读的上限也才10W,还不足以支撑某些极高读的场景,业界通用的做法是将一些热点读数据放到内存。 LocalCache 功能注意

  • 热点识别
    • TopK
    • 滑动窗口 + LFU
  • 黑名单、白名单
  • 单飞
  • 淘汰策略
    • LRU
    • LFU
    • w-tinylfu

选择库特别注意

  • 稳定性
    • 是否业务大规模使用
  • 接入、维护成本
    • 接入难易度
    • 后期是否有人维护
  • 不必一步到位
    • 一般都是被业务倒推着改进,没有必要一步做到最好,比如:
    • 背景
      • 某个榜单的读写突然变得非常高

六、架构挑战:扩大业务规模

  • 平台化
  • 标准化
  • 规模化

七、总结

八、参考文章