蒲粤山 發表於 2025-11-26 09:37:50

基于Redis的ZSET实现用户邀请排行榜

<div id="navCategory"><h5 class="catalogue">目录</h5><ul class="first_class_ul"><li><a href="#_label0">背景</a></li><li><a href="#_label1">伪代码实现</a></li><li><a href="#_label2">多维度排行榜实现</a></li></ul></div><p class="maodian"><a name="_label0"></a></p><h2>背景</h2>
<p>在我们的项目中,有用户的邀请功能,每一次邀请别人注册,会有一定的积分,然后我们同时提供了一个排行榜的功能,可以基于这个积分进行排名。</p>
<p>排名的功能比较简单,就是基于积分去排序就行了,这里面我们利用了Redis的ZSET的数据结构实现快速的排序。</p>
<p>因为ZSET是一个天然有序的数据结构,我们可以把积分当做score,用户id当做member,放到zset中,zset会默认按照SCORE进行排序的。</p>
<p class="maodian"><a name="_label1"></a></p><h2>伪代码实现</h2>
<p>以下是用户接受邀请部分的代码实现:</p>
<div class="jb51code"><pre class="brush:java;">@DistributeLock(keyExpression = "#telephone", scene = "USER_REGISTER")
public UserOperatorResponse register(String telephone, String inviteCode) {
        //用户名生成

    String inviterId = null;
    if (StringUtils.isNotBlank(inviteCode)) {
      User inviter = userMapper.findByInviteCode(inviteCode);
      if (inviter != null) {
            inviterId = inviter.getId().toString();
      }
    }

    //用户注册

    //更新排名
    updateInviteRank(inviterId);
   

        //其他逻辑
}</pre></div>
<p>updateInviteRank的额代码逻辑如下:</p>
<div class="jb51code"><pre class="brush:java;">private void updateInviteRank(String inviterId) {
    // 如果邀请者ID为空,则直接返回,不进行操作
    if (inviterId == null) {
      return;
    }

    // 获取Redisson的锁对象
    RLock rLock = redissonClient.getLock(inviterId);
    // 对邀请者ID对应的锁进行加锁操作,避免并发更新
    rLock.lock();
    try {
      // 获取邀请者的当前排名分数
      Double score = inviteRank.getScore(inviterId);
      // 如果当前分数为空,则设置默认为0.0
      if (score == null) {
            score = 0.0;
      }
      // 将邀请者的排名分数增加100.0,并更新到排行榜中
      inviteRank.add(score + 100.0, inviterId);
    } finally {
      // 最终释放邀请者ID对应的锁
      rLock.unlock();
    }
}
</pre></div>
<p>这里主要是用到了Redisson的RLock进行了加锁,并且是用的lock方法,在加锁失败时阻塞一直尝试。主要就是避免多个用户同时被邀请时,更新分数会出现并发而导致分数累加错误。</p>
<p>这里面的排行榜inviteRank,其实是:</p>
<div class="jb51code"><pre class="brush:java;">private RScoredSortedSet&lt;String&gt; inviteRank;

    @Override
    public void afterPropertiesSet() throws Exception {
      
      this.inviteRank = redissonClient.getScoredSortedSet("inviteRank");
    }</pre></div>
