" />
文章

使用 Spring Boot 和 Redisson 实现 Redis Sorted Set (ZSET) 排行榜

在构建高性能、实时更新的排行榜(如游戏积分榜、用户活跃度排名等)时,Redis 的 Sorted Set(ZSET)数据结构是一个理想的选择。结合 Redisson 作为 Redis 客户端,并在 Spring Boot 环境中集成,可以高效地实现这一功能。本文将详细介绍如何在 Spring Boot 项目中使用 Redisson 操作 Redis ZSET 来实现排行榜。

Redis Sorted Set (ZSET) 简介

Sorted Set(有序集合) 是 Redis 提供的一种数据结构,它结合了集合的唯一性和按分数排序的特性。每个成员(member)在 ZSET 中都是唯一的,并且每个成员都关联一个分数(score),Redis 会根据分数自动为成员排序。

ZSET 的主要操作包括:

  • 添加/更新成员及其分数ZADD key score member
  • 获取成员的排名ZRANK key member(从低到高排序),ZREVRANK key member(从高到低排序)
  • 获取指定范围的成员ZRANGE key start stop [WITHSCORES]
  • 获取排行榜前 N 名ZREVRANGE key 0 N-1 WITHSCORES

使用场景:

  • 游戏积分排行榜
  • 用户活跃度排名
  • 商品评分排名
  • 实时热点分析等

Redisson 简介

Redisson 是一个功能丰富的 Redis 客户端,为 Java 提供了各种分布式数据结构和服务。它不仅封装了 Redis 的基础命令,还提供了高级功能,如分布式锁、布隆过滤器、消息队列等。对于操作 Redis 的 Sorted Set,Redisson 提供了便捷的 API,使得开发者可以更高效地实现排行榜等功能。

Redisson 的主要特点:

  • 简洁的 API:易于使用,降低开发复杂度。
  • 高性能:基于 Netty 的高性能网络通信框架。
  • 丰富的功能:支持多种 Redis 数据结构和分布式服务。
  • Spring Boot 集成:方便与 Spring Boot 项目无缝集成。

项目环境搭建

1. 创建 Spring Boot 项目

使用 Spring Initializr 创建一个新的 Spring Boot 项目,选择以下依赖:

  • Spring Web
  • Spring Data Redis

2. 添加 Redisson 依赖

pom.xml 中添加 Redisson 的依赖:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.6</version> <!-- 请根据最新版本进行调整 -->
</dependency>

注意:请确保使用与 Redis 服务器版本兼容的 Redisson 版本。

配置 Redisson

application.ymlapplication.properties 中配置 Redis 连接信息。以下是使用 application.yml 的示例:

spring:
  redis:
    host: localhost
    port: 6379
    password: yourpassword # 如果有密码的话
    database: 0

Redisson 会自动读取 Spring Boot 的 Redis 配置,无需额外配置。如果需要更复杂的配置(如集群模式),可以通过自定义 Redisson 配置来实现。

排行榜功能实现

假设我们要实现一个简单的用户积分排行榜,功能包括:

  1. 添加或更新用户分数
  2. 获取用户排名
  3. 获取排行榜前 N 名
  4. 获取指定范围内的用户

1. 添加或更新用户分数

使用 ZADD 命令将用户及其分数添加到 Sorted Set 中。如果用户已经存在,则会更新其分数。

import org.redisson.api.RScoredSortedSet;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class LeaderboardService {

    private static final String LEADERBOARD_KEY = "game_leaderboard";

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 添加或更新用户分数
     *
     * @param userId 用户ID
     * @param score  用户分数
     */
    public void addOrUpdateUserScore(String userId, double score) {
        RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
        sortedSet.add(score, userId);
    }
}

2. 获取用户排名

获取用户在排行榜中的排名,注意 Redis 的排名是从 0 开始的。

/**
 * 获取用户排名(从高到低)
 *
 * @param userId 用户ID
 * @return 用户排名(1 表示第一名),如果用户不存在则返回 null
 */
public Long getUserRank(String userId) {
    RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
    Long rank = sortedSet.revRank(userId);
    return rank != null ? rank + 1 : null; // 转换为从1开始的排名
}

3. 获取排行榜前 N 名

获取排行榜中分数最高的前 N 名用户。

import org.redisson.api.ScoredEntry;

import java.util.Set;

/**
 * 获取排行榜前 N 名
 *
 * @param topN 前N名
 * @return 前N名用户及其分数
 */
public Set<ScoredEntry<String>> getTopN(int topN) {
    RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
    return sortedSet.entryRangeReversed(0, topN - 1);
}

4. 获取指定范围内的用户

例如,获取第 10 到第 20 名的用户。

