使用Redis高效率查寻游戏排行榜数据
前言
年前公司有很多活动要进行定制开发,活动中有游戏可以玩,最后对每个人的游戏分数进行排行展示,最终根据排名发放奖品。乍一看需求确实很简单,直接order by score
一下不就完事了?需求确实简单,但是有不少小坑,故在此记录一下。
需求
- 排行榜展示前100名最佳分数排行榜
- 如果当前登录人在100名之后,则展示内容有两项
- 前100名最佳分数排行榜
- 当前登录人排名以及前后两个用户的排名
数据库表设计
- user_id:用户ID
- user_nickname:用户昵称
- score:分数
- avatar_image_path:用户头像
- user_type:用户类型
一、数据库查询 - 先分组,后排序
因为用户可以玩多次游戏,所以表中同一个用户会有对此游戏分数记录。
- 查寻当前登录人的最佳分数
SELECT
user_id,
user_nickname,
score,
avatar_image_path,
user_type
FROM
game_score_record
WHERE
`user_id` = #{userId}
ORDER BY
`score` DESC
id ASC
LIMIT 1
- 查寻当前登录人的最佳排名
SELECT count(*)
FROM (
SELECT id, user_id, max( score ) AS score
FROM game_score_record
GROUP BY user_id
HAVING score > #{myBestScore}
-- 这里是判断若有和登录人相同分数的用户,前一名是先行记录的用户
OR CASE WHEN score = #{myBestScore} THEN (score = #{myBestScore} AND id < #{myBestId}) else '' end
) AS fo
- 查询前100个用户最佳分数排行榜
SELECT
user_id,
user_nickname,
max( score ) AS score,
avatar_image_path,
user_type
FROM
game_score_record
GROUP BY
user_id
ORDER BY
score DESC,
id ASC
LIMIT 100;
order by score是由分数从高到低进行排序;order by id是当相同分数时,最先记录的排在前面
- 获取前一名以及后一名排名信息
-- 前一名信息
(SELECT
id,
user_id,
max( score ) AS score,
user_nickname,
avatar_image_path,
user_type
FROM
game_score_record
GROUP BY
user_id
HAVING
score > #{myBestScore}
-- 这里是判断若有和登录人相同分数的用户,前一名是先行记录的用户
OR CASE WHEN score = #{myBestScore} THEN (score = #{myBestScore} AND id < #{myBestId}) else '' end
ORDER BY
score ASC,
id DESC
LIMIT 1)
UNION ALL
-- 后一名信息
(SELECT
id,
user_id,
max( score ) AS score,
user_nickname,
avatar_image_path,
user_type
FROM
game_score_record
GROUP BY
user_id
HAVING
score < #{myBestScore}
OR CASE WHEN score = #{myBestScore} THEN (score = #{myBestScore} AND id > #{myBestId}) else '' end
ORDER BY
score DESC,
id ASC
LIMIT 1)
数据库查询为了满足需求,sql之复杂,且效率极低。当我跑入百万级数据量时,上述所有查寻耗时均超过了5s,很明显不可取的技术方案
二、Redis强势介入
当发现数据量上去之后,SQL查寻非常之缓慢,便准备转为将数据存入Redis缓存中查询
温习一下Redis五大数据结构
1. string
- 介绍 :string 数据结构是简单的 key-value 类型。虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(simple dynamic string,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。
- 常用命令:
set,get,strlen,exists,dect,incr,setex
等等。 - 应用场景 :一般常用在需要计数的场景,比如用户的访问次数、热点文章的点赞转发数量等等。
2. list
- 介绍 :list 即是 链表。链表是一种非常常见的数据结构,特点是易于数据元素的插入和删除并且且可以灵活调整链表长度,但是链表的随机访问困难。许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 list 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。
- 常用命令:
rpush,lpop,lpush,rpop,lrange、llen
等。 - 应用场景: 发布与订阅或者说消息队列、慢查询。
3. hash
- 介绍 :hash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。不过,Redis 的 hash 做了更多优化。另外,hash 是一个 string 类型的 field 和 value 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。 比如我们可以 hash 数据结构来存储用户信息,商品信息等等。
- 常用命令:
hset,hmset,hexists,hget,hgetall,hkeys,hvals
等。 - 应用场景: 系统中对象数据的存储。
4. set
- 介绍 : set 类似于 Java 中的
HashSet
。Redis 中的 set 类型是一种无序集合,集合中的元素没有先后顺序。当你需要存储一个列表数据,又不希望出现重复数据时,set 是一个很好的选择,并且 set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 所不能提供的。可以基于 set 轻易实现交集、并集、差集的操作。比如:你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。 - 常用命令:
sadd,spop,smembers,sismember,scard,sinterstore,sunion
等。 - 应用场景: 需要存放的数据不能重复以及需要获取多个数据源交集和并集等场景
5. sorted set
- 介绍: 和 set 相比,sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。
- 常用命令:
zadd,zcard,zscore,zrange,zrevrange,zrem
等。 - 应用场景: 需要对数据根据某个权重进行排序的场景。比如在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息。
以上介绍信息来源于:https://github.com/captainkun/JavaGuide/blob/master/docs/database/Redis/redis-all.md
- 结构图
采用Redis的sorted set数据结构存储排行榜数据
初始化数据到redis中
// 获取BoundZSetOperations,后续对元素的增删改查都是操作该对象
String redisKey = "gameRank";
BoundZSetOperations<String, Object> bzo = redisTemplate.boundZSetOps(redisKey);
Set<ZSetOperations.TypedTuple<Object>> tuples = new HashSet<>();
// 这里是有限制的,一次不能写入太多,我个人测试了一下,如果一次写入70W以上就会报错,具体临界值也不太清楚
for (int i = 0; i < 500000; i++) {
// 构造函数中第一个参数为用户ID,第二个参数为分数
ZSetOperations.TypedTuple<Object> objectTypedTuple = new DefaultTypedTuple<>(i + 1, (double) i);
tuples.add(objectTypedTuple);
tuples.add(objectTypedTuple);
}
bzo.add(tuples);
即使一次写入50w条数据到redis,执行耗时也在2s之内,非常之快。这里因为每次写入数量有限制,所以稍作修改,分两次执行即可达到百万级数据
redisTemplate相关API的Demo
Integer userId = 9527;
// 所有用户,正序排名
System.out.println("==============正序排名==============");
Set rankSet = bzo.range(0, -1);
rankSet.forEach(System.out::println);
// 所有用户,降序排名
System.out.println("===============降序排名=============");
Set<Object> reverseRankSet = bzo.reverseRange(0, -1);
reverseRankSet.forEach(System.out::println);
// 获取已有具体元素的降序排名, 如果找不到数据,rank值为null
System.out.println("===============获取已有具体元素的排名=============");
Long rank = bzo.reverseRank(userId);
System.out.println(userId + " 排名:" + (rank + 1));// rank值是从0起始,所以展示要 +1
// 根据排名获取具体的元素,注意都是从0开始为第一个
System.out.println("==============获取名次区间在3, 5的元素集合==============");
Set<Object> reverseRange = bzo.reverseRange(3, 5);
reverseRange.forEach(System.out::println);
System.out.println("==============根据分数区间值排序取值==============");
Set<Object> objects = bzo.rangeByScore(20, 20);
objects.forEach(System.out::println);
System.out.println("==============获取分数区间的数量==============");
Long count = bzo.count(0, 20);
System.out.println(count);
将每个用户的最高分数存入Redis
每次游戏结束后,得到用户当前游戏分数,与redis中该用户的分数进行比较,若redis中没有该用户数据,则直接存入redis;若当前用户游戏分数 > redis中的用户分数,则存入redis,此时会自动覆盖掉历史记录。
// 当前登录用户
Integer userId = 9527;
// 当前游戏分数
double currentScore = 985.0;
// 1. 将游戏记录存入数据库
// 略
// 2. 获取操作redis sorted set的对象,将用户游戏最高分存入redis中
String redisKey = "gameRank";
BoundZSetOperations<String, Object> bzo = redisTemplate.boundZSetOps(redisKey);
// 2.1 获取用户在redis中存储的最高分数
Double bestScoreInRedis = bzo.score(userId);
// 2.2 若redis中没有该用户数据,则是第一次玩,将当前记录存入redis;若当前用户游戏分数 > redis中的用户分数,覆盖历史记录
if (Objects.isNull(bestScoreInRedis) || currentScore > bestScoreInRedis){
// 2.3 直接添加,自动覆盖历史记录
bzo.add(userId, currentScore);
}
这么写似乎没有什么问题,但redis内部机制导致业务上存在一个问题:遇到相同分数的用户数据,后记录的数据,在redis排序中却是排在前面的!
相同分数的用户数据,后记录却排序在前的解决方式
由于我们的游戏分数都是整数的,redis
中的分数是存入的double
类型,所以决定在小数点后面做文章。
整体思想: 数据的时间先后标识值,与一个提前定义的Integer.MAX_VALUE
差值,添加到小数点后面。这样以来后添加的数据分数值肯定最大,但是与Integer.MAX_VALUE
差值就是最小的,相同分数后添加的这就排在后面啦
两种方案:
- 分数后面添加时间戳(如果同时在一个时间点操作的,当前运行时间的时间戳会有相同的情况,不如下面的方案)
- 游戏记录先入库后,获取新增记录的数据库主键ID,在分数后面添加(推荐,先后顺序交给数据库来定夺,肯定不会重复)
修正后:
// 当前登录用户
Integer userId = 9527;
// 当前游戏分数
double currentScore = 985.0;
// 1. 将游戏记录存入数据库
// 获取插入到数据库的主键ID,此处简写
int id = 980;
// 2. 获取操作redis sorted set的对象,将用户游戏最高分存入redis中
String redisKey = "gameRank";
BoundZSetOperations<String, Object> bzo = redisTemplate.boundZSetOps(redisKey);
// 2.1 获取用户在redis中存储的最高分数
Double bestScoreInRedis = bzo.score(userId);
// 2.2 若redis中没有该用户数据,则是第一次玩,将当前记录存入redis;若当前用户游戏分数 > redis中的用户分数,覆盖历史记录
if (Objects.isNull(bestScoreInRedis) || currentScore > bestScoreInRedis){
// 2.3 小数点前面为用户真实分数,后面则为用户游戏记录先后值与最大Integer的差值
String redisScore = currentScore + "." + (Integer.MAX_VALUE - id);
bzo.add(userId, redisScore);
}
所有问题已经解决!上线后无问题,查寻效率非常之高!看菜鸟教程中,redis里面存储的元素可以高到离谱,但没有真实测试过存储量,个人感觉随便满足日常开发了