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

背景

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

以本站为例,用户可以在本站发表文章,其他用户可以对文章进行点赞、收藏或者评论。在专栏首页会有「月榜」、「周榜」和「年榜」的功能,其效果就是在相应的时间段内按文章排名顺序从前往后进行展示,从而起到一个「排行榜」的效果。

learnku文章排行榜

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

说明: 本文仅是站在技术的角度分析常规的实现方案,并非本站真正的实现方案。

实现方案

MySQL 实现方案

我们先来看看仅通过 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) DESC
    LIMIT {page,} {page_size}
) t1
    LEFT JOIN `articles` t2 ON t1.`article_id` = t2.`id`

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

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

假设现在平台有 50w 会员(猜的),每个会员都喜欢发文章,假设平均每个会员拥有 10 篇文章,那么一共有 500w 篇文章(:grimacing:),这还不是最恐怖的,假设每篇文章都有 5 个点赞,5 个收藏和 5 个评论的话,那用户操作表就有 6.25 亿条记录(:grimacing::grimacing::grimacing:恐怖如斯)。

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

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

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

那么该怎么优化呢?

Redis Zset 实现方案

在 MySQL 方案中,主要的限制因素就是操作表「太过庞大」,导致读写都是问题。对于「写」的问题,可以考虑使用异步队列的方式进行写入,「读」的问题呢?怎么更快地进行排序是个问题。

说到「快」,我们一般都会先考虑能不能使用 Redis 实现,毕竟是基于内存的,速度自然没得说,那行不行得通呢?

我们知道,Redis 的 Zset 结构有一个分值( score )的属性,Zset 有一个命令是 ZREVRANGE ,这个命令的用法如下(摘自 Redis 命令参考):

ZREVRANGE key start stop [WITHSCORES]
返回有序集 key 中,指定区间内的成员。
其中成员的位置按 score 值递减(从大到小)来排列。
具有相同 score 值的成员按字典序的逆序( reverse lexicographical order )排列

看到这个命令是不是有点「排行榜」的意思了?

我们只需要按照排行榜的时间范围来限制榜单的范围就可以了。比如对于「月榜」,我们可以把 key 设计成这样:

ARTICLE_STARS_MONTHLY_RANKING:月份        //文章星数月排行榜
ARTICLE_LIKES_MONTHLY_RANKING:月份        //文章点赞数月排行榜
ARTICLE_COLLECTS_MONTHLY_RANKING:月份     //文章收藏数月排行榜
ARTICLE_COMMENTS_MONTHLY_RANKING:月份     //文章评论数月排行榜

当我们对文章进行点赞时,只需要执行以下操作:

redis> ZINCRBY ARTICLE_STARS_MONTHLY_RANKING:{month} 1 {article_id}  // 文章总星数 + 1
redis> ZINCRBY ARTICLE_LIKES_MONTHLY_RANKING:{month} 1 {article_id}  // 文章点赞数 + 1

对于诸如「周榜」、「年榜」之类的榜单,实现效果类似,只不过在用户操作文章时,需要对每一个对应的「排行榜」进行「加分」操作。

有了排行榜,接下来就是看怎么展示了。这里就轮到主角 ZREVRANGE 命令登场了。

比如,我们现在需要展示月榜,我们需要执行以下操作:

redis> ZREVRANGE ARTICLE_STARS_MONTHLY_RANKING:{month} 0 -1 WITHSCORES

获取不同的排行榜之需要切换不同的排行榜「名称」即可。这里还可以通过 start 和 stop 参数来控制榜单的长度和起始位置。

因为我们设计「排行榜」的目的就是为了「突出重点」,所以榜单的长度一般都是固定的,不会「特别大」—— 如果榜单的数据占到总数据的 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 命令参考):

SORT key [BY pattern] [LIMIT offset count] [GET pattern [GET pattern …]] [ASC | DESC] [ALPHA] [STORE destination]
返回或保存给定列表、集合、有序集合 key 中经过排序的元素。
排序默认以数字作为对象,值被解释为双精度浮点数,然后进行比较。

