- 原文地址:Design a web crawler
- 原文作者:Donne Martin
- 譯文出自:掘金翻譯計劃
- 譯者:吃土小2叉
- 校對者:lsvih
設計一個網頁爬蟲
注意:這個文件中的連結會直接指向系統設計主題索引中的有關部分,以避免重複的內容。你可以參考連結的相關內容,來了解其總的要點、方案的權衡取捨以及可選的替代方案。
第一步:簡述用例與約束條件
把所有需要的東西聚集在一起,審視問題。不停的提問,以至於我們可以明確使用場景和約束。討論假設。
我們將在沒有面試官明確說明問題的情況下,自己定義一些用例以及限制條件。
用例
我們把問題限定在僅處理以下用例的範圍中
- 服務 抓取一系列連結:
- 生成包含搜尋詞的網頁倒排索引
- 生成頁面的標題和摘要資訊
- 頁面標題和摘要都是靜態的,它們不會根據搜尋詞改變
- 使用者 輸入搜尋詞後,可以看到相關的搜尋結果列表,列表每一項都包含由網頁爬蟲生成的頁面標題及摘要
- 只給該用例繪製出概要元件和互動說明,無需討論細節
- 服務 具有高可用性
無需考慮
- 搜尋分析
- 個性化搜尋結果
- 頁面排名
限制條件與假設
提出假設
- 搜尋流量分佈不均
- 有些搜尋詞非常熱門,有些則非常冷門
- 只支援匿名使用者
- 使用者很快就能看到搜尋結果
- 網頁爬蟲不應該陷入死迴圈
- 當爬蟲路徑包含環的時候,將會陷入死迴圈
- 抓取 10 億個連結
- 要定期重新抓取頁面以確保新鮮度
- 平均每週重新抓取一次,網站越熱門,那麼重新抓取的頻率越高
- 每月抓取 40 億個連結
- 每個頁面的平均儲存大小:500 KB
- 簡單起見,重新抓取的頁面算作新頁面
- 每月搜尋量 1000 億次
用更傳統的系統來練習 —— 不要使用 solr 、nutch 之類的現成系統。
計算用量
如果你需要進行粗略的用量計算,請向你的面試官說明。
- 每月儲存 2 PB 頁面
- 每月抓取 40 億個頁面,每個頁面 500 KB
- 三年儲存 72 PB 頁面
- 每秒 1600 次寫請求
- 每秒 40000 次搜尋請求
簡便換算指南:
- 一個月有 250 萬秒
- 每秒 1 個請求,即每月 250 萬個請求
- 每秒 40 個請求,即每月 1 億個請求
- 每秒 400 個請求,即每月 10 億個請求
第二步: 概要設計
列出所有重要元件以規劃概要設計。
第三步:設計核心元件
對每一個核心元件進行詳細深入的分析。
用例:爬蟲服務抓取一系列網頁
假設我們有一個初始列表 links_to_crawl
(待抓取連結),它最初基於網站整體的知名度來排序。當然如果這個假設不合理,我們可以使用 Yahoo、DMOZ 等知名入口網站作為種子連結來進行擴散 。
我們將用表 crawled_links
(已抓取連結 )來記錄已經處理過的連結以及相應的頁面簽名。
我們可以將 links_to_crawl
和 crawled_links
記錄在鍵-值型 NoSQL 資料庫中。對於 crawled_links
中已排序的連結,我們可以使用 Redis 的有序集合來維護網頁連結的排名。我們應當在 選擇 SQL 還是 NoSQL 的問題上,討論有關使用場景以及利弊 。
- 爬蟲服務按照以下流程迴圈處理每一個頁面連結:
- 選取排名最靠前的待抓取連結
- 在 NoSQL 資料庫的
crawled_links
中,檢查待抓取頁面的簽名是否與某個已抓取頁面的簽名相似- 若存在,則降低該頁面連結的優先順序
- 這樣做可以避免陷入死迴圈
- 繼續(進入下一次迴圈)
- 若不存在,則抓取該連結
- 在倒排索引服務任務佇列中,新增一個生成倒排索引任務。
- 在文件服務任務佇列中,新增一個生成靜態標題和摘要的任務。
- 生成頁面簽名
- 在 NoSQL 資料庫的
links_to_crawl
中刪除該連結 - 在 NoSQL 資料庫的
crawled_links
中插入該連結以及頁面簽名
- 若存在,則降低該頁面連結的優先順序
- 在 NoSQL 資料庫的
- 選取排名最靠前的待抓取連結
向面試官瞭解你需要寫多少程式碼。
PagesDataStore
是爬蟲服務中的一個抽象類,它使用 NoSQL 資料庫進行儲存。
class PagesDataStore(object):
def __init__(self, db);
self.db = db
...
def add_link_to_crawl(self, url):
"""將指定連結加入 `links_to_crawl`。"""
...
def remove_link_to_crawl(self, url):
"""從 `links_to_crawl` 中刪除指定連結。"""
...
def reduce_priority_link_to_crawl(self, url)
"""在 `links_to_crawl` 中降低一個連結的優先順序以避免死迴圈。"""
...
def extract_max_priority_page(self):
"""返回 `links_to_crawl` 中優先順序最高的連結。"""
...
def insert_crawled_link(self, url, signature):
"""將指定連結加入 `crawled_links`。"""
...
def crawled_similar(self, signature):
"""判斷待抓取頁面的簽名是否與某個已抓取頁面的簽名相似。"""
...複製程式碼
Page
是爬蟲服務的一個抽象類,它封裝了網頁物件,由頁面連結、頁面內容、子連結和頁面簽名構成。
class Page(object):
def __init__(self, url, contents, child_urls, signature):
self.url = url
self.contents = contents
self.child_urls = child_urls
self.signature = signature複製程式碼
Crawler
是爬蟲服務的主類,由Page
和 PagesDataStore
組成。
class Crawler(object):
def __init__(self, data_store, reverse_index_queue, doc_index_queue):
self.data_store = data_store
self.reverse_index_queue = reverse_index_queue
self.doc_index_queue = doc_index_queue
def create_signature(self, page):
"""基於頁面連結與內容生成簽名。"""
...
def crawl_page(self, page):
for url in page.child_urls:
self.data_store.add_link_to_crawl(url)
page.signature = self.create_signature(page)
self.data_store.remove_link_to_crawl(page.url)
self.data_store.insert_crawled_link(page.url, page.signature)
def crawl(self):
while True:
page = self.data_store.extract_max_priority_page()
if page is None:
break
if self.data_store.crawled_similar(page.signature):
self.data_store.reduce_priority_link_to_crawl(page.url)
else:
self.crawl_page(page)複製程式碼
處理重複內容
我們要謹防網頁爬蟲陷入死迴圈,這通常會發生在爬蟲路徑中存在環的情況。
向面試官瞭解你需要寫多少程式碼.
刪除重複連結:
- 假設資料量較小,我們可以用類似於
sort | unique
的方法。(譯註: 先排序,後去重) - 假設有 10 億條資料,我們應該使用 MapReduce 來輸出只出現 1 次的記錄。
class RemoveDuplicateUrls(MRJob):
def mapper(self, _, line):
yield line, 1
def reducer(self, key, values):
total = sum(values)
if total == 1:
yield key, total複製程式碼
比起處理重複內容,檢測重複內容更為複雜。我們可以基於網頁內容生成簽名,然後對比兩者簽名的相似度。可能會用到的演算法有 Jaccard index 以及 cosine similarity。
抓取結果更新策略
要定期重新抓取頁面以確保新鮮度。抓取結果應該有個 timestamp
欄位記錄上一次頁面抓取時間。每隔一段時間,比如說 1 周,所有頁面都需要更新一次。對於熱門網站或是內容頻繁更新的網站,爬蟲抓取間隔可以縮短。
儘管我們不會深入網頁資料分析的細節,我們仍然要做一些資料探勘工作來確定一個頁面的平均更新時間,並且根據相關的統計資料來決定爬蟲的重新抓取頻率。
當然我們也應該根據站長提供的 Robots.txt
來控制爬蟲的抓取頻率。
用例:使用者輸入搜尋詞後,可以看到相關的搜尋結果列表,列表每一項都包含由網頁爬蟲生成的頁面標題及摘要
- 客戶端向執行反向代理的 Web 伺服器傳送一個請求
- Web 伺服器 傳送請求到 Query API 伺服器
- 查詢 API 服務將會做這些事情:
- 解析查詢引數
- 刪除 HTML 標記
- 將文字分割成片語 (譯註: 分詞處理)
- 修正錯別字
- 規範化大小寫
- 將搜尋詞轉換為布林運算
- 使用倒排索引服務來查詢匹配查詢的文件
- 倒排索引服務對匹配到的結果進行排名,然後返回最符合的結果
- 使用文件服務返回文章標題與摘要
- 解析查詢引數
我們使用 REST API 與客戶端通訊:
$ curl https://search.com/api/v1/search?query=hello+world複製程式碼
響應內容:
{
"title": "foo's title",
"snippet": "foo's snippet",
"link": "https://foo.com",
},
{
"title": "bar's title",
"snippet": "bar's snippet",
"link": "https://bar.com",
},
{
"title": "baz's title",
"snippet": "baz's snippet",
"link": "https://baz.com",
},複製程式碼
對於伺服器內部通訊,我們可以使用 遠端過程呼叫協議(RPC)
第四步:架構擴充套件
根據限制條件,找到並解決瓶頸。
重要提示:不要直接從最初設計跳到最終設計!
現在你要 1) 基準測試、負載測試。2) 分析、描述效能瓶頸。3) 在解決瓶頸問題的同時,評估替代方案、權衡利弊。4) 重複以上步驟。請閱讀設計一個系統,並將其擴大到為數以百萬計的 AWS 使用者服務 來了解如何逐步擴大初始設計。
討論初始設計可能遇到的瓶頸及相關解決方案是很重要的。例如加上一套配備多臺 Web 伺服器的負載均衡器是否能夠解決問題?CDN呢?主從複製呢?它們各自的替代方案和需要權衡的利弊又有哪些呢?
我們將會介紹一些元件來完成設計,並解決架構規模擴張問題。內建的負載均衡器將不做討論以節省篇幅。
為了避免重複討論,請參考系統設計主題索引相關部分來了解其要點、方案的權衡取捨以及替代方案。
有些搜尋詞非常熱門,有些則非常冷門。熱門的搜尋詞可以通過諸如 Redis 或者 Memcached 之類的記憶體快取來縮短響應時間,避免倒排索引服務以及文件服務過載。記憶體快取同樣適用於流量分佈不均勻以及流量短時高峰問題。從記憶體中讀取 1 MB 連續資料大約需要 250 微秒,而從 SSD 讀取同樣大小的資料要花費 4 倍的時間,從機械硬碟讀取需要花費 80 倍以上的時間。1
以下是優化爬蟲服務的其他建議:
- 為了處理資料大小問題以及網路請求負載,倒排索引服務和文件服務可能需要大量應用資料分片和資料複製。
- DNS 查詢可能會成為瓶頸,爬蟲服務最好專門維護一套定期更新的 DNS 查詢服務。
- 藉助於連線池,即同時維持多個開放網路連線,可以提升爬蟲服務的效能並減少記憶體使用量。
- 改用 UDP 協議同樣可以提升效能
- 網路爬蟲受頻寬影響較大,請確保頻寬足夠維持高吞吐量。
其它要點
是否深入這些額外的主題,取決於你的問題範圍和剩下的時間。
SQL 擴充套件模式
NoSQL
快取
非同步與微服務
通訊
- 可權衡選擇的方案:
- 與客戶端的外部通訊 - 使用 REST 作為 HTTP API
- 內部通訊 - RPC
- 服務發現
安全性
請參閱安全。
延遲數值
請參閱每個程式設計師都應該知道的延遲數。
持續探討
- 持續進行基準測試並監控你的系統,以解決他們提出的瓶頸問題。
- 架構擴充套件是一個迭代的過程。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃。