王者段位排行榜如何实现?Redis有序集合实战

前言

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

但你有没有想过: 这个排行榜是怎么实现的? 为什么能实时看到自己的位置? 为什么成千上万的玩家数据能快速排序?

要知道,王者荣耀有上亿玩家。

如果每次打开都要全量排序,服务器早就崩了。

解决方案?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种注入方式你用对了吗?》

《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》

《终于找到 Axios 最优雅的封装方式了,再也不用写重复代码了》

相关推荐
墨雪遗痕几秒前
工程架构认知(二):从 CDN 到 Keep-Alive,理解流量如何被“消化”在系统之外
java·spring·架构
摆烂工程师4 分钟前
教你如何查询 Codex 最新额度是多少,以及 ChatGPT Pro、Plus、Business 最新额度变化
前端·后端·ai编程
用户66885998476614 分钟前
Sprint Boot登录案例
java
任聪聪16 分钟前
我做了一款通用本地化部署模型运行调度器,运行所有大模型!
后端
开发者如是说25 分钟前
可能是最好用的多语言管理工具
android·前端·后端
小兜全糖(xdqt)34 分钟前
Ubuntu22.04安装最新版本redis
数据库·redis·缓存
Ivanqhz35 分钟前
LLVM IR 转 SMT公式
java·开发语言
一个心烑40 分钟前
奖项届定获取方式
java
小红的布丁40 分钟前
Reactor 模型详解:单 Reactor、主从 Reactor 与 Netty 思想
android·java·开发语言
weixin_704266051 小时前
redis 的集群
java·数据库·redis