如何设计一个排行榜?
排行榜到处可见,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜等等。今天让我们从程序设计的角度,来看看如何设计一个排行榜!我们先从最基础的实现方式来说起。
MySQL 的 ORDER BY 关键字
第一种要介绍的实现方式就是直接使用 MySQL 的 ORDER BY 关键字。 ORDER BY 关键字可以对查询出来的数据按照指定的字段进行排序。
我相信但凡是学过 MySQL 的人,一定都用过 ORDER BY 关键字!没用过的,先不要看下面的文章了,麻烦默默反思 3 分钟。
我之前在一个用户数据量不大(6w 用户左右)并且排序需求并不复杂的项目中使用的就是这种方法。
这种方式的优缺点也比较明显。好处是比较简单,不需要引入额外的组件,成本比较低。坏处就是每次生成排行榜都比较耗时,对数据库的性能消耗非常之大,数据量一大,业务场景稍微复杂一点就顶不住了。
我们这里创建一个名为 cus_order 的表,来实际测试一下这种排序方式。为了测试方便, cus_order 这张表只有 id、score、name这 3 个字段。
CREATE TABLE `cus_order` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`score` int(11) NOT NULL,
`name` varchar(11) NOT NULL DEFAULT ”,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=100000 DEFAULT CHARSET=utf8mb4;
我们定义一个简单的存储过程(PROCEDURE)来插入 100w 测试数据。
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `BatchinsertDataToCusOder`(IN start_num INT,IN max_num INT)
BEGIN
DECLARE i INT default start_num;
WHILE i < max_num DO
insert into `cus_order`(`id`, `score`, `name`)
values (i,RAND() * 1000000,CONCAT(‘user’, i));
SET i = i + 1;
END WHILE;
END;;
DELIMITER ;
存储过程定义完成之后,我们执行存储过程即可!
CALL BatchinsertDataToCusOder(1, 1000000); # 插入100w+的随机数据
等待一会,100w 的测试数据就插入完成了!
为了能够对这 100w 数据按照 score 进行排序,我们需要执行下面的 SQL 语句。
SELECT `score`,`name` FROM `cus_order` ORDER BY `score` DESC;#降序排序
为了能够查看这套 SQL 语句的执行时间,我们需要通过show profiles命令。
不过,请确保你的 profiling 是开启(on)的状态(可以通过 show variables 命令查看)。
默认情况下, profiling 是关闭(off)的状态,你直接通过set @@profiling=1命令即可开启。
然后,我们就查询到了具体的执行速度。
可以看到,一共耗时了接近 4 s。
如何优化呢? 加索引并且限制排序数据量 是一种比较常见的优化方式。
我们对 score 字段加索引,并限制只排序 score 排名前 500 的数据。
这个时候,我们再执行下面的 SQL 语句,速度就快了很多,只需要 0.01 秒就排序了前 500 名的数据。
当然了,这只是一个最简单的场景,实际项目中的复杂度要比我这里列举的例子复杂很多,执行速度也会慢很多。
不过,能不用 MySQL 的 ORDER BY 关键字还是要看具体的业务场景。如果说你的项目需要排序数据量比较小并且业务场景不复杂的话(比如你对你博客的所有文章按照阅读量来排序),我觉得直接使用 MySQL 的 ORDER BY 关键字就可以了。
Redis 的 sorted set
了解过 Redis 常见数据结构的小伙伴,都知道 Redis 中有一个叫做 sorted set 的数据结构经常被用在各种排行榜的场景下。
通过 sorted set ,我们能够轻松应对百万级别的用户数据排序。这简直就是专门为排行榜设计的数据结构啊!
Redis 中 sorted set 有点类似于 Java 中的 TreeSet 和 HashMap 的结合体,sorted set 中的数据会按照权重参数 score 的值进行排序。
我们这里简单来演示一下。我们把上表中的数据添加到sorted set中。
sorted set 基本可以满足大部分排行榜的场景。
如果我们要查看包含所有用户的排行榜怎么办? 通过 ZRANGE (从小到大排序) / ZREVRANGE (从大到小排序)
如果我们要查看只包含前 3 名的排行榜怎么办? 限定范围区间即可。
如果我们需要查询某个用户的分数怎么办呢? 通过 ZSCORE 命令即可。
如果我们需要查询某个用户的排名怎么办呢? 通过 ZREVRANK 命令即可。
如何对用户的排名数据进行更新呢? 通过 ZINCRBY命令即可。
除了我上面提到的之外,还有一些其他的命令来帮助你解决更多排行榜场景的需求,想要深入研究的小伙伴可以仔细学习哦!
不过,需要注意的一点是:Redis 中只保存了排行榜展示所需的数据,需要用户的具体信息数据的话,还是需要去对应的数据库(比如 MySQL)中查。
你以为这样就完事了? 不存在的!还有一些无法仅仅通过 Redis 提供的命令解决的场景。
比如,如何实现多条件排序? 其实,答案也比较简单,对于大部分场景,我们直接对 score 值做文章即可。
更具体点的话就是,我们根据特定的条件来拼接 score 值即可。比如我们还要加上时间先后条件的话,直接在score 值添加上时间戳即可。
再比如,如何实现指定日期(比如最近 7 天)的用户数据排序?
我说一种比较简单的方法:我们把每一天的数据都按照日期为名字,比如 20350305 就代表 2035 年 3 月 5 号。
如果我们需要查询最近 n 天的排行榜数据的话,直接 ZUNIONSTORE来求 n 个 sorted set 的并集即可。
ZUNIONSTORE last_n_days n 20350305 20350306….
我不知道大家看懂了没有,我这里还是简单地造一些数据模拟一下吧!
通过 ZUNIONSTORE 命令来查看最近 3 天的排行榜情况:
现在,这 3 天的数据都集中在了 last_n_days 中。
如果一个用户同时在多个 sorted set 中的话,它最终的 score 值就等于这些 sorted set 中该用户的 score 值之和。
既然可以求并集,那必然也可以求交集。你可以通过 ZINTERSTORE 命令来求多个 n 个 sorted set 的交集。
有哪些场景可以用到多个sorted set 的交集呢? 比如每日打卡的场景,你对某一段时间每天打卡的人进行排序。
这个命令还有一个常用的权重参数 weights (默认为 1)。在进行并集/交集的过程中,每个集合中的元素会将自己的 score *weights 。
我下面演示一下这个参数的作用。
如果,我们需要将员工和管理者放在一起比较,不过,两者权重分别为 1 和 3。
# staff_set 的权重为1 manager_set的权重为3
127.0.0.1:6379> ZUNIONSTORE all_user_set 2 staff_set manager_set WEIGHTS 1 3
(integer) 4
最终排序的结果如下:
127.0.0.1:6379> ZREVRANGE all_user_set 0 -1
1) “manager2”
2) “staff2”
3) “staff1”
4) “manager1”
总结
上面我一共提到了两种设计排行榜的方法:
1、MySQL 的 ORDER BY 关键字
2、Redis 的 sorted set
其实,这两种没有孰好孰坏,还是要看具体的业务场景。如果说你的项目需要排序数据量比较小并且业务场景不复杂的话(比如你对你博客的所有文章按照阅读量来排序),我觉得直接使用 MySQL 的 ORDER BY 关键字就可以了,没必要为了排行榜引入一个 Redis。
另外,在没有分页并且数据量不大的情况下,直接在前端拿到所有需要用到的数据之后再进行排序也是可以的。