极客时间已完结课程限时免费阅读

43丨如何使用Redis搭建玩家排行榜?

43丨如何使用Redis搭建玩家排行榜?-极客时间

43丨如何使用Redis搭建玩家排行榜?

讲述:陈旸

时长12:39大小14.47M

上一篇文章中,我们使用 Redis 模拟了多用户抢票的问题,这里再回顾一下原理。我们通过使用 WATCH+MULTI 的方式实现乐观锁机制,对 ticket_count 这个键进行监视,当这个键发生变化的时候事务就会被打断,重新请求,这样做的好处就是可以保证事务对键进行操作的原子性,当然我们也可以使用 Redis 的 incr 和 decr 来实现键的原子性递增或递减。
今天我们用 Redis 搭建一个玩家的排行榜,假设一个服务器存储了 10 万名玩家的数据,我们想给这个区(这台服务器)上的玩家做个全区的排名,该如何用 Redis 实现呢?
不妨一起来思考下面几个问题:
MySQL 是如何实现玩家排行榜的?有哪些难题需要解决?
如何用 Redis 模拟 10 万名玩家数据?Redis 里的 Lua 又是什么?
Redis 如何搭建玩家排行榜?和 MySQL 相比有什么优势?

使用 MySQL 搭建玩家排行榜

我们如果用 MySQL 搭建玩家排行榜的话,首先需要生成 10 万名玩家的数据,这里我们使用之前学习过的存储过程来模拟。
为了简化,玩家排行榜主要包括 3 个字段:user_id、score、和 create_time,它们分别代表玩家的用户 ID、玩家的积分和玩家的创建时间。

王者荣耀英雄等级说明

这里我们可以模拟王者荣耀的英雄等级,具体等级标准如下:
如果想要英雄要达到最强王者的段位,那么之前需要积累 112 颗(9+12+16+25+25+25)星星,而达到最强王者之后还可以继续积累无上限的星星。在随机数模拟上,我们也分成两个阶段,第一个阶段模拟英雄的段位,我们使用随机数来模拟 score(数值范围是 1-112 之间),当 score=112 的时候,再模拟最强王者等级中的星星个数。如果我们只用一个随机数进行模拟,会出现最强王者的比例变大的情况,显然不符合实际情况。

使用存储过程模拟 10 万名玩家数据

这里我们使用存储过程,具体代码如下:
CREATE DEFINER=`root`@`localhost` PROCEDURE `insert_many_user_scores`(IN START INT(10), IN max_num INT(10))
BEGIN
DECLARE i INT DEFAULT 0;
-- 模拟玩家英雄的星星数
DECLARE score INT;
DECLARE score2 INT;
-- 初始注册时间
DECLARE date_start DATETIME DEFAULT ('2017-01-01 00:00:00');
-- 每个玩家的注册时间
DECLARE date_temp DATETIME;
SET date_temp = date_start;
SET autocommit=0;
REPEAT
SET i=i+1;
SET date_temp = date_add(date_temp, interval RAND()*60 second);
-- 1-112随机数
SET score = CEIL(RAND()*112);
-- 如果达到了王者,继续模拟王者的星星数
IF score = 112 THEN
SET score2 = FLOOR(RAND()*100);
SET score = score + score2;
END IF;
-- 插入新玩家
INSERT INTO user_score(user_id, score, create_time) VALUES((START+i), score, date_temp);
UNTIL i = max_num
END REPEAT;
COMMIT;
END
然后我们使用call insert_many_user_scores(10000,100000);模拟生成 10 万名玩家的得分数据。注意在 insert 之前,需要先设置autocommit=0,也就是关闭了自动提交,然后在批量插入结束之后再手动进行 COMMIT,这样做的好处是可以进行批量提交,提升插入效率。你可以看到整体的用时为 5.2 秒。
如上代码所示,我用 score 来模拟第一阶段的星星数,如果 score 达到了 112 再来模拟 score2 的分数,这里我限定最强王者阶段的星星个数上限为 100。同时我们还模拟了用户注册的时间,这是因为排行榜可以有两种表示方式,第二种方式需要用到这个时间。
第一种表示方式为并列排行榜,也就是分数相同的情况下,允许排名并列,如下所示:
第二种为严格排行榜。当分数相同的时候,会按照第二条件来进行排序,比如按照注册时间的长短,注册时间越长的排名越靠前。这样的话,上面那个排行榜就会变成如下所示的严格排行榜。
你能看到当 10013 和 10015 得分相同的时候,如果按照注册时间来进行排名的话,会将 10013 排到 10015 前面。
上面的数据仅仅为示意,下面我们用实际的 10 万条数据做一个严格排行榜(你可以点击下载地址下载这 10 万条数据, 也可以自己使用上面的存储过程来进行模拟)首先使用 SQL 语句进行查询:
SELECT (@rownum := @rownum + 1) AS user_rank, user_id, score, create_time
FROM user_score, (SELECT @rownum := 0) b
ORDER BY score DESC, create_time ASC
运行结果如下(10 万条数据,用时 0.17s):
这里有几点需要说明。
MySQL 不像 Oracle 一样自带 rownum 统计行编号的功能,所以这里我们需要自己来实现 rownum 功能,也就是设置 MySQL 的变量@rownum,初始化为@rownum :=0,然后每次 SELECT 一条数据的时候都自动加 1。
通过开发程序(比如 Python、PHP 和 Java 等)统计排名会更方便,这里同样需要初始化一个变量,比如rownum=0,然后每次 fetch 一条数据的时候都将该变量加 1,作为记录的排名。同时,开发程序也可以很方便地实现并列排名,因为程序可以进行上下文的统计,当两名玩家得分相同时,排名相同,否则排名会顺序加 1。
如果想要通过 SQL 来实现,可以写成下面这样:
SELECT user_id, score,
IFNULL((SELECT COUNT(*) FROM user_score WHERE score > t.score), 0) + 1 AS user_rank
FROM user_score t
ORDER BY user_rank ASC
这样做的原理是查找比当前分数大的数据行数,然后加 1,但是这样执行效率会很低,相当于需要对每个玩家都统计一遍排名。

Lua 是什么,如何在 Redis 中使用

知道如何用 MySQL 模拟数据后,我们再来看下如何在 Redis 中完成这一步。事实上,Redis 本身不提供存储过程的功能,不过在 2.6 版本之后集成了 Lua 语言,可以很方便地实现类似存储过程的函数调用方式。
Lua 是一个小巧的脚本语言,采用标准 C 语言编写,一个完整的 Lua 解析器大小只有 200K。我们之前讲到过采用标准 C 语言编写的好处就在于执行效率高,依懒性低,同时兼容性好,稳定性高。这些特性同样 Lua 也有,它可以嵌入到各种应用程序中,提供灵活的扩展和定制功能。

如何在 Redis 中使用 Lua

在 Redis 中使用 Lua 脚本的命令格式如下:
EVAL script numkeys key [key ...] arg [arg ...]
我来说明下这些命令中各个参数代表的含义。
script,代表的是 Lua 的脚本内容。
numkeys,代表后续参数 key 的个数。
key 就是我们要操作的键,可以是多个键。我们在 Lua 脚本中可以直接使用这些 key,直接通过KEYS[1]KEYS[2]来获取,默认下标是从 1 开始。
arg,表示传入到 Lua 脚本中的参数,就像调用函数传入的参数一样。在 Lua 脚本中我们可以通过ARGV[1]ARGV[2]来进行获取,同样默认下标从 1 开始。
下面我们通过 2 个例子来体会下,比如我们使用 eval "return {ARGV[1], ARGV[2]}" 0 cy 123,代表的是传入的 key 的个数为 0,后面有两个 arg,分别为 cy 和 123。在 Lua 脚本中,我们直接返回这两个参数ARGV[1], ARGV[2],执行结果如下:
比如我们要用这一条语句:
eval "math.randomseed(ARGV[1]); local temp = math.random(1,112); redis.call('SET', KEYS[1], temp); return 'ok';" 1 score 30
这条语句代表的意思是,我们传入 KEY 的个数为 1,参数是 score,arg 参数为 30。在 Lua 脚本中使用ARGV[1],也就是 30 作为随机数的种子,然后创建本地变量 temp 等于 1 到 112 之间的随机数,再使用 SET 方法对 KEY,也就是用刚才创建的随机数对 score 这个字段进行赋值,结果如下:
然后我们在 Redis 中使用GET score对刚才设置的随机数进行读取,结果为 34。
另外我们还可以在命令中调用 Lua 脚本,使用的命令格式:
redis-cli --eval lua_file key1 key2 , arg1 arg2 arg3
使用 redis-cli 的命令格式不需要输入 key 的个数,在 key 和 arg 参数之间采用了逗号进行分割,注意逗号前后都需要有空格。同时在 eval 后面可以带一个 lua 文件(以.lua 结尾)。

使用 Lua 创建 10 万名玩家数据

如果我们想要通过 Lua 脚本创建 10 万名玩家的数据,文件名为insert_user_scores.lua,代码如下:
--设置时间种子
math.randomseed(ARGV[1])
-- 设置初始的生成时间
local create_time = 1567769563 - 3600*24*365*2.0
local num = ARGV[2]
local user_id = ARGV[3]
for i=1, num do
--生成1到60之间的随机数
local interval = math.random(1, 60)
--产生1到112之间的随机数
local temp = math.random(1, 112)
if (temp == 112) then
--产生0到100之间的随机数
temp = temp + math.random(0, 100)
end
create_time = create_time + interval
temp = temp + create_time / 10000000000
redis.call('ZADD', KEYS[1], temp, user_id+i-1)
end
return 'Generation Completed'
上面这段代码可以实现严格排行榜的排名,具体方式是将 score 进行了改造,score 为浮点数。整数部分为得分,小数部分为时间差。
在调用的时候,我们通过ARGV[1]获取时间种子的参数,传入的KEYS[1]user_score,也就是创建有序集合user_score。然后通过 num 来设置生成玩家的数量,通过user_id获取初始的user_id。最后调用如下命令完成玩家数据的创建:
redis-cli -h localhost -p 6379 --eval insert_user_scores.lua user_score , 30 100000 10000

使用 Redis 实现玩家排行榜

我们通过 Lua 脚本模拟完成 10 万名玩家数据,并将其存储在了 Redis 的有序集合user_score中,下面我们就来使用 Redis 来统计玩家排行榜的数据。
首先我们需要思考的是,一个典型的游戏排行榜都包括哪些功能呢?
统计全部玩家的排行榜
按名次查询排名前 N 名的玩家
查询某个玩家的分数
查询某个玩家的排名
对玩家的分数和排名进行更新
查询指定玩家前后 M 名的玩家
增加或移除某个玩家,并对排名进行更新
在 Redis 中实现上面的功能非常简单,只需要使用 Redis 我们提供的方法即可,针对上面的排行榜功能需求,我们分别来看下 Redis 是如何实现的。

统计全部玩家的排行榜

在 Redis 里,统计全部玩家的排行榜的命令格式为ZREVRANGE 排行榜名称 起始位置 结束位置 [WITHSCORES]
我们使用这行命令即可:
ZREVRANGE user_score 0 -1 WITHSCORES
我们对玩家排行榜user_score进行统计,其中 -1 代表的是全部的玩家数据,WITHSCORES代表的是输出排名的同时也输出分数。

按名次查询排名前 N 名的玩家

同样我们可以使用ZREVRANGE完成前 N 名玩家的排名,比如我们想要统计前 10 名玩家,可以使用:ZREVRANGE user_score 0 9

查询某个玩家的分数

命令格式为ZSCORE 排行榜名称 玩家标识
时间复杂度为O(1)
如果我们想要查询玩家 10001 的分数可以使用:ZSCORE user_score 10001

查询某个玩家的排名

命令格式为ZREVRANK 排行榜名称 玩家标识
时间复杂度为O(log(N))
如果我们想要查询玩家 10001 的排名可以使用:ZREVRANK user_score 10001

对玩家的分数进行更新,同时排名进行更新

如果我们想要对玩家的分数进行增减,命令格式为ZINCRBY 排行榜名称 分数变化 玩家标识
时间复杂度为O(log(N))
比如我们想对玩家 10001 的分数减 1,可以使用:ZINCRBY user_score -1 10001
然后我们再来查看下玩家 10001 的排名,使用:ZREVRANK user_score 10001
你能看到排名由 17153 降到了 18036 名。

查询指定玩家前后 M 名的玩家

比如我们想要查询玩家 10001 前后 5 名玩家都是谁,当前已知玩家 10001 的排名是 18036,那么可以使用:ZREVRANGE user_score 18031 18041
这样就可以得到玩家 10001 前后 5 名玩家的信息。
增加或删除某个玩家,并对排名进行更新
如果我们想要删除某个玩家,命令格式为ZREM 排行榜名称 玩家标识
时间复杂度为O(log(N))
比如我们想要删除玩家 10001,可以使用:ZREM user_score 10001
这样我们再来查询下排名在 18031 到 18041 的玩家是谁,使用:ZREVRANGE user_score 18031 18041
你能看到玩家 10001 的信息被删除,同时后面的玩家排名都向前移了一位。
如果我们想要增加某个玩家的数据,命令格式为ZADD 排行榜名称 分数 玩家标识
时间复杂度为O(log(N))
这里,我们把玩家 10001 的信息再增加回来,使用:ZADD user_score 93.1504697596 10001
然后我们再来看下排名在 18031 到 18041 的玩家是谁,使用:ZREVRANGE user_score 18031 18041
你能看到插入了玩家 10001 的数据之后,排名又回来了。

总结

今天我们使用 MySQL 和 Redis 搭建了排行榜,根据相同分数的处理方式,我们可以把排行榜分成并列排行榜和严格排行榜。虽然 MySQL 和 Redis 都可以搭建排行榜,但两者还是有区别的。MySQL 擅长存储数据,而对于数据的运算来说则效率不高,比如统计排行榜的排名,通常还是需要使用后端语言(比如 Python、PHP、Java 等)再进行统计。而 Redis 本身提供了丰富的排行榜统计功能,不论是增加、删除玩家,还是对某个玩家的分数进行调整,Redis 都可以对排行榜实时更新,对于游戏的实时排名来说,这还是很重要的。
在 Redis 中还集成了 Lua 脚本语言,通过 Lua 我们可以更加灵活地扩展 Redis 的功能,同时在 Redis 中使用 Lua 语言,还可以对 Lua 脚本进行复用,减少网络开销,编写代码也更具有模块化。此外 Redis 在调用 Lua 脚本的时候,会将它作为一个整体,也就是说中间如果有其他的 Redis 命令是不会被插入进去的,也保证了 Lua 脚本执行过程中不会被其他命令所干扰。
我们今天使用 Redis 对 10 万名玩家的数据进行了排行榜的统计,相比于用 RDBMS 实现排行榜来说,使用 Redis 进行统计都有哪些优势呢?
我们使用了 Lua 脚本模拟了 10 万名玩家的数据,其中玩家的分数 score 分成了两个部分,整数部分为实际的得分,小数部分为注册时间。例子中给出的严格排行榜是在分数相同的情况下,按照注册时间的长短进行的排名,注册时间长的排名靠前。如果我们将规则进行调整,同样是在分数相同的情况下,如果注册时间长的排名靠后,又该如何编写代码呢?
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
分享给需要的人,Ta购买本课程,你将得20
生成海报并分享

赞 7

提建议

上一篇
42丨如何使用Redis来实现多用户抢票问题
下一篇
44丨DBMS篇总结和答疑:用SQLite做词云
unpreview
 写留言

