前言

系統設計實踐篇的文章將會根據《系統設計面試的萬金油》為前置模板,講解數十個常見系統的設計思路。

前置閱讀:

設計目標

讓我們設計一個像Instagram這樣的照片分享的社交網站,使用者可以上傳照片分享給其他使用者。

一. 什麼是Instagram?

Instagram是一種社交網路服務,使用者可以上傳和分享自己的照片、視訊給其他使用者。Instagram使用者可以選擇公開或私下分享資訊。公開共享的內容都可以被任何其他使用者看到,而私有共享的內容只能被指定的一組人訪問。Instagram還允許使用者通過Facebook、Twitter、Flickr和Tumblr等其他社交網路平臺進行分享。

在此,我們計劃設計一個更簡單的Instagram,使用者可以分享照片,也可以關注其他使用者。每個使用者的動態訊息將包含該使用者關注的所有人的照片動態。

二. 系統的需求與目標

在設計Instagram時,我們將重點關注以下需求

功能性需求

  1. 使用者可以上傳下載檢視照片
  2. 使用者可以根據視訊或者照片標題搜尋
  3. 使用者之間可以互相關注
  4. 該系統應該能夠生成並顯示使用者關注的所有人的熱門照片組成的“新聞動態(News Feed)”。

非功能性需求

  1. 我們的服務需要高可用性。
  2. 對於好友動態的生成,系統可接受的延遲是200ms。
  3. 一致性可能會受到影響(出於可用性的考慮),如果使用者一段時間沒有看到照片,應該沒問題
  4. 系統應高度可靠;任何上傳的照片或視訊都不能丟失。

不用考慮

給照片新增標籤,在標籤上搜索照片,在照片上評論,給照片使用者加標籤,跟蹤誰,等等

三. 系統分析

該系統將具有大量的讀操作,因此我們將重點構建一個能夠快速檢索照片的系統。

  1. 實際上,使用者可以上傳任意多的照片。在設計該系統時,有效的儲存管理應該是一個關鍵因素。
  2. 在檢視照片時,預計延遲較低。
  3. 資料應該是100%可靠的。如果使用者上傳了一張照片,系統將保證它永遠不會丟失。

四. 容量估計與約束

  • 假設我們有5億使用者,每天有100萬活躍使用者
  • 每天200萬張新照片,每秒23張。
  • 平均照片檔案大小=> 200KB
  • 一天照片需要的儲存量

2M * 200KB => 400 GB

  • 十年需要的儲存空間

400GB * 365(day a year) * 10 (years) ~= 1425TB

五. 高階設計

在高階設計中,我們需要支援兩種方案,一種是上傳圖片,另一種是檢視和搜尋圖片。

我們需要物件儲存伺服器來儲存照片,還需要一些資料庫伺服器來儲存關於照片的元資料資訊。

六. 資料庫設計

早期階段定義DB模型將有助於理解不同元件之間的資料流,並在之後進行資料分割槽

我們需要儲存使用者、使用者上傳的照片以及使用者關注的人的資料。照片表將儲存一張照片相關的所有資料,我們需要在(PhotoID, CreationDate)上有一個索引,因為我們需要獲取最近的照片。

Photo User UserFollow
[PK] Photo ID: int [PK] UserID: int [PK] UserID1: int
UserID: int Name: varchar(20) [PK] UserID2: int
PhotoPath: varchar(256) Email: varchar(20)
PhotoLatitude: int DateOfBirth: datetime
PhotoLongitude: int CreationDate: datetime
UserLatitude: int LastLoginDate: datetime
UserLongitude: int
CreationDate: datetime

儲存上述表結構的一種簡單方法是使用像MySQL這樣的RDBMS,因為我們有連表查詢的場景。但是關係資料庫也存在其他挑戰。關於細節請參閱SQL vs NoSQL

我們可以將照片儲存在像HDFS或S3這樣的分散式檔案儲存中,將上述表結構儲存在分散式鍵值儲存中,以享受NoSQL提供的好處。所有與照片相關的元資料都可以進入一個表,其中的鍵是PhotoID,值是一個包含PhotoLocation、UserLocation、CreationTimestamp等的物件。

