億級用戶遊戲排行榜設計方案

1、前言

不論是手遊仍是端遊,貌似都離不開排行榜,沒有排行榜的遊戲是沒有靈魂的遊戲,由於排行榜可讓用戶分泌多巴胺,這樣日活纔會上來,有了用戶就有錢賺。產品千方百計的讓用戶留存,設計各類排行榜:我的段位排名、我的積分或金幣排名、全球榜單實時排名。若是用戶量少的話,直接用mysql一張表存儲着用戶跟某個段位或者積分,而後查的時候再從高到低order by排序下。固然用戶量不多的話是能夠的,但隨着用戶量猛增,達到千萬、億級的話,這個確定行不通了。你可能說我加索引、再多的話分庫分表總行了吧。思路是沒錯的,但這不是很好的方案,排行榜實時更新,億級用戶這io想象都怕。java

接下來我就來講下我認爲比較好的設計方案。Redis的sorted set數據結構,這簡直就是爲了排行榜而生的數據結構呀。使用Redis排名很是簡單對於百萬級別的用戶不用耗費太多內存便可實現高效快速的排名,什麼玩意,百萬級別,題目不是億級級別嗎?客官稍安勿躁,這數據結構輕鬆應對百萬是沒問題的,與億相差100倍的話,也會有性能瓶頸的。那咱們有啥優化方案嗎?有的,那就是針對sorted set進行分桶。好了,接下來咱們就來看看如何設計。mysql

2、設計方案

在這裏插入圖片描述

這種方案就能輕鬆應對億級用戶的遊戲排行榜了,我這裏是以積分排行榜來設計的,其它的相似。這裏每一個桶按照承載百萬用戶,而後分了100個桶,若是積分分佈均勻的話,那就能夠輕鬆應對了。固然你可能會說,有不少新手好比玩了幾回這個遊戲就沒玩了,在[0,1000)區間這個桶內有不少用戶。是的,這裏咱們實行以前,會有個預估。大一點的公司會有數據分析工程師來對遊戲用戶作個合理的預估,經過一系列高數、機率論的知識把這個分桶區間預估的儘量準。小公司的話不須要分桶,不要過分設計了。固然也有小部分小公司也有這麼大的體量的話,那隻能本身預估了,而後後續動態的去調整。redis

對於查詢top排名時,只要查看最高分區桶sorted set排名便可。sql

對於查詢個體用戶的排名,根據用戶的積分判斷看在哪一個桶裏,計算本桶該用戶的排名與高於當前分桶範圍的分桶用戶相加獲得相關用戶的排名。json

3、代碼實現

一、GameRanking 遊戲排行榜類後端

@Data
@Builder
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("game_ranking")
public class GameRanking {
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;
    /**
     * 用戶暱稱
     */
    private String nickName;
    /**
     * 排行榜分數
     */
    private Double leaderboardScore;
    /**
     * 排行榜類型
     */
    private Integer leaderboardType;
    /**
     * 名次
     */
    private Long ranking;
    /**
     * 用戶稱號
     */
    private String grade;
    /**
     * 用戶編號
     */
    private String userNo;
    /**
     * 建立時間
     */
    private LocalDateTime createTime;
    /**
     * 更新時間
     */
    private LocalDateTime updateTime;
}

二、排行榜返回的RankingInfo類安全

@Data
public class RankingInfo {
    private List<GameRanking> scoreList;
    private GameRanking userSelf;
}

三、實現類數據結構

@Service
@Slf4j
public class RankingServiceImpl implements RankingService {
    public CommonVO gameRanking(String userNo, String gameId, Integer leaderboardType, Long topN) {
        RankingInfo rankingInfo = new RankingInfo();
        try {
            List<GameRanking> gameRankingList = doGameRanking(topN);
            GameRanking gameRankingSelf = doGameRankingSelf(userNo);
            rankingInfo.setScoreList(gameRankingList);
            rankingInfo.setUserSelf(gameRankingSelf);
        } catch (Exception e) {
            log.error("gameRanking exception", e);
            return CommonVO.error(CommonVOCode.SERVER_ERROR, "gameRanking exception");
        }
        return CommonVO.success(rankingInfo);
    }

    public List<GameRanking> doGameRanking(Long topN) {
        List<Map<String, Object>> dataMapList = new ArrayList<>();
        JSONArray jsonArray = JSONArray.parseArray(ConfigManager.get(GameConstant.USER_SCORE_RANKING_INTERVAL));
        int size = jsonArray.size();
        long totalNum = 0;
        for (int i = size - 1; i >= 0; i--) {
            JSONObject jsonObject = jsonArray.getJSONObject(i);
            String bucketName = jsonObject.getString("bucketName");
            long unitBucketNum = redisUtil.zCard(bucketName);
            totalNum += unitBucketNum;
            if (totalNum <= topN && unitBucketNum != 0) {
                List<Map<String,Object>> one = commonScoreList(bucketName, topN);
                dataMapList.addAll(one);
            } else if (totalNum >= topN) {
                List<Map<String,Object>> two = commonScoreList(bucketName, unitBucketNum);
                dataMapList.addAll(two);
                break;
            }
        }
        if (CollectionUtils.isEmpty(dataMapList)) {
            return Collections.emptyList();
        }
        Set<ZSetOperations.TypedTuple<String>> vals = dataMapList.stream().map(
                en -> new DefaultTypedTuple<>((String) en.get("userId"),
                        (Double) en.get("score"))).collect(Collectors.toSet());
        // 計算排行榜前先將topN桶刪除,防止以前進入桶的用戶干擾
        redisUtil.delAndZaddExec(GameConstant.USER_SCORE_RANKING_TOPN, vals);
        return doTopNScoreList(topN);
    }

    public List<Map<String, Object>> commonScoreList(String bucketValue, Long topN) {
        Set<ZSetOperations.TypedTuple<String>> rangeWithScores
                = redisUtil.zRevrangeWithScore(bucketValue, 0L, topN - 1);
        List<ZSetOperations.TypedTuple<String>> userScoreTuples = new ArrayList<>(rangeWithScores);
        return userScoreTuples.stream().map(tuple -> {
            String userId = tuple.getValue();
            Double score = tuple.getScore();
            Map<String,Object> map = new HashMap<>();
            map.put("userId", userId);
            map.put("score", score);
            return map;
        }).collect(Collectors.toList());
    }

    public List<GameRanking> doTopNScoreList(Long topN) {
        List<String> userIdList = new ArrayList<>();
        Set<ZSetOperations.TypedTuple<String>> rangeWithScores
                = redisUtil.zRevrangeWithScore(GameConstant.USER_SCORE_GENERAL_RANKING_TOPN, 0L, topN - 1);
        List<ZSetOperations.TypedTuple<String>> userScoreTuples = new ArrayList<>(rangeWithScores);
        List<GameRanking> collect = userScoreTuples.stream().map(tuple -> {
            String userId = tuple.getValue();
            Double score = tuple.getScore();
            userIdList.add(userId);
            return GameRanking.builder()
                    .userNo(userId)
                    .leaderboardScore(score)
                    .ranking((long) (userScoreTuples.indexOf(tuple) + 1))
                    .build();
        }).collect(Collectors.toList());
        List<Map<String,String>> nickNameList = gameRankingMapper.selectBatchByUserNo(userIdList);

        collect.stream().forEach(gameRanking -> {
            Map<String,String> entity = nickNameList.stream()
                    .filter(map -> map.get("userNo").equals(gameRanking.getUserNo())).findFirst().orElse(null);
            if (entity != null) {
                gameRanking.setNickName(entity.get("nickName"));
            }
        });
        // 增長段位功能
        long count = 0;
        for (int i = 0; i < collect.size(); i++) {
            count++;
            collect.get(i).setGrade(getUserGrade(count));
        }
        return collect;
    }

    public GameRanking doGameRankingSelf(String userNo) {
        Long selfRank = null;
        Double score = null;
        String nickName = null;
        try {
            GameRanking gameRanking = gameRankingMapper.selectOneByUserNo(userNo);
            if (Objects.isNull(gameRanking)) {
                nickName = getNickName(userNo);
            } else {
                nickName = gameRanking.getNickName();
            }
            score = gameRanking.getLeaderboardScore();
            // 看該用戶是否在topN的排行裏
            GameRanking rankingSelf = rankingSelfInTopN(userNo);
            if (rankingSelf != null) {
                return rankingSelf;
            }
            String bucketName = getBucketNameParseFromConfigCenter(score);
            Map<String, Object> map = Collections.synchronizedMap(new LinkedHashMap());
            Map<String, String> rankingIntervalMap = getRankingIntervalMapFromConfigCenter();
            // 桶位置比較
            for (Map.Entry<String, String> entry : rankingIntervalMap.entrySet()) {
                if (entry.getValue().compareTo(bucketName) >= 0) {
                    Long perBucketSize = redisUtil.zCard(entry.getValue());
                    map.put(entry.getValue(), perBucketSize);
                }
            }
            Long totalNum = 0L;
            for (Map.Entry<String, Object> entry : map.entrySet()) {
                if (Objects.isNull(entry.getValue())) {
                    continue;
                }
                if (bucketName.equals(entry.getKey())) {
                    // 自身桶的排名
                    Long selfNum = redisUtil.zRevrank(bucketName, userNo) + 1;
                    // 自身桶排名與自身桶前面的總人數相加
                    totalNum += selfNum;
                } else {
                    totalNum += Long.parseLong(entry.getValue().toString());
                }
            }
            selfRank = totalNum;
        } catch (NullPointerException e) {
            selfRank = null;
            score = null;
            log.warn("gameRanking userNo:{"+userNo+"} score is null", e);
        }
        return GameRanking.builder()
                .userNo(userNo)
                .leaderboardScore(score)
                .nickName(nickName)
                .ranking(selfRank)
                .grade(getUserGrade(selfRank))
                .build();
    }

    public GameRanking rankingSelfInTopN(String userNo) {
        Double score = redisUtil.zScore(GameConstant.USER_SCORE_GENERAL_RANKING_TOPN, userNo);
        if (score == null) {
            return null;
        } else {
            Long rank = redisUtil.zRevrank(GameConstant.USER_SCORE_GENERAL_RANKING_TOPN, userNo);
            return GameRanking.builder()
                    .userNo(userNo)
                    .leaderboardScore(score)
                    .ranking(rank + 1)
                    .nickName(getNickName(userNo))
                    .grade(getUserGrade(rank + 1))
                    .build();
        }
    }

    public String getBucketNameParseFromConfigCenter(Double score) {
        JSONArray jsonArray = JSONArray.parseArray(ConfigManager.get(GameConstant.USER_SCORE_GENERAL_RANKING_INTERVAL));
        int size = jsonArray.size();
        boolean flag = false;
        for (int i = 0; i < size; i++) {
            JSONObject jsonObject = jsonArray.getJSONObject(i);
            String bucketInterval = jsonObject.getString("bucketInterval");
            String bucketName = jsonObject.getString("bucketName");
            String[] split = bucketInterval.substring(1, bucketInterval.length() - 1).split(",");
            if ((score >= Double.parseDouble(split[0]) && "+endless".equals(split[1])) ||
                    (score >= Double.parseDouble(split[0]) && score < Double.parseDouble(split[1]))) {
                flag = true;
            } else {
                flag = false;
            }
            if (flag) {
                return bucketName;
            }
        }
        return "";
    }
}

四、原子性操做致使併發安全問題架構

redisUtil.delAndZaddExec(GameConstant.USER_SCORE_RANKING_TOPN, vals);

經過lua腳本保證原子一致性,解決併發安全問題。併發

public class RedisUtil {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    private static final String DELANDZADDSCRIPT =
            "if redis.call('zcard', KEYS[1]) > 0 then\n" +
            "   redis.call('del', KEYS[1])\n" +
            "   for i, v in pairs(ARGV) do\n" +
            "       if i > (table.getn(ARGV)) / 2 then\n" +
            "           break\n" +
            "       end\n" +
            "       redis.call('zadd', KEYS[1], ARGV[2*i - 1], ARGV[2*i])\n" +
            "   end\n" +
            "   return 1\n" +
            "else\n" +
            "   for i, v in pairs(ARGV) do\n" +
            "       if i > (table.getn(ARGV)) / 2 then\n" +
            "           break\n" +
            "       end\n" +
            "       redis.call('zadd',KEYS[1], ARGV[2*i - 1], ARGV[2*i])\n" +
            "   end\n" +
            "   return 1\n" +
            "end";
    
    private RedisScript<Long> redisDelAndZaddScript = new DefaultRedisScript<>(DELANDZADDSCRIPT, Long.class);
    
    /**
     * 刪除及插入
     * @param key 鍵
     * @param val 批量值
     */
    public void delAndZaddExec(String key, Set<ZSetOperations.TypedTuple<String>> val) {
        if (StringUtils.isEmpty(key)) {
            throw new IllegalArgumentException();
        }
        Object[] args = new Object[val.size()*2];
        int i= 0;
        for (ZSetOperations.TypedTuple<String> it : val ) {
            args[2*i] = String.valueOf(it.getScore());
            args[2*i + 1] = it.getValue();
            i++;
        }
        stringRedisTemplate.execute(redisDelAndZaddScript, Collections.singletonList(key), args);
    }
}

其它非核心代碼我就不貼了,至此,億級用戶遊戲排行榜設計方案到此結束,但願對你有幫助,歡迎交流意見與見解。


歡迎小夥伴們關注個人公衆號,Java後端主流技術棧的原理、源碼分析、架構以及各類互聯網高併發、高性能、高可用的解決方案。

在這裏插入圖片描述

相關文章
相關標籤/搜索