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