【DB系列】借助Redis实现排行榜功能(应用篇)

文章目录
  1. I. 方案设计
    1. 1. 业务场景说明
    2. 2. 数据结构
    3. 3. redis使用方案
  2. II. 功能实现
    1. 0. 前提准备
    2. 1. 用户上传积分
    3. 2. 获取个人排名
    4. 3. 获取个人周边用户积分及排行信息
    5. 4. 获取topn排行榜
  3. III. 测试小结
    1. 1. 测试
    2. 2. 小结
  4. III. 其他
    1. 0. 项目
    2. 1. 一灰灰Blog
    3. 2. 声明
    4. 3. 扫描关注

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

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

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

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

I. 方案设计

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

1. 业务场景说明

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

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

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

2. 数据结构

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

存储单元

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

1
2
3
4
5
6
// 用来表明具体的用户
long userId;
// 用户在排行榜上的排名
long rank;
// 用户的历史最高积分,也就是排行榜上的积分
long score;

数据结构

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

arch

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* 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

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 更新用户积分,并获取最新的个人所在排行榜信息
*
* @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操作方法如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@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. 获取个人排名

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 获取用户的排行榜位置
*
* @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);
}

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

1
2
3
4
5
6
7
8
9
10
/**
* 查询value对应的score zscore
*
* @param key
* @param value
* @return
*/
public Double score(String key, String value) {
return redisTemplate.opsForZSet().score(key, value);
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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排行榜

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

1
2
3
4
5
6
7
8
9
10
/**
* 获取前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. 测试小结

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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接口如下

1
2
3
4
5
6
7
8
9
10
@RestController
public class RankAction {
@Autowired
private RankListComponent rankListComponent;

@GetMapping(path = "/topn")
public List<RankDO> showTopN(int n) {
return rankListComponent.getTopNRanks(n);
}
}

topn

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

1
2
3
4
@GetMapping(path = "/rank")
public RankDO queryRank(long userId) {
return rankListComponent.getRank(userId);
}

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

rank

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

1
2
3
4
@GetMapping(path = "/update")
public RankDO updateScore(long userId, float score) {
return rankListComponent.updateRank(userId, score);
}

update

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

1
2
3
4
@GetMapping(path = "/around")
public List<RankDO> around(long userId, int n) {
return rankListComponent.getRankAroundUser(userId, n);
}

around

2. 小结

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

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

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

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

III. 其他

0. 项目

1. 一灰灰Blog

一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

2. 声明

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

3. 扫描关注

一灰灰blog

QrCode

知识星球

goals


打赏 如果觉得我的文章对您有帮助,请随意打赏。
分享到