前言
打王者的时候,我们经常会点开排行榜。 看看自己在好友里排第几。 再看看所在战区排多少名。 甚至还会瞄一眼全国排行榜,羡慕一下那些大神。

但你有没有想过: 这个排行榜是怎么实现的? 为什么能实时看到自己的位置? 为什么成千上万的玩家数据能快速排序?
要知道,王者荣耀有上亿玩家。
如果每次打开都要全量排序,服务器早就崩了。
解决方案? 用Redis
的有序集合(Sorted Set)。 实际场景中,比如王者荣耀,每次你打完一局,分数更新,排行榜就要重新计算。 用传统数据库肯定扛不住,所以必须用内存数据库加高效数据结构。
接下来,我们就详细讲讲下面这几个设计方案: 好友排行榜、战区排行榜、全国排行榜、英雄战力榜、城市排行榜
重要说明:本文以实现方案为目的,展示
Redis ZSet
在排行榜中的核心思想。 代码可作为学习模板,但需根据业务规模进行扩展。
基础服务类
首先定义设计一个基础服务类,用来更新玩家全局积分和获取名次等。
java
package com.ruoyi.system.example;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import java.util.Set;
@Service
public class RankService {
private final RedisTemplate<String, Object> redisTemplate;
private final ZSetOperations<String, Object> zSetOps;
/**
* 构造方法注入 RedisTemplate
*/
public RankService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.zSetOps = redisTemplate.opsForZSet(); // 获取 ZSet 操作对象
}
/**
* 更新玩家全局积分
* 如果玩家已存在,则更新分数;否则新增
*
* @param playerId 玩家ID
* @param score 积分(如:王者50星 = 9500)
*/
public void updateGlobalScore(String playerId, double score) {
zSetOps.add("rank:global", playerId, score);
}
/**
* 获取全局排行榜前 N 名(从高到低)
*
* @param topN 前多少名
* @return 包含玩家ID和分数的有序集合
*/
public Set<ZSetOperations.TypedTuple<Object>> getGlobalTop(int topN) {
return zSetOps.reverseRangeWithScores("rank:global", 0, topN - 1);
}
/**
* 查询某玩家的全球排名(从1开始)
*
* @param playerId 玩家ID
* @return 排名,若未上榜返回 -1
*/
public Long getGlobalRank(String playerId) {
Long rank = zSetOps.reverseRank("rank:global", playerId);
return rank == null ? -1 : rank + 1; // reverseRank 从0开始,+1变为从1开始
}
}
再增加一个好友数据类
java
/**
* 好友排名数据类
*/
class FriendRank {
String playerId;
int score;
public FriendRank(String playerId, int score) {
this.playerId = playerId;
this.score = score;
}
@Override
public String toString() {
return playerId + " : " + score;
}
}
定义好后,我们接着写对应的功能。测试类放最后面。
1. 好友排行榜(Friend Rank)
🔹 实现流程
css
获取当前用户的好友列表 [p1, p2, p3]
↓
遍历好友,从全局榜中查询每个人的分数
↓
将有分数的好友组成临时集合
↓
按分数从高到低排序
↓
取前 N 名作为"好友榜"
⚠️ 不单独存好友榜,节省内存,基于全局榜动态生成
🔹 代码实现
java
@Service
public class FriendRankService {
private final RedisTemplate<String, Object> redisTemplate;
private final ZSetOperations<String, Object> zSetOps;
public FriendRankService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.zSetOps = redisTemplate.opsForZSet();
}
/**
* 获取好友排行榜(前 N 名)
*
* @param playerId 当前玩家ID(用于上下文,实际不参与计算)
* @param friendIds 好友ID列表
* @param topN 返回前几名
* @return 好友排名列表,按分数降序
*/
public List<FriendRank> getFriendTop(String playerId, List<String> friendIds, int topN) {
Set<ZSetOperations.TypedTuple<Object>> allFriends = new HashSet<>();
// 遍历每个好友,查询其在全局榜的分数
for (String friendId : friendIds) {
Double score = zSetOps.score("rank:global", friendId);
if (score != null) {
// 只添加有分数的好友
allFriends.add(new DefaultTypedTuple<>(friendId, score));
}
}
// 按分数从高到低排序,取前 N 名
return allFriends.stream()
.sorted((a, b) -> Double.compare(b.getScore(), a.getScore())) // 降序
.limit(topN)
.map(tuple -> new FriendRank((String) tuple.getValue(), tuple.getScore().intValue()))
.collect(Collectors.toList());
}
}
2. 战区排行榜(Zone Rank)
🔹 实现流程
scss
玩家注册 → 根据IP分配战区(如:华东)
↓
比赛结束 → 调用 updateZoneRank("华东", playerId, score)
↓
写入 ZSet: rank:zone:华东
↓
查询时直接从该战区 Key 中获取 Top N
优点:区域隔离,性能极高
注意:战区一旦确定不可更改
🔹 代码实现
java
@Service
public class ZoneRankService {
private final RedisTemplate<String, Object> redisTemplate;
private final ZSetOperations<String, Object> zSetOps;
public ZoneRankService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.zSetOps = redisTemplate.opsForZSet();
}
/**
* 更新玩家在指定战区的排名
*
* @param zone 战区名称,如"华东"
* @param playerId 玩家ID
* @param score 分数
*/
public void updateZoneRank(String zone, String playerId, double score) {
zSetOps.add("rank:zone:" + zone, playerId, score);
}
/**
* 获取某战区的前 N 名
*
* @param zone 战区名称
* @param topN 前几名
* @return 有序集合(高→低)
*/
public Set<ZSetOperations.TypedTuple<Object>> getZoneTop(String zone, int topN) {
return zSetOps.reverseRangeWithScores("rank:zone:" + zone, 0, topN - 1);
}
/**
* 查询玩家在战区的排名
*
* @param zone 战区名称
* @param playerId 玩家ID
* @return 排名(从1开始),未上榜返回 -1
*/
public Long getZoneRank(String zone, String playerId) {
Long rank = zSetOps.reverseRank("rank:zone:" + zone, playerId);
return rank == null ? -1 : rank + 1;
}
}
3. 全国排行榜(National Rank)
🔹 实现流程
css
全国榜 ≠ 所有人排序(太慢!)
↓
策略:归并 + 缓存
↓
1. 每个战区维护自己的 Top 1000
2. 全国榜 = 合并所有战区 Top 1000
3. 统一排序,取前 N 名
4. 结果缓存 5 分钟
↓
查询时直接读缓存
性能高,延迟可控,数据略有延迟(5分钟内)
🔹 代码实现
java
@Service
public class NationalRankService {
private final RedisTemplate<String, Object> redisTemplate;
private final ZSetOperations<String, Object> zSetOps;
private final List<String> zones = Arrays.asList("华东", "华南", "华北", "西南", "西北", "东北");
public NationalRankService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.zSetOps = redisTemplate.opsForZSet();
}
/**
* 获取全国前 N 名(基于归并各战区 Top 1000)
*
* @param topN 前几名
* @return 有序集合
*/
public Set<ZSetOperations.TypedTuple<Object>> getNationalTop(int topN) {
String cacheKey = "rank:national:top" + topN;
// 1. 先尝试从缓存读取
Set<ZSetOperations.TypedTuple<Object>> cached = zSetOps.reverseRangeWithScores(cacheKey, 0, topN - 1);
if (cached != null && !cached.isEmpty()) {
System.out.println("✅ 使用缓存: " + cacheKey);
return cached;
}
System.out.println("🔁 缓存失效,重新计算全国榜...");
// 2. 缓存失效,重新归并计算
Set<ZSetOperations.TypedTuple<Object>> allPlayers = new TreeSet<>(
(a, b) -> Double.compare(b.getScore(), a.getScore()) // 按分数降序
);
// 遍历每个战区,获取其 Top 1000 并合并
for (String zone : zones) {
String zoneKey = "rank:zone:" + zone;
Set<ZSetOperations.TypedTuple<Object>> top1000 = zSetOps.reverseRangeWithScores(zoneKey, 0, 999);
if (top1000 != null) {
allPlayers.addAll(top1000);
}
}
// 3. 取前 N 名
Set<ZSetOperations.TypedTuple<Object>> result = allPlayers.stream()
.limit(topN)
.collect(Collectors.toCollection(LinkedHashSet::new));
// 4. 写回缓存,设置过期时间
result.forEach(tuple -> zSetOps.add(cacheKey, tuple.getValue(), tuple.getScore()));
redisTemplate.expire(cacheKey, Duration.ofMinutes(5)); // 5分钟后刷新
return result;
}
/**
* 查询玩家全国排名(需先缓存全国前1万名)
*/
public Long getNationalRank(String playerId) {
return zSetOps.reverseRank("rank:national:top10000", playerId);
}
}
4. 英雄战力榜(Hero Rank)
🔹 实现流程
arduino
玩家使用英雄获得战力
↓
调用 updateHeroScore("diao_chan", "p1", 21000)
↓
写入 ZSet: rank:hero:diao_chan
↓
查询时 reverseRangeWithScores 获取 Top N
支持上千个英雄独立榜单,数据隔离,互不影响
🔹 代码实现
java
@Service
public class HeroRankService {
private final RedisTemplate<String, Object> redisTemplate;
private final ZSetOperations<String, Object> zSetOps;
public HeroRankService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.zSetOps = redisTemplate.opsForZSet();
}
/**
* 更新英雄战力
*
* @param heroName 英雄名称,如"diao_chan"
* @param playerId 玩家ID
* @param score 战力值
*/
public void updateHeroScore(String heroName, String playerId, double score) {
zSetOps.add("rank:hero:" + heroName, playerId, score);
}
/**
* 获取某英雄的前 N 名
*/
public Set<ZSetOperations.TypedTuple<Object>> getHeroTop(String heroName, int topN) {
return zSetOps.reverseRangeWithScores("rank:hero:" + heroName, 0, topN - 1);
}
/**
* 查询玩家在某英雄榜的排名
*/
public Long getHeroRank(String heroName, String playerId) {
Long rank = zSetOps.reverseRank("rank:hero:" + heroName, playerId);
return rank == null ? -1 : rank + 1;
}
}
5. 城市排行榜(City Rank)
🔹 实现流程
arduino
玩家登录 → 上报地理位置 → 解析城市
↓
更新城市榜:updateCityRank("杭州", "p1", 9500)
↓
写入 ZSet: rank:city:杭州
↓
查询时直接获取该城市的 Top N
适合"同城竞技"场景,支持城市维度激励
🔹 代码实现
java
@Service
public class CityRankService {
private final RedisTemplate<String, Object> redisTemplate;
private final ZSetOperations<String, Object> zSetOps;
public CityRankService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.zSetOps = redisTemplate.opsForZSet();
}
/**
* 更新城市排行榜
*/
public void updateCityRank(String city, String playerId, double score) {
zSetOps.add("rank:city:" + city, playerId, score);
}
/**
* 获取城市前 N 名
*/
public Set<ZSetOperations.TypedTuple<Object>> getCityTop(String city, int topN) {
return zSetOps.reverseRangeWithScores("rank:city:" + city, 0, topN - 1);
}
/**
* 查询玩家在城市的排名
*/
public Long getCityRank(String city, String playerId) {
Long rank = zSetOps.reverseRank("rank:city:" + city, playerId);
return rank == null ? -1 : rank + 1;
}
}
测试类
java
package com.ruoyi.system.example;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.Arrays;
import java.util.List;
public class RankTest {
public static void main(String[] args) {
// 1. 配置 Redis 连接
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration("172.16.20.29", 6379);
config.setPassword(RedisPassword.of("80fPSw$R"));
config.setDatabase(3);
LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
factory.afterPropertiesSet(); // 初始化连接
// 2. 创建 RedisTemplate
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
// 关键:设置序列化器(必须和 RedisConfig 一致)
StringRedisSerializer stringSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer<Object> valueSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
redisTemplate.setKeySerializer(stringSerializer);
redisTemplate.setHashKeySerializer(stringSerializer);
redisTemplate.setValueSerializer(valueSerializer);
redisTemplate.setHashValueSerializer(valueSerializer);
redisTemplate.setDefaultSerializer(valueSerializer);
redisTemplate.afterPropertiesSet(); // 最后初始化
// 3. 构造服务实例(构造方法注入)
RankService rankService = new RankService(redisTemplate);
FriendRankService friendRankService = new FriendRankService(redisTemplate);
ZoneRankService zoneRankService = new ZoneRankService(redisTemplate);
NationalRankService nationalRankService = new NationalRankService(redisTemplate);
HeroRankService heroRankService = new HeroRankService(redisTemplate);
CityRankService cityRankService = new CityRankService(redisTemplate);
// 4. 模拟数据写入
System.out.println("正在模拟数据...");
rankService.updateGlobalScore("p1", 9800);
rankService.updateGlobalScore("p2", 9200);
rankService.updateGlobalScore("p3", 9500);
rankService.updateGlobalScore("p4", 9100);
rankService.updateGlobalScore("p5", 9900);
zoneRankService.updateZoneRank("华东", "p1", 9500);
zoneRankService.updateZoneRank("华东", "p4", 9600);
zoneRankService.updateZoneRank("华南", "p2", 9700);
zoneRankService.updateZoneRank("华北", "p3", 9400);
zoneRankService.updateZoneRank("西南", "p5", 9900);
heroRankService.updateHeroScore("diao_chan", "p1", 21000);
heroRankService.updateHeroScore("diao_chan", "p5", 23000);
cityRankService.updateCityRank("杭州", "p1", 9500);
cityRankService.updateCityRank("杭州", "p6", 9700);
// 5. 测试查询
System.out.println("\n=== 全局榜前3 ===");
rankService.getGlobalTop(3).forEach(t -> System.out.println(t.getValue() + " : " + t.getScore().intValue()));
System.out.println("\n=== 好友榜(p1,p2)===");
List<String> friends = Arrays.asList("p1", "p2");
System.out.println(friendRankService.getFriendTop("p0", friends, 2));
System.out.println("\n=== 华东战区前2 ===");
zoneRankService.getZoneTop("华东", 2).forEach(t -> System.out.println(t.getValue() + " : " + t.getScore().intValue()));
System.out.println("\n=== 全国榜前3 ===");
nationalRankService.getNationalTop(3).forEach(t -> System.out.println(t.getValue() + " : " + t.getScore().intValue()));
System.out.println("\n=== 貂蝉战力榜前2 ===");
heroRankService.getHeroTop("diao_chan", 2).forEach(t -> System.out.println(t.getValue() + " : " + t.getScore().intValue()));
System.out.println("\n=== 杭州排名 ===");
System.out.println("p1 在杭州排名: " + cityRankService.getCityRank("杭州", "p1"));
// 6. 关闭连接
factory.destroy();
}
}
输出结果
yaml
=== 全局榜前3 ===
p5 : 9900
p1 : 9800
p3 : 9500
=== 好友榜(p1,p2 为自己的好友)===
[p1 : 9800, p2 : 9200]
=== 华东战区前2 ===
p4 : 9600
p1 : 9500
=== 全国榜前3 ===
p5 : 9900
p2 : 9700
p4 : 9600
=== 貂蝉战力榜前2 ===
p5 : 23000
p1 : 21000
=== 杭州排名 ===
p1 在杭州排名: 2
redis
数据截图

以上步骤已经完成了我们想要的功能,但是对于王者荣耀这种大型在线游戏,必须还要考虑高可用和安全问题
所以下面再做一个防刷分机制补充:
防刷分机制(Anti-Score-Farming)
1. 为什么需要?
玩家打完一局后会调用接口更新分数。如果:
- 客户端伪造请求
- 脚本频繁调用更新
- 异常对局(秒退、挂机)却获得高分
会导致:
- 排行榜失真
- 玩家体验破坏
- 运营数据污染
2. 解决方案
方案 | 说明 |
---|---|
1. 对局校验 Token | 每场对局生成唯一 token,更新时必须携带,服务器验证合法性 |
2. 分数变动区间限制 | 如单局最多+200分,防止异常加分 |
3. 行为风控模型 | 结合胜率、时长、操作频率等判断是否作弊 |
4. Redis 记录最近一次更新时间 | 防止短时间重复提交 |
我们这里实现最实用的第4种:基于Redis的更新频率控制 + 分数波动限制
实现:ScoreUpdateLimiter(防刷分限流器)
java
/**
* 防刷分限流器
*/
@Component
public class ScoreUpdateLimiter {
private final RedisTemplate<String, Object> redisTemplate;
private final ValueOperations<String, Object> valueOps;
// 单局最大加分(防止异常高分)
private static final double MAX_SCORE_INCREASE = 200.0;
// 同一玩家两次更新最小间隔(秒)
private static final long MIN_UPDATE_INTERVAL_SECONDS = 60;
public ScoreUpdateLimiter(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.valueOps = redisTemplate.opsForValue();
}
/**
* 校验是否允许更新分数
*
* @param playerId 玩家ID
* @param newScore 新分数
* @return true 表示允许更新
*/
public boolean allowUpdate(String playerId, double newScore) {
String key = "score:update:time:" + playerId; // 上次更新时间
String scoreKey = "player:current:score:" + playerId; // 当前真实分数
// 1. 获取当前分数(用于计算增量)
Double currentScore = (Double) valueOps.get(scoreKey);
if (currentScore == null) {
// 新玩家,允许更新
valueOps.set(scoreKey, newScore, Duration.ofDays(7));
recordUpdateTime(key);
return true;
}
// 2. 检查分数变化是否过大
double diff = newScore - currentScore;
if (diff > MAX_SCORE_INCREASE) {
System.out.println("分数增长过快!玩家:" + playerId + ",增长:" + diff);
return false;
}
// 3. 检查更新频率
Long lastUpdateTime = (Long) valueOps.get(key);
long now = System.currentTimeMillis() / 1000;
if (lastUpdateTime != null && (now - lastUpdateTime) < MIN_UPDATE_INTERVAL_SECONDS) {
System.out.println("更新过于频繁!玩家:" + playerId + ",距上次更新不足 " + MIN_UPDATE_INTERVAL_SECONDS + " 秒");
return false;
}
// 4. 更新当前分数和时间
valueOps.set(scoreKey, newScore, Duration.ofDays(7));
recordUpdateTime(key);
return true;
}
private void recordUpdateTime(String key) {
valueOps.set(key, System.currentTimeMillis() / 1000, Duration.ofSeconds(MIN_UPDATE_INTERVAL_SECONDS + 10));
}
}
3. 在 RankService
中使用它
修改所有 updateXXX
方法,加入校验:
java
@Service
public class RankService {
private final RedisTemplate<String, Object> redisTemplate;
private final ZSetOperations<String, Object> zSetOps;
private final ScoreUpdateLimiter scoreLimiter;
public RankService(RedisTemplate<String, Object> redisTemplate, ScoreUpdateLimiter scoreLimiter) {
this.redisTemplate = redisTemplate;
this.zSetOps = redisTemplate.opsForZSet();
this.scoreLimiter = scoreLimiter;
}
public void updateGlobalScore(String playerId, double score) {
if (!scoreLimiter.allowUpdate(playerId, score)) {
return; // 拒绝更新
}
zSetOps.add("rank:global", playerId, score);
}
}
同样地,其他服务也应加入此校验:
java
// ZoneRankService.java
public void updateZoneRank(String zone, String playerId, double score) {
if (!scoreLimiter.allowUpdate(playerId, score)) {
return;
}
zSetOps.add("rank:zone:" + zone, playerId, score);
}
所有更新入口统一加锁,形成防护网。
总结
本文通过Redis + ZSet
展示了排行榜的核心实现思路,技术原理正确,适合开发者理解。 真实系统需结合消息队列、异步处理、多级缓存、风控机制。
你可以基于它:
- 学习
ZSet
的基本用法 - 加入周榜、月榜时间维度
- 使用异步更新、分片策略
- 接入监控和告警
欢迎大家一起探讨,如有不对的地方,还请大家口下留情。
公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!
📌往期精彩
《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》
《90%的人不知道!Spring官方早已不推荐@Autowired?这3种注入方式你用对了吗?》