邵曉文
(長春理工大學計算機科學與技術學院,長春130022)
進入21 世紀以來,互聯網技術進入了飛速發展階段,互聯網上的信息量成爆炸式增長,谷歌、百度、雅虎等搜索引擎,每天都會抓住數以億計的網頁,不同領域、不同背景的用戶,對于信息的需求不同,盡管這些搜索引擎幫助我們抓取了互聯網上的大部分信息,但是返回的結果包含大量用戶不關心的網頁。網絡爬蟲[1]是搜索引擎的基礎,目的是為了對互聯網中的海量數據進行抓取,當需要對具體網站(如知乎)數據進行抓取,通用搜索引擎無法完成這部分工作,需要設計專門的主題爬蟲[3-4]程序,自動抓取特定網頁中的信息。
知乎作為國內知名的問答社區,連接著各行各業的用戶。用戶分享著彼此的知識、經驗和見解,為中文互聯網源源不斷的提供多種多樣的信息。目前知乎的用戶已經突破1 億,但是知乎官方并沒有提供相應的數據接口,以供使用。Python 語言常被用于爬蟲程序編寫[2],但相比于Java 而言Python 的執行效率低,并且Java 語言對多線程的支持更加完善。因此本文設計了一款基于Java 語言的多線程并發網絡爬蟲,爬取知乎用戶的相關信息。實驗結果表明:多線程爬蟲,具有較快的爬取效率,可以更快地爬取數據。
整個互聯網可以看成一個無限大的有向圖,網絡爬蟲的抓取流程是以一個URL 為初始點,根據URL 發送請求,獲取對應的HTML 頁面,提取出需要采集的信息保存,同時解析出更多的URL 加入URL 隊列,從而實現從一個點擴展到整個網絡的過程。直到符合爬蟲的停止條件。爬蟲的抓取流程如圖1 所示。

圖1 普通爬蟲的抓取流程
知乎的用戶之間的關系可以看做一個有向圖,如圖2 所示。

圖2 知乎用戶關系示意圖
對圖的搜索主要有兩種方式,深度優先搜索和寬度優先搜索。深度優先遍歷測試是指網絡爬蟲會從起始頁開始,一個鏈接一個鏈接跟蹤下去,處理完這條線路的鏈接之后,再轉入下一個起始頁,繼續跟蹤鏈接。寬度優先策略是按照樹的層次進行搜索,如果此層沒有搜索完成,不會進入下一層搜索。即首先完成一個層次的搜索,其次再進行下一層次,也稱之為分層處理。
本文采用的是寬度優先搜索。因為離種子URL比較近的,往往是重要的網頁,隨著瀏覽的不斷深入,所看到的網頁重要性會越來越低。寬度優先搜索也有利于多爬蟲的合作抓取。
知乎中的每個用戶都有唯一的Urltoken 與之對應,首先選取一名知乎活躍用戶作為起點,使用httpclient 工具發送請求,返回JSON 格式的數據,提取用戶的相關信息存入MySQL 數據庫,同時獲取該用戶關注的用戶列表,用戶列表中的數據以Urltoken 的方式給出,通過Urltoken 組成新的URL 加入隊列。
爬蟲隊列的設計是網絡爬蟲的關鍵部分,小型爬蟲可以直接使用鏈表實現,但是需要爬取的數據量很大時,僅僅靠Java 自身提供的數據結構是遠遠不夠的,并且當爬取時間很長時,爬蟲程序不一定能夠在一次的啟動過程中就完成所有爬取工作,可能需要重復啟動多次,這就需要將隊列中的數據固化到硬盤中,否則再次啟動爬蟲,將重新從開始節點爬取。因此這里選用Redis 數據庫來實現爬蟲隊列。
Redis 是一款高性能的key-value 數據庫,主要具有以下特點:
●Redis 支持數據的持久化,可以將內存中的數據保存在磁盤中,重啟的時候可以再次加載進行使用,這一點對網絡爬蟲來說非常重要。
●Redis 不僅僅支持簡單的key-value 類型的數據,同時還提供list、set、hash 等數據結構的存儲。
●Redis 性能極高,讀取速度高達110000 次/s,寫入速度高達81000 次/s。
因此Redis 非常適合用來做網絡爬蟲的隊列,本文使用Redis 的List 結構,作為爬蟲程序的URL 隊列。
在采用深度優先的爬取策略時,需要對已經爬取過的URL 進行過濾,避免重復爬取,可以使用Java 提供的哈希表對已經爬取過的URL 進行記錄,從而達到避免重復的效果。對于小型爬蟲而言,通過Java 提供的哈希表來實現過濾器,能滿足需求。但是知乎的用戶數量已經超過一億,當爬取的數據量很大時,需要非常大的內存空間,一般服務器很難滿足這樣的存儲需求。因此本文采用了布隆過濾器[5-6]來過濾已爬取的URL。
布隆過濾器是由巴頓·布隆于1970 年提出,它是一種數學工具,只需要哈希表的1/8~1/4 的大小就能解決同樣的問題。它主要是由一個二進制的向量和一系列的hash 函數組成。本文通過下面的示例說明其工作原理。
利用內存中的一個長度是m 的位數組B,對其中所有位都置為0,如圖3 所示。