/**
 * 获取指定范围内的用户
 *
 * @param start 起始排名(从1开始)
 * @param end   结束排名(从1开始)
 * @return 指定范围内的用户及其分数
 */
public Set<ScoredEntry<String>> getUsersInRange(int start, int end) {
    RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
    return sortedSet.entryRangeReversed(start - 1, end - 1);
}

完整示例代码

以下是一个完整的 Spring Boot 示例,展示如何使用 Redisson 操作 Redis ZSET 来实现排行榜功能。

1. LeaderboardService.java

package com.example.leaderboard.service;

import org.redisson.api.RScoredSortedSet;
import org.redisson.api.ScoredEntry;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Set;

@Service
public class LeaderboardService {

    private static final String LEADERBOARD_KEY = "game_leaderboard";

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 添加或更新用户分数
     *
     * @param userId 用户ID
     * @param score  用户分数
     */
    public void addOrUpdateUserScore(String userId, double score) {
        RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
        sortedSet.add(score, userId);
    }

    /**
     * 获取用户排名(从高到低)
     *
     * @param userId 用户ID
     * @return 用户排名(1 表示第一名),如果用户不存在则返回 null
     */
    public Long getUserRank(String userId) {
        RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
        Long rank = sortedSet.revRank(userId);
        return rank != null ? rank + 1 : null; // 转换为从1开始的排名
    }

    /**
     * 获取排行榜前 N 名
     *
     * @param topN 前N名
     * @return 前N名用户及其分数
     */
    public Set<ScoredEntry<String>> getTopN(int topN) {
        RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
        return sortedSet.entryRangeReversed(0, topN - 1);
    }

    /**
     * 获取指定范围内的用户
     *
     * @param start 起始排名(从1开始)
     * @param end   结束排名(从1开始)
     * @return 指定范围内的用户及其分数
     */
    public Set<ScoredEntry<String>> getUsersInRange(int start, int end) {
        RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
        return sortedSet.entryRangeReversed(start - 1, end - 1);
    }
}

2. LeaderboardController.java

package com.example.leaderboard.controller;

import com.example.leaderboard.service.LeaderboardService;
import org.redisson.api.ScoredEntry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.Set;

@RestController
@RequestMapping("/leaderboard")
public class LeaderboardController {

    @Autowired
    private LeaderboardService leaderboardService;

    /**
     * 添加或更新用户分数
     */
    @PostMapping("/update")
    public String updateUserScore(@RequestParam String userId, @RequestParam double score) {
        leaderboardService.addOrUpdateUserScore(userId, score);
        return "用户分数已更新";
    }

    /**
     * 获取用户排名
     */
    @GetMapping("/rank/{userId}")
    public String getUserRank(@PathVariable String userId) {
        Long rank = leaderboardService.getUserRank(userId);
        if (rank != null) {
            return "用户 " + userId + " 的排名是第 " + rank + " 名";
        } else {
            return "用户未上榜";
        }
    }

    /**
     * 获取排行榜前 N 名
     */
    @GetMapping("/top/{n}")
    public String getTopN(@PathVariable int n) {
        Set<ScoredEntry<String>> topN = leaderboardService.getTopN(n);
        StringBuilder sb = new StringBuilder();
        int rank = 1;
        for (ScoredEntry<String> entry : topN) {
            sb.append(rank++)
              .append(". 用户ID: ")
              .append(entry.getValue())
              .append(", 分数: ")
              .append(entry.getScore())
              .append("<br>");
        }
        return sb.toString();
    }

    /**
     * 获取指定范围内的用户
     */
    @GetMapping("/range")
    public String getUsersInRange(@RequestParam int start, @RequestParam int end) {
        Set<ScoredEntry<String>> range = leaderboardService.getUsersInRange(start, end);
        StringBuilder sb = new StringBuilder();
        int rank = start;
        for (ScoredEntry<String> entry : range) {
            sb.append(rank++)
              .append(". 用户ID: ")
              .append(entry.getValue())
              .append(", 分数: ")
              .append(entry.getScore())
              .append("<br>");
        }
        return sb.toString();
    }
}

3. RedissonConfig.java(可选)

如果需要自定义 Redisson 配置,如连接池、集群模式等,可以创建一个配置类:

package com.example.leaderboard.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    @Bean(destroyMethod = "shutdown")
    public RedissonClient redissonClient() {
        Config config = new Config();
        // 单节点模式
        config.useSingleServer()
              .setAddress("redis://localhost:6379")
              .setPassword("yourpassword"); // 如果有密码的话

        // 集群模式示例
        /*
        config.useClusterServers()
              .addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001")
              .setPassword("yourpassword");
        */

        return Redisson.create(config);
    }
}

