用elasticsearch和nuxtjs搭建bt搜尋引擎

withcancer發表於2018-10-02

世界上已經有了這麼多種子搜尋引擎,為什麼你還要不厭其煩的做一個新的?

可以這麼說,地球上大多數的種子搜尋引擎的前後端技術都比較古老,雖然古老的技術既經典又好用,但是作為一個喜歡嚐鮮的人,我仍然決定使用目前最為先進的開發技術製作一個功能簡明的種子搜尋引擎。

採用了什麼技術?

前端:在vue,angular,react三大現代開發框架中選擇了vue,做出這個決定的原因也僅僅是一直以來對vue的謎之好感。有時候就是這樣,緣分到了不得不從,恰巧nuxtjs在9月更新了2.0,因此毫不猶豫選擇了vue。

後端:在koa,gin,springboot中權衡良久,由於很長時間沒有寫過java,最後選擇了springboot + jdk11,用寫javascript的感覺來寫java,還是很不錯的。從追求速度上來講,可能使用gin或Koa要更快,但是這一點提升對於我這種實驗性網站來說,意義並不是很大。

全文檢索:嘗試了全文檢索裡面的比較潮的couchbaseredissearch、elasticsearch,最後選定elasticsearch,另外兩個的速度雖然遠高於elasticsearch,但畢竟是記憶體式資料庫,簡單功能尚可,複雜度上去後則吃記憶體太多。

製作過程呢?

下面我分享下大概過程,涉及到複雜原理,請自行谷歌,我不認為我可以把複雜原理描述的很簡單。

關於命名:

從手中的十來個域名選擇了

btzhai.top

中國國內同名的網站有幾個,但是這不是問題。

關於伺服器:

幾經周折,購買了一臺美國伺服器。配置是:E5-1620|24G|1TB|200M頻寬,真正的24小時人工服務。考慮到要用cloudfare,所以不需要硬防。一月1200RMB。

在此期間嘗試了很多家伺服器,深感這免備案伺服器這一行真的是泥沙俱下。

關於爬蟲:

大約8月初終於有空來著手bt搜尋引擎這件事情。

首先擺在我面前的問題就是資料來源問題,要知道所謂的dht網路,說白了就是一個節點既是伺服器又是客戶端,你在利用dht網路下載時會廣播到網路中,別的節點就會接收到你所下載檔案的唯一識別符號infohash(有的地方稱之為神祕程式碼)和metadata,這裡麵包括了這個檔案的名稱、大小、建立時間、包含檔案等資訊,利用這個原理,dht爬蟲就可以收集dht網路中的即時熱門下載。

如果僅僅依靠依靠dht爬蟲去爬的話,理論上初期速度大約為40w一天,30天可以收集上千萬,但是dht網路裡面的節點不可能總是下載新的檔案,現實情況是:大多數情況下冷門的種子幾年無人問津,熱門種子天天數十萬人下載。可以推想,隨著種子基數增加,重複的infohash會越來越多,慢慢地只會增加所謂的種子熱度而不會增加基數,但是沒有1000w+的種子,從門面上來講不好看。

去哪裡弄1000w種子成了當時我主要研究的問題。首先我從github上選取了幾個我認為比較好用的dht爬蟲進行改造,讓之可以直接將資料入庫到elasticsearch中,並且在infohash重複的時候自動對熱度+1。

elasticsearch的mapping如下,考慮到中文分詞,選用了smartcn作為分詞器,當然ik也是可以的。種子內的檔案列表files,本來設定為nested object,因為nested query效能不高已經取消:

{
	"properties": {
		"name": {
			"type": "text",
			"analyzer": "smartcn",
			"search_analyzer": "smartcn"
		},
		"length": {
			"type": "long"
		},
		"popularity": {
			"type": "integer"
		},
		"create_time": {
			"type": "date",
			"format": "epoch_millis"
		},
		"files": {
			"properties": {
				"length": {
					"type": "long"
				},
				"path": {
					"type": "text",
					"analyzer": "smartcn",
					"search_analyzer": "smartcn"
				}
			}
		}
	}
}
複製程式碼

