使用Redis实现积分排行榜,并支持同积分按时间排序

原创 吴就业 96 0 2021-06-11

本文为博主原创文章,未经博主允许不得转载。

本文链接:https://wujiuye.com/article/8582e6941dcc4072bf984e546b750606

作者:吴就业
链接:https://wujiuye.com/article/8582e6941dcc4072bf984e546b750606
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。

排行榜这个功能很常见,多用于激励用户活跃和拉新,比如CSDN平台实现的周榜,按照每周文章总阅读量进行排名,用排名和奖品激励用户持续在平台上输出高质量内容。

最近笔者也做了一个积分排行榜的功能,在某些场景下我们需要处理同分排名问题。

如张三和李四、王五等人的积分都是100,我们需要实现按最先达到100积分的顺序对他们进行排名,也就是需要按时间排名。

使用Redis实现实时更新的排行榜并不难,Redis提供的ZSet数据结构就很适合用于实现排行榜,但如何实现相同积分情况下再支持按时间排序呢?

实现思路

可能是在此之前笔者刚实现分布式ID生成Base Service,下意识想到了分布式ID雪花算法的原理,即用一个long类型变量存储多个信息。一个long类型长度为8个字节(64bit),雪花算法使用其中41bit记录时间戳,其余bit位存储机房id、机器id、序列号。

Redis的ZSet支持分值为double类型,也是8字节,那么我们也可以使用41位存储时间戳,其实位存储用户的实际积分。

在雪花算法中最高位是不用的,目的是不允许生成负数ID,而在实现排行榜中没有这个限制,因为我们最终要的只是用户的积分,而不是加上时间戳的分值。但也要求最高位要么全为0,要么全为1,避免排序错乱。如实现积分倒序排名时可设置最高位全为1,只不过ZSet已经支持倒序获取,不需要多此一举,所以最高位我们依然不使用。

除去最高位和存储时间戳的41位后,剩余22位表示积分,这时我们还需要结合业务考虑,如果觉得22bit不够表示积分,那么还可以继续压缩时间戳占用的bit。

由于排行榜是周期性的,如周榜、月榜,所以我们没必要存储完整的时间戳,可以取当前时间与周期开始时间相差的毫秒数,这样就可以将41bit压缩到32bit、16bit、或者更低,具体需要多少个bit留给看官们自己算啦。

如果是用41bit表示时间戳,22bit表示积分的话,那么score的组成就是这样的:

0(最高位不用)`|` 0000000 00000000 0000000(22bit表示积分)`|` 0 00000000 00000000 00000000 00000000 00000000(41bit表示时间戳)

因为排序首先按积分排再按时间排,所以积分在高位,时间戳在低位,这样不管时间戳的值是多少,积分越大,64bit表示的数值就越大。

不过这样还没完。

在积分相同的情况下,是不是时间戳越大64bit表示的数值就越大?而我们需要的是按时间升序排,也就是最先达到xx积分的用户排在最前面,所以我们不能单纯的使用41bit存储时间戳,而应该是存储一个随时间流逝而变小的数值。

由于排行榜都会有一个周期,如周榜是一周,月榜是一个月,所以我们使用41bit存储的是一个周期的结束时间yyy-MM-dd 23:59:59对应的时间戳与用户积分更新时间的时间戳的差值,这个值会随着时间的推移而变小,而且不会出现负数的情况,刚好能够达到目的。

实现关键代码

1.实现积分+时间戳差值转score

// periodEndTimestamp: 当前周期结束时间的时间戳 
// 需确保point不会超过22bit所能表示的数值:2097151
private static long toScore(int point, long periodEndTimestamp) {
    long score = 0L;
    score = (score | point) << 41;
    score = score | (periodEndTimestamp - TimestampUtils.currentTimeMillis());
    return score;
}

2.实现从score中获取积分

private static int getPoint(long score) {
     return (int) (score >> 41);
}

3.更新积分

@Override
public void updateRanking(Integer periodId, Long accountId, Integer addPoint) {
    String key = String.format(RankingCacheKeys.REALTIME_POINT_RANKING_KEY, periodId);
    Double score = redisTemplate.opsForZSet().score(key, String.valueOf(accountId));
    score = (score == null) ? 0d : score;
    int curPoint = getPoint(score.longValue());
    long newScore = toScore(curPoint + addPoint, getCurPeriodEndDateTimestamp(periodId));
    redisTemplate.opsForZSet().add(key, String.valueOf(accountId), newScore);
}

总结

基于Redis ZSet实现积分排行榜(倒序)并支持按时间(升序)排序原理与注意事项:

留一个思考题:如果再加一个条件排序,你觉得能实现吗?

关于实现排行榜用到ZSet的几个命令

1.获取倒序排名:

ZREVRANGEBYSCORE key min max offset count

2.获取某个用户的score:

ZSCORE key member

3.获取参与排名的总用户数(本期参与人数):

ZCARD key

4.获取某个用户的当前排名(倒序排序,从大到小的排名):

ZREVRANK key member
#后端

声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。

文章推荐

如何将项目打包部署到私有仓库(Nexus)

公司项目使用的是maven,并且不是推送到maven中央仓库,而是推送到私有仓库nexus,本篇将介绍如何将sdk项目打包部署到私有仓库。

写业务系统,更重要的是设计

写业务系统,我们应该更注重设计,好的设计能解决百分之八十的问题。

Spring Native与WebFlux一样注定昙花一现?

从Spring Native官方文档来看,我是承认它的优秀的,我也会继续关注它,或许将来在合适的项目中去使用它,至少从目前的了解来看,我还不会只为性能买单,一是对现有项目的改造成本略高,二是出于目前项目的成熟度考虑我们还缺少一些云原生组件的支持。

如何写出健壮的业务代码

我们一开始总会自信的觉得自己写出来的代码是个美女,只是写着写着越来越胖,最终写成了个200斤的胖子,自己见了都嫌弃……

在网关实现合并多个微服务Swagger接口文档的详细步骤

由于微服务的划分,使用Swagger生成的接口文档也随之拆散,前端同事不得不把每个微服务的接口文档保存为浏览器标签,方便快速切换。在引入网关之后我们想改善这个问题,统一多个微服务接口文档的入口,最好不需要将每个微服务暴露到外网,能够统一配置是否开启接口文档功能,也不需要为接口文档配置路由规则。

多人协作如何管理Git分支

关于Git分支管理,每个团队在不同阶段都有自己的管理策略,最近我们团队也争论过这个问题。