Redis 實用小技巧——如何實現一個排行榜功能

快樂的皮拉夫發表於2023-05-15

背景

在常見的站點中,我們會遇到各種各樣的「排行榜」:

以本站為例,使用者可以在本站發表文章,其他使用者可以對文章進行點贊、收藏或者評論。在專欄首頁會有「月榜」、「周榜」和「年榜」的功能,其效果就是在相應的時間段內按文章排名順序從前往後進行展示,從而起到一個「排行榜」的效果。

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} {article_id} 1 // 文章總星數 + 1
redis> ZINCRBY ARTICLE_LIKES_MONTHLY_RANKING:{month} {article_id} 1 // 文章點贊數 + 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 協議》,轉載必須註明作者和本文連結
你應該瞭解真相,真相會讓你自由。

相關文章