不论是手游仍是端游,貌似都离不开排行榜,没有排行榜的游戏是没有灵魂的游戏,由于排行榜可让用户分泌多巴胺,这样日活才会上来,有了用户就有钱赚。产品千方百计的让用户留存,设计各类排行榜:我的段位排名、我的积分或金币排名、全球榜单实时排名。若是用户量少的话,直接用mysql一张表存储着用户跟某个段位或者积分,而后查的时候再从高到低order by排序下。固然用户量不多的话是能够的,但随着用户量猛增,达到千万、亿级的话,这个确定行不通了。你可能说我加索引、再多的话分库分表总行了吧。思路是没错的,但这不是很好的方案,排行榜实时更新,亿级用户这io想象都怕。java
接下来我就来讲下我认为比较好的设计方案。Redis的sorted set数据结构,这简直就是为了排行榜而生的数据结构呀。使用Redis排名很是简单对于百万级别的用户不用耗费太多内存便可实现高效快速的排名,什么玩意,百万级别,题目不是亿级级别吗?客官稍安勿躁,这数据结构轻松应对百万是没问题的,与亿相差100倍的话,也会有性能瓶颈的。那咱们有啥优化方案吗?有的,那就是针对sorted set进行分桶。好了,接下来咱们就来看看如何设计。mysql
这种方案就能轻松应对亿级用户的游戏排行榜了,我这里是以积分排行榜来设计的,其它的相似。这里每一个桶按照承载百万用户,而后分了100个桶,若是积分分布均匀的话,那就能够轻松应对了。固然你可能会说,有不少新手好比玩了几回这个游戏就没玩了,在[0,1000)区间这个桶内有不少用户。是的,这里咱们实行以前,会有个预估。大一点的公司会有数据分析工程师来对游戏用户作个合理的预估,经过一系列高数、几率论的知识把这个分桶区间预估的尽量准。小公司的话不须要分桶,不要过分设计了。固然也有小部分小公司也有这么大的体量的话,那只能本身预估了,而后后续动态的去调整。redis
对于查询top排名时,只要查看最高分区桶sorted set排名便可。sql
对于查询个体用户的排名,根据用户的积分判断看在哪一个桶里,计算本桶该用户的排名与高于当前分桶范围的分桶用户相加获得相关用户的排名。json
一、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后端主流技术栈的原理、源码分析、架构以及各类互联网高并发、高性能、高可用的解决方案。