系統設計 - 設計Instagram服務
February 11, 2020設計Instagram/Facebook Feed/Twitter是系統設計的常見考題 這篇文章我們就來Mock一下迷途書僮 看看這些年來他準備系統設系準備的如何
還記得上次我們跟迷途書僮聊天 聊了許多關於如何大方向的準備系統設計 請參考系統設計 - Design cache
我們還討論了如何設計縮網址服務
至於為什麼把instagram/Facebook feed/Twitter集合在一起 是因為news feed的概念比較類似
Design Instagram
jyt0532: 今天我們來設計Instagram 概念很簡單
1.使用者可以發文 這個文章可以有文字/照片/影片
2.可以follow別人
3.可以生成NewsFeed 代表你follow的所有人的所有post 按照重要性順序列出來給你看
Requirement
迷途書僮: 好 現在我要問系統的需求 Consistency跟Availability哪個重要
jyt0532: Availability 畢竟有一個人發文之後 過了一下子才出現在別人的動態牆上問題也不大
迷途書僮: 感覺最麻煩的地方是News Feed generation 這個需求的latency要求呢
jyt0532: 200ms
迷途書僮: 還有其他要求嗎
jyt0532: 系統要非常reliable 使用者上傳的照片跟影片不能丟失
Constraints
迷途書僮: 我想知道我們要處理的是多大的系統 Daily Active User/Daily Post等等
jyt0532:
1.300M DAU
2.平均每個人每天會去看timeline 5次
3.200M new post everyday
4.發的文有文字也有照片 就假設平均post大小是10KB吧
迷途書僮:
300M * 5 / 24 / 3600 = 17361 QPS for feed generation request
200M / 24 / 3600 = 2314 QPS for create post
Space needed to store post a day: 200M * 10KB = 2TB
Space needed for 10 years: 2 * 10 * 365 = 7.3PB
API design
jyt0532: 接下來來設計一下服務的API吧
第一個: createPost(api_dev_key, user_id, text, media_ids)
就這麼簡單 text是這篇post的文字 media_ids是array of photos/videos ids
api_dev_key 指的是允許呼叫你的前端的token 讓server知道現在是誰在發送請求 我們就可以分配/控制每個前端發送請求的bandwidth 比如我們可以限制每個使用者的請求數量 用途是避免惡意對手DDOS搞爆你的服務
回傳feed id
第二個: getUserFeed(api_dev_key, user_id, since_id, count)
回傳array of feed id
jyt0532: 你這個since_id
跟count
基本上就是pagination的參數 為什麼你pagination的參數這麼選擇呢
迷途書僮: 就像斯斯有兩種 pagination也有兩種
1.Offset based: 就是給定從第幾個開始(offset) 要回傳幾個(count) 這是最常見的
2.Cursor based: 就是給一個參考的物件(cursor)(可以是postId 也可以是timestamp) 從那個之後要回傳幾個(count)
jyt0532: 什麼時候用哪個呢
迷途書僮: 取決於你內容是靜態還是動態 如果是靜態的就用offset based 比如說Google search的結果 如果是動態的就用Cursor based 比如說feed generation 這樣不會說我過了一分鐘後發一樣的request拿到不一樣的結果
更多細節可以參考How to do Pagination?
Database design
迷途書僮: 我們需要至少以下幾個table
User Table:
UserId(Primary Key) | Name | DateOfBirth | CreateDate | LastLogin | |
---|---|---|---|---|---|
int | varchar(20) | varchar(32) | datetime | datetime | datetime |
UserFollow:
Follower(Primary Key) | Followee(Primary Key) |
---|---|
int | int |
Feed Table:
FeedId(Primary Key) | UserId | Content | CreateDate | NumsOfLike |
---|---|---|---|---|
int | int | varchar(256) | datetime | int |
Media Table:
MediaId(Primary Key) | Type | Title | Path | CreateDate |
---|---|---|---|---|
int | int | varchar(256) | varchar(256) | datetime |
Path就是實際存放這個照片/影片的路徑 我們可以放在S3的機器上面
凡是跟檔案有關的系統設計題 只有檔案的meta可以用Database存儲 檔案本身必須用其他的存儲 比如HDFS或是S3
FeedMedia Table:
FeedId(Primary Key) | MediaId(Primary Key) |
---|---|
int | int |
把這些Table都使用RMDB存雖然簡單 但很難scale
對於不同的table 我們可以選擇不同的NoSQL 比如說在這個例子 User
Feed
Media
就可以用key-value store的NoSQL
比如說Redis/Dynamo/Voldemort 在這裡key就是PhotoId, value就是Photo的metaData等等
但對於Association table UserFollow
FeedMedia
我們可以用wide-column的NoSQL比如說Cassandra 以UserFollow
為例 key就是UserId, value就是所有這個user follow的人 以FeedMedia
為例 key就是FeedId, value就是這個feed所擁有的mediaId
把Cassandra想的簡單一點 就是把數據存進這個資料結構
Map<PartitionKey, SortedMap<ColumnKey, ColumnValue>>
Algorithm
jyt0532: 這個系統設計問題的難點有兩個 第一個是如何生成Feed 第二個是如何推送Feed
生成Feed
迷途書僮: 基本演算法如下 當Alex要看他的Timeline時
1.系統找出所有Alex的朋友userIds
2.對於每個userIds 找出他們最popular或最重要的post
3.對於post排序
4.把結果存在cache 回傳前20個當作請求的回傳值
5.當前端讀完了前20個 可以再發個請求要接下來的20個
要注意的是如果Alex一直在線上 那我們可能每五分鐘要重新生成一次NewsFeed 更新Alex的Timeline給他
基本的SQL長這樣(當然我們不一定選擇的是SQL Database 但SQL語言是最好理解的 所以解說的時候用SQL 實作的時候在轉成選定的NoSQL syntax)
假設Alex的ID是123
看起來簡單 但有幾個問題
1.慢 很慢 非常慢 特別是如果Alex follow了很多人的情況下
2.這個之後還要sort
3.每當被follow的人有了新的post 這個SQL都必須重跑 才跑得出新的那篇post
既然online生成news feed無法接受 那就試試offline吧 做法也簡單 就是有事沒事就先跑出來 Alex想看Feed的時候就直接從Memory拿出來給他
那既然我們都已經花時間預先生成了News Feeds 我們勢必要有個資料結構可以讓我們online存取更快 這個資料結構需要達成幾件事
-
每個使用者都預先生成好
-
要知道上次替這個使用者預先生成的時間
-
當使用者讀完前20個 要再往下看的時候 可以直接再給他20個
答案呼之欲出 我們需要的資料結構就是
LinkedHashMap基本上就是Double-LinkedList 加上HashMap 就是我們在實作LRU的時候常用到的資料結構
當我們有了這個之後呢 我們可以輕鬆的iterate feeds
這個map 當使用者想要再看20個 我們就輸入最後一個feed的feedId 就可以輕鬆再iterate 20個
jyt0532: 看起來不錯 但你這個LinkedHashMap要為每個使用者存多少entry呢 50? 500? 5000?
迷途書僮: 可以先預設500 但也可以根據每個使用者改變 如果是重度使用者 我們可以很頻繁的更新 每次都500個 如果是個很少使用的人 那50個也無妨
jyt0532: 對每個使用者都這樣做未免也太累了吧
迷途書僮: 我們可以只對DAU做 我們也可以用一個LRU Cache來把很少login的使用者的預先計算的map踢出cache 再聰明一點 我們學習每個user的使用pattern 在他上線之前先算好就可以 這樣可以省去很多重複的計算
推送Feed
jyt0532:剛剛說明的都是一般的情況 Alex想看他的News Feed 系統就給他
那如果Alex一直把Instagram/Facebook的頁面開著 如果Alex follow的人有了新的post 我們如何更新Alex的Timeline呢
換個角度來說 如果Alex發了篇新的文章 我們如何快速的讓所有follow Alex的人看到更新呢
迷途書僮: 基本上有兩種方式
Pull model
又稱為Fan-out on load 意思就是client定期的跟server要更新 缺點有兩個
1.當有新的post進來時 follower不會第一時間知道 要等到client去問的時候才知道
2.大多數情況下 client跟server問更新時什麼都不會回傳 白白浪費bandwidth
Push model
又稱為Fan-out on write 意思就是只要有Followee寫了新post 就會發送給所有followers 這就只會在真的有更新的時候才需要使用到網路流量 但先決條件就是client跟server必須先維持一個Long Poll
缺點也很明顯 如果一個名人發文的話 push model會在短時間內需要做很多的事
jyt0532: 那你的選擇是?
迷途書僮:
我們兩種一起用!
hybrid model
我們對於follower不多的人 使用Push model
對於follower多的人 使用Pull model
所以今天如果一般人發了一篇feed 就會直接即時的保存到所有追蹤者的預先生成的資料結構裡 如果是名人 就先不做事
當有人上線要看timeline時 系統要做的是就是除了找到預先算好的feed之外 再另外去問你追蹤的名人的feed 合在一起再傳給使用者
這是最簡單的綜合解法 當然對於follower很多的名人 我們可以對所有在線上的followers使用Push model也可以 這也大幅的減少了對所有人Push所需要的bandwidth
Ranking
jyt0532: 對於一個Timeline裡面的Feed排序 該怎麼做
迷途書僮: 當然就是把 createdTime, numberOfLikes, numberOfComments, numberOfShares, havePhotos/Videos等等的feature用機器學習的模型產生個分數 這就需要ML專家出馬
當然好的Ranking算法是會自我調整 一直觀察使用者的使用情況(stickiness/retention/revenue)來分析改進的
Data sharding
jyt0532: 我們一天產生2TB的文章要存 總不可能都放在同一個機器吧 所以勢必要處理sharding的問題 有兩個東西要分區
1.Sharding feed
2.Sharding post and metadata
迷途書僮:我們各個擊破
Sharding feed
簡單 我們有的是
那就shard by UserId 搞定
Sharding post and metadata
迷途書僮: 最直觀的方式 Shard by UserID
假設有N台機器 就把發文的使用者UserId -> Hash(UserId)%N
就知道要存到哪台機器 要讀取的時候用一樣的算法找到我們把UserId的文章存在哪台機器中
jyt0532: 這個做法會有一些問題 最常見的就是hot user 這樣對於少量的機會很容易會有大量的請求 況且除了unbalanced Traffic之外 還有unbalanced Storage的問題 也就是發文的都是那幾個人 不平均分佈的存儲也是很惱人的地方 有其他解法嗎
迷途書僮: 不然就 Shard by PostId
PostId -> Hash(PostId)%N
jyt0532: 這樣同一個發文者的Post分散在各台機器 你怎麼集合起來?
迷途書僮: 比如我們要找某個名人的發文 我們要對所有的機器找 每台機器回傳它所存的名人的發文
演算法變成
1.Alex要看他的timeline
2.系統拿到了預先生成好的Feed
3.系統問UserFollow table 來找出Alex所有朋友/Followee
4.對所有FeedTable問說每個Alex追蹤的名人的文章
5.Combine and Rank
jyt0532: 這的確是解決了hot server的問題 但每次都問所有database 也會造成極高的latency 有其他解法嗎
迷途書僮: 不然就 Shard by creation time
jyt0532: 這麼狂的嗎
迷途書僮: 按照發文的creation time來存可以保證每次都只需要query一小部分的機器
而且不得不說 如果key裡面有creation time真的很爽 我們就不需要在creationTime這個field另外index 省掉的index可以讓我們讀寫latency下降
jyt0532: 那unbalanced traffic的問題又出現了不是嗎 每次都寫進讀取那小部分的機器
迷途書僮: 那就只好再做一次撒尿牛丸了
jyt0532: 願聞其詳
迷途書僮: 我們把PostId跟Creation time合起來 shard
我們需要做的事 就是把timestamp加進PostId裡面
jyt0532: 你的PostId到底要多長?
迷途書僮: PostId = timeStamp + counter 假設我們系統維持50年
50 * 365 * 24 * 3600 = 1.57*10^9 < 2^31
我們總共需要31個bit來代表一個timestamp
每個秒需要多少counter呢 我們的QPS是2314 < 2^12
12bit可以應付2314(平均)的QPS 但考慮到尖峰時段的QPS 還有給多留點後路加上湊個8的倍數 我們就選17吧
這樣PostId的長度就是31+17 = 48 bits
假設現在時間是1575936000
那這一秒產生的FeedId就是
1575936000 000001
1575936000 000002
1575936000 000003
…依此類推
這個設計避免了只shard by postId的熱門文章問題 也避免了相同timestamp的文章跑到同一機器的hot partition問題 因為這個Id的Hash被平均的打散了
這個做法雖然在生成timeline的時候還是需要問所有的database 但是第一 這個設計減少了寫的時間(因為我們不需要對createdTime index) 第二 read會變得非常的快 因為我們不用再filter by createdTime
總結
jyt0532: 很好 我想今天也討論的差不多了 今天的面試中 我們複習了不少概念 其中重要的是:
1.系統設計面試的流程
2.API design中的pagination設計
3.不同Table可以用不同的Database存
4.快速Serve Timeline的資料結構
5.推/拉/混合 的推送Feed方式
6.Shard Data的方法
有機會再來討論其他題目吧 我們下次見!