前言面试中被问到 “1 亿玩家实时排名如何实现”,90% 的开发者只会说 “用 Redis”,但能讲清分片策略、同分数排序、性能优化 的人不足 10%。
这个问题的核心诉求远不止 “能用 Redis”,而是要满足:
排名毫秒级实时更新(玩家分数变化立即反映);
高并发查询(百万 QPS 下排名查询不卡顿);
海量数据支撑(1 亿玩家无性能瓶颈);
边界场景兼容(同分数、跨分片全局排名)。
在游戏、直播等互联网场景中,实时排名系统是提升用户参与感和刺激竞争的核心功能。本文将从零开始,详细讲解如何设计并实现一个支持1亿玩家、高并发实时更新的排名系统。
核心实现思路:为什么选 Redis ZSet?传统 MySQL 实现排名的痛点:
排序操作(ORDER BY)时间复杂度 O (n log n),1 亿数据直接卡死;
分数更新需要行锁/表锁,并发性能极差;
无法支撑高频次的排名查询。
而 Redis ZSet(有序集合)是最优解,核心优势:
性能极致:底层基于「跳表 + 哈希表」,增 / 删 / 改 / 查排名的时间复杂度均为 O (log N),1 亿数据的 log₂(1e8)≈27,毫秒级响应;
天然有序:自动按分数排序,无需手动维护;
原子操作:ZINCRBY、ZADD 等命令天然原子性,避免并发问题。
但单 ZSet 无法承载 1 亿数据,需补充 3 个关键策略:
分片策略:按玩家 ID 哈希拆分到多个 ZSet(如 100 个分片,每个 1000 万数据),规避单 Key 性能瓶颈;
分数重构:解决同分数排序问题(如 “先到钻石段位的玩家排名靠前”);
高可用保障:Redis 集群(主从 + 哨兵)+ 异步持久化到 MySQL 兜底,避免数据丢失。
一、Redis ZSet 基础先科普一下zset,Redis 的有序集合(Sorted Set,简称 ZSet)是一个功能强大且独特的数据结构。它的每个元素都由两部分组成:
成员 (Member):就像排行榜上的名字,比如玩家的ID、商品的编号,它在集合中是唯一的,不能重复。
分数 (Score):一个双精度浮点数,代表成员的“权重”或“分值”,比如玩家的得分、商品的销量。分数是排序的依据,可以重复。
ZSet 最核心的特性是,它会自动根据分数对成员进行从小到大的排序。如果多个成员分数相同,则再按成员自身的字典顺序排序 。它既保持了集合(Set)成员唯一的特性,又像列表(List)一样有序,但排序规则不是插入顺序,而是分数。
高频命令速查(实战常用)命令示例:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113# 添加数据,张三(100分)、李四(95分)、王五(98分)ZADD player_scores 100 "zhangsan" 95 "lisi" 98 "wangwu" 90 "zhaoliu" 88 "sunqi" 80 "zhouba" # 添加数据ZADD player_scores 60 "wujiu"# 给整个 Key 设置过期时间(两种常用方式)# 方式1:相对时间(单位:秒),示例:1小时后过期(3600秒)EXPIRE player_scores 3600# 方式2:绝对时间(单位:毫秒时间戳)PEXPIREAT player_scores 1770266046000# ====验证过期时间======# 返回剩余过期时间(秒),-1=永不过期,-2=已过期TTL player_scores# 返回剩余过期时间(毫秒)PTTL player_scores # 获取 王五 分数ZSCORE player_scores "wangwu"# 给王五增加10排序分数ZINCRBY player_scores 10 "wangwu"# 按排名查询,升序,前三名ZRANGE player_scores 0 2 # 按排名查询,升序,前三名及其积分WITHSCORES选项会同时返回积分ZRANGE player_scores 0 2 WITHSCORES# 按排名查询,升序,所有人ZRANGE player_scores 0 -1 WITHSCORES# 查询赵六排第几名,升序,索引从0开始ZRANK player_scores "zhaoliu"# 查询赵六排第几名,降序,索引从0开始ZREVRANK player_scores "zhaoliu"# 按排名查询,降序,前三名ZREVRANGE player_scores 0 2 WITHSCORES# ========== 按分数区间精准查询 ==========# ZRANGEBYSCORE:按分数升序查85-100分的成员(含边界,返回成员+分数)ZRANGEBYSCORE player_scores 85 100 WITHSCORES# 查≤100分的成员(-inf表示负无穷)ZRANGEBYSCORE player_scores -inf 100 WITHSCORES# 查>90且≤100分的成员(( 表示开区间,不含90)ZRANGEBYSCORE player_scores (90 100 WITHSCORES# ZREVRANGEBYSCORE:按分数降序查90-110分的成员ZREVRANGEBYSCORE player_scores 110 90 WITHSCORES# ========== 弹出元素(移除+返回) ==========# ZPOPMIN:弹出分数最小的1个成员(可指定数量,如ZPOPMIN key 3)ZPOPMIN player_scores# ZPOPMAX:弹出分数最大的1个成员ZPOPMAX player_scores# BZPOPMIN/BZPOPMAX:阻塞式弹出(无元素时阻塞,超时单位秒)# 阻塞5秒,无元素返回nilBZPOPMIN player_scores 5# 阻塞10秒,适用于异步消费场景BZPOPMAX player_scores 10# 删除指定成员ZREM player_scores "lisi"# 删除多个成员ZREM player_scores "lisi" "wangwu"# 按积分区间删除,删除积分在0到50之间的所有成员ZREMRANGEBYSCORE player_scores 0 50# 按排名区间删除,删除正序排名中最低的三名玩家ZREMRANGEBYRANK player_scores 0 2# 查询积分区间人数ZCOUNT player_scores 90 110# 查询成员数量ZCARD player_scores# ========== 多集合聚合运算 ==========# 先创建第二个集合(示例:另一场比赛的分数)ZADD player_scores_2 95 "zhangsan" 92 "lisi" 98 "zhaoliu"# ZUNIONSTORE:合并2个集合的并集,结果存入union_scores,分数求和ZUNIONSTORE union_scores 2 player_scores player_scores_2 AGGREGATE SUM# 查看并集结果ZRANGE union_scores 0 -1 WITHSCORES # ZINTERSTORE:计算2个集合的交集,结果存入inter_scores,分数取最大值ZINTERSTORE inter_scores 2 player_scores player_scores_2 AGGREGATE MAX# 查看交集结果ZRANGE inter_scores 0 -1 WITHSCORES # ========== 字典序查询(分数相同场景) ==========# 创建分数全为0的集合(字典序查询要求分数一致)ZADD dict_zset 0 "apple" 0 "banana" 0 "cherry" 0 "date"# ZLEXCOUNT:统计字典序区间内的成员数([b (d 表示b≤x 关键注意点 ZSet 的 Score 是双精度浮点数,需注意精度丢失(后文会用 Long 组合分数规避); 单 ZSet 建议数据量不超过 1000 万(Redis 单 Key 极限); 避免用 ZRANGE 0 -1 遍历大集合,优先用 ZSCAN 游标遍历。 二、实战为了方便操作redis,使用Redisson的RScoredSortedSet,简化了开发复杂度。 1、Maven 依赖123456789101112131415161718192021 2、配置文件12345678910111213141516171819202122spring: application: name: springboot-redis-zset data: redis: host: 127.0.0.1 port: 6379 password: yourPassword timeout: 5000ms lettuce: pool: max-active: 200 max-idle: 8 min-idle: 0 max-wait: 1000ms# 自定义排名配置rank: # 分片数(1亿/100=1000万/分片,单节点Redis可承载) shard-count: 200 key-prefix: "game:player:rank:" 3、当分数相同时,如何保证公平?在实际场景中(如王者荣耀段位排名),大量玩家可能拥有相同分数,但系统要求先达到该分数的玩家排名更靠前。 我们设计了分数组合策略:将原始分数与时间戳组合成一个Long类型数值: 1234567891011121314// 主分数占用高30位,时间戳占用低34位private static final int TIMESTAMP_BITS = 34;private static final long TIMESTAMP_MASK = (1L << TIMESTAMP_BITS) - 1;public static long combineScore(long mainScore, long updateTime) { if (mainScore < 0 || mainScore > 1_000_000_000) { throw new IllegalArgumentException("主分数必须在0-10亿之间: " + mainScore); } long timestampPart = updateTime & TIMESTAMP_MASK; timestampPart = TIMESTAMP_MASK - timestampPart; // 反转时间戳 return (mainScore << TIMESTAMP_BITS) | timestampPart;} 这种设计确保了相同分数下,先达到分数的玩家获得更高的组合分数,从而排名更靠前。 我们还有需要一个通过组合分数得到真正的主分数的方法: 1234public static long parseMainScore(double combinedScore) { // 右移34位获取主分数 return (long)combinedScore >>> TIMESTAMP_BITS;} 4、分片策略:平衡负载与查询效率单ZSet承载1亿数据仍有压力,我们采用哈希分片策略: 分片数过多(如 1000)→ 全局排名需查询 1000 个分片,性能下降. 分片数过少(如 10)→ 单个 ZSet 数据量 1 亿,Redis 单 Key 瓶颈 单分片数据量控制在 500 万~1000 万,1 亿玩家建议 100~200 个分片 分片键计算: 12345678910/** * 计算玩家所属的分片Key * @param playerId 玩家ID * @param rankType 排名类型 * @return 分片键 */private String getShardKey(Long playerId, int rankType) { int shardIndex = (int) (playerId % shardCount); return rankKeyPrefix + rankType + ":" + shardIndex;} 5、核心服务类设计1234567891011121314151617181920212223@Service@RequiredArgsConstructorpublic class PlayerRankService { private final RedissonClient redissonClient; // 分片配置 @Value("${rank.shard-count:100}") private int shardCount; @Value("${rank.key-prefix:game:player:rank:}") private String rankKeyPrefix; // 关键方法:实时更新玩家分数 public void updatePlayerScore(Long playerId, int rankType, long mainScore, long updateTime) { double combineScore = combineScore(mainScore, updateTime); String shardKey = getShardKey(playerId, rankType); RScoredSortedSet 6、获取玩家所在的全局排名12345678910111213141516171819202122232425262728293031323334353637383940/** * 获取玩家详细信息 */@SneakyThrowspublic RankItem getPlayerRankDetail(Long playerId, int rankType) { String shardKey = getShardKey(playerId, rankType); RScoredSortedSet 我们采用并行查询+本地聚合策略 7、查询榜单前N名1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950/** * 获取榜单前N名(带分数和排名) */public List 对于榜单前N名查询,我们采用各分片预取+全局归并策略 这样有个问题就是,如果end如果很大,在allEntries.addAll 可能会存在内存溢出引发 OOM,所以搞了个优化版 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778@SneakyThrowspublic List 定义最小堆:minHeap,添加同步锁,避免并发修改异常(ConcurrentModificationException)。 随便这样避免了内存溢出,但是因为加了锁会导致性能下降,如果end可控,第一版更好,根据业务自行决断 8、获取玩家周围的排名1234567891011121314151617181920212223/** * 获取玩家周围的排名(用于显示自己及前后几名) */public List 9、测试接口Controller123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269import lombok.RequiredArgsConstructor;import lombok.SneakyThrows;import org.springframework.web.bind.annotation.*;import top.lrshuai.redis.zset.service.PlayerRankService;import top.lrshuai.redis.zset.vo.R;import top.lrshuai.redis.zset.vo.RankItem;import java.util.Arrays;import java.util.List;import java.util.Map;import java.util.concurrent.*;import java.util.stream.Collectors;/** * 玩家排名接口层 */@RestController@RequestMapping("/api/rank")@RequiredArgsConstructorpublic class PlayerRankController { private final PlayerRankService playerRankService; // 批次 private final long BATCH_SIZE=10000; private final long TOTAL=100000000; private final ThreadLocalRandom random = ThreadLocalRandom.current(); // 核心线程池 private final ExecutorService executor = new ThreadPoolExecutor( 5, // 核心线程数(初始并发) 10, // 最大线程数(峰值并发) 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), // 任务队列,避免线程过多 new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时,由调用线程执行(避免任务丢失) ); /** * 批量生成示例排名数据 */ @GetMapping("/batchAdd") public R 我的电脑配置一般,测试了新增100万数据,需要76秒。 三、结尾实现的思路和代码写完了,虽然有点粗糙,但是思路是没问题的,通过分片策略、分数组合算法和并行查询优化,实现了高性能、高可用的排名服务。 资源获取:本文完整代码已上传至 GitHub,欢迎 Star ⭐ 和 Fork Gitee地址:https://gitee.com/rstyro/enterprise-examples Github地址:https://github.com/rstyro/enterprise-examples 如果这篇文章帮到了你,欢迎点赞、收藏、转发~关注我,后续分享更多高并发、分布式实战方案!🔥>> future = CompletableFuture.supplyAsync(() -> { RScoredSortedSet
>> future : futures) { allEntries.addAll(future.get()); } // 按分数降序排序 allEntries.sort((a, b) -> Double.compare(b.getScore(), a.getScore())); // 转换为RankItem列表,并添加排名 List