这个命令看着参数挺复杂,其实只有第一个参数是必须的,其他参数都是可选的,我们把命令参数拆开来看的话就比较容易理解了:

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_LIST
1) "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 1
redis> 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 中,永远都需要关注的一个话题就是「时间复杂度」。

SORT命令的时间复杂度:O(N + M * log(M)), N 为要排序的列表或集合内的元素数量, M 为要返回的元素数量

通俗点讲,就是这个命令会随着排序的元素数量和返回的元素数量变多时,造成 Redis 线程阻塞。

所以,你在使用这种方案的时候,需要时刻关注该方案的时间复杂度带来的瓶颈问题。

难道没有更好的方案了吗?

写在最后的话

其实更多情况下,我们需要根据各种限制条件进行权衡(tradeoff),选择一套更「适合自己」的方案。

比如当我们文章的数据量已经达到千万甚至上亿的级别时,我们一般都会考虑通过「大数据」进行存储了。这时候,像榜单这样的逻辑,我们可能就会考虑牺牲一部分「即时性」,而换取系统整体的「稳定性」。

我们可能会通过各种离线的分析任务来「统计」出榜单,再转换成热数据进行存储,提供给前端查询使用。这样的话,既兼顾了「功能性」,同时系统整体的「稳定性」也不会受到太大的影响。

每一种方案都有它产生的背景和局限性,我们要做的就是追随这种变化,并在适当时机作出改变。

永远记住:「 No Silver Bullet 」

本作品采用《CC 协议》,转载必须注明作者和本文链接
你应该了解真相,真相会让你自由。
本帖由 MArtian 于 10个月前 加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 17
Junwind

一个 redis zset 搞定,需要落地就直接 json 字符串落地就行。

10个月前 评论

很不错,很棒。

10个月前 评论

好文,很棒 :+1:

10个月前 评论

文章写得不错,继续加油

10个月前 评论

场景:要实现一个拉新排行榜的功能,数据库表设计成每拉新一人插入一条记录,现改用zset实现排行榜功能,监听表插入事件,每插入一条就用zincrby记录分享人id和拉新人数,现有以下疑问: 1、key是否需要设置过期时间?如果设置了一个比较长的过期时间是否会影响性能?(比如几个月后过期) 2、如何保持排名的一致?(内存满或其它原因造成key重置,这样排名与实际排名不一致)

10个月前 评论
快乐的皮拉夫 (楼主) 10个月前
zzzzzq (作者) 10个月前

维护起来是不是有点太麻烦了 如果改个查询条件 例如按小时 按天查询 还要跑批统计到redis,直接数据表里面order by xx不好吗

10个月前 评论
快乐的皮拉夫 (楼主) 10个月前
彭彭 (作者) 10个月前

原文:ZINCRBY ARTICLE_STARS_MONTHLY_RANKING:{month} {article_id} 1 // 文章总星数 + 1 ZINCRBY的用法我觉得应该是下边这样吧

ZINCRBY ARTICLE_STARS_MONTHLY_RANKING:{month} 1 {article_id} // 文章总星数 + 1

7个月前 评论
快乐的皮拉夫 (楼主) 7个月前

我在用laravel的redis的facade去实现。 发现在用sort上有一个问题。

        $data =$client->sort('ARTICLE_ID_LIST', array('BY' => 'laravel_database_ARTICLE_DETAIL_*->stars', 'GET' => array('laravel_database_ARTICLE_DETAIL_*->id', 'laravel_database_ARTICLE_DETAIL_*->title', 'laravel_database_ARTICLE_DETAIL_*->stars'), 'LIMIT' => array(0, 4), 'sort'=>'desc'));

sort后面的key是会自动添加laravel_database的前缀,但是后面的array是需要手动添加的

6个月前 评论

月榜,周榜,日榜;你这时间挺长的,脚本命令慢慢跑一个统计表就行了。

4个月前 评论
快乐的皮拉夫 (楼主) 4个月前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!