涼了呀,面試官叫我設計一個排行榜。

這是why哥的第89篇原創文章前端

前兩天,有一個讀者給我發了一張圖片。面試

我問:發什麼腎麼事了?redis

因而有了這樣的對話:算法

.png)sql

他發的圖,就是微信運動步數排行榜的截圖:數據庫

其實扯了這麼多,這就是個常見的面試場景題:如何設計一個排行榜?微信

這個題吧,其實就是考你面試準備範圍的廣度,見過就會答,沒見過...就難說了。數據結構

固然,若是你在實際業務中作過排行榜,那麼這題正中下懷,你也不要笑出聲來,場景題面試官是會給你思考時間的。函數

因此你不要張口就來,你只須要眉頭稍稍一皺,給面試官說:這題我想一想啊。工具

而後稍微組織一下語言,說出來就行。

此次的文章,就帶着你們分析一下「排行榜」這個場景題,到底應該怎麼作。

基於數據庫

這個題,若是是真的以前沒有碰見過,可能最容易進入你們視野的就是平時接觸的最多的數據庫了。

由於一想到「排行榜」,就想到了 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 個段位:

  • 一、倔強青銅
  • 二、秩序白銀
  • 三、榮耀黃金
  • 四、尊貴鉑金
  • 五、永恆鑽石
  • 六、至尊星耀
  • 七、最強王者
  • 八、榮耀王者

因此咱們能夠有 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

因此千萬不要錯誤的覺得排行榜是一個很是簡單的需求,這裏面涉及到一些很是複雜的算法。

最後說一句

感謝你們的閱讀。

才疏學淺,不免會有紕漏,若是你發現了錯誤的地方,能夠在後臺提出來,我對其加以修改。

相關文章
相關標籤/搜索