我們需要儲存使用者和照片之間的關係,知道誰擁有哪張照片。我們還需要儲存使用者關注的人的列表。對於這兩個表,我們可以使用像Cassandra這樣的寬列資料儲存。對於UserPhoto表,鍵是UserID,值是使用者擁有的PhotoID列表,儲存在不同的列中。對於UserFollow表,我們將有一個類似的方案。

一般來說,Cassandra或鍵值儲存總是維護一定數量的副本,以提供可靠性。此外,在這樣的資料儲存中,刪除操作不會立即生效,資料在從系統中永久刪除之前會保留幾天(以支援反刪除)。

七. 資料估算

讓我們估算一下每個表中會有多少資料,以及10年裡我們總共需要多少儲存空間。

使用者表

假設每個intdateTime都是四個位元組,那麼使用者表中的每一行都是 68 個位元組

UserID (4 bytes) + Name (20 bytes) + Email (32 bytes) + DateOfBirth (4 bytes) + CreationDate (4 bytes) + LastLogin (4 bytes) = 68 bytes

如果我們有5億使用者,我們將需要32GB的總儲存空間。

500 million * 68 ~= 32GB

照片表

Photo表中的每一行都是284位元組

PhotoID (4 bytes) + UserID (4 bytes) + PhotoPath (256 bytes) + PhotoLatitude (4 bytes) + PhotLongitude(4 bytes) + UserLatitude (4 bytes) + UserLongitude (4 bytes) + CreationDate (4 bytes) = 284 bytes

如果每天有2M的新照片上傳,我們一天需要0.5GB的儲存空間

2M * 284 bytes ~= 0.5GB per day

未來10年,我們需要1.88TB的儲存空間。

使用者關注表

UserFollow表中的每一行都由8個位元組組成。如果我們有5億使用者,平均每個使用者關注500個使用者。我們需要1.82TB的儲存空間來儲存UserFollow表。

500 million users * 500 followers * 8 bytes ~= 1.82TB

所有表10年所需的總空間為3.7TB

32GB + 1.88TB + 1.82TB ~= 3.7TB

八. 元件設計

照片上傳(寫操作)可能會很慢,因為它們必須進入磁碟,而讀取將會更快,特別是當它們是從快取讀取來提供服務時。

使用者上傳操作有可以消耗所有可用的連線,因為上傳是一個緩慢的過程。 這意味著如果系統忙於處理所有寫入請求,則無法提供讀取服務。 在設計我們的系統之前,我們應該記住 Web 伺服器有一個連線限制。 如果我們假設一個 Web 伺服器同時最多可以有 500 個連線,那麼它的併發上傳或讀取不能超過 500。 為了解決這個瓶頸,我們可以將讀取和寫入拆分為單獨的服務。 我們將用於讀取的伺服器和用於寫入的伺服器進行分離,以確保上傳不會影響到讀取。

九. 可靠性與冗餘性

我們需要保證使用者上傳的圖片不會丟失,並且可檢視。因此,我們將為每個檔案儲存多個副本,這樣,如果一個儲存伺服器掛掉,我們可以從另一個儲存伺服器上的副本檢索照片。

同樣的原則也適用於系統的其他元件。如果我們希望系統具有高可用性,我們需要在系統中執行多個服務副本,這樣,如果一些服務宕機,系統仍然可用並在執行。冗餘消除了系統中的單點故障。

如果在任何時刻只需要一個服務例項執行,我們可以執行不服務任何流量的服務的冗餘輔助副本,但當主伺服器出現問題時,它可以在故障轉移後接管控制權。

在系統中建立冗餘可以消除單點故障,並在主節點出現問題時提供備份或備用功能。例如,如果同一服務的兩個例項執行在生產環境中,其中一個例項發生故障或降級,則系統可以故障轉移到正常的副本。故障轉移可以自動發生,也可以人工干預。

十. 資料分片

讓我們討論元資料分片的不同方案。

方案一. 基於UserID分片

假設我們基於UserID進行分片,這樣我們就可以將一個使用者的所有照片儲存在同一個分片上。如果一個DB Shard是1TB,那麼我們需要4個shard來儲存3.7TB的資料。讓我們假設為了更好的效能和可伸縮性,我們保留10個分片

因此,我們將根據UserID % 10找到分片數,然後將資料儲存在那裡。為了在我們的系統中唯一地識別任何照片,我們可以在每個PhotoID後面新增分片號。

如何生成PhotoID? 每個DB分片都可以有自己的PhotoID自動遞增序列,因為我們將用每個PhotoID新增ShardID,它將使它在整個系統中獨一無二。

這個分割槽方案有什麼問題

  • 我們將如何處理熱門使用者? 一些人關注這些熱門使用者,很多人看到他們上傳的任何照片。
  • 有些使用者會比其他人擁有更多的照片,從而造成儲存的不均勻分佈。
  • 如果我們不能將一個使用者的所有圖片儲存在一個分片上,該怎麼辦? 如果我們將一個使用者的照片分發到多個分片上會導致更高的延遲
  • 將一個使用者的所有照片儲存在一個分片上可能會導致一些問題,比如如果分片宕機,所有使用者的資料都不可用,或者如果分片負載高,延遲會更高,等等。

方案二. 基於PhotoID分片

如果我們能先生成唯一的PhotoID,然後通過PhotoID % 10找到一個分片號,那麼上述問題就解決了。在這種情況下,我們不需要在ShardID後面加上PhotoID,因為PhotoID本身在整個系統中是唯一的。

我們怎樣才能生成PhotoIds?

在這裡,我們不能在每個Shard中使用自動遞增序列來定義PhotoID,因為我們需要知道PhotoID才能找到儲存它的Shard。一種解決方案是我們專門使用一個單獨的資料庫例項來生成自動遞增的 ID(參考美團Leaf實現)。 如果我們的 PhotoID 可以容納 64 位,我們可以定義一個只包含 64 位 ID 欄位的表。 所以每當我們想在我們的系統中新增一張照片時,我們可以在這個表中插入一個新行,並將該 ID 作為我們新照片的 PhotoID。

生成DB的金鑰不是單點故障嗎?

是的。解決方法是定義兩個這樣的資料庫,一個生成偶數id,另一個生成奇數id。對於MySQL,下面的指令碼可以定義這樣的序列。

KeyGeneratingServer1:
auto-increment-increment = 2
auto-increment-offset = 1
KeyGeneratingServer2:
auto-increment-increment = 2
auto-increment-offset = 2

我們可以在這兩個資料庫前面放置一個負載均衡器,以便在它們之間進行輪詢並處理。這兩個伺服器可能不同步,其中一個生成的金鑰比另一個多,但這不會在我們的系統中造成任何問題。我們可以通過為Users、Photo-Comments或系統中存在的其他物件定義單獨的ID表來擴充套件這種設計。

另外,我們可以實現一個金鑰生成方案,類似於我們在設計一個URL短鏈服務(如TinyURL)中討論過的方案KGS。

如何規劃我們系統的未來發展?

我們可以有大量的邏輯分割槽來適應未來的資料增長,這樣以來,多個邏輯分割槽駐留在單個物理資料庫伺服器上。 由於每個資料庫伺服器上可以有多個數據庫例項,我們可以為任何伺服器上的每個邏輯分割槽擁有單獨的資料庫。 所以每當我們覺得某個特定的資料庫伺服器有很多資料時,我們可以將一些邏輯分割槽從它遷移到另一臺伺服器。 我們可以維護一個配置檔案(或一個單獨的資料庫),將我們的邏輯分割槽對映到資料庫伺服器; 這將使我們能夠輕鬆地移動分割槽。 每當我們想要移動一個分割槽時,我們只需要更新配置檔案來更改。

十一. 好友動態生成

要為給定使用者建立好友動態(類似於朋友圈),我們需要獲取該使用者關注的人的最新、最受歡迎的照片。

