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

3,338 阅读5分钟

排行榜这个功能很常见,多用于激励用户活跃和拉新,比如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实现积分排行榜(倒序)并支持按时间(升序)排序原理与注意事项:

  • 1.将分值score的8字节拆分使用,最高位不用,其余一部分存储实际分值,一部分存储时间戳;
  • 2.先按积分排序,再按时间排序,所以需要高位存储积分,低位存储时间戳,这样才能保证积分越高对应score越大;
  • 3.同分值情况下按时间升序排序,必然让达到当前积分时间最早的score越大;
  • 4.避免积分或者时间戳溢出,如8bit最大可以表示255,如果积分最大可以超过255,那么就需要考虑给积分加到9位…
  • 5.由于每次更新用户积分都需要重新计算score,不能使用ZSet的原子性操作命令,因此可能存在并发数据一致性问题,这点需要考虑。

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

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

1.获取倒序排名:

ZREVRANGEBYSCORE key min max offset count

2.获取某个用户的score:

ZSCORE key member

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

ZCARD key

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

ZREVRANK key member