背景
在常見的站點中,我們會遇到各種各樣的「排行榜」:
以本站為例,使用者可以在本站發表文章,其他使用者可以對文章進行點贊、收藏或者評論。在專欄首頁會有「月榜」、「周榜」和「年榜」的功能,其效果就是在相應的時間段內按文章排名順序從前往後進行展示,從而起到一個「排行榜」的效果。
那這個功能是怎麼實現的呢?接下來我們就來探討一下這個問題。
說明: 本文僅是站在技術的角度分析常規的實現方案,並非本站真正的實現方案。
實現方案
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 篇文章(),這還不是最恐怖的,假設每篇文章都有 5 個點贊,5 個收藏和 5 個評論的話,那使用者操作表就有 6.25 億條記錄(恐怖如斯)。
想象一下,每次在這樣龐大的一個表裡進行統計資料的話,是怎樣的煎熬。
其實查詢還只是其中一個限制因素,還有一種比較頭疼的問題就是寫入的場景,在每次使用者對文章進行點贊時,為了進行併發控制,需要對文章表進行加鎖控制,這就限制了讀寫速度,同時對「億級」的操作記錄表進行插入操作,速度也可想而知。
所以,這種最簡單的方案看似「豐滿」,實則「骨感」;
那麼該怎麼最佳化呢?
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 協議》,轉載必須註明作者和本文連結