SpringBoot应用篇之借助Redis实现排行榜功能

简介: 上面可以说是一个排行榜需要实现的几个基本要素了,正好我们刚讲到了redis这一节,本篇则开始实战,详细描述如何借助redis来实现一份全球排行榜

在一些游戏和活动中,当涉及到社交元素的时候,排行榜可以说是一个很常见的需求场景了,就我们通常见到的排行榜而言,会提供以下基本功能


  • 全球榜单,对所有用户根据积分进行排名,并在榜单上展示前多少
  • 个人排名,用户查询自己所在榜单的位置,并获知周边小伙伴的积分,方便自己比较和超越
  • 实时更新,用户的积分实时更改,榜单也需要实时更新


上面可以说是一个排行榜需要实现的几个基本要素了,正好我们刚讲到了redis这一节,本篇则开始实战,详细描述如何借助redis来实现一份全球排行榜


I. 方案设计



在进行方案设计之前,先模拟一个真实的应用场景,然后进行辅助设计与实现


1. 业务场景说明


以前一段时间特别🔥的跳一跳这个小游戏进行说明,假设我们这个游戏用户遍布全球,因此我们要设计一个全球的榜单,每个玩家都会根据自己的战绩在排行榜中获取一个排名,我们需要支持全球榜单的查询,自己排位的查询这两种最基本的查询场景;此外当我的分数比上一次的高时,我需要更新我的积分,重新获得我的排名;

此外也会有一些高级的统计,比如哪个分段的人数最多,什么分段是瓶颈点,再根据地理位置计算平均分等等


本篇博文主要内容将放在排行榜的设计与实现上;至于高级的功能实现,后续有机会再说


2. 数据结构


因为排行榜的功能比较简单了,也不需要什么复杂的结构设计,也没有什么复杂的交互,因此我们需要确认的无非就是数据结构 + 存储单元


存储单元


表示排行榜中每一位上应该持有的信息,一个最简单的如下

// 用来表明具体的用户
long userId;
// 用户在排行榜上的排名
long rank;
// 用户的历史最高积分,也就是排行榜上的积分
long score;
复制代码


数据结构


排行榜,一般而言都是连续的,借此我们可以联想到一个合适的数据结构LinkedList,好处在于排名变动时,不需要数组的拷贝


image.png

上图演示,当一个用户积分改变时,需要向前遍历找到合适的位置,插入并获取新的排名, 在更新和插入时,相比较于ArrayList要好很多,但依然有以下几个缺陷


问题1:用户如何获取自己的排名?


使用LinkedList在更新插入和删除的带来优势之外,在随机获取元素的支持会差一点,最差的情况就是从头到尾进行扫描


问题2:并发支持的问题?


当有多个用户同时更新score时,并发的更新排名问题就比较突出了,当然可以使用jdk中类似写时拷贝数组的方案


上面是我们自己来实现这个数据结构时,会遇到的一些问题,当然我们的主题是借助redis来实现排行榜,下面则来看下,利用redis可以怎么简单的支持我们的需求场景


3. redis使用方案


这里主要使用的是redis的ZSET数据结构,带权重的集合,下面分析一下可能性


  • set: 集合确保里面元素的唯一性
  • 权重:这个可以看做我们的score,这样每个元素都有一个score;
  • zset:根据score进行排序的集合


从zset的特性来看,我们每个用户的积分,丢到zset中,就是一个带权重的元素,而且是已经排好序的了,只需要获取元素对应的index,就是我们预期的排名


II. 功能实现



再具体的实现之前,可以先查看一下redis中zset的相关方法和操作姿势:SpringBoot高级篇Redis之ZSet数据结构使用姿势


我们主要是借助zset提供的一些方法来实现排行榜的需求,下面的具体方法设计中,也会有相关说明


0. 前提准备


首先准备好redis环境,spring项目搭建好,然后配置好redisTemplate

/**
 * Created by @author yihui in 15:05 18/11/8.
 */
public class DefaultSerializer implements RedisSerializer<Object> {
    private final Charset charset;
    public DefaultSerializer() {
        this(Charset.forName("UTF8"));
    }
    public DefaultSerializer(Charset charset) {
        Assert.notNull(charset, "Charset must not be null!");
        this.charset = charset;
    }
    @Override
    public byte[] serialize(Object o) throws SerializationException {
        return o == null ? null : String.valueOf(o).getBytes(charset);
    }
    @Override
    public Object deserialize(byte[] bytes) throws SerializationException {
        return bytes == null ? null : new String(bytes, charset);
    }
}
@Configuration
public class AutoConfig {
    @Bean(value = "selfRedisTemplate")
    public RedisTemplate<String, String> stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        StringRedisTemplate redis = new StringRedisTemplate();
        redis.setConnectionFactory(redisConnectionFactory);
        // 设置redis的String/Value的默认序列化方式
        DefaultSerializer stringRedisSerializer = new DefaultSerializer();
        redis.setKeySerializer(stringRedisSerializer);
        redis.setValueSerializer(stringRedisSerializer);
        redis.setHashKeySerializer(stringRedisSerializer);
        redis.setHashValueSerializer(stringRedisSerializer);
        redis.afterPropertiesSet();
        return redis;
    }
}
复制代码


1. 用户上传积分


上传用户积分,然而zset中有一点需要注意的是其排行是根据score进行升序排列,这个就和我们实际的情况不太一样了;为了和实际情况一致,可以将score取反;另外一个就是排行默认是从0开始的,这个与我们的实际也不太一样,需要+1


/**
 * 更新用户积分,并获取最新的个人所在排行榜信息
 *
 * @param userId
 * @param score
 * @return
 */
public RankDO updateRank(Long userId, Float score) {
    // 因为zset默认积分小的在前面,所以我们对score进行取反,这样用户的积分越大,对应的score越小,排名越高
    redisComponent.add(RANK_PREFIX, String.valueOf(userId), -score);
    Long rank = redisComponent.rank(RANK_PREFIX, String.valueOf(userId));
    return new RankDO(rank + 1, score, userId);
}
复制代码


上面的实现,主要利用了zset的两个方法,一个是添加元素,一个是查询排名,对应的redis操作方法如下,


@Resource(name = "selfRedisTemplate")
private StringRedisTemplate redisTemplate;
/**
 * 添加一个元素, zset与set最大的区别就是每个元素都有一个score,因此有个排序的辅助功能;  zadd
 *
 * @param key
 * @param value
 * @param score
 */
public void add(String key, String value, double score) {
    redisTemplate.opsForZSet().add(key, value, score);
}
    /**
 * 判断value在zset中的排名  zrank
 *
 * 积分小的在前面
 *
 * @param key
 * @param value
 * @return
 */
public Long rank(String key, String value) {
    return redisTemplate.opsForZSet().rank(key, value);
}
复制代码


2. 获取个人排名


获取个人排行信息,主要就是两个一个是排名一个是积分;需要注意的是当用户没有积分时(即没有上榜时),需要额外处理


/**
 * 获取用户的排行榜位置
 *
 * @param userId
 * @return
 */
public RankDO getRank(Long userId) {
    // 获取排行, 因为默认是0为开头,因此实际的排名需要+1
    Long rank = redisComponent.rank(RANK_PREFIX, String.valueOf(userId));
    if (rank == null) {
        // 没有排行时,直接返回一个默认的
        return new RankDO(-1L, 0F, userId);
    }
    // 获取积分
    Double score = redisComponent.score(RANK_PREFIX, String.valueOf(userId));
    return new RankDO(rank + 1, Math.abs(score.floatValue()), userId);
}
复制代码


上面的封装中,除了使用前面的获取用户排名之外,还有获取用户积分

/**
 * 查询value对应的score   zscore
 *
 * @param key
 * @param value
 * @return
 */
public Double score(String key, String value) {
    return redisTemplate.opsForZSet().score(key, value);
}
复制代码


3. 获取个人周边用户积分及排行信息


有了前面的基础之后,这个就比较简单了,首先获取用户的个人排名,然后查询固定排名段的数据即可


