前言
系統設計實踐篇的文章將會根據《系統設計面試的萬金油》為前置模板,講解數十個常見系統的設計思路。
設計目標
設計一個像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。
