Pastebin設(shè)計(jì)之旅:從零設(shè)計(jì)網(wǎng)絡(luò)文本存儲系統(tǒng)
項(xiàng)目簡介:Pastebin是一個在線的文本存儲平臺,讓用戶可以存儲和分享代碼片段或者其他類型的文本。它支持多種編程和標(biāo)記語言的語法高亮,用戶可以選擇讓他們的"paste"公開或私有。無需注冊就可以使用,但注冊用戶可以更方便地管理他們的"paste"。Pastebin常被開發(fā)者、系統(tǒng)管理員以及其他技術(shù)專業(yè)人員用于分享和協(xié)作。
現(xiàn)在讓我們來設(shè)計(jì)一個類似Pastebin的網(wǎng)絡(luò)服務(wù),用戶可以在這里儲存純文本。用戶可以輸入一段文本,并獲取一個隨機(jī)生成的URL來訪問這段文本。
類似的產(chǎn)品有:pastebin.com,controlc.com,hastebin.com,privatebin.net
系統(tǒng)難度等級:初級
1、什么是Pastebin
Pastebin及類似服務(wù)讓用戶能夠在網(wǎng)絡(luò)(通常指的是互聯(lián)網(wǎng))上存儲純文本或圖像,并生成唯一的URL來訪問上傳的數(shù)據(jù)。這樣的服務(wù)也被用來快速地在網(wǎng)絡(luò)上共享數(shù)據(jù),用戶只需傳遞URL,其他用戶就可以查看其內(nèi)容。
如果你以前沒有使用過pastebin.com,建議嘗試在那里創(chuàng)建一個新的“Paste”,并花些時間瀏覽他們服務(wù)提供的不同選項(xiàng)。這將在理解本章時有很大幫助。
對于類似于Pastebin這樣的代碼或文本分享平臺,中國并未有一款特別知名或廣泛使用的網(wǎng)站。很多開發(fā)者會使用GitHub Gist來分享代碼片段,此外,國內(nèi)也有一些代碼托管平臺,比如Coding.net和Gitee,也提供代碼分享和協(xié)作的功能。
2、系統(tǒng)的需求和目標(biāo)
Pastebin服務(wù)需要滿足以下要求:
功能需求:
- 用戶應(yīng)能夠上傳或“粘貼”他們的數(shù)據(jù),并獲得一個獨(dú)特的URL來訪問它。
- 用戶只能上傳文本。
- 數(shù)據(jù)和鏈接將在特定的時間段后自動過期;用戶應(yīng)可以指定過期時間。
- 用戶可以選擇為他們的粘貼內(nèi)容設(shè)置一個自定義的別名。
非功能性需求:
- 系統(tǒng)必須具有高度的可靠性,上傳的任何數(shù)據(jù)都不應(yīng)丟失。
- 系統(tǒng)必須始終可用。這是因?yàn)槿绻覀兊姆?wù)暫停,用戶將無法訪問他們的粘貼內(nèi)容。
- 用戶應(yīng)能實(shí)時訪問他們的粘貼內(nèi)容,延遲要最小。
- 粘貼的鏈接不能被輕易猜出(不能被預(yù)測)。
擴(kuò)展需求:
- 分析,例如,一個粘貼內(nèi)容被訪問了多少次?
- 我們的服務(wù)也應(yīng)該通過REST APIs供其他服務(wù)訪問。
3、注意事項(xiàng)
Pastebin與URL縮短服務(wù)(系統(tǒng)設(shè)計(jì)上一個案例)有一些相同的需求,但是我們還應(yīng)該考慮一些額外的設(shè)計(jì)因素。
用戶每次粘貼的文本量應(yīng)有何限制?我們可以限制用戶不得粘貼超過10MB10MB10MB的數(shù)據(jù),以防止服務(wù)被濫用。
我們是否應(yīng)對自定義URL的大小設(shè)置限制?由于我們的服務(wù)支持自定義URL,用戶可以選擇他們喜歡的任何URL,但是提供自定義URL并非強(qiáng)制性的。然而,對自定義URL設(shè)置大小限制是合理的(而且通常是我們期望的),這樣我們可以保持一致的URL數(shù)據(jù)庫。
4、容量估計(jì)與約束
我們的服務(wù)將是讀取密集型的;相比新建Paste,讀取請求會更多。我們可以假設(shè)讀取與寫入之間的比例是5:1。
流量預(yù)估:Pastebin類似的服務(wù)并不預(yù)期有如微信或今日頭條那樣的流量,這里我們假設(shè)每天有一百萬個新的粘貼內(nèi)容添加到我們的系統(tǒng)中。這樣算來,我們每天有五百萬次的讀取。
每秒新粘貼內(nèi)容:
1M / (24 小時 * 3600 秒) ~= 12 粘貼/秒
每秒粘貼讀取次數(shù):
5M / (24 小時 * 3600 秒) ~= 58 讀取/秒
存儲預(yù)估:用戶最多可以上傳10MB的數(shù)據(jù);一般來說,Pastebin類似的服務(wù)用于分享源代碼、配置文件或日志。這些文本并不大,所以我們假設(shè)每個粘貼內(nèi)容平均含有10KB10KB10KB。
按照這個速率,我們每天將存儲10GB的數(shù)據(jù)。
1M * 10KB => 10 GB/天
如果我們想將這些數(shù)據(jù)存儲十年,那我們總共需要36TB的存儲容量。
每天有1M的粘貼內(nèi)容,十年后我們將有36億的粘貼內(nèi)容。我們需要生成并存儲鍵來唯一地標(biāo)識這些粘貼內(nèi)容。如果我們使用Base64編碼([A-Z, a-z, 0-9, ., -]),我們將需要六個字符的字符串:
64^6 ~= 68.7億個唯一字符串
如果存儲一個字符需要一個字節(jié),存儲36億個鍵所需的總大小將是:
3.6B?6=>22GB
相比于36TB,22GB微不足道。為了保留一些余量,我們將假設(shè)一個70%的容量模型(意味著我們在任何時候都不希望使用超過總存儲容量的70%),這將使我們的存儲需求增加到 51.4TB。
帶寬預(yù)估:對于寫入請求,我們預(yù)計(jì)每秒新增12個粘貼內(nèi)容,導(dǎo)致每秒進(jìn)入120KB的數(shù)據(jù)。
12?10KB=>120KB/s
至于讀取請求,我們預(yù)計(jì)每秒58個請求。因此,總的數(shù)據(jù)出口(發(fā)送給用戶)將是0.6MB/s。
58?10KB=>0.6MB/s
盡管總的進(jìn)出口并不大,我們在設(shè)計(jì)服務(wù)時應(yīng)記住這些數(shù)字。
內(nèi)存預(yù)估:我們可以緩存一些被頻繁訪問的熱門粘貼內(nèi)容。根據(jù)80-20原則,意味著20%的熱門粘貼內(nèi)容產(chǎn)生了80%的流量,我們希望將這20%的粘貼內(nèi)容進(jìn)行緩存。
既然我們每天有500萬次的讀取請求,要緩存這些請求中的20%,我們需要:
0.2?5M?10KB =10GB
因此,我們大約需要10GB的內(nèi)存來緩存那些熱門的粘貼內(nèi)容。
5、系統(tǒng)API
我們可以有SOAP或REST API來公開我們服務(wù)的功能。以下是創(chuàng)建/檢索/刪除粘貼內(nèi)容的API定義:
addPaste(api_dev_key, paste_data, custom_url=None user_name=None, paste_name=None, expire_date=None)
參數(shù):
api_dev_key (字符串): 已注冊帳戶的API開發(fā)者密鑰。這將用于基于分配的配額對用戶進(jìn)行限流等操作。
paste_data (字符串): 粘貼的文本數(shù)據(jù)。
custom_url (字符串): 可選的自定義URL。
user_name (字符串): 可選的用于生成URL的用戶名。
paste_name (字符串): 粘貼內(nèi)容的可選名稱。
expire_date (字符串): 粘貼內(nèi)容的可選過期日期。
返回: (字符串)
成功插入返回可以訪問粘貼內(nèi)容的URL,否則,它將返回一個錯誤碼。
類似地,我們可以有檢索和刪除粘貼內(nèi)容的API:
getPaste(api_dev_key, api_paste_key)
其中api_paste_key是一個表示要檢索的粘貼內(nèi)容的粘貼鍵的字符串。這個API將返回粘貼內(nèi)容的文本數(shù)據(jù)。
deletePaste(api_dev_key, api_paste_key)
成功刪除返回true,否則返回false。
6、數(shù)據(jù)庫設(shè)計(jì)
關(guān)于我們存儲的數(shù)據(jù)性質(zhì),我們有一些觀察:
- 我們需要存儲數(shù)十億條記錄。
- 我們存儲的每個元數(shù)據(jù)對象都很?。ㄐ∮?KB)。
- 我們存儲的每個粘貼對象大小適中(可以達(dá)到幾MB)。
- 記錄之間沒有關(guān)系,除非我們要存儲哪個用戶創(chuàng)建了哪個粘貼內(nèi)容。
- 我們的服務(wù)主要是讀取操作。
數(shù)據(jù)庫架構(gòu):
我們需要兩個表,一個用于存儲粘貼內(nèi)容的信息,另一個用于存儲用戶的數(shù)據(jù)。
這里,URlHash是TinyURL的URL等效項(xiàng),ContentKey是指向一個外部對象的引用,該對象存儲粘貼內(nèi)容的內(nèi)容;我們將在本章后面討論粘貼內(nèi)容的外部存儲。
7、頂層設(shè)計(jì)
在頂層設(shè)計(jì)上,我們需要一個應(yīng)用層來處理所有的讀取和寫入請求。應(yīng)用層將與存儲層進(jìn)行通信,以存儲和檢索數(shù)據(jù)。我們可以將存儲層劃分為兩部分,一部分?jǐn)?shù)據(jù)庫存儲與每個粘貼內(nèi)容、用戶等相關(guān)的元數(shù)據(jù),另一部分將粘貼內(nèi)容存儲在某些對象存儲中(如阿里云OSS)。這種數(shù)據(jù)的劃分也允許我們單獨(dú)進(jìn)行擴(kuò)展。
8、組件設(shè)計(jì)
A. 應(yīng)用層
我們的應(yīng)用層將處理所有的進(jìn)出請求。應(yīng)用服務(wù)器將與后端數(shù)據(jù)存儲組件進(jìn)行通信以服務(wù)這些請求。
如何處理寫入請求?在收到寫入請求后,我們的應(yīng)用服務(wù)器將生成一個六位隨機(jī)字符串,這將作為粘貼的鍵(如果用戶沒有提供自定義鍵)。然后,應(yīng)用服務(wù)器將粘貼的內(nèi)容和生成的鍵存儲在數(shù)據(jù)庫中。成功插入后,服務(wù)器可以將鍵返回給用戶。這里可能存在的一個問題是由于鍵重復(fù)而導(dǎo)致插入失敗。由于我們是生成一個隨機(jī)鍵,所以新生成的鍵可能與現(xiàn)有的鍵相匹配。在這種情況下,我們應(yīng)該重新生成一個新的鍵并再試一次。我們應(yīng)該一直重試,直到我們不再因?yàn)橹貜?fù)鍵看到失敗。如果用戶提供的自定義鍵已經(jīng)在我們的數(shù)據(jù)庫中存在,我們應(yīng)該向用戶返回一個錯誤。
上述問題的另一種解決方案可能是運(yùn)行一個獨(dú)立的鍵生成服務(wù)(KGS),它提前生成隨機(jī)的六位字符串,并將它們存儲在數(shù)據(jù)庫中(我們稱之為key-DB)。每當(dāng)我們想要存儲一個新的粘貼,我們只需要取一個已經(jīng)生成的鍵并使用它。這種方法將使事情變得非常簡單和快速,因?yàn)槲覀儾粫?dān)心重復(fù)或碰撞。KGS將確保所有插入key-DB的鍵都是唯一的。
KGS可以使用兩個表來存儲鍵,一個用于尚未使用的鍵,一個用于所有已使用的鍵。一旦KGS向應(yīng)用服務(wù)器提供了一些鍵,它可以將這些移動到已使用的鍵表中。KGS可以始終在內(nèi)存中保持一些鍵,以便每當(dāng)服務(wù)器需要它們時,它可以快速提供。一旦KGS在內(nèi)存中加載了一些鍵,它就可以將它們移動到已使用的鍵表中;這樣我們就可以確保每個服務(wù)器獲取到的鍵都是唯一的。如果KGS在使用所有加載到內(nèi)存中的鍵之前死掉,我們將浪費(fèi)這些鍵。我們可以忽略這些鍵,因?yàn)槲覀冇写罅康逆I。
KGS會出現(xiàn)單點(diǎn)故障嗎?會的。為了解決這個問題,我們可以有一個備用的KGS副本,每當(dāng)主服務(wù)器死掉時,它可以接管來生成和提供鍵。
每個應(yīng)用服務(wù)器可以從key-DB中緩存一些鍵嗎?是的,這肯定可以加快速度。雖然在這種情況下,如果應(yīng)用服務(wù)器在消費(fèi)所有鍵之前死掉,我們將最終丟失那些鍵。這可能是可以接受的,因?yàn)槲覀冇?8B個唯一的六位字母鍵,這比我們需要的要多得多。
如何處理粘貼讀取請求?在收到讀取粘貼請求后,應(yīng)用服務(wù)層聯(lián)系數(shù)據(jù)存儲。數(shù)據(jù)存儲搜索鍵,如果找到了,就返回粘貼的內(nèi)容。否則,返回一個錯誤碼。
B. 數(shù)據(jù)存儲層
我們可以將我們的數(shù)據(jù)存儲層分為兩部分:
- 元數(shù)據(jù)數(shù)據(jù)庫:我們可以使用像MySQL這樣的關(guān)系數(shù)據(jù)庫,或者像Dynamo或Cassandra這樣的分布式Key-Value存儲。
- 對象存儲:我們可以將我們的內(nèi)容存儲在像阿里云OSS這樣的對象存儲中。每當(dāng)我們感覺到我們的內(nèi)容存儲容量已經(jīng)滿了,我們可以通過平臺直接擴(kuò)容。
9、數(shù)據(jù)分區(qū)和復(fù)制
為了擴(kuò)展我們的數(shù)據(jù)庫,我們需要對其進(jìn)行分區(qū),以便它能存儲數(shù)十億個URL的信息。因此,我們需要設(shè)計(jì)一個分區(qū)方案,將我們的數(shù)據(jù)劃分并存儲到不同的數(shù)據(jù)庫中。
A. 基于范圍的分區(qū):我們可以根據(jù)哈希鍵的第一個字母在不同的分區(qū)中存儲URL。因此,我們將所有以字母“A”(和“a”)開頭的URL哈希鍵存儲在一個分區(qū)中,將以字母“B”開頭的URL存儲在另一個分區(qū)中,以此類推。這種方法被稱為基于范圍的分區(qū)。我們甚至可以將某些出現(xiàn)頻率較低的字母組合到一個數(shù)據(jù)庫分區(qū)中。因此,我們應(yīng)開發(fā)一個靜態(tài)分區(qū)方案,始終以可預(yù)測的方式存儲/查找URL。
這種方法的主要問題是可能導(dǎo)致數(shù)據(jù)庫服務(wù)器不平衡。例如,我們決定將所有以字母“E”開頭的URL放入一個數(shù)據(jù)庫分區(qū),但后來我們發(fā)現(xiàn)以字母“E”開頭的URL過多。
B. 基于哈希的分區(qū):在這種方案中,我們對存儲的對象進(jìn)行哈希。然后,我們根據(jù)哈希值計(jì)算使用哪個分區(qū)。在我們的情況下,我們可以獲取key或短鏈接的哈希值,以確定我們存儲數(shù)據(jù)對象的分區(qū)。
我們的哈希函數(shù)將隨機(jī)地將URL分配到不同的分區(qū)(例如,我們的哈希函數(shù)可以始終將任何key映射到[1...256]之間的一個數(shù)字)。這個數(shù)字將表示我們存儲對象的分區(qū)。
這種方法仍然可能導(dǎo)致分區(qū)負(fù)載過大,這可以通過使用'一致性哈希'來解決。
10、緩存
我們可以緩存頻繁訪問的URL。我們可以使用任何現(xiàn)成的解決方案,比如Memcached,它可以存儲完整的URL及其對應(yīng)的哈希值。因此,應(yīng)用服務(wù)器在訪問后端存儲之前,可以快速檢查緩存是否有所需的URL。
我們應(yīng)該有多少緩存內(nèi)存?我們可以從日流量的20%開始,根據(jù)客戶的使用模式,我們可以調(diào)整需要多少緩存服務(wù)器。如上所估計(jì),我們需要170GB的內(nèi)存來緩存日流量的20%。目前的一些服務(wù)器可以擁有256GB的內(nèi)存,我們可以輕松將所有緩存放入一臺機(jī)器?;蛘?,我們可以使用幾臺小一點(diǎn)的服務(wù)器來存儲所有這些熱門URL。
哪種緩存驅(qū)逐策略最適合我們的需求?當(dāng)緩存滿了,我們想用一個新的/更熱門的URL替換一個鏈接,我們應(yīng)該如何選擇?最近最少使用(LRU)可能是我們系統(tǒng)的一個合理策略。根據(jù)這個策略,我們首先丟棄最近最少使用的URL。我們可以使用Linked Hash Map或類似的數(shù)據(jù)結(jié)構(gòu)來存儲我們的URL和哈希值,這也會記錄最近訪問過的URL。
為了進(jìn)一步提高效率,我們可以復(fù)制我們的緩存服務(wù)器來分配它們之間的負(fù)載。
每個緩存副本如何更新?每當(dāng)有一個緩存未命中,我們的服務(wù)器會訪問后端數(shù)據(jù)庫。當(dāng)這種情況發(fā)生,我們可以更新緩存,并將新的條目傳遞給所有的緩存副本。每個副本都可以通過添加新的條目來更新其緩存。如果副本已經(jīng)有了該條目,它可以簡單地忽略它。
11、負(fù)載均衡(LB)
我們可以在系統(tǒng)中的三個位置添加負(fù)載均衡層:
- 客戶端與應(yīng)用服務(wù)器之間
- 應(yīng)用服務(wù)器與數(shù)據(jù)庫服務(wù)器之間
- 應(yīng)用服務(wù)器與緩存服務(wù)器之間
最初,我們可以使用一個簡單的輪詢方法,將進(jìn)入的請求均等地分配到后端服務(wù)器。這種負(fù)載均衡方法簡單易行,不會引入任何額外的開銷。這種方法的另一個優(yōu)點(diǎn)是,如果一個服務(wù)器死機(jī),負(fù)載均衡器會將其從輪詢中移除,停止向其發(fā)送任何流量。
輪詢負(fù)載均衡的一個問題是,我們沒有考慮到服務(wù)器的負(fù)載。如果一個服務(wù)器過載或運(yùn)行緩慢,負(fù)載均衡器不會停止向該服務(wù)器發(fā)送新的請求。為了處理這個問題,我們可以放置一個更優(yōu)的負(fù)載均衡解決方案,它定期查詢后端服務(wù)器的負(fù)載,并根據(jù)負(fù)載情況調(diào)整流量。
12、清除或數(shù)據(jù)庫清理
key條目是否應(yīng)永久存在,還是應(yīng)該被清除?如果達(dá)到用戶設(shè)定的過期時間,鏈接應(yīng)該怎么處理?
如果我們選擇持續(xù)尋找過期鏈接并將其刪除,這將對我們的數(shù)據(jù)庫產(chǎn)生很大壓力。相反,我們可以慢慢地刪除過期的鏈接,進(jìn)行懶人清理。我們的服務(wù)將確保只刪除過期的鏈接,盡管有些過期鏈接可能會存在更長時間,但永遠(yuǎn)不會被返回給用戶。
- 每當(dāng)用戶試圖訪問一個已過期的鏈接,我們可以刪除該鏈接并向用戶返回錯誤。
- 我們可以設(shè)置一個單獨(dú)的清理服務(wù),定期從我們的存儲和緩存中刪除過期的鏈接。這個服務(wù)需要非常輕量,只在預(yù)計(jì)用戶流量較低的時候運(yùn)行。
- 我們可以為每個鏈接設(shè)定一個默認(rèn)的過期時間(例如兩年)。
- 刪除過期鏈接后,我們可以將該Key重新放回Key-DB,以供重復(fù)使用。
- 我們是否應(yīng)刪除一段時間(比如說六個月)內(nèi)沒有被訪問過的鏈接?這可能有點(diǎn)不合適。由于存儲成本越來越低,我們可以決定永久保存鏈接。
13、數(shù)據(jù)跟蹤
我們?nèi)绾谓y(tǒng)計(jì)短鏈接被使用的次數(shù)、用戶的位置等信息?我們?nèi)绾蝺Υ孢@些統(tǒng)計(jì)信息?如果它是數(shù)據(jù)庫中的一部分,每次查看都需要更新,那么當(dāng)一個流行的短鏈接被大量并發(fā)請求瞬間涌入時,會發(fā)生什么?
一些值得追蹤的統(tǒng)計(jì)數(shù)據(jù):訪客的國家、訪問的日期和時間、引導(dǎo)點(diǎn)擊的網(wǎng)頁、訪問頁面的瀏覽器或平臺。
14、安全和權(quán)限
用戶能否創(chuàng)建私有URL或者允許特定的用戶組訪問某個URL?
我們可以在數(shù)據(jù)庫中每個URL的條目里存儲訪問權(quán)限級別(公開/私有)。我們也可以創(chuàng)建一個單獨(dú)的表來存儲有權(quán)訪問特定URL的用戶的UserID。如果一個用戶沒有權(quán)限但試圖訪問一個URL,我們可以返回一個錯誤(HTTP 401)??紤]到我們的數(shù)據(jù)儲存在一個類似Cassandra的NoSQL寬列數(shù)據(jù)庫中,儲存權(quán)限的表的key將是‘哈希值’(或KGS生成的key)。列將儲存有權(quán)查看URL的用戶的UserID。