亿级用户游戏排行榜设计方案

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
简介: 亿级用户游戏排行榜设计方案

一、前言


小伙伴们新年好,我又重新回来运营这个公众号了。去年老想着要有留言功能再来运营,因为和小伙伴们有直接的一个技术交流我觉得会更好,发现后面注册的公众号不支持留言功能了。年前和钊哥、兴哥吃饭聊到我这个事,兴哥阔绰的给了我一个有留言的公众号,当时把我高兴的。前些日子想改它那个公众号的名称以及主体信息,异常的麻烦,主题信息还不能变更,然后我后面登录、写文啥的都需要兴哥扫码我才能进行操作。想了想还是先运营着吧,需要和我交流的可以通过本公众号加我微信联系到我。好了,下面我们就开始聊今天的主题了~


不管是手游还是端游,貌似都离不开排行榜,没有排行榜的游戏是没有灵魂的游戏,因为排行榜可以让用户分泌多巴胺,这样日活才会上来,有了用户就有钱赚。产品想方设法的让用户留存,设计各种排行榜:个人段位排名、个人积分或金币排名、全球榜单实时排名。如果用户量少的话,直接用mysql一张表存储着用户跟某个段位或者积分,然后查的时候再从高到低order by排序下。当然用户量很少的话是可以的,但随着用户量猛增,达到千万、亿级的话,这个肯定行不通了。你可能说我加索引、再多的话分库分表总行了吧。思路是没错的,但这不是很好的方案,排行榜实时更新,亿级用户这io想象都怕。


接下来我就来说下我认为比较好的设计方案。Redis的sorted set数据结构,这简直就是为了排行榜而生的数据结构呀。使用Redis排名非常简单对于百万级别的用户不用耗费太多内存即可实现高效快速的排名,什么玩意,百万级别,题目不是亿级级别吗?客官稍安勿躁,这数据结构轻松应对百万是没问题的,与亿相差100倍的话,也会有性能瓶颈的。那我们有啥优化方案吗?有的,那就是针对sorted set进行分桶。好了,接下来我们就来看看如何设计。


二、设计方案



这种方案就能轻松应对亿级用户的游戏排行榜了,我这里是以积分排行榜来设计的,其它的类似。这里每个桶按照承载百万用户,然后分了100个桶,如果积分分布均匀的话,那就可以轻松应对了。当然你可能会说,有很多新手比如玩了几次这个游戏就没玩了,在[0,1000)区间这个桶内有很多用户。是的,这里我们实行之前,会有个预估。大一点的公司会有数据分析工程师来对游戏用户做个合理的预估,通过一系列高数、概率论的知识把这个分桶区间预估的尽可能准。小公司的话不需要分桶,不要过度设计了。当然也有小部分小公司也有这么大的体量的话,那只能自己预估了,然后后续动态的去调整。


对于查询top排名时,只要查看最高分区桶sorted set排名即可。


对于查询个体用户的排名,根据用户的积分判断看在哪个桶里,计算本桶该用户的排名与高于当前分桶范围的分桶用户相加得到相关用户的排名。


三、代码实现


1、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;
}

2、排行榜返回的RankingInfo类


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


3、实现类

@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 "";
    }
}

4、原子性操作导致并发安全问题



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后端主流技术栈的原理、源码分析、架构以及各种互联网高并发、高性能、高可用的解决方案。


喜欢的话,点赞、再看、分享三连。

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
9月前
|
人工智能 数据可视化 Python
可视化 | 全国双一流大学、超高人气本科专业等排行榜
可视化 | 全国双一流大学、超高人气本科专业等排行榜
|
消息中间件 缓存 NoSQL
如何设计电商行业亿级用户秒杀系统
电商行业在近十几年中,经历过大大小小的促销活动和秒杀上百次,每次做秒杀瞬时访问量会翻数十倍,甚至数百倍。对系统架构是巨大的考验,期间也曾经历过系统宕机,甚至整体雪崩。那么我们怎么设计秒杀系统,才能保证秒杀系统的高性能和稳定性,同时还要保证日常业务不受影响呢? 先看看秒杀场景特点。
如何设计电商行业亿级用户秒杀系统
|
机器学习/深度学习 人工智能 城市大脑
大咖说|支撑10万人同时在线互动,是实现元宇宙的基本前提?
关于元宇宙,有人说它是噱头炒作,甚至是一场骗局,但也有人认为它是下一代互联网。到底什么是元宇宙?实现逻辑是什么?可能会产生什么影响?
189 0
大咖说|支撑10万人同时在线互动,是实现元宇宙的基本前提?
|
机器学习/深度学习 人工智能 城市大脑
【计算讲谈社】第一讲:支撑10万人同时在线互动,是实现元宇宙的基本前提?
关于元宇宙,有人说它是噱头炒作,甚至是一场骗局,但也有人认为它是下一代互联网。到底什么是元宇宙?实现逻辑是什么?可能会产生什么影响?
199 2
【计算讲谈社】第一讲:支撑10万人同时在线互动,是实现元宇宙的基本前提?
陪玩平台源码开发,如何提升用户的约单体验?
陪玩平台源码开发,如何提升用户的约单体验?
|
Android开发 iOS开发
友盟: 手游玩家整体口味偏“轻” 冒险游戏增长最快
由友盟与ChinaJoy等多家海内外产业机构共同编写的《2013全球移动游戏产业白皮书(年终版)》已于近日发布,其中友盟在白皮书中重点分享了中国移动游戏市场发展现状及趋势分析、游戏品类分析及玩家行为分析数据。主要信息点汇总如下:
友盟: 手游玩家整体口味偏“轻” 冒险游戏增长最快
|
机器学习/深度学习 缓存 前端开发
|
安全 大数据 Windows
快手:直播日活已突破1亿;果冻有家,房联网概念的平台化应用
快手:直播日活已突破1亿;果冻有家,房联网概念的平台化应用
291 0
|
双11
96秒100亿!哪些“黑科技”支撑全球最大流量洪峰?| 双11特别策划之二
每秒订单峰值54.4万笔!这项“不可思议”的挑战背后是众多阿里“黑科技”的支撑,究竟是哪些技术撑起了如此强大的流量洪峰?开发者社区双11特别策划带你揭秘——收纳阿里巴巴集团CTO张建锋精彩演讲,淘系技术、支付宝“不为人知”的黑科技,更有超燃阿里工程师纪录片《一心一役》等你发现!
21431 0