private List<RankDO> buildRedisRankToBizDO(Set<ZSetOperations.TypedTuple<String>> result, long offset) {
    List<RankDO> rankList = new ArrayList<>(result.size());
    long rank = offset;
    for (ZSetOperations.TypedTuple<String> sub : result) {
        rankList.add(new RankDO(rank++, Math.abs(sub.getScore().floatValue()), Long.parseLong(sub.getValue())));
    }
    return rankList;
}
/**
 * 获取用户所在排行榜的位置,以及排行榜中其前后n个用户的排行信息
 *
 * @param userId
 * @param n
 * @return
 */
public List<RankDO> getRankAroundUser(Long userId, int n) {
    // 首先是获取用户对应的排名
    RankDO rank = getRank(userId);
    if (rank.getRank() <= 0) {
        // fixme 用户没有上榜时,不返回
        return Collections.emptyList();
    }
    // 因为实际的排名是从0开始的,所以查询周边排名时,需要将n-1
    Set<ZSetOperations.TypedTuple<String>> result =
            redisComponent.rangeWithScore(RANK_PREFIX, Math.max(0, rank.getRank() - n - 1), rank.getRank() + n - 1);
    return buildRedisRankToBizDO(result, rank.getRank() - n);
}
复制代码


看下上面的实现,获取用户排名之后,就可以计算要查询的排名范围[Math.max(0, rank.getRank() - n - 1), rank.getRank() + n - 1]


其次需要注意的如何将返回的结果进行封装,上面写了个转换类,主要起始排行榜信息


4. 获取topn排行榜


上面的理解之后,这个就很简答了

/**
 * 获取前n名的排行榜数据
 *
 * @param n
 * @return
 */
public List<RankDO> getTopNRanks(int n) {
    Set<ZSetOperations.TypedTuple<String>> result = redisComponent.rangeWithScore(RANK_PREFIX, 0, n - 1);
    return buildRedisRankToBizDO(result, 1);
}
复制代码


III. 测试小结



首先准备一个测试脚本,批量的插入一下积分,用于后续的查询更新使用

public class RankInitTest {
    private Random random;
    private RestTemplate restTemplate;
    @Before
    public void init() {
        random = new Random();
        restTemplate = new RestTemplate();
    }
    private int genUserId() {
        return random.nextInt(1024);
    }
    private double genScore() {
        return random.nextDouble() * 100;
    }
    @Test
    public void testInitRank() {
        for (int i = 0; i < 30; i++) {
            restTemplate.getForObject("http://localhost:8080/update?userId=" + genUserId() + "&score=" + genScore(),
                    String.class);
        }
    }
}
复制代码


1. 测试


上面执行完毕之后,排行榜中应该就有三十条数据,接下来我们开始逐个接口测试,首先获取top10排行


对应的rest接口如下

@RestController
public class RankAction {
    @Autowired
    private RankListComponent rankListComponent;
    @GetMapping(path = "/topn")
    public List<RankDO> showTopN(int n) {
        return rankListComponent.getTopNRanks(n);
    }
}
复制代码

image.png

接下来我们挑选第15名,获取对应的排行榜信息

@GetMapping(path = "/rank")
public RankDO queryRank(long userId) {
    return rankListComponent.getRank(userId);
}
复制代码

首先我们从redis中获取第15名的userId,然后再来查询

image.png


然后尝试修改下他的积分,改大一点,将score改成80分,则会排到第五名

@GetMapping(path = "/update")
public RankDO updateScore(long userId, float score) {
    return rankListComponent.updateRank(userId, score);
}
复制代码

image.png


最后我们查询下这个用户周边2个的排名信息

@GetMapping(path = "/around")
public List<RankDO> around(long userId, int n) {
    return rankListComponent.getRankAroundUser(userId, n);
}
复制代码

image.png


2. 小结


上面利用redis的zset实现了排行榜的基本功能,主要借助下面三个方法


  • range 获取范围排行信息
  • score 获取对应的score
  • range 获取对应的排名


虽然实现了基本功能,但是问题还是有不少的


  • 上面的实现,redis的复合操作,原子性问题
  • 由原子性问题导致并发安全问题
  • 性能怎么样需要测试



