即時搜尋引擎Zoie
來源:http://www.cnblogs.com/forfuture1978/archive/2010/11/29/1891476.html
一、總體架構
Zoie是linkedin公司基於Lucene實現的即時搜尋引擎系統,按照其官方wiki的描述為:
http://snaprojects.jira.com/wiki/display/ZOIE/Overview
Zoie is a realtime indexing and search system, and as such needs to have relatively close coupling between the logically distinct Indexing and Searching subsystems: as soon as a document made available to be indexed, it must be immediately searchable.
The ZoieSystem is the primary component of Zoie, that incorporates both Indexing (via implementing DataConsumer<V>) and Search (via implementingIndexReaderFactory<ZoieIndexReader<R extends IndexReader>>).
Zoie是一個即時的搜尋引擎系統,其需要邏輯上獨立的索引和搜尋子系統相對緊密的結合在一起,從而使得一篇文檔一經索引,就能夠立刻被搜尋的到。
ZoieSystem是Zoie的重要組成部分,其一方面通過實現DataConsumer介面而完成了索引功能,一方面通過實現IndexReaderFactory<ZoieIndexReader<R extends IndexReader>>而完成了搜尋功能,並將二者緊密的結合在一起。
下面就是ZoieSystem的總體架構圖:
- 對於索引系統來講,ZoieSystem是一個DataConsumer,也即是一個消費者,其有函數consume用於消費DataEvent對象而完成索引功能。
- 既然其是消費者,則向其提供資料的就應該是生產者DataProvider,要想使用Zoie建立即時搜尋系統,必須提供自己的生產者。
- 對於搜尋系統來講,ZoieSystem是一個IndexReaderFactory,也即是一個能夠得到讀取索引的IndexReader的工廠,其有函數getIndexReaders得到所有的IndexReader列表,從而可以完成對索引資料讀取的功能。
- 熟悉Lucene的讀者應該很清楚,要想對Lucene的索引進行搜尋,則首先要得到IndexReader,然後根據IndexReader產生IndexSearcher,從而可以進行搜尋,收集結果,打分,排序等過程。既然IndexReader可以通過Zoie的工廠得到,使用者需要實現自己的搜尋邏輯方可。
二、配置一個ZoieSystem
ZoieSystem是可以使用spring進行配置的,一個典型的配置如下:
<!--An instance of a DataProvider: FileDataProvider recurses through a given directory and provides the DataConsumer indexing requests built from the gathered files. In the example, this provider needs to be started manually, and it is done via jmx. 一個DataProvider的執行個體: FileDataProvider遞迴的訪問一個指定的路徑,將得到的檔案構造成索引請求提供給DataConsumer。 在本例中,此生產者需要通過jmx進行手動啟動。 --> <bean id="dataprovider" class="proj.zoie.impl.indexing.FileDataProvider"> <constructor-arg value="file:${source.directory}"/> <property name="dataConsumer" ref="indexingSystem" /> </bean> <!-- an instance of an IndexableInterpreter: FileIndexableInterpreter converts a text file into a lucene document, for example purposes only 一個IndexableInterpreter的執行個體: 在本例中,FileIndexableInterpreter將一個文字檔轉換成為一個Lucene的Document對象。 從上面的介紹中我們知道,DataProvider作為一個生產者生產了DataEvent對象供消費者DataConsumer進行消費,然而由於Zoie最終是基於Lucene的,Lucene是不能夠索引DataEvent對象的,這就需要有人負責將DataEvent轉換成為Lucene的Document對象,根據應用的需要控制添加那些Field,添加什麼樣的Field等,此工作由翻譯器Interpreter完成。 --> <bean id="fileInterpreter" class="proj.zoie.impl.indexing.FileIndexableInterpreter" /> <!-- A decorator for an IndexReader instance: The default decorator is just a pass through, the input IndexReader is returned. 一個IndexReader的裝飾者: 預設的裝飾者什麼都不做,將原IndexReader返回。 注意這裡使用的是一個重要的設計模式,裝飾者模式。被封裝的IndexReader是直接開啟Lucene索引的IndexReader,IndexReaderFactory在得到這些IndexReader後,都會經過此類封裝一下,再返回給使用者。基本的Lucene的IndexReader開啟,會載入和初始化一些基本的東西,然而有時候,使用者需要在IndexReader開啟的時候,同時載入一些自己的東西,此類給了使用者這樣一個機會,使用者只要實現自己的裝飾者就可以了。在和Zoie同一個項目Bobo(實現Facet搜尋,使用過Solr的同學可能會比較熟悉)中,實現了BoboIndexReaderDecorator,其作用就是在IndexReader開啟的時候,將Facet資訊載入到記憶體中形成某種資料結構,從而在收集Facet的時候快速的使用。 --> <bean id="idxDecorator" class="proj.zoie.impl.indexing.DefaultIndexReaderDecorator" /> <!-- A zoie system declaration, passed as a DataConsumer to the DataProvider declared above 一個ZoieSystem的聲明,在上面的DataProvider的聲明中,其是作為一個DataConsumer傳入的。 --> <bean id="indexingSystem" class="proj.zoie.impl.indexing.ZoieSystem" init-method="start" destroy-method="shutdown"> <!-- disk index directory 索引檔案夾--> <constructor-arg index="0" value="file:${index.directory}"/> <!-- sets the interpreter 設定翻譯器--> <constructor-arg index="1" ref="fileInterpreter" /> <!-- sets the decorator 設定裝飾器--> <constructor-arg index="2"> <ref bean="idxDecorator"/> </constructor-arg> <!-- set the Analyzer, if null is passed, Lucene's StandardAnalyzer is used 設定分詞器,如果為null,則使用預設的Lucene的StandardAnalyzer --> <constructor-arg index="3"> <null/> </constructor-arg> <!-- sets the Similarity, if null is passed, Lucene's DefaultSimilarity is used 設定相似性評分器,如果為null,則使用Lucene預設的DefaultSimilarity --> <constructor-arg index="4"> <null/> </constructor-arg> <!-- the following parameters indicate how often to triggered batched indexing, whichever the first of the following two event happens will triggered indexing 下面的兩個參數表示觸發批量索引的頻率,任意一個滿足條件則觸發索引。 --> <!-- Batch size: how many items to put on the queue before indexing is triggered 批量大小:即隊列中放入多少項方才觸發索引 --> <constructor-arg index="5" value="1000" /> <!-- Batch delay, how long to wait before indxing is triggered 批量延時:即等待多長時間方才觸發索引 --> <constructor-arg index="6" value="300000" /> <!-- flag turning on/off real time indexing 是否開啟即時索引的標誌位 --> <constructor-arg index="7" value="true" /> </bean> <!-- a search service 一個搜尋服務 --> <bean id="mySearchService" class="com.mycompany.search.SearchService"> <!-- IndexReader factory that produces index readers to build Searchers from ZoieSystem作為IndexReaderFactory向搜尋服務提供IndexReader列表,使其可以構造Searcher。 --> <constructor-arg ref="indexingSystem" /> </bean> |
看完了ZoieSystem的配置以後,我們首先來看看ZoieSystem的建構函式是如何使用這些參數進行初始化的:
(1) 其根據制定的索引檔案夾${index.directory}產生一個DefaultDirectoryManager _dirMgr,用於管理索引檔案夾及索引的版本號碼IndexSignature。
(2) 產生一個SearchIndexManager _searchIdxMgr,它是實現即時搜尋的關鍵類,包含如下的成員變數:
- 第一步中產生的DefaultDirectoryManager
- spring設定檔中傳進來的IndexReader的裝飾器IndexReaderDecorator _indexReaderDecorator
- DefaultDocIDMapperFactory _docIDMapperFactory用來維護Zoie的文檔ID同Lucene的文檔ID號之間的對應關係
- DiskSearchIndex _diskIndex用於操作硬碟上的索引,此時便得到一個指向硬碟索引的IndexReader
- Status _diskIndexerStatus當前索引的狀態,共兩種狀態Sleeping和Working,所謂的Sleeping就是新添加的文檔僅僅進入記憶體索引,所謂的Working即其中一個記憶體索引正在和硬碟上的索引進行合并,下一節即時機制的時候,我們會詳細討論
- Mem _mem結構,是利用兩個記憶體索引,一個硬碟索引配合實現即時索引的關鍵,詳細的機制,我們下一節會討論。Mem結構包含以下部分:
- RAMSearchIndex<R> _memIndexA用於操作記憶體索引A
- RAMSearchIndex<R> _memIndexB用於操作記憶體索引B
- RAMSearchIndex<R> _currentWritable根據索引所處的狀態,有時候A是用於添加新文檔的記憶體索引,有時候B是用於添加新文檔的索引
- RAMSearchIndex<R> _currentReadOnly同上一個相反,這是當前不會被添加新文檔的記憶體索引,從下面的討論中我們可以知道,此記憶體索引此時正在和硬碟上的索引進行合并。
- ZoieIndexReader<R> _diskIndexReader硬碟索引的IndexReader
(3) 將參數賦值成員變數ZoieIndexableInterpreter _interpreter,Analyzer _analyzer,Similarity _similarity
(4) 建立DiskLuceneIndexDataLoader _diskLoader對象,用於索引到硬碟索引
(5) 如果即時索引_realtimeIndexing設定為true,則建立RealtimeIndexDataLoader _rtdc,第四步中的_diskLoader作為其成員變數。將其設定為ZoieSystem的父類AsyncDataConsumer的成員變數setDataConsumer(_rtdc)
三、Zoie實現即時搜尋的原理3.1、利用兩個記憶體索引一個硬碟索引實現即時搜尋的原理
(1) 當系統啟動的時候,索引處在Sleeping狀態,這時Mem結構中,只有索引A,索引B為null,索引A為_currentWritable,_currentReadOnly為null,_diskIndexReader為硬碟索引的IndexReader。由於記憶體中索引的IndexReader是每添加完文檔後立刻更新的,而且速度很快,而硬碟上的索引一旦開啟,在下次合并之前,一直使用,可以保證新添加的文檔能夠馬上被搜尋到。
(2) 當A中的文檔數量達到一定的數量的時候,需要同硬碟上的索引進行合并,因此要進入Working狀態。合并是一個相對比較長的過程,這時候會建立記憶體索引B,在合并過程中新添加的文檔全部索引到B中。此時的Mem結構中,有記憶體索引A,記憶體索引B,索引A為currentReadOnly,索引B為currentWritable,diskIndexReader為硬碟索引的IndexReader。此時要獲得ZoieSystem的IndexReader,則三個IndexReader全都返回,由於索引B的IndexReader是添加文檔後立刻更新的,因而能夠保證新添加的文檔能夠馬上被搜尋到,這個時候雖然索引A已經在同硬碟索引進行合并,然而由於硬碟索引的IndexReader還沒有重新開啟,因而索引A中的資料不會被重複搜到。
(3) 當索引A中的資料已經完全合并到硬碟上之後,則要重新開啟硬碟索引的IndexReader,開啟完畢後,建立一個新的Mem結構,原來的索引B作為索引A,為currentWritable,原來的索引A被拋棄,設為null,currentReadOnly也設為null,diskIndexReader為新開啟的硬碟索引的IndexReader。然後通過無縫切換用新的Mem結構替代舊的Mem結構,然後索引進入Sleeping狀態。
3.2、有關文檔的更新問題
上面一節中,我們可以看到,對於新添加的文檔的即時搜尋問題相對簡單,然而當遇到文檔更新的時候,就相對複雜了。
如何即時的刪除已經索引在硬碟上的文檔是一個很大的問題,為此Zoie實現了ZoieSegmentReader:
- 成員變數_decoratedReader是ZoieSegmentReader把Lucene的IndexReader被使用者指定的裝飾器裝飾後又封裝了一層。
- long[] _uidArray是從Lucene的文檔ID到Zoie的文檔ID的一個對應,Lucene的文檔ID是下標,Zoie的文檔ID是對應項的值。
- IntRBTreeSet _delDocIdSet表示在此索引中刪除的Lucene的文檔ID
- 在索引中,Zoie的文檔ID是作為一個特殊的Term("_ID", "_UID")的倒排表中每個Lucene的文檔號的Payload資訊儲存的,儲存為如下格式,其fillDocumentID函數就是將Zoie的文檔ID放入Payload中。
- 當要從此ZoieSegmentReader中刪除文檔的時候,調用markDeletes函數,將要刪除的文檔的Zoie文檔號通過DocIDMapper轉換為Lucene的文檔號,將Lucene的文檔號加入_delDocIdSet
- 熟悉Lucene的讀者應該知道,IndexReader是通過TermDocs介面從索引中取得倒排表的,Zoie也實現了自己的ZoieSegmentTermDocs,其有一個DocIdSetIterator作為成員變數,是在產生的時候由ZoieSegmentReader將自己的_delDocIdSet的遍曆器傳給它的,每當取下一個文檔號的時候,其會將DocIdSetIterator中有的文檔號過濾掉。對於TermPositions也是同樣實現了ZoieSegmentTermPositions
- ZoieSegmentReader使得較慢的從硬碟索引中刪除文檔的操作變為較快的在記憶體中的標記操作,並且不用重新開啟IndexReader刪除就能夠被看到,還保證了更新的完整性(更新的操作是一個刪除,外加一個添加,新添加的文檔最初是在記憶體索引中,則刪除操作也應該在記憶體中被標記,否則一旦系統crash,會出現新添加的丟了,老的版本也被刪除了的情況,即便有重做機制也難以實現).
有了ZoieSegmentReader,下面我們來看文檔更新情況下的即時搜尋機制。
(1) 最初系統啟動的時候,是在Sleeping狀態下的,這個時候,記憶體索引為空白,硬碟索引上有文檔A,B,C。
(2) 在Sleeping狀態下,更新文檔B,則新的文檔B進入記憶體索引,而硬碟索引中B被標記刪除。
(3) 當記憶體中索引足夠大的時候,索引會進入Working狀態,進入合并過程。合并過程會首先將硬碟索引中被標記刪除的文檔先真實的刪除,然後再將記憶體索引向硬碟索引進行合并。此時如果有新的更新進入,比如更新文檔A,則將在另外一個記憶體索引和硬碟索引中都標記刪除,然後將新文檔添加到記憶體索引中。
(4) 當合并完畢後,硬碟索引會標記刪除原來在記憶體索引中標記刪除的文檔,被合并的索引以及其標記刪除的文檔全部丟棄,索引進入Working狀態。
四、Zoie的索引過程4.1、將文檔添加到記憶體索引
(1) Zoie的索引過程由DataProvider中調用ZoieSystem的consume函數開始,其實是調用AsyncDataConsumer的consume(Collection<DataEvent<V>> data)函數,其僅僅將DataEvent放在LinkedList<DataEvent<V>> _batch中。
(2) AsyncDataConsumer有一個背後的線程ConsumerThread _consumerThread,其會調用_consumer.consume(currentBatch),由ZoieSystem的建構函式中第(5)步我們知道,此處的_consumer為RealtimeIndexDataLoader _rtdc。
(3) RealtimeIndexDataLoader.consume函數分一下幾個步驟:
- 調用_interpreter的convertAndInterpret函數,將所有的DataEvent轉換為ZoieIndexable,放入鏈表ArrayList<DataEvent<ZoieIndexable>> indexableList。ZoieIndexable其中封裝了Lucene的Document
- RealtimeIndexDataLoader在建立的時候,除了傳進去的DiskLuceneIndexDataLoader作為成員變數_luceneDataLoader,還會建立成員變數RAMLuceneIndexDataLoader _ramConsumer用於索引到記憶體索引。在上一步做完後,調用_ramConsumer.consume(indexableList)將這些ZoieIndexable索引到記憶體中。
(4) RAMLuceneIndexDataLoader的consume函數會調用LuceneIndexDataLoader的consume函數,其包含以下步驟:
- 得到RAMSearchIndex idx
- Zoie對所有的文檔都做更新操作,將文檔ID放入LongOpenHashSet delSet,將封裝Lucene的Document的IndexingReq放入List<IndexingReq> docList中
- 對於每一篇文檔,使用ZoieSegmentReader.fillDocumentID(doc, uid)向Payload中添加Zoie的文檔ID
- 更新記憶體索引idx.updateIndex(delSet, docList, _analyzer,_similarity),其中先用IndexReader刪除,再用IndexWriter進行添加
- 當然要被刪除的文檔除了在記憶體索引中刪除掉之外,還要在另外一個記憶體索引和硬碟索引中過濾掉。因而調用RAMLuceneIndexDataLoader的propagateDeletes(LongSet delDocs)函數:
- 首先得到另一個記憶體索引,這個時候應該是ReadOnly並正在和硬碟索引合并的索引:RAMSearchIndex<R> readOnlyMemoryIdx = _idxMgr.getCurrentReadOnlyMemoryIndex()
- 在ReadOnly的記憶體索引中標記刪除,從而搜尋的時候可以將其過濾掉,readOnlyMemoryIdx.markDeletes(delDocs)
- 然後得到硬碟索引,DiskSearchIndex<R> diskIdx = _idxMgr.getDiskIndex()
- 在硬碟索引中標記刪除,diskIdx.markDeletes(delDocs),從而在搜尋中可以將其過濾掉
4.2、將記憶體索引合并到硬碟索引
RealtimeIndexDataLoader的父類是BatchedIndexDataLoader,其有一個背後的線程LoaderThread,其會調用processBatch函數。
RealtimeIndexDataLoader的processBatch函數過程如下:
(1) 當記憶體索引中的文檔數量超過配置的batch size或者時間超過設定的_delay的時候,就進行記憶體索引到硬碟索引的合并。
(2) 設定索引的狀態從Sleeping到Working,_idxMgr.setDiskIndexerStatus(SearchIndexManager.Status.Working)
- 重新構造Mem<R> _mem結構
- 原來在Sleeping狀態下用於添加新文檔的memIndexA變成_currentReadOnly的
- 建立在Working狀態下用於添加新文檔的memIndexB為_currentWritable
- 在合并階段,硬碟索引的IndexReader還是老的IndexReader
- 從代碼我們也可以看出,記憶體索引A和B交換了位置:Mem<R> mem = new Mem<R>(memIndexA, memIndexB, memIndexB, memIndexA, oldMem.get_diskIndexReader());
(3) 得到需要合并的記憶體索引readOnlyMemIndex = _idxMgr.getCurrentReadOnlyMemoryIndex()
(4) 將記憶體索引合并到硬碟索引:_luceneDataLoader.loadFromIndex(readOnlyMemIndex),DiskLuceneIndexDataLoader的loadFromIndex函數做以下事情
- 得到DiskSearchIndex<R> idx = getSearchIndex()
- idx.loadFromIndex(ramIndex),其中首先用IndexReader刪除被標記的文檔,然後調用IndexWriter的addIndexesNoOptimize函數將記憶體索引合并到硬碟
- 重新整理硬碟索引的IndexReader,idx.refresh()
- idx.markDeletes(ramIndex.getDelDocs())繼承記憶體索引中被標記刪除的文檔
(5) 設定索引的狀態從Working到Sleeping,_idxMgr.setDiskIndexerStatus(Status.Sleep)
- 重新構造Mem<R> _mem結構
- 將在Working狀態下的memIndexB付給memIndexA以及currentWritable,而memIndexB設為null,也即把B當做A,沒有B
- Mem<R> mem = new Mem<R>(oldMem.get_memIndexB(), null, oldMem.get_memIndexB(), null, diskIndexReader)
- lockAndSwapMem將Mem結構進行無縫切換
五、Zoie的搜尋過程
在使用Zoie進行搜尋的時候,要調用ZoieSystem的getIndexReaders()函數,其調用了_searchIdxMgr.getIndexReaders()。
SearchIndexManager的getIndexReaders函數,分別得到RAMSearchIndex<R> memIndexA的IndexReader,RAMSearchIndex<R> memIndexB的IndexReader,以及硬碟索引的IndexReader。在Sleeping狀態下得到兩個IndexReader,在Working狀態下得到三個IndexReader。