伺服器上開始24小時掛著dht爬蟲。期間我也嘗試過多種不同語言的開源爬蟲來比較效能,甚至還找人試圖購買bt種子。下面這些爬蟲我都實際使用過:

https://github.com/shiyanhui/dht
https://github.com/fanpei91/p2pspider
https://github.com/fanpei91/simDHT
https://github.com/keenwon/antcolony
https://github.com/wenguonideshou/zsky
複製程式碼

然而這些dht爬蟲經試驗,或多或少都有些問題,有的是隻能採集infohash而不能採集metadata,有的採集速度不夠,有的則隨時間增加資源佔用越來越大。

最終確定的是這個最優解:

github.com/neoql/btlet

唯一不妥是執行一段時間(大約10個小時)後就會崩潰退出,可能跟採集速度有關。而在我寫這篇文章的前幾天,作者稱已經將此問題修復,我還沒有來得及跟進更新。可以說這是我實驗過採集速度最快的dht爬蟲。有興趣的同學可以去嘗試、PR。

爬蟲正常化執行以後,我終於發現了基數問題的解決之道,那就是skytorrent關閉後dump出來的資料庫和openbay,利用這大約4000w infohash資料和bthub,每天都一定可以保證有數萬新的metadata入庫。

關於bthub我要說的是,api請求頻率太高會被封ip,發郵件詢問的結果如下。經過我的反覆測試,api請求間隔設為1s也是沒問題的:

bthub郵件.png

關於前端:

我比較習慣於先畫出簡單的前端再開始寫後端,前端確定清楚功能以後就可以很快寫出對應的介面。bt搜尋引擎目前具有以下這麼幾個功能就足夠了:

  1. 可以搜尋關鍵詞

  2. 首頁可以展現之前搜尋過的排行前十的關鍵詞

  3. 可以隨機推薦一些檔案

  4. 可以按照相關性、大小、建立時間、熱度排序

首頁啟動時,為了提高速度,從後臺讀cache,包括收錄了多少infohash、隨機推薦的檔名稱、搜尋關鍵詞top10等等,這些cache使用@Scheduled每天自動更新一次。

點選搜尋後,跳轉到結果展現頁面,這裡只展現elasticsearch處理過highlight之後的結果而不展現所有原始結果,每頁展示10個結果。

原始結果的展現放在最後一個詳細畫面上。

前端承載的另一個重要問題就是seo,這也是我使用nuxtjs的原因。前端功能完成以後,我為它新增了meta描述、google analytics、百度。

sitemap的新增倒是耗廢了一些時間,因為是動態網頁的緣故,只能用nuxt-sitemap來動態生成。

另外用媒體查詢和vh、vw做了移動適配。不敢說100%,至少可以覆蓋90%的裝置。

關於後端:

spring data在實現核心搜尋api時遇到了問題,核心搜尋如果寫成json,舉個例子的話,可能是下面的這個樣子:

{
	"from": 0,
	"size": 10,
	"sort": [{
		"_score": "desc"
	}, {
		"length": "desc"
	}, {
		"popularity": "desc"
	}, {
		"create_time": "desc"
	}],
	"query": {
		"multi_match": {
			"query": "這裡是要搜尋的關鍵詞",
			"fields": ["name", "files.path"]
		}
	},
	"highlight": {
		"pre_tags": ["<strong>"],
		"post_tags": ["</strong>"],
		"fields": {
			"name": {
				"number_of_fragments": 1,
				"no_match_size": 150
			},
			"files.path": {
				"number_of_fragments": 3,
				"no_match_size": 150
			}
		}
	}
}
複製程式碼

