王者段位排行榜如何实现?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 最优雅的封装方式了,再也不用写重复代码了》

相关推荐
豌豆花下猫2 小时前
Python 潮流周刊#120:新型 Python 类型检查器对比(摘要)
后端·python·ai
南方者2 小时前
当小学生的手写体也能识别出来,PP-OCRv5 稳了!
后端·图像识别
RoyLin3 小时前
TypeScript设计模式:解释器模式
前端·后端·typescript
易元4 小时前
模式组合应用-享元模式
后端·设计模式
对象存储与RustFS4 小时前
零基础小白手把手教程:用Docker和MinIO打造专属私有图床,并完美搭配PicGo
后端
德育处主任4 小时前
文字识别:辛辛苦苦练模型,不如调用PP-OCRv5
后端·图像识别
TeamDev4 小时前
用一个 prompt 搭建带 React 界面的 Java 桌面应用
java·前端·后端
知其然亦知其所以然4 小时前
国产大模型也能无缝接入!Spring AI + 智谱 AI 实战指南
java·后端·算法