0%

一亿玩家实时排名怎么实现?

前言

面试中被问到 “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)一样有序,但排序规则不是插入顺序,而是分数。

高频命令速查(实战常用)命令示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# 添加数据,张三(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秒,无元素返回nil
BZPOPMIN 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<d)
# 输出:2(banana、cherry)
ZLEXCOUNT dict_zset [b (d
# ZRANGEBYLEX:按字典序查[b,c]区间的成员
ZRANGEBYLEX dict_zset [b [c


# ========== 大数据量遍历 ==========
# ZSCAN:游标式遍历(适合百万级大集合),匹配以z开头的成员,每次返回5个
ZSCAN player_scores 0 MATCH "z*" COUNT 5

# ========== 批量操作(Redis 6.2+) ==========
# ZMSCORE:批量获取多个成员的分数,减少网络往返
ZMSCORE player_scores "zhangsan" "lisi" "zhaoliu"

命令执行示例图1

命令执行示例图2

命令执行示例图3

关键注意点

  • ZSet 的 Score 是双精度浮点数,需注意精度丢失(后文会用 Long 组合分数规避);
  • 单 ZSet 建议数据量不超过 1000 万(Redis 单 Key 极限);
  • 避免用 ZRANGE 0 -1 遍历大集合,优先用 ZSCAN 游标遍历。

二、实战

为了方便操作redis,使用Redisson的RScoredSortedSet,简化了开发复杂度。

1、Maven 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<properties>
<java.version>17</java.version>
<redisson.version>3.52.0</redisson.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- redisson集成 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>${redisson.version}</version>
</dependency>
</dependencies>

2、配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
spring:
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类型数值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 主分数占用高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;
}

这种设计确保了相同分数下,先达到分数的玩家获得更高的组合分数,从而排名更靠前。

我们还有需要一个通过组合分数得到真正的主分数的方法:

1
2
3
4
public 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 个分片

分片键计算

1
2
3
4
5
6
7
8
9
10
/**
* 计算玩家所属的分片Key
* @param playerId 玩家ID
* @param rankType 排名类型
* @return 分片键
*/
private String getShardKey(Long playerId, int rankType) {
int shardIndex = (int) (playerId % shardCount);
return rankKeyPrefix + rankType + ":" + shardIndex;
}

5、核心服务类设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
@RequiredArgsConstructor
public 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<Long> rankSet = redissonClient.getScoredSortedSet(shardKey);

// 原子操作:添加或更新分数
rankSet.add(combineScore, playerId);
}
}

6、获取玩家所在的全局排名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 获取玩家详细信息
*/
@SneakyThrows
public RankItem getPlayerRankDetail(Long playerId, int rankType) {
String shardKey = getShardKey(playerId, rankType);
RScoredSortedSet<Long> rankSet = redissonClient.getScoredSortedSet(shardKey);
// 获取玩家排序分数
Double combineScore = rankSet.getScore(playerId);
if (combineScore == null) {
return null;
}
// 并行计算所有分片中分数大于当前玩家的数量
List<CompletableFuture<Integer>> futures = new ArrayList<>();
for (int i = 0; i < shardCount; i++) {
String currentShardKey = rankKeyPrefix + rankType + ":" + i;
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
RScoredSortedSet<Long> shardSet = redissonClient.getScoredSortedSet(currentShardKey);
// 对于当前玩家所在的分片,排除等于自己分数的情况
if (currentShardKey.equals(shardKey)) {
return shardSet.count(combineScore, false, Double.MAX_VALUE, true);
}
// 对于其他分片,统计所有分数大于当前分数的玩家
else {
return shardSet.count(combineScore, false, Double.MAX_VALUE, true);
}
});
futures.add(future);
}

// 汇总所有分片的结果
long higherCount = 0;
for (CompletableFuture<Integer> future : futures) {
higherCount += future.get();
}

long rank = higherCount + 1;
long mainScore = parseMainScore(combineScore);
return new RankItem(playerId, mainScore, rank, combineScore);
}
  • 我们采用并行查询+本地聚合策略

7、查询榜单前N名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* 获取榜单前N名(带分数和排名)
*/
public List<RankItem> getTopRankList(int rankType, int start, int end) throws ExecutionException, InterruptedException {
int neededCount = end; // 需要获取到第end名

// 从每个分片获取前neededCount名
List<CompletableFuture<List<ScoredEntry<Integer>>>> futures = new ArrayList<>();
for (int i = 0; i < shardCount; i++) {
String shardKey = rankKeyPrefix + rankType + ":" + i;
CompletableFuture<List<ScoredEntry<Integer>>> future = CompletableFuture.supplyAsync(() -> {
RScoredSortedSet<Integer> rankSet = redissonClient.getScoredSortedSet(shardKey);
// 获取每个分片的前neededCount名(包含分数)
Collection<ScoredEntry<Integer>> entries = rankSet.entryRangeReversed(0, neededCount - 1);
return new ArrayList<>(entries);
});
futures.add(future);
}

// 合并所有分片结果
List<ScoredEntry<Integer>> allEntries = new ArrayList<>();
for (CompletableFuture<List<ScoredEntry<Integer>>> future : futures) {
allEntries.addAll(future.get());
}

// 按分数降序排序
allEntries.sort((a, b) -> Double.compare(b.getScore(), a.getScore()));

// 转换为RankItem列表,并添加排名
List<RankItem> result = new ArrayList<>();
int currentRank = 0;
Double lastScore = null;
Long actualRank = 1L;

for (int i = 0; i < allEntries.size(); i++) {
ScoredEntry<Integer> entry = allEntries.get(i);
double combineScore = entry.getScore();
int playerId = entry.getValue();

// 解析主分数
long mainScore = parseMainScore(combineScore);
actualRank = (long) (i + 1);
result.add(new RankItem(playerId, mainScore, actualRank, combineScore));
}

// 返回指定范围的排名
int fromIndex = Math.min(start - 1, result.size());
int toIndex = Math.min(end, result.size());
return result.subList(fromIndex, toIndex);
}
  • 对于榜单前N名查询,我们采用各分片预取+全局归并策略
  • 这样有个问题就是,如果end如果很大,在allEntries.addAll 可能会存在内存溢出引发 OOM,所以搞了个优化版
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@SneakyThrows
public List<RankItem> getTopRankListOptimize(int rankType, int start, int end) {
if (start < 1 || end < start) {
throw new IllegalArgumentException("排名范围无效: start=" + start + ", end=" + end);
}
// 需要获取的实际数量(从第1名到第end名)
int needCount = end;
// ========== 使用最小堆聚合所有分片的Top N ==========
// 定义最小堆:堆顶是分数最小的,方便淘汰
PriorityQueue<ScoredEntry<Integer>> minHeap = new PriorityQueue<>(
needCount + 1, // 容量稍大,避免频繁扩容
Comparator.comparingDouble(ScoredEntry::getScore) // 按分数升序
);

// 并行查询所有分片,每个分片取needCount名
List<CompletableFuture<Void>> futures = new ArrayList<>(shardCount);
for (int i = 0; i < shardCount; i++) {
final int shardIndex = i;
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
String shardKey = rankKeyPrefix + rankType + ":" + shardIndex;
RScoredSortedSet<Integer> rankSet = redissonClient.getScoredSortedSet(shardKey);

// 从每个分片获取前needCount名(降序:分数高在前)
Collection<ScoredEntry<Integer>> entries = rankSet.entryRangeReversed(0, needCount - 1);

// 同步加锁更新堆(多线程安全)
synchronized (minHeap) {
for (ScoredEntry<Integer> entry : entries) {
minHeap.offer(entry);
// 堆大小超过needCount,弹出最小值(淘汰机制)
if (minHeap.size() > needCount) {
minHeap.poll();
}
}
}
} catch (Exception e) {
log.error("查询分片 {} 失败", shardIndex, e);
}
}, executor);
futures.add(future);
}

// 等待所有分片查询完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get();

// 堆转列表并降序排序(分数高在前)
List<ScoredEntry<Integer>> topEntries = new ArrayList<>(minHeap);
topEntries.sort((a, b) -> Double.compare(b.getScore(), a.getScore())); // 降序

// ========== 第三步:转换为RankItem,处理同分排名逻辑 ==========
List<RankItem> result = new ArrayList<>(topEntries.size());
// 真实排名(同分排名相同)
long actualRank = 1;
// 当前索引(从0开始)
int currentIndex = 0;

for (ScoredEntry<Integer> entry : topEntries) {
double combineScore = entry.getScore();
Integer playerId = entry.getValue();
// 解析主分数
long mainScore = parseMainScore(combineScore);
// 分数变化:排名 = 当前索引 + 1
actualRank = currentIndex + 1;
// 构建RankItem
result.add(new RankItem(playerId, mainScore, actualRank, combineScore));
currentIndex++;
}

// 截取指定范围 [start, end]
// 注意:start/end 从1开始,列表索引从0开始
int fromIndex = Math.max(0, start - 1);
int toIndex = Math.min(end, result.size());
if (fromIndex >= toIndex) {
return new ArrayList<>(); // 范围无效,返回空列表
}
return result.subList(fromIndex, toIndex);
}
  • 定义最小堆:minHeap,添加同步锁,避免并发修改异常(ConcurrentModificationException)。
  • 随便这样避免了内存溢出,但是因为加了锁会导致性能下降,如果end可控,第一版更好,根据业务自行决断

8、获取玩家周围的排名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 获取玩家周围的排名(用于显示自己及前后几名)
*/
public List<RankItem> getSurroundingRanks(Long playerId, int rankType, int before, int after)
throws ExecutionException, InterruptedException {
RankItem playerRank = getPlayerRankDetail(playerId, rankType);
if (playerRank == null) {
return new ArrayList<>();
}
long playerRankValue = playerRank.getRank();
int start = (int) Math.max(1, playerRankValue - before);
int end = (int) (playerRankValue + after);

List<RankItem> range = getRankRange(rankType, start, end);
// 标记当前玩家
for (RankItem item : range) {
if (item.getPlayerId().equals(playerId)) {
// 可以在这里添加标记,或者调用方根据ID判断
item.setIsCurrentUser(Boolean.TRUE);
}
}
return range;
}

9、测试接口Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
import 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")
@RequiredArgsConstructor
public 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<String> batchAddPlayerData(
@RequestParam(defaultValue = "1") int rankType,
@RequestParam(defaultValue = "100") int totalCount,
@RequestParam(defaultValue = "0") long beginId, //从什么用户ID开始
@RequestParam(defaultValue = "100") long minScore,
@RequestParam(defaultValue = "30000") long maxScore) {
try {
if (totalCount <= 0 || minScore > maxScore) {
return R.fail("参数错误:数量需大于0,最小分数不能大于最大分数");
}

long batchNum = totalCount / BATCH_SIZE;
long remainCount = totalCount % BATCH_SIZE;
long updateTime = System.currentTimeMillis();

// 创建CountDownLatch,用于等待所有任务完成
CountDownLatch latch = new CountDownLatch((int)(batchNum + (remainCount > 0 ? 1 : 0)));

// 提交分批任务
for (long batch = 0; batch < batchNum; batch++) {
long startId = batch * BATCH_SIZE + 1+beginId; // 玩家ID起始值
long endId = (batch + 1) * BATCH_SIZE+beginId;
submitBatchTaskWithLatch(rankType, startId, endId, minScore, maxScore, updateTime, latch);
// 每批提交后稍微停顿一下,避免压力过大
Thread.sleep(100);
}

// 处理剩余数据
if (remainCount > 0) {
long startId = batchNum * BATCH_SIZE + 1+beginId;
long endId = batchNum * BATCH_SIZE + remainCount+beginId;
submitBatchTaskWithLatch(rankType, startId, endId, minScore, maxScore, updateTime, latch);
}

// 等待所有批次任务完成
boolean allCompleted = latch.await(5, TimeUnit.MINUTES); // 设置5分钟超时
if (!allCompleted) {
return R.fail("批量生成任务超时,部分数据可能未完成");
}

return R.ok("批量生成" + totalCount + "条排名数据成功,所有任务已完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return R.fail("批量添加被中断:" + e.getMessage());
} catch (Exception e) {
return R.fail("批量添加失败:" + e.getMessage());
}
}

/**
* 提交单批插入任务(带同步锁)
*/
private void submitBatchTaskWithLatch(int rankType, long startId, long endId,
long minScore, long maxScore, long updateTime,
CountDownLatch latch) {
executor.submit(() -> {
try {
for (long i = startId; i <= endId; i++) {
// 生成随机主分数
// long mainScore = minScore + random.nextLong() % (maxScore - minScore + 1);
// 为了方便测试定位,分数和id 有关
long mainScore = TOTAL-i;
System.out.println("i=" + i + ", mainScore=" + mainScore);
// 调用服务插入
playerRankService.updatePlayerScore(i, rankType, mainScore, updateTime);
}
System.out.println("批次完成:player_" + startId + " ~ player_" + endId);
} catch (Exception e) {
System.err.println("批次插入失败:" + startId + "~" + endId + ",原因:" + e.getMessage());
} finally {
// 无论成功还是失败,都释放锁
latch.countDown();
}
});
}

/**
* 更新玩家分数(核心接口)
*
* @param playerId 玩家ID
* @param rankType 榜单类型(1=战力榜,2=积分榜)
* @param mainScore 主分数(如战力值)
*/
@PostMapping("/update")
public R<String> updateScore(
@RequestParam Long playerId,
@RequestParam int rankType,
@RequestParam long mainScore) {
// 用当前时间戳作为次分数,保证同主分数时的排序
long updateTime = System.currentTimeMillis();
playerRankService.updatePlayerScore(playerId, rankType, mainScore, updateTime);
return R.ok("分数更新成功");
}


/**
* 查询榜单前N名玩家
*
* @param rankType 榜单类型
* @param start 起始排名(1开始)
* @param end 结束排名(1开始)
*/
@SneakyThrows
@GetMapping("/top/{rankType}/{start}/{end}")
public R getTopRankList(@PathVariable int rankType, @PathVariable int start, @PathVariable int end) {
return R.ok(playerRankService.getTopRankList(rankType, start, end));
}

/**
* 获取玩家排名详情(包含分数和排名)
*
* @param playerId 玩家ID
* @param rankType 榜单类型
*/
@SneakyThrows
@GetMapping("/detail/{rankType}/{playerId}")
public R getPlayerRankDetail(@PathVariable int rankType,
@PathVariable Long playerId) {
RankItem rankDetail = playerRankService.getPlayerRankDetail(playerId, rankType);
if (rankDetail == null) {
return R.fail("玩家不存在或未参与排名");
}
return R.ok(rankDetail);
}

/**
* 获取指定分数的所有玩家
*
* @param rankType 榜单类型
* @param mainScore 主分数
*/
@SneakyThrows
@GetMapping("/players-by-score/{rankType}/{mainScore}")
public R getPlayersByMainScore(@PathVariable int rankType,
@PathVariable long mainScore) {
List<Long> players = playerRankService.getPlayersByMainScore(rankType, mainScore);
return R.ok(players);
}

/**
* 批量获取玩家排名信息
*
* @param rankType 榜单类型
* @param playerIds 玩家ID列表,用逗号分隔
*/
@SneakyThrows
@GetMapping("/batch-detail/{rankType}")
public R batchGetRankDetails(@PathVariable int rankType,
@RequestParam String playerIds) {
List<Long> idList = Arrays.stream(playerIds.split(",")).map(Long::parseLong).collect(Collectors.toList());
List<RankItem> rankDetails = playerRankService.batchGetRankDetails(idList, rankType);
return R.ok(rankDetails);
}

/**
* 获取玩家周围的排名(显示玩家及前后几名)
*
* @param rankType 榜单类型
* @param playerId 玩家ID
* @param before 向前获取几名
* @param after 向后获取几名
*/
@SneakyThrows
@GetMapping("/surrounding/{rankType}/{playerId}")
public R getSurroundingRanks(@PathVariable int rankType,
@PathVariable Long playerId,
@RequestParam(defaultValue = "5") int before,
@RequestParam(defaultValue = "5") int after) {
List<RankItem> surrounding = playerRankService.getSurroundingRanks(playerId, rankType, before, after);
return R.ok(surrounding);
}

/**
* 分页查询排行榜
*
* @param rankType 榜单类型
* @param page 页码(从1开始)
* @param pageSize 每页大小
*/
@SneakyThrows
@GetMapping("/page/{rankType}")
public R getRankByPage(@PathVariable int rankType,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
if (page < 1 || pageSize < 1 || pageSize > 1000) {
return R.fail("参数错误:页码需大于0,每页大小在1-1000之间");
}

int start = (page - 1) * pageSize + 1;
int end = page * pageSize;

List<RankItem> rankList = playerRankService.getTopRankList(rankType, start, end);
return R.ok(rankList);
}

/**
* 获取排行榜统计信息
*
* @param rankType 榜单类型
*/
@SneakyThrows
@GetMapping("/stats/{rankType}")
public R getRankStats(@PathVariable int rankType) {
// 这里可以返回一些统计信息,比如总玩家数、平均分数等
// 注意:这些数据可能需要额外计算,这里只是示例
Map<String, Object> stats = new ConcurrentHashMap<>();
stats.put("rankType", rankType);
stats.put("timestamp", System.currentTimeMillis());

// 获取前10名
List<RankItem> top10 = playerRankService.getTopRankList(rankType, 1, 10);
stats.put("top10", top10);

// 获取玩家总数(需要遍历所有分片)
long totalPlayers = 0;
for (int i = 0; i < playerRankService.getShardCount(); i++) {
String shardKey = playerRankService.getRankKeyPrefix() + rankType + ":" + i;
org.redisson.api.RScoredSortedSet<Long> rankSet = playerRankService.getRedissonClient().getScoredSortedSet(shardKey);
totalPlayers += rankSet.size();
}
stats.put("totalPlayers", totalPlayers);

return R.ok(stats);
}


}

接口请求测试添加数据

  • 我的电脑配置一般,测试了新增100万数据,需要76秒。

接口请求测试数据

接口请求测试数据

三、结尾

实现的思路和代码写完了,虽然有点粗糙,但是思路是没问题的,通过分片策略分数组合算法并行查询优化,实现了高性能、高可用的排名服务。

资源获取:

本文完整代码已上传至 GitHub,欢迎 Star ⭐ 和 Fork

如果这篇文章帮到了你,欢迎点赞、收藏、转发~关注我,后续分享更多高并发、分布式实战方案!🔥

您的打赏,是我创作的动力!不给钱?那我只能靠想象力充饥了。