首页 > 数据库 >Chapter 10.利用Redis Zset实现双维度排行榜

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

时间:2022-12-15 23:31:07浏览次数:83  
标签:Chapter 10 ZSet Zset 分值 member score 时间 排序

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

背景


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

核心需求


  • 能获得连续的部分的榜单:比如前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值在超过2^54后存储和计算会出现问题,保险起见,采用2^53最为最大整数: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

标签:Chapter,10,ZSet,Zset,分值,member,score,时间,排序
From: https://blog.51cto.com/u_14681672/5946692

相关文章

  • 高手必备10大难题:Mysql如何实现RR级隔离时,不会幻读?
    文章很长,而且持续更新,建议收藏起来,慢慢读!疯狂创客圈总目录博客园版为您奉上珍贵的学习资源:免费赠送:《尼恩Java面试宝典》持续更新+史上最全+面试必备2000页+面......
  • S1 - Lesson 99 - 100
    Wordsow slipbecarefulslipperyfloorcautionwetfloor  downstairsgodownstairs hurthurthurthurt backmybackthebackdoor strandup ......
  • RNA-seq 详细教程:Wald test(10)
    学习目标了解生成比较结果所需的步骤(Wald检验)总结不同层次的基因过滤了解对数倍变化收缩结果探索默认情况下,DESeq2使用Wald检验来识别在两个样本之间差异表达的......
  • Opencv3.4.10 (CMake 编译)windows
    准备工作:下载opencv以及opencv_contrib(包括一些附加功能)源码或opencv下载(下载后解压即可)opencv_contrib下载(下载后解压即可)cmake下载安装MinGW下载(下载后解......
  • Android 10还没适配完,Android 11又要出了?
    在谷歌的内部峰会视频中出现了一页幻灯片,其中明确给出了Android11正式版将于9月8日(周二)正式发布的消息。去年Android10是9月3日正式上线的,这次Android11在9月8日推出也并......
  • 惠普笔记本电脑更新BISO时,Firmware Updete 100%后卡住
    一般来说只会卡5-10分钟,耐心等待半小时后应该会好,如果半小时后还是没有反应,可按如下步骤尝试:1、长按开机键关机。2、轻按开机键开机。3、若是步骤2无效,拔掉电源线,以及所......
  • 安装vue-element-admin启动报错error:0308010C:digital envelope routines::unsupport
    这个,一般都是,node.js的版本匹配问题造成的。后面我通过安装nvm,在nvm下重新安装了一个低版本的Node.js才搞定。1、安装nvm管理工具(先关掉360等软件,不然会弹出警告!)从官网......
  • 10.Oracle存储过程
    1.Oracle存储过程语法结构create[orreplace]procedure过程名(p1in|outdatatype,p2in|outdatatype,...pnin|outdatatype)is....--声明部......
  • ASEMI肖特基二极管MBR10100FCT图片,MBR10100FCT大小
    编辑-ZASEMI肖特基二极管MBR10100FCT参数:型号:MBR10100FCT最大重复峰值反向电压(VRRM):100V最大RMS电桥输入电压(VRMS):70V最大直流阻断电压(VDC):100V最大平均正向整流输出电......
  • 104. 二叉树的最大深度
    给定一个二叉树,找出其最大深度。二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。说明: 叶子节点是指没有子节点的节点。示例:给定二叉树[3,9,20,null,null......