Chapter 10.利用Redis Zset实现双维度排行榜

欢迎来到「我是真的狗杂谈世界」,关注不迷路

背景


最近需要将遇到的几个排行需求点抽出来做一个独立的通用排行组件,整理记录一下。

核心需求


  • 能获得连续的部分的榜单:比如前100名、第300~400名的用户以及分值
  • 能获得任意单用户信息:比如用户A的分值与当前排名
  • 能操作榜单单用户分值:比如为用户A增加3分
  • 上述功能要求实时
  • 分值相同时,要求按照达到分值时间的先后排序,先达到分值的用户排在前面
  • 分值是整数(或类似金额这种有限位小数,也可以看为整数),业务上一般高精度小数无场景意义

思考过程


说白了就是排序问题,相关的算法和结构有很多了,可问题是总不能自己从零开始实现吧(也不是不可以)~~

那么在现有的存储介质上很容易想到Redis的ZSet数据类型(MySQL不是今天的主题,假装想不到咳咳)

Redis ZSet


底层结构与操作过程

ZSet数据类型的主要数据结构是跳表,具有多层级结构(因此对内存要求稍稍高一点),具体的结构和操作过程在「Tool 1.Redis捣腾系列」中继续捣腾。

相关功能情况

现在只关注上述需求用到的几个操作是否支持以及性能开销情况:

  • ZADD/ZINCRBY: O(log(N))
  • ZSCORE: O(1)
  • ZCARD: O(1)
  • ZRANGE/ZREVRANGE: O(log(N)+M);N为有序集的基数,而M为结果集的基数
  • ZRANK/ZREVRANK: O(log(N))

O(log(N))的时间复杂度理论上完全可以扛住上亿数据的并发查询(当然并不建议真的在生产环境这么干)。

O(log(N)+M)需要特别注意,M是线性增长的,需要严格控制上限

一些特点

ZSet在使用上是member->score结构:

  • member: 被排序的标识,也是默认的第二排序维度(score相同时,redis以member的字典序排列)
  • score: 被排序的分值,存储类型是double

双维度问题


如果直接按照上述用法进行使用,那么只有第一排序维度score是我需要的,虽然有第二排序维度member,而需要的第二排序维度是时间。那怎么办呢?

思考技巧

我常常用来解决问题的思考技巧有:

  • 加:加入其他模块、组件来满足需求
  • 借:借助现有的但不满足需求的模块、组件利用转换来满足需求
  • 合:将几个模块、组件合并起来实现一个需求,跟拆可以互相转换只是视角层次不同
  • 拆:将一个模块、组件拆分为多个,以满足需求,跟合可以互相转换只是视角层次不同

实现思路

思路1:加结构

粗暴一点:一个ZSet只有一个score满足需求,那就同时维护两个ZSet,一个存分值一个存时间


  • 优点:从方案上说实现比较简单
  • 缺点:具体实现上需要同时维护两个ZSet,需要的空间略大;且对并发控制粒度要求大(读写锁+锁范围是操作两个ZSet期间)

思路2:借member

前面提到member是默认的第二排序维度,但直接使用是无法满足时间排序的需求的,那如果member的内容中涵盖了时间呢?


比如原始数据是这样:

user=user1;score=100;time=2022-12-15 13:30:30
user=user2;score=100;time=2022-12-15 13:30:35

而存储时将time+user作为member:

member=2022-12-15 13:30:30_user1;score=100
member=2022-12-15 13:30:35_user2;score=100

这样就借助了member来实现了时间维度的排序。


  • 优点: 从方案上说实现也比较简单
  • 缺点:在维护上仍旧需要另一个结构来辅助映射用户当前时间(比如hash),否则获取用户排行信息的时候无法确定member的值;同时每次修改用户分值时必须控制并发和原子操作删除原member和添加新member

思路3:拆/合score

前面还提到score的存储类型是double,也就是说有很多位(不管二进制位也好、十进制位也好),而数字的比较是高位相等时以低位来决定结果,这不是跟多维度排序的需求一样吗?那如果我把这么多位拆成两部分分别代表分值和时间,存储时计算合并呢?


比如(以十进制位举例)原始数据是这样:

user=user1;score=100;time=3092
user=user2;score=100;time=3093

而存储时将score+time作为score(当然这样时间维度是顺序反了,用一个大数去减就可以把顺序带回来):

