1. 程式人生 > >有關Lucene的問題:用Lucene構建實時索引

有關Lucene的問題:用Lucene構建實時索引

由於前一章所述的Lucene的事務性,使得Lucene可以增量的新增一個段,我們知道,倒排索引是有一定的格式的,而這個格式一旦寫入是非常難以改變的,那麼如何能夠增量建索引呢?Lucene使用段這個概念解決了這個問題,對於每個已經生成的段,其倒排索引結構不會再改變,而增量新增的文件新增到新的段中,段之間在一定的時刻進行合併,從而形成新的倒排索引結構。

然而也正因為Lucene的事務性,使得Lucene的索引不夠實時,如果想Lucene實時,則必須新新增的文件後IndexWriter需要commit,在搜尋的時候IndexReader需要重新的開啟,然而當索引在硬碟上的時候,尤其是索引非常大的時候,IndexWriter的commit操作和IndexReader的open操作都是非常慢的,根本達不到實時性的需要。

好在Lucene提供了RAMDirectory,也即記憶體中的索引,能夠很快的commit和open,然而又存在如果索引很大,記憶體中不能夠放下的問題。

所以要構建實時的索引,就需要記憶體中的索引RAMDirectory和硬碟上的索引FSDirectory相互配合來解決問題。

1、初始化階段

首先假設我們硬碟上已經有一個索引FileSystemIndex,由於IndexReader開啟此索引非常的慢,因而其是需要事先開啟的,並且不會時常的重新開啟。

我們在記憶體中有一個索引MemoryIndex,新來的文件全部索引到記憶體索引中,並且是索引完IndexWriter就commit,IndexReader就重新開啟,這兩個操作時非常快的。

如下圖,則此時新索引的文件全部能被使用者看到,達到實時的目的。

繪圖8

2、合併索引階段

然而經過一段時間,記憶體中的索引會比較大了,如果不合併到硬碟上,則可能造成記憶體不夠用,則需要進行合併的過程。

當然在合併的過程中,我們依然想讓我們的搜尋是實時的,這是就需要一個過渡的索引,我們稱為MergingIndex。

一旦記憶體索引達到一定的程度,則我們重新建立一個空的記憶體索引,用於合併階段索引新的文件,然後將原來的記憶體索引稱為合併中索引,並啟動一個後臺執行緒進行合併的操作。

在合併的過程中,如果有查詢過來,則需要三個IndexReader,一個是記憶體索引的IndexReader開啟,這個過程是很快的,一個是合併中索引的IndexReader開啟,這個過程也是很快的,一個是已經開啟的硬碟索引的IndexReader,無需重新開啟。這三個IndexReader可以覆蓋所有的文件,唯一有可能重複的是,硬碟索引中已經有一些從合併中索引合併過去的文件了,然而不用擔心,根據Lucene的事務性,在硬碟索引的IndexReader沒有重新開啟的情況下,背後的合併操作它是看不到的,因而這三個IndexReader所看到的文件應該是既不少也不多。合併使用IndexWriter(硬碟索引).addIndexes(IndexReader(合併中索引)),合併結束後Commit。

如下圖:

merging

3、重新開啟硬碟索引的IndexReader

當合並結束後,是應該重新開啟硬碟索引的時候了,然而這是一個可能比較慢的過程,在此過程中,我們仍然想保持實時性,因而在此過程中,合併中的索引不能丟棄,硬碟索引的IndexReader也不要動,而是為硬碟索引開啟一個臨時的IndexReader,在開啟的過程中,如果有搜尋進來,返回的仍然是上述的三個IndexReader,仍能夠不多不少的看到所有的文件,而將要開啟的臨時的IndexReader將能看到合併中索引和原來的硬碟索引所有的文件,此IndexReader並不返回給客戶。如下圖:

reopen

4、替代IndexReader

當臨時的IndexReader被開啟的時候,其看到的是合併中索引的IndexReader和硬碟索引原來的IndexReader之和,下面要做的是:

(1) 關閉合並中索引的IndexReader

(2) 拋棄合併中索引

(3) 用臨時的IndexReader替換硬碟索引原來的IndexReader

(4) 關閉硬碟索引原來的IndexReader。

上面說的這幾個操作必須是原子性的,如果做了(2)但沒有做(3),如果來一個搜尋,則將少看到一部分資料,如果做了(3)沒有做(2)則,多看到一部分資料。

所以在進行上述四步操作的時候,需要加一個鎖,如果這個時候有搜尋進來的時候,或者在完全沒有做的時候得到所有的IndexReader,或者在完全做好的時候得到所有的IndexReader,這時此搜尋可能被block,但是沒有關係,這四步是非常快的,絲毫不影響替代性。

如下圖:

replace

經過這幾個過程,又達到了第一步的狀態,則進行下一個合併的過程。

5、多個索引

有一點需要注意的是,在上述的合併過程中,新新增的文件是始終新增到記憶體索引中的,如果存在如下的情況,索引速度實在太快,在合併過程沒有完成的時候,記憶體索引又滿了,或者硬碟上的索引實在太大,合併和重新開啟要花費太長的時間,使得記憶體索引以及滿的情況下,還沒有合併完成。