圖3 位數組的初始狀態
對于每一個URL 地址,選取8 個不同的隨機質數,根據這8 個質數,計算出八個不同的hash 值,并將位數組中對應hash 值的位置設為1,如圖4 所示。

圖4 布隆過濾器的實現
對于5000 萬級別的URL,布隆過濾器值占用200M 左右的空間,大大節省了存儲空間。
正如上文所述,當爬取數據量很大,爬取時間很長的時候,當遇到爬蟲程序意外終止的情況,再次啟動爬蟲程序,就得重新開始爬取,對于過濾器也一樣,如果使用HashMap 或者Set,再次啟動爬蟲時,過濾器中的數據丟失,因此,鑒于前面對Redis 的介紹,這里采用Redis 的List 結構來實現。
爬蟲主要消耗三種資源:網絡帶寬、中央處理器和磁盤。三者中的任何一個都可能成為會爬蟲程序的瓶頸。在這三者都無法控制的前提下,單線程爬蟲的效率低下,爬取速度很慢。為了增加爬蟲的效率,最直接方便的辦法就是使用多線程技術[7]并行抓取,Java 語言對多線程提供了很好的支持。在爬蟲系統中,要處理的URL 隊列是唯一的,多個線程依次從隊列中取出URL,各自對用戶信息進行抓取,再將用戶關注者的URL 加入隊列,因此,線程既是生產者也是消費者,這里使用線程池來管理線程。
使用多線程并發爬取數據時可以大大提升爬取的效率,但同時也帶來數據競爭的問題。即存在同一塊內存上,存在兩個并發的操作,其中至少有一個為寫操作。數據競爭會導致讀取的數據和預期的不一致。本文通過Redis 實現了URL 隊列和布隆過濾器,Redis 的基本操作,在并發環境下是具有原子性的,因此不用考慮數據競爭。
本文的爬蟲程序結束邏輯是使用了一個共享變量count 作為計數器,記錄已爬取的URL 數量,當達到指定數量,就不再向URL 隊列中加入新的URL,直至將URL 隊列中的數據爬取完畢,URL 隊列為空,爬蟲程序終止,因此爬取頁面的時間遠遠長于向隊列中加入URL 的時間,因此不會出現URL 隊列為空,而count 沒有達到指定數量的情況。由于多線程的執行順序使無法確定的,且線程之間的操作是相互透明的,因此對count 的操作是存在線程安全問題的,會產生數據競爭的問題。對此,通常的解決方案是將操作count 的臨界區通過Java 提供的同步鎖進行保護,保證每一次只有一個線程修改count 變量,但是當線程數量很多是,鎖機制會大大降低爬蟲的效率。因此采用樂觀鎖來解決count 的數據競爭問題,樂觀鎖的主要元素是通過CAS(Compare And Swap)操作實現,使用Java 提供的原子操作類:AtomicInteger 原子更新整型類,該類保證更新的原子性,性能高效,線程安全。本文的爬蟲程序結構如圖5 所示。

圖5 本文的爬蟲架構

表1

表2
首先從種子URL 開始,使用HttpClient,根據URL,向服務器發送請求,知乎服務器響應一段HTML代碼,其中包含用戶相關信息的JSON 格式數據,使用Jsoup 解析HTML 網頁,獲取用戶的信息,通過JsonObject,解析出用戶的相關數據并存入MySQL 數據庫,根據用戶的token,發起新的請求,獲取用戶關注列表,得到JSON 格式數據,解析出關注列表的Urltoken,組成新的URL,存入隊列,最終完成隊列的初始化工作。
完成初始化工作后,開啟線程開始爬取工作,從隊列中取出URL,判斷URL 是否已經被爬取,如果沒有,就執行爬取工作,否則重新從隊列中取出新的URL,當URL 計數器達到指定數量時,停止向隊列中加入新的URL,繼續爬取隊列中剩下的URL,最終URL 隊列為空,結束爬蟲程序。

表3
實驗結果表明:相比單線程爬蟲的抓取速度遠遠小于多線程爬蟲,因此本文設計的爬蟲,能夠有效提高了爬蟲的效率。
本文的爬蟲采用Java 語言針對知乎的用戶數據進行抓取,設計了多線程爬蟲架構,實現了多數據的并發抓起,加快了爬取的速度,并使用了Redis 實現了URL隊列,提升了存取的效率。本文實現了布隆過濾器,過濾已爬取的URL,大大減少了內存的消耗,放棄使用同步鎖,使用Java 提供的原子操作類AtomicInteger,保證了線程的安全,提升了爬蟲的效率,最終達到預期的爬取效果。