在常见的站点中,我们会遇到各种各样的「排行榜」:
那这个功能是怎么实现的呢?接下来我们就来探讨一下这个问题。
我们先来看看仅通过 MySQL 是如何实现这个功能的。
我们可能需要以下几个表:
文章表:articles
字段 | 描述 |
---|---|
id | 文章 ID |
title | 文章标题 |
likes | 总点赞数 |
collects | 总收藏数 |
comments | 总评论数 |
stars | 总星数 |
created_at | 创建时间 |
用户表:users
字段 | 描述 |
---|---|
id | 用户 ID |
nickname | 昵称 |
用户行为表:user_actions
字段 | 描述 |
---|---|
id | ID |
article_id | 文章 ID |
user_id | 用户 ID |
type | 行为类型 1.点赞 2.收藏 3.评论 |
is_cancel | 是否取消 Y.是 N.否 |
triggered_at | 出发时间 |
SELECT t2.*, t1.`points`FROM (SELECT `article_id`, count(1) as `points`FROM `user_actions`WHERE `triggered_at` BETWEEN {start_time} AND {end_time}AND `is_cancel` = 'N'AND `type` = {type}GROUP BY `article_id`ORDER BY count(1) DESCLIMIT {page,} {page_size}) t1LEFT JOIN `articles` t2 ON t1.`article_id` = t2.`id`
乍一看,这样实现也没有什么问题。
但实际的场景可能是这样的:
想象一下,每次在这样庞大的一个表里进行统计数据的话,是怎样的煎熬。
其实查询还只是其中一个限制因素,还有一种比较头疼的问题就是写入的场景,在每次用户对文章进行点赞时,为了进行并发控制,需要对文章表进行加锁控制,这就限制了读写速度,同时对「亿级」的操作记录表进行插入操作,速度也可想而知。
所以,这种最简单的方案看似「丰满」,实则「骨感」;
那么该怎么优化呢?
在 MySQL 方案中,主要的限制因素就是操作表「太过庞大」,导致读写都是问题。对于「写」的问题,可以考虑使用异步队列的方式进行写入,「读」的问题呢?怎么更快地进行排序是个问题。
说到「快」,我们一般都会先考虑能不能使用 Redis 实现,毕竟是基于内存的,速度自然没得说,那行不行得通呢?
我们知道,Redis 的 Zset 结构有一个分值( score )的属性,Zset 有一个命令是 ZREVRANGE
,这个命令的用法如下(摘自 Redis 命令参考):
看到这个命令是不是有点「排行榜」的意思了?
我们只需要按照排行榜的时间范围来限制榜单的范围就可以了。比如对于「月榜」,我们可以把 key 设计成这样:
ARTICLE_STARS_MONTHLY_RANKING:月份 //文章星数月排行榜ARTICLE_LIKES_MONTHLY_RANKING:月份 //文章点赞数月排行榜ARTICLE_COLLECTS_MONTHLY_RANKING:月份 //文章收藏数月排行榜ARTICLE_COMMENTS_MONTHLY_RANKING:月份 //文章评论数月排行榜
当我们对文章进行点赞时,只需要执行以下操作:
redis> ZINCRBY ARTICLE_STARS_MONTHLY_RANKING:{month} {article_id} 1 // 文章总星数 + 1redis> ZINCRBY ARTICLE_LIKES_MONTHLY_RANKING:{month} {article_id} 1 // 文章点赞数 + 1
对于诸如「周榜」、「年榜」之类的榜单,实现效果类似,只不过在用户操作文章时,需要对每一个对应的「排行榜」进行「加分」操作。
有了排行榜,接下来就是看怎么展示了。这里就轮到主角 ZREVRANGE
命令登场了。
比如,我们现在需要展示月榜,我们需要执行以下操作:
redis> ZREVRANGE ARTICLE_STARS_MONTHLY_RANKING:{month} 0 -1 WITHSCORES
因为我们设计「排行榜」的目的就是为了「突出重点」,所以榜单的长度一般都是固定的,不会「特别大」—— 如果榜单的数据占到总数据的 95% 以上,那这个「排行榜」意义就不大了。
到这里我们貌似已经通过 Zset 结构实现了一个不错的排行榜功能,但是这样设计也有一些问题:
我们的排行榜是按照 排名字段(M 表示) + 时间范围(N 表示)
来定义的,所以共需要 M × N
个 Zset 来记录「榜单数据」,当 M 和 N 越来越大时,每一次操作至少需要更新 N 个 Zset 的数据,操作会变的复杂。
时间范围越大,Zset 里需要存储的数据就越多。虽然理论上讲,Zset 可以存储 2^32 – 1
个元素,每个元素最多可以存储 512 MB 数据。但实际应用中我们可不敢这么玩,要知道,Redis 是单线程的,对于 bigkey
的操作会阻塞其他正常的操作,所以这也是我们需要考虑的一个问题。
其实除了使用 Zset 可以实现排序功能以外,还可以使用 SORT
这个命令来实现排序。接下来,我们就看看用 SORT
是怎么实现的。
这个命令的用法如下(摘自 Redis 命令参考):
这个命令看着参数挺复杂,其实只有第一个参数是必须的,其他参数都是可选的,我们把命令参数拆开来看的话就比较容易理解了:
reids> SORT key //必传参数,给定排序的 key ,可以是列表、集合或有序集合[BY pattern] // 排序规则的模式,下文会详细介绍[LIMIT offset count] // 限制返回的个数,offset 表示偏移量,count 表示返回元素个数 [GET pattern [GET pattern ...]] // 返回规则的模式,下文会详细介绍[ASC | DESC] // 排序方式 ASC 表示正序,DESC 表示降序[ALPHA] // 是否需要按照字符串规则排序(默认按数字规则排序)[STORE destination] // 存储位置,当需要对排序完的对象进行存储时会用到
这样看是不是对参数有个大致的印象了呢。接下来我们分别介绍一个「最简单」的用法和一个「最复杂」的用法,来具体了解一下它的用法。
「最简单」的用法:
# 文章 ID 列表 redis> LPUSH ARTICLE_ID_LIST 1 4 5 2(integer) 4# 对文章 ID 排序 redis> SORT ARTICLE_ID_LIST1) "1"2) "2"3) "4"4) "5"
「最复杂」的用法:
# 文章 ID 列表 redis> LPUSH ARTICLE_ID_LIST 1 4 5 2# 文章基本信息 redis> HMSET ARTICLE_DETAIL_1 id 1 title "article-1-title" likes 3 collects 5 comments 2 stars 10 redis> HMSET ARTICLE_DETAIL_2 id 2 title "article-2-title" likes 1 collects 3 comments 2 stars 6 redis> HMSET ARTICLE_DETAIL_4 id 4 title "article-4-title" likes 5 collects 7 comments 8 stars 20 redis> HMSET ARTICLE_DETAIL_5 id 5 title "article-5-title" likes 1 collects 2 comments 1 stars 4 # 文章点赞 redis> HINCRBY ARTICLE_DETAIL_5 likes 1redis> HINCRBY ARTICLE_DETAIL_5 stars 1# 按字段排序,返回所需字段 redis> SORT ARTICLE_ID_LIST BY ARTICLE_DETAIL_*->stars GET ARTICLE_DETAIL_*->id GET ARTICLE_DETAIL_*->title GET ARTICLE_DETAIL_*->stars LIMIT 0 4 DESC 1) "4" 2) "article-4-title" 3) "20" 4) "1" 5) "article-1-title" 6) "10" 7) "2" 8) "article-2-title" 9) "6"10) "5"11) "article-5-title"12) "5"
「最简单」的做法比较容易理解,就是对列表、集合和有序集合的元素进行排序。有序集合利用分值排序的用法在上一个方法中已经介绍了,列表本身并不支持排序,虽然用 SORT 可以对列表进行排序了,但是列表中存储的本来就是任务对象的 ID 或者任务对象的序列化表示,对它进行排序看上去「并无卵用」,那这个 SORT 命令设计的是不是就略显鸡肋呢?
别急,这就是我们为什么要引出「最复杂」的这个例子。
看命令很长,一头雾水。别急我们把它拆成「五大步」来描述,你可能就比较清楚了:
Step 1: 把 ARTICLE_ID_LIST
中的元素 article_id
「取」出来
SORT ARTICLE_ID_LIST ...
Step 2: 筛选出所有 key 匹配 ARTICLE_DETAIL_{article_id}
模式的 Hash
... BY ARTICLE_DETAIL_*->stars ... // 关注箭头前半部分 内容
Step 3: 把筛选出来的 Hash 按照 stars
字段进行逆向排序
... BY ARTICLE_DETAIL_*->stars ... DESC // 关注箭头后半部分内容
Step 4: 将 Hash 的这三个字段作为返回结果显示
... GET ARTICLE_DETAIL_*->id GET ARTICLE_DETAIL_*->title GET ARTICLE_DETAIL_*->stars ...
Step 5: 返回排序结果的前 4 名
... LIMIT 0 4 ...
这样看是不是就比较清楚了呢,是不是感觉眼前一亮 —— SORT
还能这么玩?牛掰。
这样做排序的话就比较灵活了,比起上一种用 Zset 做排序的方案,这种方式仅需要一个存储对象 ID 的 List 和存储每个对象详情的 Hash 就可以了,操作起来比上一种方案更加简单 —— 即使排序的字段越来越多时,也不用再单独创建「榜单」,只需要在 Hash 中增加对应的字段即可,是不是很方便呢。
难道这就是「终极方案」了吗?
当然不是,SORT
这个命令固然「很好用」,但是在 Redis 中,永远都需要关注的一个话题就是「时间复杂度」。
通俗点讲,就是这个命令会随着排序的元素数量和返回的元素数量变多时,造成 Redis 线程阻塞。
所以,你在使用这种方案的时候,需要时刻关注该方案的时间复杂度带来的瓶颈问题。
难道没有更好的方案了吗?
其实更多情况下,我们需要根据各种限制条件进行权衡(tradeoff),选择一套更「适合自己」的方案。
比如当我们文章的数据量已经达到千万甚至上亿的级别时,我们一般都会考虑通过「大数据」进行存储了。这时候,像榜单这样的逻辑,我们可能就会考虑牺牲一部分「即时性」,而换取系统整体的「稳定性」。
我们可能会通过各种离线的分析任务来「统计」出榜单,再转换成热数据进行存储,提供给前端查询使用。这样的话,既兼顾了「功能性」,同时系统整体的「稳定性」也不会受到太大的影响。
每一种方案都有它产生的背景和局限性,我们要做的就是追随这种变化,并在适当时机作出改变。
永远记住:「 No Silver Bullet 」。
你应该了解真相,真相会让你自由。
如果觉得博客文章对您有帮助,异或土豪有钱任性,可以通过以下扫码向我捐助。也可以动动手指,帮我分享和传播。您的肯定,是我不懈努力的动力!感谢各位亲~