注意:如果使用 redisson-spring-boot-starter,Redisson 会自动配置,通常无需手动创建 RedissonClient Bean。上述配置类仅供需要自定义配置时参考。

最佳实践与注意事项

  1. 排行榜 Key 的设计:使用有意义的 Key 名称,如 game_leaderboard,避免与其他业务数据混淆。
  2. 分数的数据类型:Redis 的 ZSET 分数是 double 类型,可以精确到小数点后多位,确保分数的精确性。
  3. 分布式环境下的 Redisson
    • 确保 Redisson 的配置与 Redis 集群或主从架构相匹配。
    • 使用连接池和合理的超时设置,提升性能和稳定性。
  4. 性能优化
    • 对于高并发的更新操作,Redisson 已经做了优化,但仍需根据实际负载调整 Redis 服务器的配置。
    • 使用管道(Pipeline)或批量操作来减少网络延迟。
  5. 异常处理:在生产环境中,确保对 Redis 连接异常、操作失败等情况进行妥善处理,避免影响整个应用的稳定性。
  6. 数据持久化:配置 Redis 的持久化策略(RDB 或 AOF),确保数据在 Redis 重启或故障后能够恢复。
  7. 安全性
    • 设置强密码,防止未授权访问。
    • 使用防火墙或其他安全措施,限制 Redis 的访问范围。

完整示例代码

以下是一个完整的 Spring Boot 项目示例,包含了上述所有内容。

1. pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>leaderboard</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>leaderboard</name>
    <description>Redis ZSET Leaderboard Example</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.5</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <java.version>11</java.version>
        <redisson.version>3.23.6</redisson.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring Data Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- Redisson Spring Boot Starter -->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>${redisson.version}</version>
        </dependency>

        <!-- Lombok (可选,用于简化代码) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- Spring Boot Test (可选,用于测试) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- Maven Compiler Plugin -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                            <version>1.18.24</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

2. application.yml

spring:
  redis:
    host: localhost
    port: 6379
    password: yourpassword # 如果有密码的话
    database: 0

3. RedissonConfig.java(可选)

package com.example.leaderboard.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    @Bean(destroyMethod = "shutdown")
    public RedissonClient redissonClient() {
        Config config = new Config();
        // 单节点模式
        config.useSingleServer()
              .setAddress("redis://localhost:6379")
              .setPassword("yourpassword"); // 如果有密码的话

        // 集群模式示例
        /*
        config.useClusterServers()
              .addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001")
              .setPassword("yourpassword");
        */

        return Redisson.create(config);
    }
}

4. LeaderboardService.java

package com.example.leaderboard.service;

import org.redisson.api.RScoredSortedSet;
import org.redisson.api.ScoredEntry;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Set;

@Service
public class LeaderboardService {

    private static final String LEADERBOARD_KEY = "game_leaderboard";

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 添加或更新用户分数
     *
     * @param userId 用户ID
     * @param score  用户分数
     */
    public void addOrUpdateUserScore(String userId, double score) {
        RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
        sortedSet.add(score, userId);
    }

    /**
     * 获取用户排名(从高到低)
     *
     * @param userId 用户ID
     * @return 用户排名(1 表示第一名),如果用户不存在则返回 null
     */
    public Long getUserRank(String userId) {
        RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
        Long rank = sortedSet.revRank(userId);
        return rank != null ? rank + 1 : null; // 转换为从1开始的排名
    }

    /**
     * 获取排行榜前 N 名
     *
     * @param topN 前N名
     * @return 前N名用户及其分数
     */
    public Set<ScoredEntry<String>> getTopN(int topN) {
        RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
        return sortedSet.entryRangeReversed(0, topN - 1);
    }

    /**
     * 获取指定范围内的用户
     *
     * @param start 起始排名(从1开始)
     * @param end   结束排名(从1开始)
     * @return 指定范围内的用户及其分数
     */
    public Set<ScoredEntry<String>> getUsersInRange(int start, int end) {
        RScoredSortedSet<String> sortedSet = redissonClient.getScoredSortedSet(LEADERBOARD_KEY);
        return sortedSet.entryRangeReversed(start - 1, end - 1);
    }
}

5. LeaderboardController.java

package com.example.leaderboard.controller;

import com.example.leaderboard.service.LeaderboardService;
import org.redisson.api.ScoredEntry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.Set;

@RestController
@RequestMapping("/leaderboard")
public class LeaderboardController {

    @Autowired
    private LeaderboardService leaderboardService;

    /**
     * 添加或更新用户分数
     */
    @PostMapping("/update")
    public String updateUserScore(@RequestParam String userId, @RequestParam double score) {
        leaderboardService.addOrUpdateUserScore(userId, score);
        return "用户分数已更新";
    }