相关文章
|
8月前
|
NoSQL Java 网络安全
SpringBoot启动时连接Redis报错:ERR This instance has cluster support disabled - 如何解决?
通过以上步骤一般可以解决由于配置不匹配造成的连接错误。在调试问题时,一定要确保服务端和客户端的Redis配置保持同步一致。这能够确保SpringBoot应用顺利连接到正确配置的Redis服务,无论是单机模式还是集群模式。
680 5
|
9月前
|
NoSQL Java 调度
分布式锁与分布式锁使用 Redis 和 Spring Boot 进行调度锁(不带 ShedLock)
分布式锁是分布式系统中用于同步多节点访问共享资源的机制,防止并发操作带来的冲突。本文介绍了基于Spring Boot和Redis实现分布式锁的技术方案,涵盖锁的获取与释放、Redis配置、服务调度及多实例运行等内容,通过Docker Compose搭建环境,验证了锁的有效性与互斥特性。
794 0
分布式锁与分布式锁使用 Redis 和 Spring Boot 进行调度锁(不带 ShedLock)
|
8月前
|
XML Java 应用服务中间件
【SpringBoot(一)】Spring的认知、容器功能讲解与自动装配原理的入门,带你熟悉Springboot中基本的注解使用
SpringBoot专栏开篇第一章,讲述认识SpringBoot、Bean容器功能的讲解、自动装配原理的入门,还有其他常用的Springboot注解!如果想要了解SpringBoot,那么就进来看看吧!
742 2
|
11月前
|
缓存 前端开发 Java
SpringBoot 实现动态菜单功能完整指南
本文介绍了一个动态菜单系统的实现方案,涵盖数据库设计、SpringBoot后端实现、Vue前端展示及权限控制等内容,适用于中后台系统的权限管理。
1145 1
|
10月前
|
存储 NoSQL Redis
采用Redis的Bitmaps实现类似Github连续提交状态的功能。
在现实世界的应用开发中,实现类似于Github提交跟踪系统时,还可能需要考虑用户时区、闰年等日期相关的边界条件,以及辅助数据的存储和查询优化,例如对活跃用户的即时查询和统计等。不过这些都可以在Bitmaps的基础功能之上通过额外的代码逻辑来实现。
213 0
|
机器学习/深度学习 数据采集 人机交互
springboot+redis互联网医院智能导诊系统源码,基于医疗大模型、知识图谱、人机交互方式实现
智能导诊系统基于医疗大模型、知识图谱与人机交互技术,解决患者“知症不知病”“挂错号”等问题。通过多模态交互(语音、文字、图片等)收集病情信息,结合医学知识图谱和深度推理,实现精准的科室推荐和分级诊疗引导。系统支持基于规则模板和数据模型两种开发原理:前者依赖人工设定症状-科室规则,后者通过机器学习或深度学习分析问诊数据。其特点包括快速病情收集、智能病症关联推理、最佳就医推荐、分级导流以及与院内平台联动,提升患者就诊效率和服务体验。技术架构采用 SpringBoot+Redis+MyBatis Plus+MySQL+RocketMQ,确保高效稳定运行。
836 0
|
监控 安全 Java
Java 开发中基于 Spring Boot 3.2 框架集成 MQTT 5.0 协议实现消息推送与订阅功能的技术方案解析
本文介绍基于Spring Boot 3.2集成MQTT 5.0的消息推送与订阅技术方案,涵盖核心技术栈选型(Spring Boot、Eclipse Paho、HiveMQ)、项目搭建与配置、消息发布与订阅服务实现,以及在智能家居控制系统中的应用实例。同时,详细探讨了安全增强(TLS/SSL)、性能优化(异步处理与背压控制)、测试监控及生产环境部署方案,为构建高可用、高性能的消息通信系统提供全面指导。附资源下载链接:[https://pan.quark.cn/s/14fcf913bae6](https://pan.quark.cn/s/14fcf913bae6)。
2494 0
|
前端开发 JavaScript Java
SpringBoot实现国际化i18n功能
SpringBoot实现国际化i18n功能
1424 0
SpringBoot实现国际化i18n功能
|
8月前
|
JavaScript Java 关系型数据库
基于springboot的项目管理系统
本文探讨项目管理系统在现代企业中的应用与实现,分析其研究背景、意义及现状,阐述基于SSM、Java、MySQL和Vue等技术构建系统的关键方法,展现其在提升管理效率、协同水平与风险管控方面的价值。