<p>在以上逻辑中进行初始化和实例化的,其实他是一个RScoredSortedSet,是一个支持排序的Set,他提供了很多方法可以方便的实现排名的功能,如:</p>
<ul><li><strong>getScore:</strong>获取指定成员的分数。</li><li><strong>add:</strong>向有序集合中添加一个成员,指定该成员的分数。</li><li><strong>rank</strong>:获取指定成员在有序集合中的排名(从小到大排序,排名从 0 开始)。</li><li><strong>revRank:</strong>获取指定成员在有序集合中的排名(从大到小排序,排名从 0 开始)。</li><li><strong>entryRange</strong>:获取分数在指定范围内的成员及其分数的集合。</li></ul>
<p>比如我们提供了以下几个和排名有关的方法,其实就是对上述方法的一些封装:</p>
<div class="jb51code"><pre class="brush:java;">//获取指定用户的排名,按照分数从高到低
public Integer getInviteRank(String userId) {
    Integer rank = inviteRank.revRank(userId);
    if (rank != null) {
      return rank + 1;
    }
    return null;
}</pre></div>
<div class="jb51code"><pre class="brush:java;">//按照分数从高到低,获取前N个用户的排名信息
public List&lt;InviteRankInfo&gt; getTopN(Integer topN) {
    Collection&lt;ScoredEntry&lt;String&gt;&gt; rankInfos = inviteRank.entryRangeReversed(0, topN - 1);
    List&lt;InviteRankInfo&gt; inviteRankInfos = new ArrayList&lt;&gt;();

    if (rankInfos != null) {
      for (ScoredEntry&lt;String&gt; rankInfo : rankInfos) {
            InviteRankInfo inviteRankInfo = new InviteRankInfo();
            String userId = rankInfo.getValue();
            if (StringUtils.isNotBlank(userId)) {
                User user = findById(Long.valueOf(userId));
                if (user != null) {
                  inviteRankInfo.setNickName(user.getNickName());
                  inviteRankInfo.setInviteCode(user.getInviteCode());
                  inviteRankInfo.setInviteCount(rankInfo.getScore().intValue() / 100);
                  inviteRankInfos.add(inviteRankInfo);
                }
            }
      }
    }

    return inviteRankInfos;
}</pre></div>
<p class="maodian"><a name="_label2"></a></p><h2>多维度排行榜实现</h2>
<p>前面的实现中,如果分数相同,那么排序的结果是不确定的,那么如果我们想要实现多维度排名,即先按照分数排,分数相同的话按照上榜时间排,如何实现呢?</p>
<p>为了实现分数相同按照时间顺序排序,<strong>我们可以将分数score设置为一个浮点数,其中整数部分为得分,小数部分为时间戳</strong>,如下所示:</p>
<blockquote><p>score = 分数 + 时间戳/1e13</p></blockquote>
<p>假设现在的时间戳是1680417299000,除以1e13得到0.1680417299000,再加上一个固定的分数(比如10),那么最终的分数就是10.1680417299000,可以将它作为zset中某个成员的分数,用来排序。</p>
<p>这么做了之后,假如有四个数字:</p>
<p>10.1680417299000、10.1680417299011、11.1680417299000、11.1680417299011</p>
<p>他们按照倒序拍完顺序之后,会是:</p>
<p>11.1680417299011&gt;11.1680417299000&gt;10.1680417299011&gt;10.1680417299000</p>
<p>实现了分数倒序排列,分数相同时间戳大(上榜更晚的)的排在了前面,这和我们的需求相反了,所以,就需要在做一次转换。</p>
<blockquote><p>score = 分数 + 1-时间戳/1e13</p></blockquote>
<p>因为时间戳是这种形式1708746590000 ,共有13位,而1e13是10000000000000,即1后面13个0,所以用时间戳/1e13就能得到一个小数</p>
<p>这样可以保证分数相同时,按照时间戳从小到大排序,即先得分的先被排在前面。</p>
<p>修改后的代码如下:</p>
<div class="jb51code"><pre class="brush:java;">private void updateInviteRank(String inviterId) {
    if (inviterId == null) {
      return;
    }
    //1、这里因为是一个私有方法,无法通过注解方式实现分布式锁。
    //2、register方法已经加了锁,这里需要二次加锁的原因是register锁的是注册人,这里锁的是邀请人
    RLock rLock = redissonClient.getLock(inviterId);
    rLock.lock();
    try {
      //获取当前用户的积分
      Double score = inviteRank.getScore(inviterId);
      if (score == null) {
            score = 0.0;
      }

      //获取最近一次上榜时间
      long currentTimeStamp = System.currentTimeMillis();
      //把上榜时间转成小数(时间戳13位,所以除以10000000000000能转成小数),并且倒序排列(用1减),即上榜时间越早,分数越大(时间越晚,时间戳越大,用1减一下,就反过来了)
      double timePartScore = 1 - (double) currentTimeStamp / 10000000000000L;

      //1、当前积分保留整数,即移除上一次的小数位
      //2、当前积分加100,表示新邀请了一个用户
      //3、加上“最近一次上榜时间的倒序小数位“作为score
      inviteRank.add(score.intValue() + 100.0 + timePartScore, inviterId);
    } finally {
      rLock.unlock();
    }
}</pre></div>
頁: [1]
查看完整版本: 基于Redis的ZSET实现用户邀请排行榜