    /**
     * 获取用户排名
     */
    @GetMapping("/rank/{userId}")
    public String getUserRank(@PathVariable String userId) {
        Long rank = leaderboardService.getUserRank(userId);
        if (rank != null) {
            return "用户 " + userId + " 的排名是第 " + rank + " 名";
        } else {
            return "用户未上榜";
        }
    }

    /**
     * 获取排行榜前 N 名
     */
    @GetMapping("/top/{n}")
    public String getTopN(@PathVariable int n) {
        Set<ScoredEntry<String>> topN = leaderboardService.getTopN(n);
        StringBuilder sb = new StringBuilder();
        int rank = 1;
        for (ScoredEntry<String> entry : topN) {
            sb.append(rank++)
              .append(". 用户ID: ")
              .append(entry.getValue())
              .append(", 分数: ")
              .append(entry.getScore())
              .append("<br>");
        }
        return sb.toString();
    }

    /**
     * 获取指定范围内的用户
     */
    @GetMapping("/range")
    public String getUsersInRange(@RequestParam int start, @RequestParam int end) {
        Set<ScoredEntry<String>> range = leaderboardService.getUsersInRange(start, end);
        StringBuilder sb = new StringBuilder();
        int rank = start;
        for (ScoredEntry<String> entry : range) {
            sb.append(rank++)
              .append(". 用户ID: ")
              .append(entry.getValue())
              .append(", 分数: ")
              .append(entry.getScore())
              .append("<br>");
        }
        return sb.toString();
    }
}

6. LeaderboardApplication.java

package com.example.leaderboard;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LeaderboardApplication {

    public static void main(String[] args) {
        SpringApplication.run(LeaderboardApplication.class, args);
    }
}

测试接口

启动 Spring Boot 应用后,可以通过以下方式测试排行榜功能:

1. 添加或更新用户分数

请求:

POST /leaderboard/update?userId=user1&score=1500

响应:

用户分数已更新

2. 获取用户排名

请求:

GET /leaderboard/rank/user1

响应:

用户 user1 的排名是第 1 名

3. 获取排行榜前 N 名

请求:

GET /leaderboard/top/10

响应:

1. 用户ID: user1, 分数: 1500.0<br>
2. 用户ID: user2, 分数: 1200.0<br>
...

4. 获取指定范围内的用户

请求:

GET /leaderboard/range?start=5&end=15

响应:

5. 用户ID: user5, 分数: 900.0<br>
6. 用户ID: user6, 分数: 850.0<br>
...
15. 用户ID: user15, 分数: 500.0<br>

最佳实践与注意事项

  1. 分数更新策略
    • 确保分数的更新是幂等的,避免重复添加导致排名混乱。
    • 根据业务需求选择合适的分数类型(如积分、时间戳等)。
  2. 数据持久化
    • 配置 Redis 的持久化机制(RDB 或 AOF),确保排行榜数据在 Redis 重启后不丢失。
    • 定期备份 Redis 数据,防止意外数据丢失。
  3. 性能优化
    • 对于高并发的更新和查询操作,确保 Redis 服务器性能足够。
    • 使用集群模式扩展 Redis,提升吞吐量和可用性。
    • 利用 Redisson 的连接池和线程池配置,优化网络资源使用。
  4. 安全性
    • 设置强密码,防止未授权访问 Redis。
    • 使用防火墙或安全组限制 Redis 的访问范围,仅允许应用服务器访问。
  5. 错误处理
    • 在服务中加入 Redis 操作的异常处理机制,保证在 Redis 连接失败或操作异常时,能够优雅降级或重试。
  6. 监控与报警
    • 监控 Redis 的性能指标(如内存使用、命中率、延迟等)。
    • 配置 Redis 相关的监控工具(如 Redis Exporter 配合 Prometheus),及时发现和处理问题。

总结

通过结合 Spring Boot 和 Redisson,可以高效地利用 Redis 的 Sorted Set(ZSET)数据结构,实现实时更新、快速查询的排行榜功能。Redisson 提供了简洁且功能强大的 API,降低了开发复杂度,同时保证了操作的高性能和可靠性。本文介绍了从项目搭建、配置 Redisson 到具体实现排行榜功能的各个步骤,希望能够帮助你快速上手并应用于实际项目中。

在实际生产环境中,根据具体业务需求,还可以进一步扩展排行榜功能,如支持多维度排序、分区域排行榜、缓存优化等。结合 Redis 的高性能和 Redisson 的丰富功能,能够满足大多数实时排行榜的需求。

License:  CC BY 4.0