基于Redis的排行榜系统

上个月做了一次营销活动,活动大概是分成两个队,用户可以随意加入战队,通过不断做任务来提高自己的战力值,提升自己战力值的同时也会提升所在队伍的战力值,在不断PK的过程中,队伍和具体用户的战力值及排名信息是在不断变化的,承蒙组织厚爱,把这个光荣的任务交给了我,经过对比,我最终选择了redis的zset来做我们本次排名机制的技术方案。

但是我们的活动忽略了一个很重要的东西,就是在相同的战力值下,如何确定二级排名,由于产品意识到这个问题较晚,最终没有实现,一是项目已经提测,无法承担改动的风险,二是时间真的不够。虽然现在活动结束了,但是这个问题一直摆在我心里,作为一个举一反三,勤思好学的程序员,我绝对要把这个问题解决掉,哈哈。

网上百度方案,发现大多数的方案为:通过分数和时间戳来组合成一个分数存到redis,随机选择一个方案的具体实现如下:

我想说:这个方案的确可行,但是存在缺陷:

1、战力值相同的情况下,我如果要改成最晚达到战力值的排名在前,怎么改?

2、如果我有三级排名,需要用到三个字段来排名,怎么办?

经过我痛定思痛,我想了一套可以完美解决此类问题的方案:一级的战力值排名依然放在redis,二级(甚至更多级)的排名信息,我通过一个另外的方式存储在其他地方。

直接上代码:

package chen.huai.jie.springboot.ranking.service;

import chen.huai.jie.springboot.ranking.model.UserRanking;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

/**
 * @author chenhuaijie
 */
@Service
public class RankingService {

    private String key = "ranking";
    private String updateTimeKey = "updateTime";

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private void putUpdateTime(String userId, Long updateTime) {
        stringRedisTemplate.opsForHash().put(updateTimeKey, userId, updateTime);
    }

    private Long getUpdateTime(String userId) {
        return (Long) stringRedisTemplate.opsForHash().get(updateTimeKey, userId);
    }

    /**
     * 给用户增加分数
     *
     * @param userId
     * @param score
     * @return
     */
    public Double increaseUserScore(String userId, Double score) {
        putUpdateTime(userId, System.currentTimeMillis());
        return stringRedisTemplate.opsForZSet().incrementScore(key, userId, score);
    }

    /**
     * 获取排名
     * 根据分分数倒序排列,时间升序排列(越早达到这个分数的越排名越靠前)
     * 说明:大的排序在Redis里做,只是分数相同的情况下,需要在内存里再进行一次排序
     *
     * @return
     */
    public List<UserRanking> getRankings() {
        Long size = stringRedisTemplate.opsForZSet().size(key);
        Set<ZSetOperations.TypedTuple<String>> typedTuples = Objects.requireNonNull(stringRedisTemplate.opsForZSet().reverseRangeWithScores(key, 0, size));
        if (CollectionUtils.isEmpty(typedTuples)) {
            return new ArrayList<>(0);
        }

        return getRankings(typedTuples, size);
    }


    /**
     * 获取排名
     * 根据分分数倒序排列,时间升序排列(越早达到这个分数的越排名越靠前)
     * 说明:大的排序在Redis里做,只是分数相同的情况下,需要在内存里再进行一次排序
     *
     * @return
     */
    public List<UserRanking> getRankings(Long limit) {
        Long size = stringRedisTemplate.opsForZSet().size(key);
        if (limit > size) {
            limit = size;
        }

        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeWithScores(key, 0, limit - 1);

        // 获取第一名和最后一名的分数
        Double maxScore = new ArrayList<>(typedTuples).get(0).getScore();
        Double score = new ArrayList<>(typedTuples).get(typedTuples.size() - 1).getScore();

        if (stringRedisTemplate.opsForZSet().count(key, score, score) > 1) {
            typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, score, maxScore);
        }

        return getRankings(typedTuples, limit);
    }

    private List<UserRanking> getRankings(Set<ZSetOperations.TypedTuple<String>> typedTuples, Long limit) {
        AtomicInteger atomicInteger = new AtomicInteger(0);
        List<UserRanking> userRankings = typedTuples.stream().map(x ->
                UserRanking.builder()
                        .userId(x.getValue())
                        .score(x.getScore())
                        .updateTime(getUpdateTime(x.getValue()))
                        .build()
        ).collect(Collectors.toList());

        if (userRankings.stream().map(UserRanking::getScore).distinct().count() < typedTuples.size()) {
            // 有排名重复的情况下,内存做二次排名
            userRankings = userRankings.stream()
                    .sorted(Comparator
                            // 按照分数倒序排列,分数为空的排在最后
                            .comparing(UserRanking::getScore, Comparator.nullsLast(Double::compareTo)).reversed()
                            // 接着按照时间正序排列,时间为空的排在最后
                            .thenComparing(UserRanking::getUpdateTime, Comparator.nullsLast(Long::compareTo)))
                    .limit(limit)
                    .collect(Collectors.toList());
        }

        userRankings.forEach(x -> x.setRanking(atomicInteger.incrementAndGet()));
        return userRankings;
    }

    /**
     * 获取某个用户的排名
     *
     * @param userId
     * @return
     */
    public UserRanking getUserRanking(String userId) {
        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 0);

        Double score = stringRedisTemplate.opsForZSet().score(key, userId);
        Double maxScore = new ArrayList<>(typedTuples).get(0).getScore();

        Long countOfScore = stringRedisTemplate.opsForZSet().count(key, score, score);
        if (countOfScore == 1) {
            // 本人排名没有重复的情况
            Long ranking = stringRedisTemplate.opsForZSet().rank(key, score);

            return UserRanking.builder()
                    .ranking(ranking.intValue() + 1)
                    .userId(userId)
                    .score(score)
                    .updateTime(getUpdateTime(userId))
                    .build();
        } else {
            // 本人排名有重复的情况
            Long countBetweenScores = stringRedisTemplate.opsForZSet().count(key, score, maxScore);

            typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, score, score);
            List<UserRanking> userRankings = getRankings(typedTuples, (long) typedTuples.size());
            UserRanking userRanking = userRankings.stream().filter(x -> x.getUserId().equals(userId)).findAny().orElse(null);
            if (userRanking == null) {
                return null;
            }

            // 重新计算排名信息
            userRanking.setRanking((countBetweenScores.intValue() - countOfScore.intValue() + userRanking.getRanking()));
            return userRanking;
        }
    }
}

 我一共实现了四个方法:

给用户增加战力值

获取全部用户排名

获取指定数量用户排名

获取某个用户的排名

相比上面引入图例其他的实现方式,我这个的方案的优缺点总结如下:

缺点:

计算逻辑稍微复杂(但是还是很清晰的);

同事每次计算需要访问多次redis(这个并不影响性能);

不能再redis里直观地看到排名信息;

优点:

redis存储的分数数据保留了原始值,避免了跟时间戳混在一起;

后续需求变更改动少,如果需要增加二级以上排序,我这个修改逻辑还可以实现,用上面的方案根本无法实现。

总结:

记录一下日常开发的趣事,老铁给我一个三连吧。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值