精选留言(7)

  • 石维康
    2019-09-12
    在生成数据时,把"temp = temp + create_time / 10000000000"换成 temp = temp +1 - create_time / 10000000000 哈哈

    作者回复: 不错的方法 哈哈

    共 3 条评论
    11
  • Demon.Lee
    2019-09-12
    感觉用redis,最终还是需要结合程序以及MySQL来处理,因为排行榜展示,前端还是需要用户名的,光给个用户id不知道是谁,除非redis有序集合的member包含了用户id和name,请指正。

    作者回复: 通常一个功能用一种DBMS即可,所以会用到有序集合的member保存id和name。因为这些数据是需要高频访问的,所以放到redis中会更适合。如果想要查询更具体的数据,需要用户点击排行榜中的某个玩家的时候,可以使用MySQL来处理,比如查看这个玩家具体使用过哪些英雄,具体战绩如何等。

    共 5 条评论
    11
  • JustDoDT
    2019-09-11
    注册时间排名靠后MySQL语法:create_time按照降序排列。 SELECT (@rownum := @rownum + 1) AS user_rank, user_id, score, create_time FROM user_score, (SELECT @rownum := 0) b ORDER BY score DESC, create_time DESC

    作者回复: Good Job

    5
  • 博弈
    2020-03-27
    Redis在实现排行榜方面优势显著,有现成命令且在内存操作,速度快

    作者回复: 对的

    2
  • felix
    2019-09-14
    咨询老师一个关于ip匹配的索引问题: 有一个IP的库表,每一条记录了一个开始ip和结束ip,然后想批量匹配ip,查询为何没有用上“联合索引KEY `ip_range_int` (`start_int`,`end_int`) USING BTREE”?要怎么设置索引才有效? CREATE TABLE `t_dt_ip` ( `id` int(11) NOT NULL AUTO_INCREMENT, `start_ip` char(15) DEFAULT NULL, `end_ip` char(15) DEFAULT NULL, `location` varchar(100) DEFAULT NULL, `start_int` int(10) unsigned DEFAULT '0', `end_int` int(10) unsigned DEFAULT '0', PRIMARY KEY (`id`), KEY `ip_range` (`start_ip`,`end_ip`) USING BTREE, KEY `ip_range_int` (`start_int`,`end_int`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; explain update t_tmp_ip t, t_dt_ip i set t.ip_id = i.id where INET_ATON(t.ip_address) between i.start_int and i.end_int; | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | | 1 | UPDATE | t | NULL | ALL | NULL | NULL | NULL | NULL | 1000 | 100.00 | NULL | | 1 | SIMPLE | i | NULL | ALL | ip_range_int | NULL | NULL | NULL | 541942 | 11.11 | Range checked for each record (index map: 0xC) | 甚至加上单个字段索引也没有用?? alter table `t_dt_ip` add index indx_t_dt_ip_start_int (start_int); mysql> explain select * from t_dt_ip i join t_tmp_ip t on 1= 1 where t.ip_address >= i.start_int limit 1; | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | | 1 | SIMPLE | t | NULL | ALL | NULL | NULL | NULL | NULL | 73126 | 100.00 | NULL | | 1 | SIMPLE | i | NULL | ALL | ip_range_int,indx_t_dt_ip_start_int | NULL | NULL | NULL | 541942 | 33.33 | Range checked for each record (index map: 0xC) |
    展开

    作者回复: 感谢提问,where INET_ATON(t.ip_address) between i.start_int and i.end_int 这里会对i.start_int 和 i.end_int进行比较。所以如果设置了联合索引KEY `ip_range_int` (`start_int`,`end_int`) USING BTREE 是不会起作用的,因为联合索引具有最左匹配原则 下面 add index indx_t_dt_ip_start_int (start_int),说明对单个字段start_int 创建了index,所以在possible_keys 中存在start_int。具体是否需要索引MySQL还是会根据实际情况来判断,因为每次比较是判断where t.ip_address >= i.start_int 这里会和根据t.ip_address以及i.start_int的数据分布情况而定,如果MySQL认为这需要扫描大部分索引,因此意义不大时就不会采用索引字段start_int

    2
  • 白菜炒五花肉
    2022-08-25 来自上海
    ERR Error running script (call to f_84b63a152215a96efa70b8935ae3d2b0e5ab93d1): @user_script:3: user_script:3: bad argument #1 to 'randomseed' (number expected, got nil) 老师,使用redis创建10w名玩家数据,执行lua脚本,报这个错误是啥问题
  • 完美坚持
    2021-06-09
    为什么 查询某个玩家的排名 对玩家的分数和排名进行更新 查询指定玩家前后 M 名的玩家 增加或移除某个玩家,并对排名进行更新 的时间复杂度都是O(log(N)),这和有序集合的数据存储结构有关吗?老师能不能简单解释一下,或者给个链接
    展开
    共 1 条评论