前言
系統設計實踐篇的文章將會根據《系統設計面試的萬金油》為前置模板,講解數十個常見系統的設計思路。
設計目標
設計一個像TinyURL這樣的URL縮短服務。該服務將提供一個較短的URL,重定向到原本長的URL。
一. 為什麼我們需要URL短鏈
URL縮短用於為長URL建立更短的別名。我們稱這些縮短的別名為短連結。當使用者點選這些短連結時,它們會被重定向到原始URL。短連結在展示、列印、傳送或發推時可以節省大量空間,而且便於使用者手動輸入。
例如,如果我們通過TinyURL縮短這個URL
可以得到:
縮短後的網址幾乎是實際網址的三分之一大小。
URL縮短用於優化跨裝置的連結,跟蹤單個連結以分析受眾和活動表現,並隱藏關聯的原始URL。
如果您以前沒有使用過tinyurl.com
,請嘗試建立一個新的短網址,並花一些時間瀏覽他們提供的各種服務選項,對你理解有很大幫助。
二. 系統的需求與目標
你應該在面試一開始就明確要求。一定要問問題,找出面試官腦海中系統的確切範圍。
我們的網址縮短系統應該滿足以下要求:
功能性需求:
- 給定一個URL,我們的服務應該生成一個更短且唯一的別名。
- 當使用者訪問一個短連結時,我們的服務應該將他們重定向到原始連結。
- 使用者應該能夠選擇一個自定義的短連結為他們的URL。
- 連結將在標準的預設時間間隔之後過期。使用者應該能夠指定過期時間。
非功能性需求:
- 系統應該是高度可用的。這是必需的,因為如果我們的服務停止,所有的URL重定向將開始失敗。
- URL重定向應該實時發生,延遲最小。
- 縮短的連結不應該是可猜測的(不可預測的)。
擴充套件性需求:
- 分析: 例如,發生了多少次重定向?
- 我們的服務也應該可以通過REST API被其他服務訪問。
三. 容量估算與約束
短鏈系統從請求讀寫量上來說,屬於是讀取量很大的。與縮短一個URL相比,訪問短鏈將會有大量的重定向請求。可以假設讀和寫的比率是100:1。
流量估算
假設我們每個月有 500M 的新 URL 縮短,讀/寫比為 100:1,我們可以預期在同一時期有 50B 的重定向。
100 * 500M => 50B
我們系統的每秒查詢(QPS)是多少?
500 million / (30 days * 24 hours * 3600 seconds) = ~200 URLs/s
考慮到 100:1 的讀/寫比率,每秒 URL 重定向將是:
100 * 200 URLs/s = 20K/s
儲存估計
假設我們將每個URL目標連結(以及相關的縮短連結)儲存5年。因為我們預計每個月有5億個新url,所以我們預計儲存的物件總數將達到300億個
500 million * 5 years * 12 months = 30 billion
讓我們假設每個儲存物件大約為500位元組(這只是一個粗略的估計,我們稍後將深入研究),我們總共需要15TB的儲存空間。
頻寬估計
對於寫請求,由於我們預計每秒有200個新url,所以我們服務的總傳入資料將是每秒100KB
200 * 500 bytes = 100 KB/s
對於讀請求,由於我們預計每秒鐘有大約20K的url重定向,所以我們的服務的總輸出資料將是每秒10MB
20K * 500 bytes = ~10 MB/s
記憶體估計
如果我們想快取一些經常被訪問的熱點url,我們需要多少記憶體來儲存它們?
如果我們遵循80-20原則,即20%的url產生80%的流量,我們希望快取這些20%的熱點url。
由於我們每秒有2萬次請求,我們每天將會收到17億次請求。
20K * 3600 seconds * 24 hours = ~1.7 billion
要快取20%的請求,我們需要170GB記憶體。
0.2 * 1.7 billion * 500 bytes = ~170GB
這裡需要注意的一點是,由於會有很多重複的請求(相同的URL),因此,我們的實際記憶體使用量將少於170GB。
估算概述
假設每個月有5億個新url,讀:寫比率為100:1,下面是對我們服務容量估算的總結。
- 建立短鏈 200/s
- 短鏈重定向 20K/s
- 入口流量 100KB/s
- 出口流量 10MB/s
- 五年需要儲存量 15TB
- 記憶體用量 170GB
四. 系統API設計
一旦我們確定了需求,定義系統API總是一個好主意。這時候應該明確說明系統期望做到什麼。
我們可以使用SOAP或REST API來公開服務的功能。下面是用於建立和刪除url的api的定義。
createURL(api_dev_key, original_url, custom_alias=None, user_name=None, expire_date=None)
引數
- api_dev_key (string) : 註冊帳號的api開發金鑰。此外,這將用於根據使用者分配的配額限制使用者
- original_url (string): 可選的短鏈地址
- custom_alias (string) : URL 的可選自定義鍵
- user_name (string) : 用於編碼的可選使用者名稱
*• expire_date (string) : 可選的過期時間
返回
成功將返回縮短的URL。否則,它將返回錯誤程式碼。
deleteURL(api_dev_key, url_key)
其中url鍵是一個字串,表示要檢索的縮短的url。成功的刪除返回URL Removed。
我們如何發現和預防濫用?
惡意使用者可能會請求佔用當前系統中所有URL鍵,從而讓我們的業務失去新建短鏈的能力。為了防止濫用,我們可以通過api_dev_key來限制使用者。每個api_dev_key可以被限制在一段時間特定數量的URL建立和重定向。
五. 資料庫設計
在早期階段定義DB模式將有助於理解不同元件之間的資料流,並在之後幫助我們處理資料分割槽。
關於我們將要儲存資料的性質的一些觀察
- 我們需要儲存數十億條記錄
- 我們儲存的每個物件都很小(小於1K)。
- 除了儲存哪個使用者建立了URL之外,記錄之間沒有任何關係。
- 我們的服務讀請求量很大。
資料庫模型
我們需要兩個表。一個用於儲存關於URL對映的資訊,另一個用於建立短連結的使用者資料。
URL | User |
---|---|
[PK] Hash: varchar(16) | [PK] UserID: int |
OriginalURL: varchar(512) | Name: varchar(20) |
CreationDate: datetime | Email: varchar(20) |
ExpirationDate: datatime | CreationDate: datetime |
LastLoginDate: datetime |
我們應該使用什麼樣的資料庫?
因為我們預期儲存數十億行資料,而且我們不需要使用物件之間的關係,像DynamoDB這樣的NoSQL鍵值儲存,Cassandra或Riak是一個更好的選擇。選擇NoSQL也更容易擴充套件。請參閱SQL vs NoSQL瞭解更多細節
六. 基本系統設計與演算法
我們在這裡要解決的問題是,如何為給定的URL生成一個簡短且唯一的主鍵。
在第一節為什麼我們需要URL短鏈
示例中,縮短的 URL 是http://tinyurl.com/jlg8zpc
。 這個 URL 的最後六個字元就是我們要生成的主鍵。
我們將在這裡探索兩種解決方案。
方案一. 編碼URL
我們可以計算給定URL的唯一雜湊值(例如,MD5或SHA256等)。然後可以對雜湊進行編碼以用於顯示。
編碼方式可以是base36 ([a-z,0-9])
或base62 ([A-Z, a-z, 0-9])
,如果加上-
和.
我們可以使用base64編碼。問題是,短鍵的長度應該是多少?
使用 base64 編碼,一個 6 字母長的金鑰將產生 64^6 = ~687 億個可能的字串,一個 8 字母長的金鑰將產生 64^8 = ~281 萬億個可能的字串。
68.7B唯一的字串對於我們的系統來說就足夠了,所以我們可以使用6個字母的鍵。
如果我們使用 MD5 演算法作為我們的雜湊函式,它將產生一個 128 位的雜湊值。 base64 編碼後,我們將得到一個超過 21 個字元的字串(因為每個 base64 字元編碼 6 位雜湊值)。 既然我們每個快捷鍵只有8個字元的空間,那麼我們將如何選擇我們的金鑰呢? 我們可以取前 6 個(或 8 個)字母作為金鑰。 不過,這可能會導致金鑰重複,在此基礎上我們可以從編碼字串中選擇一些其他字元或交換一些字元。
該解決方案有哪些不同的問題?
我們的編碼方案有以下幾個問題
-
如果多個使用者輸入相同的URL,他們會得到相同的縮短URL,這是不可接受的。
-
如果 URL 的一部分是 URL 編碼的怎麼辦? 例如,http://www.education.io/distributed.php?id=design 和 http://www.education.io/distributed.php%3Fid%3Ddesign
解決方法
我們可以向每個輸入URL新增遞增的序列號,使其惟一,然後生成它的雜湊。我們不需要把這個序列號儲存在資料庫中。這種方法可能存在的問題是不斷增加的序列號它會溢位,附加遞增的序列號也會影響服務的效能。
另一種解決方案是在輸入URL中附加使用者id(它應該是唯一的)。但是,如果使用者還沒有登入,我們就必須要求使用者選擇惟一金鑰。即使在這之後如果我們有衝突,我們必須不斷生成一個金鑰,直到我們得到一個唯一的金鑰。
方案二. 離線生成金鑰
我們可以有一個獨立的金鑰生成服務(KGS),它事先生成隨機的6個字母字串,並將它們儲存在一個資料庫中(我們稱之為Key-db)。當我們想要縮短一個URL時,我們只需要一個已經生成的鍵並使用它。這種方法將使事情變得非常簡單和快速。我們不僅沒有對URL進行編碼,而且還不必擔心重複或衝突。KGS將確保插入到key-DB中的所有鍵都是唯一的。
併發性問題
一旦金鑰被使用,就應該在資料庫中進行標記,以確保不會再次使用。如果有多個伺服器併發地讀取金鑰,我們可能會遇到兩個或更多伺服器試圖從資料庫讀取相同金鑰的場景。我們如何解決這個併發問題?
伺服器可以使用 KGS 讀取/標記資料庫中的金鑰。 KGS 可以使用兩張表來儲存金鑰:一張用於儲存尚未使用的金鑰,另一張用於儲存所有使用過的金鑰。 一旦 KGS 將金鑰提供給其中一臺伺服器,它就可以將它們移動到已使用的金鑰表中。 KGS 可以始終在記憶體中保留一些金鑰,以便在伺服器需要時快速提供它們。
為了簡單起見,一旦KGS在記憶體中載入了一些鍵,它就可以將它們移動到所使用的鍵表中。這確保了每個伺服器獲得唯一的金鑰。如果KGS在將所有載入的金鑰分配給某個伺服器之前掛掉,這部分金鑰將會被浪費,這是可以接受的,因為我們有大量的金鑰。
KGS還必須確保不向多個伺服器提供相同的金鑰。為此,它必須同步(或獲得鎖)持有金鑰的資料結構,然後從該資料結構中刪除金鑰並將它們交給伺服器。
金鑰資料庫大小是多少?
使用 base64 編碼,我們可以生成 68.7B 個唯一的六個字母鍵。如果我們需要一個位元組來儲存一個字母數字字元,我們可以將所有鍵儲存在412GB的磁碟。
6 (characters per key) * 68.7B (unique keys) = 412 GB
KGS不是單點故障嗎?
是的。為了解決這個問題,我們可以有一個備用的KGS副本,當主伺服器死亡時,備用伺服器可以接管生成並提供金鑰。
每個應用伺服器是否可以從key-DB中快取一些key?
是的,而且可以加快響應速度。儘管在這種情況下,如果應用伺服器在使用所有金鑰之前就死掉了,我們最終會丟失這些金鑰。但這是可以接受的,因為我們有68B唯一的6個字母的key。
如何執行鍵查詢?
我們可以在資料庫或鍵值儲存中查詢鍵以獲得完整的URL。如果存在,則向瀏覽器發出一個HTTP 302重定向狀態,並在請求的Location欄位中傳遞儲存的URL。如果該金鑰不在我們的系統中,則發出HTTP 404 not Found狀態或將使用者重定向回主頁。
我們應該對自定義別名施加大小限制嗎?
我們的服務支援自定義別名。 使用者可以選擇他們喜歡的任何金鑰
,但提供自定義別名不是強制性的。但是,對自定義別名施加大小限制以確保我們擁有一致的 URL 資料庫是合理的(並且通常是可取的)。 假設使用者可以為每個客戶鍵指定最多 16 個字元(如資料庫架構所示)
七. 資料分割槽與備份
為了擴充套件我們的資料庫,我們需要對它進行分割槽,以便它能夠儲存數十億url的資訊。我們需要想出一個分割槽方案,將我們的資料劃分並儲存到不同的DB伺服器上。
區間劃分
我們可以根據 URL 的第一個字母或雜湊鍵將 URL 儲存在單獨的分割槽中。 因此,我們將所有以字母A
開頭的 URL 儲存在一個分割槽中,將那些以字母`B``開頭的 URL 儲存在另一個分割槽中,依此類推。 這種方法稱為基於範圍的分割槽。 我們甚至可以將某些不太頻繁出現的字母組合到一個資料庫分割槽中。 我們應該提供一個靜態分割槽方案,以便我們可以以可預測的方式儲存/查詢檔案。
這種方法的主要問題是,它可能導致伺服器不平衡。例如: 我們決定將所有以字母E開頭的url放到一個DB分割槽中,但後來我們意識到有太多的url以字母E開頭。
基於雜湊分割槽
在這個方案中,我們取所儲存物件的雜湊值。然後根據雜湊計算要使用哪個分割槽。在本例中,我們可以使用鍵或實際URL的雜湊值來確定儲存資料物件的分割槽。
我們的雜湊函式將隨機地將url分配到不同的分割槽中(例如,我們的雜湊函式總是可以將任意鍵對映到[1…256]之間的一個數字),這個數字將代表我們儲存物件的分割槽。
這種方法仍然會導致過載分割槽,這個問題可以通過一致性雜湊來解決。
八. 快取
我們可以快取頻繁訪問的url。可以使用一些現成的解決方案,如Memcache,它可以儲存帶有各自雜湊的完整url。應用伺服器在訪問後端儲存之前,可以快速檢查快取是否具有所需的URL。
快取容量應該有多大?
我們快取每日流量的20%,然後根據客戶端使用模式調整我們需要多少快取伺服器。如上所述,我們需要170GB記憶體來快取每日流量的20%。因為現在的伺服器可以有256GB的記憶體,我們可以很容易地把所有的快取放到一臺機器上。或者,我們可以使用一些較小的伺服器來儲存所有這些熱點URL。
哪種快取驅逐策略最適合我們的需求?
當快取已滿,而我們想用更新/更熱的 URL 替換連結時,我們將如何選擇? 最近最少使用 (LRU) 可能是比較合適的。 根據此策略,我們首先丟棄最近最少使用的 URL。 我們可以使用 LinkedHashMap 或類似的資料結構來儲存我們的 URL 和 Hash,這也可以跟蹤最近訪問過的 URL。
為了進一步提高效率,我們可以複製快取伺服器來在它們之間分配負載。
如何更新每個快取副本?
每當快取丟失時,我們的伺服器就會擊中後端資料庫。每當發生這種情況時,我們就可以更新快取並將新條目傳遞給所有快取副本。每個副本都可以通過新增新條目來更新它們的快取。如果副本已經有該條目,則可以簡單地忽略它。
九. 負載均衡
我們可以在系統的三個地方新增負載均衡
- 客戶端和應用伺服器之間
- 應用伺服器與資料庫伺服器之間的連線
- 應用伺服器和快取伺服器之間
最初,我們可以使用簡單的Round Robin方法,將傳入請求平均分配到後端伺服器。這種LB實現簡單,而且不引入任何開銷。這種方法的另一個好處是,如果一個伺服器當機了,LB將停止向它傳送任何流量。
輪詢LB的問題是沒有考慮伺服器負載。如果伺服器負載過重或速度變慢,LB不會停止向該伺服器傳送新的請求。為了解決這個問題,可以放置一個更智慧的LB解決方案,定期查詢後端伺服器的負載,並基於此調整流量。
十. 資料清理
短鏈是應該永久儲存還是應該到期清除? 如果到達使用者指定的過期時間,該連結將發生什麼情況?
如果我們選擇主動搜尋過期連結來刪除它們,這將給我們的資料庫帶來很大的壓力。相反,我們可以緩慢地刪除過期連結,並進行惰性清理。我們的服務將確保只有過期的連結將被刪除,儘管一些過期連結可以活得更長,但永遠不會返回給使用者。
- 當使用者試圖訪問過期連結時,我們可以刪除連結並向使用者返回一個錯誤
- 可以定期執行一個單獨的Cleanup服務,從儲存和快取中刪除過期的連結。該服務應該是非常輕量級的,並且只在使用者流量預期較低時才可以排程執行
- 我們可以為每個連結設定一個預設的過期時間(例如,兩年)。
- 在刪除過期連結之後,我們可以將金鑰放回key-db中以供重用。
- 應該刪除6個月沒有訪問過的連結嗎? 這可能有點棘手。由於儲存變得越來越便宜,我們可以決定永遠保持連結。
十一. 追蹤擴充套件
一個短 URL 被使用了多少次,使用者位置是什麼,等等? 我們將如何儲存這些統計資訊? 如果它是在每個檢視上更新的 DB 行的一部分,那麼當一個熱點的 URL 受到大量併發請求的衝擊時會發生什麼?
一些值得跟蹤的統計資料: 訪問者的國家、訪問日期和時間、涉及點選的網頁、瀏覽器或訪問頁面的平臺。
十二. 安全與許可權
使用者是否可以建立私有URL或允許特定使用者組訪問URL。
我們可以在資料庫中儲存每個 URL 的許可權級別(公共/私有)。 我們還可以建立一個單獨的表來儲存有權檢視特定 URL 的使用者 ID。 如果使用者沒有許可權並嘗試訪問 URL,我們可以發回錯誤 (HTTP 401)。 鑑於我們將資料儲存在像 Cassandra 這樣的 NoSQL 寬列資料庫中,表儲存許可權的金鑰將是雜湊
(或 KGS 生成的金鑰
)。 這些列將儲存有權檢視 URL 的那些使用者的使用者 ID。