欢迎来到《我是真狗说世界》,注意背景不要迷路,最近需要抽取遇到的几个排名需求点来做一个独立的总排名组件,整理记录。核心需求是获取列表的连续部分:比如前100、300-400的用户及其评分可以获取任意单个用户信息:比如用户A的评分和当前排名可以操作用户评分列表:比如给用户A加3分,当上面的功能需要相同的实时分数时,需要按照分数到达的时间排序,最先到达分数的用户排在第一位。也可以看成一个整数),而在业务中,一般的高精度小数是没有场景意义的。思考过程简单来说就是一道排序题。相关的算法和结构有很多,但是问题是你不能从头实现(不是不能)~~然后很容易想到Redis在现有存储介质上的ZSet数据类型(MySQL不是今天的话题,假装不想咳咳)RedisZSet底层结构和运行过程层次结构(所以对内存要求高一点),具体结构和运行过程在《工具1.Redis系列”。相关函数现在只关注是否支持上述需求中使用的操作以及性能开销:ZADD/ZINCRBY:O(log(N))ZSCORE:O(1)ZCARD:O(1)ZRANGE/ZREVRANGE:O(日志(N)+M);N为有序集的基数,M为结果集的基数ZRANK/ZREVRANK:O(log(N))O(log(N))的时间复杂度理论上可以承载上亿级的并发查询数据量(当然生产环境不建议这样做)。O(log(N)+M)需要特别注意。M为线性增长,需要严格控制上限。ZSet的一些特点是在使用member->score结构:member:待排序的标识,也是默认的第二排序维度(当scores相同时,redis按照members的字典顺序排列。)score:排序后的分数,存储类型为double。如果直接按照上面的用法使用,那么我需要的只有第一个排序维度score,虽然有第二个排序维度是member,需要的第二个排序维度是time。那我们该怎么办呢?思维技巧我解决问题经常使用的思维技巧是:添加:添加其他模块和组件来满足需求借用:使用现有的不满足需求的模块和组件,并使用转换来满足需求组合:组合几个模块和components组合起来实现一个需求,后续和拆解可以相互转化,但是视角层次不同:一个ZSet只有一个分数满足需求,那么同时维护两个ZSet,一个用于存储存储时间的值和一个。;并且需要大粒度的并发控制(读写锁+锁范围在两个ZSet运行期间)思路2:前面提到的借用member是默认的第二排序维度,但是直接使用不能满足时间排序的需求,时间被会员内容覆盖怎么办?例如,原始数据是这样的:user=user1;分数=100;时间=2022-12-1513:30:30用户=用户2;分数=100;time=2022-12-1513:30:35andtime+userasmember:member=2022-12-1513:30:30_user1;得分=100member=2022-12-1513:30:35_user2;score=100这样就通过成员的方式实现了时间维度的排序。优点:从解决方案来看,实现起来比较简单。缺点:在维护方面,仍然需要另外一个结构来辅助映射用户当前时间(比如hash),否则在获取用户排名信息时无法确定member的值;同时,每次修改用户评分时,必须控制并发和原子操作,删除原来的成员,增加新的成员。思路三:拆分/合并分数前面也提到了分数的存储类型是double,也就是有很多bits(不管是二进制还是十进制),比较数字就是判断结果的时候高位相等。这不和多维排序的要求一样吗?那如果我把那么多的bit拆分成两部分分别代表分数和时间,存储的时候计算和组合呢?例如(以十进制数字为例)原始数据是这样的:user=user1;分数=100;时间=3092用户=用户2;分数=100;time=3093,score+time作为存储时的分数(当然,时间维度是倒序的,可以减大数带回顺序):member=user1;分数=1003092成员=用户2;score=1003093这会将一个分数分成多个存储(或将多个存储放在一个分数中)。优点:不需要维护额外的结构,空间开销较小;并且只需要控制写锁,不会干??扰读并发缺点:score的计算比较复杂,score和time的取值范围缩小;另外,scoreisreadable性能也不太好(取值范围和可读性都在实践中进行了优化)。实用流程细化方案1:整数位除以十进制。RedisZSet的score值超过2^54后存储和计算后会出现问题,为了保险起见,使用2^53作为最大整数:9007199254740992二级时间戳需要10位,所以低10位填充的是二级时间戳,高6位可以填充score值,但score值最大为900718优点:可读性更高缺点:scores范围小方案2:整数位除以二进制还是2^53为最大整数,低32位作为时间戳(可以表示为2106-02-0714:28:16)剩下的高21位作为分数(可以表示为2097152)优点:分数范围比方案一大(如果压缩了时间戳的位数,可以再翻一倍)缺点:可读性更差(想象一下把两个数转换成二进制,然后组合成一个新的数)方案三:使用浮动ppointofdouble计算double的有效位数可以保证score作为15位以上score的整数部分,时间戳取反score的小数部分优点:可读性高;利用浮点数的特性,score的取值范围可以很大(至少2^53没问题)缺点:也是因为浮点数的特性,随着score(整数部分)逐渐增大,并且时间戳(小数部分)的准确性逐渐降低。最后我选择了方案3,因为考虑到score值和时间精度这两个维度的指标,方案3在不同场景下的匹配度分别为:Smallscore,hightimeprecision,highmatchingdegree,highmatchingdegree,lowtime精度、低匹配度、高匹配度、高匹配度。3能保证时间和高精度对于分数上限高的业务场景(千万级以上),分数往往是从小到大积累的,分数小的时候竞争激烈(容易有多个分数,并且达到相同分数的时间间隔小的情况下)分数大的时候,不要大刀阔斧。使用方案3在score较小的情况下依然可以保证时间的高精度。当分值较大时,时间精度要求一般较低。另外,时间戳可以根据业务(小数部分)范围收缩,扩大秒级时间精度下score(整数部分)的范围DEMO//lockacquire//计算score部分$nowScore=$redis->zScore($key,$member);$setScore=$addScore+intval($nowScore);//计算时间戳部分(对于百万级分数仍能保持秒级时间戳的精度,可继续优化这块提高分数上限)$最大时间=4102415999;//2099-12-3123:59:59$nowTime=$maxTime-time();$timeScore=bcdiv($nowTime,$maxTime,10);$finalScore=bcadd($setScore,$timeScore,10);//设置$redis->zAdd($key,$finalScore,$member);//释放锁