highlight返回的結果將沒有辦法自動和entity匹配,因為這一部分資料不在source中,spring data無法通過getSourceAsMap來獲取。這裡需要用到NativeSearchQueryBuilder去手動配置,如果有更好的方式,請務必賜教。java程式碼如下:

var searchQuery = new NativeSearchQueryBuilder()
                .withIndices("torrent_info").withTypes("common")
                .withQuery(QueryBuilders.multiMatchQuery(param.getKeyword(), "name", "files.path"))
                .withHighlightFields(new HighlightBuilder.Field("name").preTags("<strong>").postTags("</strong>").noMatchSize(150).numOfFragments(1), new HighlightBuilder.Field("files.path").preTags("<strong>").postTags("</strong>").noMatchSize(150).numOfFragments(3))
                .withPageable(PageRequest.of(param.getPageNum(), param.getPageSize(), sort))
                .build();
var torrentInfoPage = elasticsearchTemplate.queryForPage(searchQuery, TorrentInfoDo.class, new SearchResultMapper() {
            @SuppressWarnings("unchecked")
            @Override
            public <T> AggregatedPage<T> mapResults(SearchResponse searchResponse, Class<T> aClass, Pageable pageable) {
                if (searchResponse.getHits().getHits().length <= 0) {
                    return null;
                }
                var chunk = new ArrayList<>();

                for (var searchHit : searchResponse.getHits()) {
                    // 設定info部分
                    var torrentInfo = new TorrentInfoDo();
                    torrentInfo.setId(searchHit.getId());
                    torrentInfo.setName((String) searchHit.getSourceAsMap().get("name"));
                    torrentInfo.setLength(Long.parseLong(searchHit.getSourceAsMap().get("length").toString()));
                    torrentInfo.setCreate_time(Long.parseLong(searchHit.getSourceAsMap().get("create_time").toString()));
                    torrentInfo.setPopularity((Integer) searchHit.getSourceAsMap().get("popularity"));
                    // ArrayList<Map>->Map->FileList->List<FileList>
                    var resList = ((ArrayList<Map>) searchHit.getSourceAsMap().get("files"));
                    var fileList = new ArrayList<FileList>();
                    for (var map : resList) {
                        FileList file = new FileList();
                        file.setPath((String) map.get("path"));
                        file.setLength(Long.parseLong(map.get("length").toString()));
                        fileList.add(file);
                    }
                    torrentInfo.setFiles(fileList);
                    // 設定highlight部分
                    // 種子名稱highlight(一般只有一個)
                    var nameHighlight = searchHit.getHighlightFields().get("name").getFragments()[0].toString();
                    // path highlight列表
                    var pathHighlight = getFileListFromHighLightFields(searchHit.getHighlightFields().get("files.path").fragments(), fileList);
                    torrentInfo.setNameHighLight(nameHighlight);
                    torrentInfo.setPathHighlight(pathHighlight);
                    chunk.add(torrentInfo);
                }
                if (chunk.size() > 0) {
                    // 不設定total返回不了正確的page結果
                    return new AggregatedPageImpl<>((List<T>) chunk, pageable, searchResponse.getHits().getTotalHits());
                }
                return null;
            }
        });
複製程式碼

關於elasticsearch:

種子搜尋不需要多高的實時性,一臺伺服器也不需要副本,因此,index的設定都是這樣:

{
	"settings": {
		"number_of_shards": 2,
		"number_of_replicas": 0,
		"refresh_interval": "90s"
	}
}
複製程式碼

jvm配置了8G記憶體,G1GC,另外還禁了swapping:

## IMPORTANT: JVM heap size 
-Xms8g
-Xmx8g
## GC configuration 
-XX:+UseG1GC
-XX:MaxGCPauseMillis=50
複製程式碼

執行得怎麼樣?

由於搜尋比較複雜,平均搜尋時間1s左右,搜尋命中上百萬資料時會大於2s。

下面是cloudfare的統計:

cloudfare.jpg

相關文章