原創不易,求分享、求一鍵三連
什麼是短URL
把URL縮短,用於為長URL建立更短的別名,我們稱這些縮短的別名為「短連結」
當使用者點選這些短連結時,會被重定向到原始URL,短連結在顯示、列印、傳送訊息或發推時可以節省大量空間;此外,使用者也不太可能輸入錯誤的短url。
例如,如果我們通過TinyURL縮短這個頁面:
https://www.educative.io/collection/page/5668639101419520/5649050225344512/5668600916475904/
可以得到:
http://tinyurl.com/jlg8zpc
縮短URL的大小接近實際URL的三分之一。
URL縮短用於優化跨裝置的連結,跟蹤單個連結以分析受眾和活動表現,以及隱藏關聯的原始URL。
那麼我們應該如何設計這樣的服務呢?像TinyURL
需求和目標
首先我們應該從一開始就明確要求,考慮系統的具體範圍。
我們的網址縮短系統應符合一些規定。
功能需求:
- 給定一個URL,我們的服務應該生成一個更短且唯一的URL別名,這被稱為短連結。這個連結應該足夠短,便於複製和貼上到應用程式中。
- 當使用者訪問短連結時,我們的服務應該將他們重定向到原始連結。
- 使用者應該能夠選擇一個定製的短連結為他們的URL。
- 連結將在一個預設時間間隔之後過期,使用者應該能夠指定過期時間。
非功能性需求:
- 系統應該是高可用的。因為如果我們的服務停止,所有的URL重定向都將開始失敗。
- URL重定向應該以最小的延遲實時發生。
- 縮短的連結不應該是可猜測的(不可預測的)。
擴充套件要求:
- 分析;例如,發生了多少次重定向?
- 我們的服務也應該可以被其他服務通過REST api訪問。
容量估算與約束
我們的系統將會有大量的讀取,與新的URL縮短相比,將會有大量的重定向請求。讓我們假設讀和寫的比率是100:1。
流量估計:假設,我們每個月將有5億個新的URL縮短,以100:1的讀/寫比率,我們可以預期在同一時期有50B的重定向:
「100 * 500 => 50b」
每秒新增url縮短:
5億/(30天 * 24小時 * 3600秒) = ~200個url /s
考慮到100:1的讀寫比率,每秒url重定向為:
100 * 200 url /s = 20K/s
儲存估計:假設我們將每個URL縮短請求(以及相關的縮短連結)儲存5年,由於我們預計每個月有5億個新url,我們預計儲存的物件總數將是300億個:
5億 * 5年 * 12個月 = 300億
讓我們假設每個儲存的物件大約為500位元組(這只是一個粗略的估計——我們將在後面深入研究)。我們將需要15TB的總儲存空間:
300億 * 500位元組 = 15tb
頻寬估計:對於寫請求,因為我們預計每秒鐘有200個新的url,所以我們服務的總傳入資料將是每秒100KB:
200 * 500位元組 = 100kb /s
對於讀請求,由於我們預計每秒鐘有20K個url重定向,我們服務的總輸出資料將是每秒10MB:
20K * 500位元組 = ~ 10mb /s
記憶體估計:如果我們想快取一些經常被訪問的熱門url,我們需要多少記憶體來儲存它們?如果我們遵循2-8原則,即20%的url產生80%的流量,我們希望快取這20%的熱url。
由於我們每秒有2萬次請求,我們每天將收到17億次請求:
20K * 3600秒 * 24小時 = ~ 17億
為了快取這些請求的20%,我們將需要170GB的記憶體。
0.2 * 17億 * 500位元組 = ~170GB
這裡需要注意的一點是,由於會有很多重複的請求(相同的URL),因此,我們的實際記憶體使用將小於170GB。
高水平估計:假設每月新增5億url,讀寫比例為100:1,以下是我們服務的高水平估計總結:
系統api設計
一旦我們確定了需求,定義系統api總是一個好主意,明確宣告系統所期望的內容。
我們可以使用SOAP或REST api來公開服務的功能。下面是建立和刪除url的api的定義:
createURL(api_dev_key, original_url, custom_alias=無,user_name=無,expire_date=無)
引數:
api_dev_key (string):註冊帳戶的API開發人員金鑰。這將用於根據使用者分配的配額限制使用者;
original_url (string):需要縮短的原始URL;
custom_alias (string): URL的可選自定義鍵。
user_name (string):可選用於編碼的使用者名稱。
expire_date (string):可選的縮短URL的過期日期。
返回:(string)一個成功的插入返回縮短的URL;否則,它返回一個錯誤程式碼。
deleteURL (api_dev_key url_key)
其中“url_key”是表示要檢索的縮短URL的字串,成功刪除將返回“URL已刪除”。
我們如何發現和防止濫用?惡意使用者可以通過消耗當前設計中的所有URL鍵來讓我們用盡?
為了防止濫用,我們可以通過api_dev_key限制使用者。每個api_dev_key可以被限制在一定數量的URL建立和重定向每一段時間(可以為每個使用者的鍵設定不同的持續時間)。
資料庫設計
在面試的早期階段定義DB模式將有助於理解不同元件之間的資料流,並在隨後指導資料分割槽。
關於我們將要儲存的資料的性質的幾點觀察:
- 我們需要儲存數十億條記錄。
- 我們儲存的每個物件都很小(小於1K)。
- 記錄之間沒有關係——除了儲存哪個使用者建立了URL之外。
- 我們的服務是讀多的。
資料庫結構
我們需要兩個表,一個用於儲存關於URL對映的資訊,另一個用於儲存建立短連結的使用者資料:
我們應該使用什麼樣的資料庫?因為我們預期儲存數十億行,而且我們不需要使用物件之間的關係——像DynamoDB這樣的NoSQL儲存,Cassandra或Riak是更好的選擇。
NoSQL選擇也更容易擴充套件。詳情請參閱SQL vs NoSQL。
系統設計和演算法
這裡我們要解決的問題是,如何為給定的URL生成一個簡短且唯一的鍵。
在第1節的TinyURL示例中,縮寫URL是“http://tinyurl.com/jlg8zpc”。這個URL的最後七個字元是我們想要生成的短鍵,我們將在這裡探索兩種解決方案:
a.編碼實際URL
我們可以計算給定URL的唯一雜湊值(例如,MD5或SHA256等),然後可以對雜湊進行編碼以顯示。
這種編碼可以是base36 ([a-z,0-9])或base62 ([a-z, a-z, 0-9]),如果我們加上' + '和' / ',我們可以使用Base64編碼。
一個合理的問題是,短鍵的長度是多少?6個、8個還是10個字元?
使用base64編碼,一個6個字母長的鍵將產生64^6 = ~ 687億可用字串,一個8個字母長的鍵將產生64^8 = ~281萬億可用字串。
對於68.7B唯一的字串,讓我們假設六個字母的鍵就足以滿足我們的系統。
如果我們使用MD5演算法作為我們的雜湊函式,它將產生一個128位的雜湊值。在base64編碼之後,我們將得到一個超過21個字元的字串(因為每個base64字元編碼了6位的雜湊值)。
現在我們每個短鍵只有8個字元的空間,那麼我們如何選擇我們的鍵呢?我們可以取前6(或8)個字母作為金鑰。這可能導致鍵重複,為了解決這個問題,我們可以從編碼字串中選擇一些其他字元或交換一些字元。
我們的解決方案有哪些不同的問題?我們的編碼方案有以下幾個問題:
- 如果多個使用者輸入相同的URL,他們可以得到相同的縮短URL,這是不可接受的。
- 如果部分URL是URL編碼的呢?例如,http://www.educative.io/distributed.php?id=design和http://www.educative.io/distributed.php%3Fid%3Ddesign除了URL編碼之外都是相同的。
問題的解決方法:
我們可以在每個輸入URL後面附加一個遞增的序列號,使其惟一,然後生成它的雜湊值。
不過,我們不需要將這個序列號儲存在資料庫中,這種方法的可能問題可能是不斷增加的序列號。能溢位嗎?增加序列號也會影響服務的效能。
另一種解決方案是將使用者id(應該是唯一的)附加到輸入URL後面,但如果使用者沒有登入,我們就必須要求使用者選擇惟一鍵。即使在這之後,如果有衝突,我們必須不斷生成一個鍵,直到我們得到一個唯一的。
b.離線生成金鑰
我們可以有一個獨立的金鑰生成服務(KGS),它預先生成隨機的六個字母的字串,並將它們儲存在資料庫中(我們稱之為Key - db),每當我們想要縮短一個URL時,我們只需要獲取一個已經生成的鍵並使用它。
這種方法將使事情變得非常簡單和快速。我們不僅不用對URL進行編碼,而且不必擔心重複或衝突。KGS將確保插入key-DB的所有金鑰是唯一的。
併發性會導致問題嗎?一旦使用了一個鍵,就應該在資料庫中標記它,以確保它不會得到重用。如果有多個伺服器同時讀取鍵值,我們可能會遇到這樣的情況:
兩個或多個伺服器試圖從資料庫讀取相同的鍵值,我們如何解決這個併發問題?
伺服器可以使用KGS讀取/標記資料庫中的金鑰,KGS可以使用兩個表來儲存鍵:
一個用於尚未使用的鍵,另一個用於所有已使用的鍵。只要KGS向其中一個伺服器提供金鑰,它就可以將金鑰移動到使用的金鑰表中。KGS總是可以在記憶體中儲存一些金鑰,以便在伺服器需要時快速提供它們。
為了簡單起見,只要KGS將一些鍵載入到記憶體中,它就可以將它們移動到使用的鍵表中。這確保每個伺服器獲得唯一的金鑰。如果KGS在將所有載入的鍵分配給某個伺服器之前死亡,我們將浪費這些鍵——考慮到我們擁有的鍵的數量巨大,這是可以接受的。
KGS還必須確保不會向多個伺服器提供相同的金鑰。為此,它必須同步(或鎖定)儲存鍵的資料結構,然後從該結構中刪除鍵並將它們交給伺服器。
key-DB的大小是多少?使用base64編碼,我們可以生成68.7B唯一的6個字母的金鑰。如果我們需要一個位元組來儲存一個字母數字字元,我們可以將所有這些鍵儲存在:
6個字元(每個鍵) * 68.7B(唯一鍵) = 412 GB。
KGS不是單點故障嗎?是的。
為了解決這個問題,我們可以建立一個備用的KGS副本,當主伺服器失效時,備用伺服器可以接管生成並提供金鑰。
每個應用伺服器可以快取key-DB中的一些鍵嗎?是的,這肯定能加快速度。儘管在本例中,如果應用程式伺服器在使用所有鍵之前死亡,我們最終將失去這些鍵。這是可以接受的,因為我們有68B唯一的六個字母的鑰匙。
如何執行鍵查詢?我們可以在資料庫中查詢鍵來獲得完整的URL。如果它出現在資料庫中,發出一個“HTTP 302重定向”狀態回傳給瀏覽器,在請求的“位置”欄位中傳遞儲存的URL。如果該金鑰在我們的系統中不存在,則發出“HTTP 404 not Found”狀態或將使用者重定向回主頁。
我們應該對自定義別名施加大小限制嗎?我們的服務支援自定義別名。使用者可以選擇任何他們喜歡的“鍵”,但提供自定義別名不是強制性的。
然而,對自定義別名施加大小限制是合理的(而且通常是可取的),以確保我們擁有一致的URL資料庫。讓我們假設使用者可以為每個客戶鍵指定最多16個字元(如上面的資料庫模式所示):
資料分割槽和複製
為了擴充套件我們的資料庫,我們需要對它進行分割槽,以便它能夠儲存關於數十億個url的資訊。我們需要提出一種分割槽方案,將我們的資料劃分並儲存到不同的DB伺服器中。
a.基於範圍的分割槽
我們可以根據雜湊鍵的首字母將url儲存在單獨的分割槽中。因此,我們將所有以字母“A”(和“A”)開頭的url儲存在一個分割槽中,將那些以字母“B”開頭的url儲存在另一個分割槽中,以此類推。這種方法稱為基於範圍的分割槽。
我們甚至可以將某些不太經常出現的字母組合到一個資料庫分割槽中。我們應該提出一個靜態分割槽方案,這樣我們就可以始終以一種可預測的方式儲存/查詢URL。
這種方法的主要問題是:它可能導致不平衡的DB伺服器。
例如,我們決定將所有以字母' E '開頭的url放入一個DB分割槽中,但是後來我們意識到我們有太多以字母' E '開頭的url。
b.基於雜湊的分割槽
在這個方案中,我們獲取儲存物件的雜湊值,然後根據雜湊值計算要使用哪個分割槽。
在本例中,我們可以採用“鍵”或短連結的雜湊值來確定儲存資料物件的分割槽。
我們的雜湊函式將隨機地將url分配到不同的分割槽中(例如,我們的雜湊函式總是可以將任何“鍵”對映到[1…256]之間的一個數字),而這個數字將代表我們儲存物件的分割槽。
這種方法仍然會導致分割槽的過載,這可以通過使用一致的雜湊來解決。
快取
我們可以快取經常被訪問的url。我們可以使用一些現成的解決方案,比如:
Memcached,它可以用它們各自的雜湊值儲存完整的url。應用伺服器在訪問後端儲存之前,可以快速檢查快取是否具有所需的URL。
我們應該有多少快取記憶體?我們可以從每天流量的20%開始,根據客戶的使用模式,我們可以調整需要多少快取伺服器。
如上所述,我們需要170GB的記憶體來快取20%的日常流量。由於現在的伺服器可以擁有256GB的記憶體,我們可以很容易地將所有快取放入一臺機器中。或者,我們可以使用幾個較小的伺服器來儲存所有這些熱url。
哪種cache逐出策略最適合我們的需求?當快取是滿的,我們想用一個更新/更熱的URL替換一個連結,我們應該如何選擇?最近最少使用(Least Recently Used, LRU)對於我們的系統來說是一個合理的策略。
在此策略下,我們首先丟棄最近最少使用的URL。我們可以使用一個連結雜湊圖或類似的資料結構來儲存我們的url和雜湊表,它也會跟蹤最近被訪問的url。
為了進一步提高效率,我們可以複製快取伺服器來在它們之間分配負載。
如何更新每個快取副本?每當快取丟失時,我們的伺服器就會碰到後端資料庫,每當發生這種情況時,我們可以更新快取並將新條目傳遞到所有快取副本,每個副本都可以通過新增新條目來更新其快取。
如果副本已經擁有該條目,則可以忽略它。
負載均衡
我們可以在系統的三個地方新增一個負載均衡層:
- 客戶端和應用伺服器之間
- 在應用程式伺服器和資料庫伺服器之間
- 應用伺服器和快取伺服器之間
最初,我們可以使用簡單的輪詢方法,在後端伺服器之間平均分配傳入的請求。這個LB實現起來很簡單,而且不引入任何開銷。這種方法的另一個好處是,如果伺服器死亡,LB將把它從輪換中取出,並停止向它傳送任何流量。
Round Robin LB的一個問題是我們沒有考慮伺服器負載。如果伺服器負載過重或執行緩慢,LB將不會停止向該伺服器傳送新請求。
要處理這個問題,可以放置一個更智慧的LB解決方案,它可以週期性地查詢後端伺服器的負載,並據此調整流量。
清除或DB清理
條目應該永遠存在還是應該被清除?如果達到了使用者指定的過期時間,該連結會發生什麼?
如果我們選擇主動搜尋過期連結並刪除它們,這將給我們的資料庫帶來很大的壓力。相反,我們可以慢慢地刪除過期的連結,並進行惰性清理。我們的服務將確保只有過期的連結將被刪除,儘管一些過期的連結可以存活更長時間,但永遠不會返回給使用者。
當使用者試圖訪問一個過期的連結時,我們可以刪除該連結並返回一個錯誤給使用者。一個單獨的清理服務可以定期執行,從我們的儲存和快取中刪除過期的連結。此服務應該是非常輕量級的,並且只能在預期使用者流量較低時排程執行。
我們可以為每個連結設定一個預設過期時間(例如,兩年)。
在移除一個過期的連結後,我們可以把key放回key- db中以進行重用。
我們是否應該刪除一段時間內(比如6個月)未被訪問的連結?這可能很棘手。由於儲存變得越來越便宜,我們可以決定永遠保持連結。
特殊場景考慮
短URL被使用了多少次,使用者位置是什麼,等等?我們如何儲存這些統計資料?如果它是每個檢視上更新的DB行的一部分,當一個流行的URL被大量併發請求攻擊時會發生什麼?
一些值得跟蹤的統計資料:訪問者的國家,訪問的日期和時間,點選的網頁,瀏覽器,或頁面被訪問的平臺。
安全與許可權
使用者是否可以建立私有URL或允許特定的一組使用者訪問URL?
我們可以將許可權級別(公有/私有)與每個URL儲存在資料庫中。我們還可以建立一個單獨的表來儲存具有檢視特定URL許可權的userid。如果使用者沒有許可權並試圖訪問URL,我們可以返回一個錯誤(HTTP 401)。
假設我們將資料儲存在一個像Cassandra這樣的NoSQL寬列資料庫中,表儲存許可權的鍵將是“Hash”(或KGS生成的“鍵”)。這些列將儲存那些有許可權檢視URL的使用者的userid。
好了,今天的分享就到這裡, 想要更多交流可以加我微信: