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

31次阅读

共计 6935 个字符,预计需要花费 18 分钟才能阅读完成。

更多 Spring 文章,欢迎点击 一灰灰 Blog-Spring 专题

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

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

上面可以说是一个排行榜需要实现的几个基本要素了,正好我们刚讲到了 redis 这一节,本篇则开始实战,详细描述如何借助 redis 来实现一份全球排行榜
<!– more –>
I. 方案设计
在进行方案设计之前,先模拟一个真实的应用场景,然后进行辅助设计与实现
1. 业务场景说明
以前一段时间特别???? 的跳一跳这个小游戏进行说明,假设我们这个游戏用户遍布全球,因此我们要设计一个全球的榜单,每个玩家都会根据自己的战绩在排行榜中获取一个排名,我们需要支持全球榜单的查询,自己排位的查询这两种最基本的查询场景;此外当我的分数比上一次的高时,我需要更新我的积分,重新获得我的排名;
此外也会有一些高级的统计,比如哪个分段的人数最多,什么分段是瓶颈点,再根据地理位置计算平均分等等
本篇博文主要内容将放在排行榜的设计与实现上;至于高级的功能实现,后续有机会再说
2. 数据结构
因为排行榜的功能比较简单了,也不需要什么复杂的结构设计,也没有什么复杂的交互,因此我们需要确认的无非就是数据结构 + 存储单元
存储单元
表示排行榜中每一位上应该持有的信息,一个最简单的如下
// 用来表明具体的用户
long userId;
// 用户在排行榜上的排名
long rank;
// 用户的历史最高积分,也就是排行榜上的积分
long score;
数据结构
排行榜,一般而言都是连续的,借此我们可以联想到一个合适的数据结构 LinkedList,好处在于排名变动时,不需要数组的拷贝

上图演示,当一个用户积分改变时,需要向前遍历找到合适的位置,插入并获取新的排名, 在更新和插入时,相比较于 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);
}
}

接下来我们挑选第 15 名,获取对应的排行榜信息
@GetMapping(path = “/rank”)
public RankDO queryRank(long userId) {
return rankListComponent.getRank(userId);
}
首先我们从 redis 中获取第 15 名的 userId,然后再来查询

然后尝试修改下他的积分,改大一点,将 score 改成 80 分,则会排到第五名
@GetMapping(path = “/update”)
public RankDO updateScore(long userId, float score) {
return rankListComponent.updateRank(userId, score);
}

最后我们查询下这个用户周边 2 个的排名信息
@GetMapping(path = “/around”)
public List<RankDO> around(long userId, int n) {
return rankListComponent.getRankAroundUser(userId, n);
}

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

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

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

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

III. 其他
0. 项目

工程:spring-boot-demo

module 源码: spring-case/120-redis-ranklist

1. 一灰灰 Blog

一灰灰 Blog 个人博客 https://blog.hhui.top

一灰灰 Blog-Spring 专题博客 http://spring.hhui.top

一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛
2. 声明
尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现 bug 或者有更好的建议,欢迎批评指正,不吝感激

微博地址: 小灰灰 Blog

QQ:一灰灰 /3302797840

3. 扫描关注
一灰灰 blog

知识星球

正文完
 0