一、排行榜业务简介
从直播的业务角度看,排行榜是激励用户的一个机制。现在互联网的内容平台都会有充斥个各种排行榜。以和钱关系最强的直播行业【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
- …
选择库特别注意
- 稳定性
- 是否业务大规模使用
- 接入、维护成本
- 接入难易度
- 后期是否有人维护
不必一步到位
- 一般都是被业务倒推着改进,没有必要一步做到最好,比如:
背景
某个榜单的读写突然变得非常高
六、架构挑战:扩大业务规模
平台化
标准化
规模化
七、总结
…
八、参考文章
…