這是why哥的第89篇原創文章
前兩天,有一個讀者給我發了一張圖片。
我問:發什麼腎麼事了?
於是有了這樣的對話:
他發的圖,就是微信運動步數排行榜的截圖:
其實扯了這麼多,這就是個常見的面試場景題:如何設計一個排行榜?
這個題吧,其實就是考你面試準備範圍的廣度,見過就會答,沒見過...就難說了。
當然,如果你在實際業務中做過排行榜,那麼這題正中下懷,你也不要笑出聲來,場景題面試官是會給你思考時間的。
所以你不要張口就來,你只需要眉頭稍稍一皺,給面試官說:這題我想想啊。
然後稍微組織一下語言,說出來就行。
這次的文章,就帶著大家分析一下“排行榜”這個場景題,到底應該怎麼做。
基於資料庫
這個題,如果是真的之前沒有遇見過,可能最容易進入大家視野的就是平時接觸的最多的資料庫了。
因為一想到“排行榜”,就想到了 order by。
一想了 order by,就想到了資料庫。
一想到了資料庫...
兄弟,你路就走窄了。
雖然我曾經就基於 MySQL 做過排行榜,因為當時是為了一個比賽臨時搭建的服務,根本就沒有引入 Redis。我評估了一下搭建 Redis 的時間和用 MySQL 直接開發的時間。
於是選擇了 MySQL。
而讓我選擇 MySQL 的根本原因還是我已經知道進入決賽的隊伍只有 10 支,也就是說我的排行榜表裡面從始至終也只有 10 條資料。
選手提交程式碼之後,系統實時算分,然後更新排行榜表。
然後介面返回給前端頁面下面這些資料,而下面這些資料都在一個表裡面:
隊伍按照歷史最高分數排名 隊伍名稱 歷史最高分數 最近一次提交得分 最近一次提交時間
前端每隔一分鐘呼叫我的介面,相同分數,名次相同,所以我在介面裡面用一條比較複雜的 sql 去查詢資料庫,上面的這些欄位就都有了。
你看,排行榜確實是可以用 MySQL 來做的。
不一定非得上 Redis,記住一句話:脫離業務場景的方案設計,都是耍流氓。
但是這玩意和“萬物皆物件”一樣,別對著面試官說,這一定不是面試官想要聽到的答案。
或者說,這只是想要聽到的一部分回答。
這個回答能用的原因是我給了一個具體的場景,使用者量非常的小,怎麼玩都可以。
甚至我們不借助 MySQL 的排序,把資料查出來,在記憶體裡面排序都可以。
但是如果,這是一個遊戲排行榜,隨著遊戲玩家的增加,達到千萬使用者級別的話,這個方案肯定是不行了。
當然,也許你會給我扯什麼查詢慢就加索引,資料量大就分庫分表的方案。
怎麼說呢,上面這句話是沒有錯的。
但是一旦資料量大起來了,這個方案其實就不是一個特別好的方案。
這問題,得從根上治理。
基於 Redis
這個場景其實就是在考察你對於 Redis 的 sorted set 資料結構的掌握。
sorted set,見名知意,就是有序集合的意思。
在 Redis 中它大概是長這樣的:
前面的 sport:ranking:20210227 是 Redis 中的 key。
value 是一個集合,且可以看出這個集合是有序的。集合中的每一個 member 都有一個 score,然後按照這個 score 進行降序排序。
需要注意的是,圖片中的 score/member 不是我隨便寫的,官網上就是這樣定義的:
https://redis.io/commands/zadd#sorted-sets-101
而且官網上說的是: score / member pairs。
所以我畫圖的時候,score 在前,member 在後。這可不是隨便畫的,雖然誰前誰後好像也不影響什麼玩意。
另一個需要注意的點是,雖然我的示意圖中沒有體現出來,但是在有序集合中,元素即 member 是不可以重複的,但是 score 是可以重複的。
這個很好理解,就比如 20210227 這一天的微信步數,我可以走 6666 步,你也可以走 6666 步,這個是不衝突:
但是,問題就隨之而來了:當 member 的 score 一樣的時候,member 是怎麼排序的呢?
看一下來自官網的答案:
當多個元素具有相同的分數時,它們按照 lexicographically 進行排序。
哎呀,lexicographically 這個單詞不認識。
不慌,你知道的 why哥還兼職教英文:
當分數一樣的時候,按照字典序排序,所以上面的示意圖 jay 在 why 之前。
接下來,看一下有序集合的操作函式,一共有 32 個:
我這裡就不一個個的做 API 教學了,官網上已經寫的很清楚了,如果對於不熟悉的命令,可以去官網上檢視,都是有示例程式碼的。
https://redis.io/commands/zadd#sorted-sets-101
比如這個 ZADD 方法:
為了後面分享的順利進行,我這裡只講幾個需要用到的操作:
新增 member 命令格式:zadd key score member [score member ...] 增加 member 的 score 命令格式:zincrby key increment member 獲取 member 排名命令格式:zrank/zrevrank key member 返回指定排名範圍內的 member 命令格式:zrange/zrevrange key start end [withscores]
先看第一個:新增 member。
比如我們把示意圖中的資料新增到到有序集合裡面去,語法是這樣的:
zadd key score member [score member ...]
意思是可以一次新增一對或者多對 score-member,比如下面這兩個命令:
zadd sport:ranking:20210227 10026 why zadd sport:ranking:20210227 10158 mx 30169 les 48858 skr 66079 jay
執行之後,返回的數字代表新增成功的 member 個數。
我用專門操作 Redis 的 RDM 視覺化工具來檢視插入的資料,和我自己畫的示意圖相差無幾:
接著看第二個:增加 member 的 score
微信運動排行榜的資料是實時更新的。
目前 member 為 why 的步數是 10268,假設我吃完晚飯出門跑步去了,又跑了 5000 步。
這時得更新我的步數,就用 zincrby 命令,語法是這樣的:
zincrby key increment member
對應上面場景的執行命令是這樣的:
zincrby sport:ranking:20210227 5000 why
執行完成後,會返回 why 的步數,可以看到從 10026 變成了 15026 :
同時由於我的步數增加,按照 score 倒序,也導致了排序的變化:
所以我們只需要更新 score 就行了,至於排名的變化,Redis 會幫忙保證的。
然後看第三個命令:獲取 member 排名
語法是這樣的:
獲取 member 排名:zrank key member 獲取 member 排名:zrevrank key member
首先,排名都是 0 開始計算的。
zrank 是按照分數從低到高返回 member 排名。
zrevrank 是按照分數從高到低返回 member 排名。
比如現在要獲取 jay 的排名,用 zrank 返回結果就是 4。
zrank sport:ranking:20210227 jay
當用 zrevrank 時,jay 的排名就是 0:
zrevrank sport:ranking:20210227 jay
所以,在微信步數排行榜的這個需求中,步數越多排名越靠前,我們應該用 zrevrank。
第四個需要掌握的命令是:返回指定排名範圍內的 member。
zrange/zrevrange key start end [withscores] 返回指定排名範圍內的 member
這個命令就很關鍵了。
zrange 是按照 score 從低到高返回指定排名範圍內的 member。
zrevrange 是按照 score 從高到低返回指定排名範圍內的 member。
在這裡,我只演示 zrevrange 的命令。
比如我要獲取步數排名前三的 member:
zrevrange sport:ranking:20210227 0 2
這個命令有個可選引數:withscores
當帶上這個引數之後,會返回對應 member 的 score:
你想,這不就是排行榜 top N 的場景嗎?
假設我現在要獲取所有使用者的排名,怎麼寫呢?
如下:
zrevrange sport:ranking:20210227 0 -1
這就是當前的微信步數排行榜,jay 步數最多,mx 步數最少。
咦,怎麼回事,排行榜好久就出來了呢?
你想想,講完幾個 API 操作,好像功能就實現了呢?
是的,確實是這樣的,甚至我們只需要這兩個 API 就能完成排行榜的需求:
zadd key score member [score member ...] 新增 member zrange/zrevrange key start end [withscores] 返回指定排名範圍內的 member
好了,如果大家喜歡的話,感謝大家一鍵三連。本次的文章就到這裡了...
那是不可能的。
索然無味的 API 文章多沒有意思啊。
雖然前面的部分我們已經可以基於 Redis 的有序集合加上幾個簡單的命令,就可以實現排行榜需求了。
但是前面只是鋪墊,接下來,好戲才剛剛開始。
再次審視排行榜
上面的微信步數排行榜有個問題,你發現了嗎?
就上面這個場景而言,所有人來看,看到的都是這樣的排序:
而真實情況是,每個人看見的資料排行資料來源自己的微信好友,而微信好友各不相同,所以看到的排行榜也各不相同。
這個特性,我們並沒有體現出來。
我們上面的場景更加類似於遊戲排行榜,所有的人看到的全服排行榜都是一樣的。
那麼怎麼保證我們每個人看到的各不相同呢?
你思考一下,該從什麼角度去解決這個問題呢?
有序集合的 key 不同,就獲取到不同的 value 集合。
我們當前的 key 是 sport:ranking:20210227,裡面只包含了某一天的資訊。
只要我們在 key 裡面加上使用者的屬性就可以了,假設我的微訊號是 why。
那麼 key 可以設計為這樣 sport:ranking:why:20210227。
這樣,由於 key 裡面多了使用者資訊,每個人的 key 都各不相同,就像這樣的:
對應的命令如下:
zadd sport:ranking:why:20210227 10026 why 10158 mx 30169 les 48858 skr 66079 jay zadd sport:ranking:mx:20210227 7688 趙四 9688 劉能 10026 why 10158 mx 54367 大腳
why 和 mx 看到的都是各自好友某一天的微信步數排行榜。
只要把 key 設計好了,這個問題就迎刃而解了。
但是你仔細思考一下,真的就迎刃而解了嗎?
這個問題,我在寫第一版的時候可能是被豬油矇蔽了雙眼,沒發現。
有種“只緣身在此山中”的味道,一心想著 Redis 了。
你想,如果每個使用者都有在redis有一個自己的排行榜,一個使用者的分數更新的時候就需要對所有好友的zset更新,這多大的代價啊,對吧?
當以使用者為緯度做排行榜的時候,就會出現排行榜巨多的情況,導致維護成本升高。
Redis能做,但不是最佳方案。
那麼用什麼方案去做呢?
我提個思路吧:
每個使用者看到的排行榜不一樣,我們其實不用時時刻刻幫使用者維護好排行榜。
維護好了,使用者還不一定來看,出力不討好的節奏。
所以還不如延遲到使用者請求的階段。
當使用者請求檢視排行榜的時候,再去根據使用者的好友關係,迴圈獲取好友的步數,生成排行榜。
具體方案,大家自己思考一下吧。
另外多說一嘴,前段時間不是微信支援了修改微訊號嗎,贏得一大片叫好聲。
其實我當時認真的想了一下,從技術上的實現來說這個需求到底有多難。
我不知道有沒有歷史技術債務在裡面。
但是就說當前這個場景,key 裡面包含了微訊號,注意是微訊號,不是微信暱稱。
因為在設計之初,產品打包票說:放心,微訊號絕對全域性唯一,一旦確定,不可變更。
結果呢,現在要變化了。
產品屁顛屁顛的說:怎麼實現我不管,這個需求使用者呼籲很大,趕緊上線。
你說,對這些類似場景的衝擊有多大?
其實衝擊也不算特別大,一個欄位的變化而已。
但是,微信 14 億使用者啊。
一個簡單的需求,涉及到這個體量之後,就一句話:
量變引起質變。
好了,好了,扯遠了。說回來。
當我把目光再次放到微信排行榜上的時候,我發現,其實我只是給了一個閹割版的排行榜。
是的,我們現在可以獲取到 why 的當前步數是 1680 步,當前排名是 814 名。
比如還是沿用上面的例子,假設現在要獲取我的微信好友 jay 的微信步數排行榜情況。
先獲取 jay 的名次:
zrevrank sport:ranking:why:20210227 jay
名次為 0,程式裡面可以對其進行加一操作。就是第一名了。
接著獲取 jay 的今日步數:
zscore sport:ranking:why:20210227 jay
66079,步數也有了。
現在我們知道了:why 的好友 jay 今日運動步數 66079 步,在 why 的微信好友中排第一名。
但是你仔細看,這上面我還漏了兩個欄位:
微信頭像 朋友點贊個數
兩個欄位應該怎麼放呢?
放資料庫裡面當然可以,但是我們主要還是說一下 Redis 的解決方案。
這個時候其實我們想要儲存的是 User 物件,物件裡面有這幾個欄位:暱稱、頭像圖片連結、點贊數、步數。
你說,這個用 Redis 的啥資料結構來存?
可不就得用 Hash 結構了嗎。
Hash 結構同樣涉及到 key 和 value,那麼它們分別是什麼呢?
key 就是我們的有序集合的 key 後面再加上好友暱稱,比如這樣的:
對應的命令是這樣的:
hmset sport:ranking:why:20210227:jay nickName jay headPhoto xxx likeNum 520 walkNum 66079
執行完成之後,在 RDM 裡面看起來是這樣的:
當後續有更多的讚的時候,需要呼叫更新命令更新 likeNum:
hincrby sport:ranking:why:20210227:jay likeNum 500
執行完成之後點贊數就會變成 1020:
這樣,排行榜上的所有欄位我們都能獲取到了,微信排行榜就說完了。
呃......
怎麼感覺還是 API 教學呢?
不得勁,換個其他的。
最近七天排行榜怎麼弄?
前面我們說的都是每日排行榜。
假設面試官要求我們提供一個最近七天、上一週、上一月、上個季度、這一年排行榜啥的,又該怎麼搞呢?
其實這還是在考察你對於 Redis 有序集合 API 的掌握程度。
也就是這個 API:
zinterstore/zunionstore destination numkeys key [key ...] [weights weight [weight ...]] [aggregate sum|min|max] 獲取交集/並集
這個 API 看起來有點複雜,不要怕,一個個的講:
zinterstore/zunionstore其實就是交集/並集 destination 將交集/並集的結果儲存到這個鍵中 numkeys 需要做交集/並集的集合的個數 key [key ...] 具體參與交集/並集的集合 weights weight [weight ...] 每個參與計算的集合的權重。在做交集/並集計算時,每個集合中的 member 會把自己的 score 乘以這個權重,預設為 1。 aggregate sum|min|max 對於各個集合中的相同元素是 sum(求和)、min(取最小值)還是max(取最大值),預設為 sum。
拿最近七天舉例,我們隨便搞點資料進來,你可以直接粘過去玩:
zadd sport:ranking:why:20210222 43243 why 2341 mx 8764 les 42321 skr zadd sport:ranking:why:20210223 57632 why 24354 mx 4231 les 43512 skr 5341 jay zadd sport:ranking:why:20210224 10026 why 12344 mx 54312 les 34531 skr 43512 jay zadd sport:ranking:why:20210225 54312 why 32451 mx 23412 les 21341 skr 56321 jay zadd sport:ranking:why:20210226 3212 why 63421 mx 53652 les 45621 skr 5723 jay zadd sport:ranking:why:20210227 5462 why 10158 mx 30169 les 48858 skr 66079 jay zadd sport:ranking:why:20210228 43553 why 4451 mx 7431 les 9563 skr 8232 jay
可以看到我們一共有 7 天的資料:
而且需要注意的是 20210222 這一天是沒有 jay 的資料的。
現在我們要求出最近 7 天的排行榜,就用下面這行命令,命令有點複雜,但是對著命令格式看,還是很清晰的:
zunionstore sport:ranking:why:last_seven_day 7 sport:ranking:why:20210222 sport:ranking:why:20210223 sport:ranking:why:20210224 sport:ranking:why:20210225 sport:ranking:why:20210226 sport:ranking:why:20210227 sport:ranking:why:20210228 weights 1 1 1 1 1 1 1 aggregate sum
這條命令後面的 weights 和 aggregate 都是可以不用寫的,有預設值,我這裡為了不隱藏資料,都寫了出來。
執行完成後,可以看到多了一個 key,裡面放的就是最近 7 天的資料彙總:
上面用的是並集,如果我們的要求是對最近 7 天,每天都上傳運動資料的人進行排序,就用交集來算。
命令和上面的一致,只是把 zunionstore 修改為 zinterstore 即可。
另外為了有對比,合併之後的佇列名稱也修改一下,命令如下:
zinterstore sport:ranking:why:last_seven_day_zinterstore 7 sport:ranking:why:20210222 sport:ranking:why:20210223 sport:ranking:why:20210224 sport:ranking:why:20210225 sport:ranking:why:20210226 sport:ranking:why:20210227 sport:ranking:why:20210228 weights 1 1 1 1 1 1 1 aggregate sum
從執行結果可以看出來,由於 jay 同學在 20210222 這一天沒有上傳運動資料,所以取交集的時候沒有他了:
知道最近 7 天的做法了,我們又有每一天資料,上一週、上一月、上個季度、這一年排行榜啥的不都是這個套路嗎?
呃......
怎麼感覺還是 API 教學呢?
還是不得勁,再換個其他的。
億級使用者排行榜
王者榮耀,妥妥的億級使用者吧。比如我想看看我在億級使用者中排多少名,於是我開啟了遊戲,二十多分鐘(玩了一局)之後我終於找到排行榜的位置。
結果,未上榜:
我這個千年老夫子,當然是未上榜了。
就算真的有排名了,排名好幾千萬,8 位數字,在頁面上也不好放呀。
但是假設現在的需求就是要查詢使用者的全服排名,怎麼查?
我瞎說一個我能想到的基於 Redis 的初版方案,注意是我瞎想的,實際做起來肯定是異常複雜的方案。
我是怎麼想的呢?
我就尋思,一般面試遇到什麼千萬條資料、幾個 G 檔案、上億的資料啥的,首先想到的方案就是分而治之。
這個億級使用者排行榜的需求也得用分治的思想。
王者一共 8 個段位:
1、倔強青銅 2、秩序白銀 3、榮耀黃金 4、尊貴鉑金 5、永恆鑽石 6、至尊星耀 7、最強王者 8、榮耀王者
所以我們可以有 8 個桶。
這個桶可以是一個 Redis 裡面的 8 個不同的 key,甚至可以是 8 個 Redis 裡面各一個 key,看面試官給你的經費是多少,錢多就可勁造。
如下圖所示:
解釋一下上面的圖片中 score 為 8588 是怎麼來的。
首先我們用 Redis 的有序集合,那麼我們就得給每個 member 一個 score。
所以,每個使用者在桶裡面都一個經過公式計算後得出的積分。
比如why哥現在的段位就是星耀,假設計算出來的分數是 8588。
那麼現在要算why哥在全服的排名就很好算了:
寫程式的時候是可以知道我現在的段位是星耀,那麼直接去星耀的桶裡面,用 zrevrank 計算出當前桶裡面的排名,假設為 n。
然後再通過 zcard 這個 O(1) 的命令獲取到,前面的桶,也就是最強王者和榮耀王者這兩個桶的集合大小,分別為 y 和 x。
那麼why哥的全服排名就是 n+y+x。
所以獲取任何一個使用者的全服排名,就是看他在自己的桶裡面的排名加上前面桶裡面的元素個數即可。
而且現在要計算全服 top 100 就很容易了嘛。
直接取最前面的桶,也就是榮耀王者裡面的前 100 個就完事了。
搞定。
等等,真的搞定了嗎?
思路是對了,但是對於億級使用者只分 8 個桶未免太少了吧?
那就繼續分桶唄,別忘了,每個段位裡面還有小段位的。
比如星耀,裡面就有星耀五到星耀一五個小段位,青銅三到青銅一三個小段位。
全部算上就是 27 個桶。
但是,27 個桶也少。
那麼星耀二到星耀一還需要五顆星、青銅三到青銅二要三顆星才行呢。
這樣算下來,就是 160 個桶。
160 個桶還是不夠?
額。。。
推翻重來,直接把段位加上各種其他條件換算成積分,然後按照積分來拆分:
這樣,想怎麼拆分數段都行、拆多細都行。
完美。
等等,真的完美嗎?
你看我的積分範圍,都劃分的非常的均勻。
按照段位拆分,有些菜雞選手,打了兩把覺得沒意思,罵罵咧咧的退出遊戲,就一直留在了青銅段位。
所以青銅段位的選手肯定是遠大於榮耀王者的。
所以,實際情況下,使用者的落點其實並不是均勻的。
怎麼辦?
這個時候就需要進行資料分析,通過一系列的高數、概率、離散等知識去做個桶大小的預估。
啊,這玩意就超綱了啊。
那就告辭,收工。
技術之外的考慮
做一個排行榜好像是一個很簡單的事情。
但是其實不然,特別是推薦類的排行榜,需要避免馬太效應:
比如作者推薦榜單,被推薦到前面的作者,曝光度很高。即使輸出質量下降,但是還是很容易獲得更多的關注。
位於榜單尾部的作者就很沒有參與感。
於是兩極分化就出現了,馬太效應就來了。
對於這種情況怎麼處理呢?
裡面就涉及到一個複雜的計算公式了,比如掘金社群的掘力值,用於訊息流推薦和作者榜單:
https://juejin.cn/book/6844733795329900551/section/6844733795380232206
所以千萬不要錯誤的以為排行榜是一個非常簡單的需求,這裡面涉及到一些非常複雜的演算法。
最後說一句
感謝大家的閱讀。
才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,可以在後臺提出來,我對其加以修改。