為了處理這種情況,我們可以擁有多個合併中的索引,多個硬碟上的索引,如下圖:

multiple

  • 新新增的文件永遠是進入記憶體索引
  • 當記憶體索引到達一定的大小的時候,將其加入合併中索引連結串列
  • 有一個後臺執行緒,每隔一定的時刻,將合併中索引寫入一個新的硬碟索引中取。這樣可以避免由於硬碟索引過大而合併較慢的情況。硬碟索引的IndexReader也是寫完並重新開啟後才替換合併中索引的IndexReader,新的硬碟索引也可保證開啟的過程不會花費太長時間。
  • 這樣會造成硬碟索引很多,所以,每隔一定的時刻,將硬碟索引合併成一個大的索引。也是合併完成後方才替換IndexReader

大家可能會發現,此合併的過程和Lucene的段的合併很相似。然而Lucene的一個函式IndexReader.reopen一直是沒有實現的,也即我們不能選擇哪個段是在記憶體中的,可以被開啟,哪些是硬碟中的,需要在後臺開啟然後進行替換,而IndexReader.open是會開啟所有的記憶體中的和硬碟上的索引,因而會很慢,從而降低了實時性。

在有關Lucene的問題(7),討論了使用Lucene記憶體索引和硬碟索引構建實時索引的問題。

然而有的讀者提到,如果涉及到文件的刪除及更新,那麼如何構建實時的索引呢?本節來討論這個問題。

1、Lucene刪除文件的幾種方式

  • IndexReader.deleteDocument(int docID)是用 IndexReader 按文件號刪除。  
  • IndexReader.deleteDocuments(Term  term)是用 IndexReader 刪除包含此詞(Term)的文件。  
  • IndexWriter.deleteDocuments(Term  term)是用 IndexWriter 刪除包含此詞(Term)的文件。  
  • IndexWriter.deleteDocuments(Term[]  terms)是用 IndexWriter 刪除包含這些詞(Term)的文件。  
  • IndexWriter.deleteDocuments(Query  query)是用 IndexWriter 刪除能滿足此查詢(Query)的文件。  
  • IndexWriter.deleteDocuments(Query[] queries)是用 IndexWriter 刪除能滿足這些查詢(Query)的文件。

刪除文件既可以用reader進行刪除,也可以用writer進行刪除,不同的是,reader進行刪除後,此reader馬上能夠生效,而用writer刪除後,會被快取,只有寫入到索引檔案中,當reader再次開啟的時候,才能夠看到。

2、Lucene文件更新的幾個問題

2.1、使用IndexReader還是IndexWriter進行刪除

既然IndexReader和IndexWriter都能夠進行文件刪除,那麼到底是應該用哪個來進行刪除呢?

本文的建議是,用IndexWriter來進行刪除。

因為用IndexReader可能存在以下的問題:

(1) 當有一個IndexWriter開啟的時候,IndexReader的刪除操作是不能夠進行的,否則會報LockObtainFailedException

(2) 當IndexReader被多個執行緒使用的時候,一個執行緒用其進行刪除,會使得另一個執行緒看到的索引有所改變,使得另一個執行緒的結果帶有不確定性。

(3) 對於更新操作,在Lucene中是先刪除,再新增的,然而刪除的被立刻看到的,而新增卻不能夠立刻看到,造成了資料的不一致性。

(4) 即便以上問題可以通過鎖來解決,然而背後的操作影響到了搜尋的速度,是我們不想看到的。

2.2、如何在記憶體中快取文件的刪除

在上一節中,為了能夠做到實時性,我們使用記憶體中的索引,而硬碟上的索引則不經常開啟,即便開啟也在背後執行緒中開啟。

而要刪除的文件如果在硬碟索引中,如果不重新開啟則看不到新的刪除,則需要將刪除的文件快取到記憶體中。

那如何將快取在記憶體中的文件刪除在不重新開啟IndexReader的情況下應用於硬碟上的索引呢?

在Lucene中,有一種IndexReader為FilterIndexReader,可以對一個IndexReader進行封裝,我們可以實現一個自己的FilterIndexReader來過濾掉刪除的文件。

一個例子如下:

public class MyFilterIndexReader extends FilterIndexReader {

  OpenBitSet dels;

  public MyFilterIndexReader(IndexReader in) {

    super(in);

    dels = new OpenBitSet(in.maxDoc());

  }

  public MyFilterIndexReader(IndexReader in, List<String> idToDelete) throws IOException {

    super(in);

    dels = new OpenBitSet(in.maxDoc());

    for(String id : idToDelete){

      TermDocs td = in.termDocs(new Term("id", id)); //如果能在記憶體中Cache從Lucene的ID到應用的ID的對映,Reader的生成將快得多。

      if(td.next()){

        dels.set(td.doc());

      }

    }

  }

  @Override

  public int numDocs() {

    return in.numDocs() - (int) dels.cardinality();

  }

  @Override