member=user1;score=1003092
member=user2;score=1003093

这样就将一个score拆成了多个存储(或者说将多个存储合存在一个score中)了。


  • 优点:无需维护额外的结构,空间开销少;且只需要控制写锁,不会干扰读并发
  • 缺点:score的计算相对复杂一点,且缩小了分值和时间的取值范围;另外score的可读性不那么好(取值范围和可读性在实践中进行了优化)

实践过程


细化方案


方案1:以十进制对整数位进行划分

  • Redis ZSet的score值在超过254后存储和计算会出现问题,保险起见,采用253最为最大整数:900719 9254740992
  • 秒级时间戳需要10位,因此低10位由秒级时间戳填充
  • 高6位则可以由分值填充,但分值最大为900718

  • 优点:可读性较高
  • 缺点:分值范围小

方案2:以二进制对整数位进行划分

  • 仍旧是2^53作为最大整数
  • 采用低32作为时间戳(可表示到2106-02-07 14:28:16)
  • 剩余高21位作为分值(可表示到2097152)

  • 优点:分值范围较方案1变大了一些(如果压缩时间戳位数,还可以再变大一倍)
  • 缺点:可读性更差了(想象一下两个数转换成二进制后再组合成一个新的数)

方案3:利用double的浮点计算

  • double的有效位可以保证在15位以上
  • 将分值作为score的整数部分
  • 将时间戳逆向后作为score的小数部分

  • 优点:可读性较高;利用浮点数特点,分值取值范围可以很大(起码2^53没有问题了)
  • 缺点:也是因为浮点数特点,随着分值(整数部分)的逐渐增大,时间戳(小数部分)精度逐渐变小

选择


最后我选择了方案3,因为考虑到有分值大小和时间精度高低两个维度的指标,方案3在不同场景下的匹配度如下:

分值小 分值大
时间精度高 匹配度高 匹配度低
时间精度低 匹配度高 匹配度高

根据上表,业务业务需要:

  • 对于分值上限小(百万级别以下)的业务场景,方案3可以保障时间高精度
  • 对于分值上限高(千万级别以上)的业务场景,分值往往是从小累计到大的且在小分值时竞争激烈(容易出现同分多,达到同分的时间间隔小的情况)大分值时则不激烈,采用方案3可以在分值较小时仍旧保障时间高精度,分值大时一般对时间精度要求也低了
  • 另外还可以根据业务来收缩时间戳(小数部分)的范围来扩大秒级时间精度下的分值(整数部分)范围

DEMO

// lock acquire

// 计算分值部分
$nowScore = $redis->zScore($key, $member);
$setScore = $addScore + intval($nowScore);

// 计算时间戳部分(可做到百万级别分值仍旧保持秒级时间戳精度,还可以继续优化这块提升分值上限)
$maxTime = 4102415999; // 2099-12-31 23:59:59
$nowTime = $maxTime - time();
$timeScore = bcdiv($nowTime, $maxTime, 10);
$finalScore = bcadd($setScore, $timeScore, 10);

// 设置
$redis->zAdd($key, $finalScore, $member);

// lock release
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,525评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,203评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,862评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,728评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,743评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,590评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,330评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,244评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,693评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,885评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,001评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,723评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,343评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,919评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,042评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,191评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,955评论 2 355

推荐阅读更多精彩内容

  • 游戏中存在各种各样的排行榜,比如玩家的等级排名、分数排名等。玩家在排行榜中的名次是其实力的象征,位于榜单前列的玩家...
    Gundy_阅读 5,342评论 0 11
  • 1 常见排行榜 排行榜主要分为两种,一种是并列排行榜(存在相同排名的情况),一种是严格排行榜(分先后顺序,不存在并...
    jesse_cheng阅读 1,052评论 0 1
  • 游戏中存在各种各样的排行榜,比如玩家的等级排名、分数排名等。玩家在排行榜中的名次是其实力的象征,位于榜单前列的玩家...
    ChinaLeee阅读 1,200评论 0 15
  • 问题描述 排行榜主要分为两种,一种是并列排行榜(存在相同排名的情况),一种是严格排行榜(分先后顺序,不存在并列名次...
    youthcity阅读 5,550评论 0 50
  • 一、zset(sorted set:有序集合) Redis zset和Set一样也是String类型元素的集合,且...
    AC编程阅读 17,267评论 0 2