Redis 实用小技巧——如何实现一个排行榜功能

Mysql   2023-07-18 09:03   204   0  

背景

在常见的站点中,我们会遇到各种各样的「排行榜」:

learnku文章排行榜

那这个功能是怎么实现的呢?接下来我们就来探讨一下这个问题。

实现方案

MySQL 实现方案

我们先来看看仅通过 MySQL 是如何实现这个功能的。

我们可能需要以下几个表:

文章表:articles

字段描述
id文章 ID
title文章标题
likes总点赞数
collects总收藏数
comments总评论数
stars总星数
created_at创建时间

用户表:users

字段描述
id用户 ID
nickname昵称

用户行为表:user_actions

字段描述
idID
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`

乍一看,这样实现也没有什么问题。

但实际的场景可能是这样的:

想象一下,每次在这样庞大的一个表里进行统计数据的话,是怎样的煎熬。

其实查询还只是其中一个限制因素,还有一种比较头疼的问题就是写入的场景,在每次用户对文章进行点赞时,为了进行并发控制,需要对文章表进行加锁控制,这就限制了读写速度,同时对「亿级」的操作记录表进行插入操作,速度也可想而知。

所以,这种最简单的方案看似「丰满」,实则「骨感」;

那么该怎么优化呢?

Redis Zset 实现方案

在 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 的操作会阻塞其他正常的操作,所以这也是我们需要考虑的一个问题。

Redis Sort 实现方案

其实除了使用 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 」

你应该了解真相,真相会让你自由。

博客评论
还没有人评论,赶紧抢个沙发~
发表评论
说明:请文明发言,共建和谐网络,您的个人信息不会被公开显示。
闲言碎语
你有诗和远方也没用,生活对你虽远必诛。
赞赏支持

如果觉得博客文章对您有帮助,异或土豪有钱任性,可以通过以下扫码向我捐助。也可以动动手指,帮我分享和传播。您的肯定,是我不懈努力的动力!感谢各位亲~