  public TermDocs termDocs(Term term) throws IOException {

    return new FilterTermDocs(in.termDocs(term)) {

      @Override

      public boolean next() throws IOException {

        boolean res;

        while ((res = super.next())) {

          if (!dels.get(doc())) {

            break;

          }

        }

        return res;

      }

    };

  }

  @Override

  public TermDocs termDocs() throws IOException {

    return new FilterTermDocs(in.termDocs()) {

      @Override

      public boolean next() throws IOException {

        boolean res;

        while ((res = super.next())) {

          if (!dels.get(doc())) {

            break;

          }

        }

        return res;

      }

    };

  }

}

2.3、文件更新的順序性問題

Lucene的文件更新其實是刪除舊的文件,然後新增新的文件。如上所述,刪除的文件是快取在記憶體中的,並通過FilterIndexReader應用於硬碟上的索引,然而新的文件也是以相同的id加入到索引中去的,這就需要保證快取的刪除不會將新的文件也過濾掉,將快取的刪除合併到索引中的時候不會將新的文件也刪除掉。

Lucene的兩次更新一定要後一次覆蓋前一次,而不能讓前一次覆蓋後一次。

所以記憶體中已經硬碟中的多個索引是要被保持一個順序的,哪個是老的索引,哪個是新的索引,快取的刪除自然是應該應用於所有比他老的索引的,而不應該應用於他自己以及比他新的索引。

3、具有更新功能的Lucene實時索引方案

3.1、初始化

首先假設我們硬碟上已經有一個索引FileSystemIndex,被事先開啟的,其中包含文件1,2,3,4,5,6。

我們在記憶體中有一個索引MemoryIndex,新來的文件全部索引到記憶體索引中,並且是索引完IndexWriter就commit,IndexReader就重新開啟,其中包含文件7,8。

繪圖8

3.2、更新文件5

這時候來一個新的更新文件5, 需要首先將文件5刪除,然後加入新的文件5。

需要做的事情是:

  • 首先在記憶體索引中刪除文件5,當然沒有文件5,刪除無效。
  • 其次將對文件5的刪除放入記憶體文件刪除列表,並與硬碟的IndexReader組成FilterIndexReader
  • 最後,將新的文件5加入記憶體索引,這時候,使用者可以看到的就是新的文件5了。
  • 將文件5放入刪除列表以及將文件5提交到記憶體索引兩者應該是一個原子操作,好在這兩者都是比較塊的。

注:此處對硬碟上的索引,也可以進行對文件5的刪除,由於IndexReader沒有重新開啟,此刪除是刪不掉的,我們之所以沒有這樣做,是想保持此次更新要麼全部在記憶體中,要麼全部在硬碟中,而非刪除部分已經應用到硬碟中,而新文件卻在記憶體中,此時,如果系統crash,則新的文件5丟失了,而舊的文件5也已經在硬碟上被刪除。我們將硬碟上對文件5的刪除放到從記憶體索引向硬碟索引的合併過程。

更新文件5

如果再有一次對文件5的更新,則首先將記憶體索引中的文件5刪除,新增新的文件5,然後將文件5加入刪除列表,發現已經存在,則不必刪除。

3.3、合併索引

然而經過一段時間,記憶體中的索引需要合併到硬碟上。

在合併的過程中,需要重新建立一個空的記憶體索引,用於合併階段索引新的文件,而合併中的索引的IndexReader以及硬碟索引和刪除列表所組成的FilterIndexReader仍然保持開啟,對外提供服務,而合併階段從後臺進行。

後臺的合併包括以下幾步:

  • 將刪除列表應用到硬碟索引中。
  • 將記憶體索引合併到硬碟索引中。
  • IndexWriter提交。

合併

3.4、合併的過程中更新文件5

在合併的過程中,如果還有更新那怎麼辦呢?

  • 首先將合併中索引的文件5刪除,此刪除不會影響合併,因為合併之前,合併中索引的IndexReader已經開啟,索引合併中索引的文件5還是會合併到硬碟中去的。此刪除影響的是此後的查詢在合併中索引是看不到文件5的。
  • 然後將文件5的刪除放入刪除列表,並同合併中索引的刪除列表,已經硬碟索引一起構成FilterIndexReader。
  • 將新的文件5新增到記憶體中索引。
  • 提交在合併中索引對文件5的刪除,將文件5新增到刪除列表,提交在記憶體索引中對文件5的新增三者應該是一個原子操作,好在三者也是很快的。

合併時更新

3.5、重新開啟硬碟索引的IndexReader

當合並中索引合併到硬碟中的時候,是時候重新開啟硬碟上的索引了,新開啟的IndexReader是可以看到文件5的刪除的。

如果這個時候有新的更新,也是新增到記憶體索引和刪除列表的,比如我們更新文件6.

重新開啟

3.6、替代IndexReader 

當IndexReader被重新開啟後,則需要刪除合併中的索引及其刪除列表,將硬碟索引原來的IndexReader關閉,使用新的IndexReader。

替換IndexReaderhttp://www.360doc.com/content/15/0817/00/18167315_492203143.shtml