為簡單起見,假設我們需要為使用者的朋友圈獲取前 100 張照片。 我們的應用伺服器將首先獲取使用者關注的人列表,然後從每個使用者獲取最新 100 張照片的元資料資訊。 在最後一步,伺服器將所有這些照片提交給我們的排名演算法,該演算法將確定前 100 張照片(基於時間維度、相似度等)並將它們返回給使用者。 這種方法的問題是延遲很高,因為我們必須查詢多個表並對結果執行排序/合併/排名。 為了提高效率,我們可以預先生成好友動態資料並將其儲存在單獨的表中。

預生成好友動態: 我們可以使用專門伺服器,不斷生成使用者好友動態資料並將它們儲存在UserNewsFeed表中。因此,當任何使用者需要檢視他的好友動態照片時,我們只需查詢這個表並將結果返回給使用者。

每當伺服器需要生成使用者的NewsFeed時,它們將首先查詢UserNewsFeed表,以查詢最後一次為該使用者生成好友動態的時間。然後,新的好友動態資料將從那時起生成(按照上面提到的步驟)。

向用戶傳送動態訊息內容有哪些不同的方法?

  • 拉模式: 客戶可以定期或在需要的時候手動從伺服器拉出好友動態內容。這種方法可能存在的問題是:

    • 直到客戶端發出拉取請求時,新資料可能不會顯示給使用者;
    • 如果沒有新資料,大多數情況下拉取請求會導致空響應
  • 推模式 : 伺服器可以在新資料可用時將其推送給使用者。為了有效地管理這一點,使用者必須在伺服器上維護一個Long Poll請求以接收更新。這種方法可能存在的一個問題是,一個關注了很多人的使用者,或者一個擁有數百萬粉絲的名人使用者;在這種情況下,伺服器必須非常頻繁地推送更新。
  • 混合模式 : 我們可以採用混合方法。 我們可以將所有擁有大量關注的使用者轉移到基於拉取的模型,並且只將資料推送給那些擁有數百(或數千)關注的使用者。 另一種方法可能是伺服器以不超過一定頻率向所有使用者推送更新,讓有大量關注/更新的使用者定期拉取資料.

十二. 使用分片資料建立好友動態

為任何給定使用者建立好友動態最重要的要求之一就是從該使用者關注的所有人那裡獲取最新的照片。為此,我們需要一種機制來對照片在建立時進行排序。為了有效地做到這一點,我們可以將照片建立時間作為PhotoID的一部分。因為我們在PhotoID上有一個主索引,所以很快就能找到最新的PhotoID。

我們可以使用時間來記錄。假設PhotoID有兩部分,第一部分將表示epoch時間,而第二部分將是一個自動遞增的序列。因此,要建立一個新的PhotoID時,我們可以取當前的epoch time,並從生成金鑰的DB中新增一個自動遞增的ID。我們可以從這個PhotoID (PhotoID % 10)中計算出分片數,並將照片儲存在那裡。

PhotoID的大小是多少? 假設我們的時間從今天開始,我們需要多少位來儲存未來 50 年的秒數?

86400 sec/day * 365 (days a year) * 50 (years) => 1.6 billion seconds

我們需要31位來儲存這個數字。平均而言,我們預計每秒會有23張新照片,我們可以分配9位來儲存自動遞增序列。所以每一秒我們都能儲存(2^9 => 512)新照片。我們可以每秒重置自動遞增序列。

十三. 快取與負載均衡

我們的服務將需要一個大規模的照片傳送系統來服務全球分佈的使用者。我們的服務應該使用大量地理分佈的照片快取伺服器,並使用cdn(詳細資訊請參見快取),將內容推向使用者。

為元資料伺服器引入一個快取來快取部分熱點資料。我們可以使用Memcache來快取資料,應用伺服器在訪問資料庫之前可以快速檢查快取中是否有所需的行。對於我們的系統來說,最近最少使用(LRU)是一種合理的快取回收策略。在這個策略下,我們首先丟棄最近檢視次數最少的行。

如何構建更智慧的快取? 如果我們採用80-20法則,即每天20%的照片閱讀量產生80%的流量,這些照片非常受歡迎,大多數人都會檢視訪問它們。這意味著我們可以嘗試快取每日照片和元資料讀取量的20%。