1. 程式人生 > >Lucene近實時搜尋應用總結

Lucene近實時搜尋應用總結

最近因工作需要,用到了Lucene,在需求中,需要對生成的索引檔案不斷的更新、新增、刪除等操作,同時還要實時的看到索引改動後的資料,在使用過程中碰到了Lucene裡幾個比較常見的問題,特來總結記錄下。

我使用的是Lucene4.3,本來是想使用最高的版本Lucene4.9的(不知道現在又有麼有新的版本出現),但是因為公司專案的JDK都是JDK6的,而Lucene4.9的支援的最低JDK版本是7+的,所以最後選擇了這個版本。第一次碰這個東東,也是在網上搜羅各種資料,當然官網是少不了的,還有一個網址,這個裡面的版本更全面,包含了Lucene所有的版本,有需要的可以去下載自己想要的版本。

OK,進入正題。

  • 1: 在讀取索引檔案內容時,索引檔案的開啟操作new IndexSearcher(DirectoryReader.open(FSDirectory.open(new File(indexPath)))) 是個非常耗時耗資源的操作,所以在搜尋索引資料時把IndexSearcher物件給快取起來可以提高搜尋效能,這個地方可以將索引目錄對應的IndexSearcher物件做成一個單例模式進行獲取。

  • 2: 在問題1的操作基礎上,我對索引進行了更新操作,緊接著我就發現了一個問題,索引更新後我查詢出來的結果和我更新後的結果對不上號。再網上查了半天沒找到原因,後來在一個群裡請教之後,才知道更新完索引之後,索引檔案需要重新開啟

    ,否則搜尋得到的還是原來索引的資料,Lucene裡面的這個原理非常重要。

  • 3: 因為當時對Lucene瞭解的不是很多,所以為了每次更新後能搜尋到正確的資料,我的做法是每次更新完索引就將我之前快取的IndexSearcher物件和Reader物件給移除掉,下次搜尋時重新開啟索引,這樣來保證搜尋結果的正確性。

索引的更新程式碼大致如下:

//獲取索引的寫物件
  public static IndexWriter getIndexWriter(String indexPath) throws Exception {
      Directory dir = FSDirectory.open(new File(indexPath));
      if(IndexWriter.isLocked(dir)) {
          IndexWriter.unlock(dir);
      }
      IndexWriterConfig iwriterConfig = new IndexWriterConfig(Version.LUCENE_43, new ComplexAnalyzer());
      IndexWriter indexWriter=new IndexWriter(dir, iwriterConfig);
     return indexWriter;
  }
  //索引更新操作,索引中的更新邏輯是將舊的文件刪除,再將新的文件新增進去
  public static void updateLucene(String indexPath,要更新的資料引數) {
      IndexSearcher indexSearcher = getIndexSearcher(indexPath);
      Document oldDoc = ....//根據IndexSearcher查詢得到被更新的舊文件--1處
      Document newDoc = ....//根據舊文件和要更新的具體資料得到要更新的新文件
      indexWriter.updateDocument(doc);//更新操作--3處
      removeReader(indexPath);//移除IndexSearcher快取物件--2處
}
  • 4:問題3中的這個跟新操作寫完之後實際應用的時候,出了好幾個問題,都是Lucene裡面比較常見,容易犯錯的問題。
    異常1:org.apache.lucene.store.AlreadyClosedException: this IndexReader is closed
    經過分析之後發現是因為多併發情況下時,同一個索引檔案對應的Reader物件別多個執行緒持有,在我這個程式碼裡存線上程1剛好執行完2處程式碼將Reader快取清理掉(同時關掉了該Reader物件),而剛好,執行緒2在執行程式碼1處的查詢操作,這個時候就會出現這個異常。在我這個程式碼裡的更新操作總共分3部,首先查詢出舊的文件,緊接著構造新文件並執行更新操作,第三步是移除快取,所以這三步應該歸結為一個原子操作,所以我很自然的就想到了同步鎖synchronized,在該方法上新增Class級的同步鎖,由於我的程式碼裡有多個這種更新索引的方法,所以每個方法都加一個同步鎖,後來證明這種方式也不行仍然出現了該異常,具體程式碼的問題出在哪我現在真記不起來了,而且這種多個靜態方法都加Class級的同步鎖效能註定不怎麼樣,後續的解決辦法下面問題中繼續說。
    異常2:LockObtainFailedException
    經過查詢資料知道這是因為在IndexWriter的建構函式在試圖獲取另外一個IndexWriter已經加鎖的索引目錄時丟擲的錯誤,這是因為在Lucene中只能允許一個執行緒去進行寫操作,當該執行緒獲取到這個寫物件後,會在索引目錄中生成一個write.lock檔案,所以在這個執行緒沒有釋放該索引目錄的鎖物件前,其他執行緒無法獲取該目錄的寫物件,根據這個write.lock檔案,我們可以很方便的判斷當前索引目錄有沒有被寫物件佔用,改造後的程式碼如下:
    private Object synchronized_w = new Object();
          private IndexWriter getIndexWriter() {
              synchronized (synchronized_w) {
                  try {
                      Directory dir = null;
                      while (true) {
                          dir = FSDirectory.open(new File(indexPath));
                          if(!IndexWriter.isLocked(dir)) {
                              if(indexWriter == null) {
                                  indexWriter = new IndexWriter(dir, new IndexWriterConfig(Version.LUCENE_43, LuceneManager.getAnalyzer()));
                              }
                              break;
                          } else {
                              try {
                                  dir.close();
                                  Thread.sleep(100);
                                  Thread.yield();
                              } catch (InterruptedException e) {
                                  logger.error("獲取索引" + indexPath + "寫物件IndexWriter失敗", e);
                              }
                          }
                      }
                  } catch (Exception e) {
                      logger.error("獲取索引" + indexPath + "寫物件IndexWriter失敗", e);
                  }
              }
              return indexWriter;
        }

獲取索引寫物件的程式碼經過如此改造之後,成功執行,沒有出現問題。
5:繼續問題4中的第一個異常,分析之後覺得問題出現的最終原因還是在第三步移除快取時關閉Reader物件這裡,所以如果Lucene能不需要手動關閉Reader物件就可以解決這個問題,帶著這個問題我重新去查看了Lucene4.3的API文件,看了之後發現了兩個個比較重要的API,DirectoryReader.openIfChanged(dirReader)和DirectoryReader.isCurrent(),前者是個靜態方法,可以判斷當前Reader物件的索引有沒有被修改過,如果索引檔案被更新過則重新載入該索引目錄,但是這個時候的重新載入則比單純的open(indexPath)要高效很多,它只是重新載入被更新過的文件,而單純的open則是載入全部的文件,重新載入後我們查詢的時候就可以查詢到最新的資料結果了。而第二個API是個例項方法,用來判斷當前Reader物件所代表的索引檔案是不是最開始那個,即判斷當前索引檔案有沒有被更新過,有更新則返回false,否則返回true,則兩個API結合在一起剛好可以解決我之前的Reader關閉問題。有了這種重新載入機制,我就不需要每次更新索引之後清除快取關掉舊的Reader物件並開啟新的物件。改造的程式碼如下:

public IndexSearcher getIndexSearcher() {
          synchronized (lock_r) {
              try {
                  if(indexSearcher == null && dirReader == null) {
                      dirReader = DirectoryReader.open(FSDirectory.open(new File(indexPath)));
                      indexSearcher = new IndexSearcher(dirReader);
                  } else if(indexSearcher != null && dirReader != null && !dirReader.isCurrent()) {
                      //判斷有沒有更新過,有更新則重新載入更新過的文件
                      DirectoryReader newDirReader = DirectoryReader.openIfChanged(dirReader);
                      indexSearcher = new IndexSearcher(newDirReader);
                      dirReader = newDirReader;
                  }
              } catch (IOException e) {
                  logger.error("獲取索引" + indexPath + "搜尋物件IndexSearcher失敗", e);
              }
          }
          return indexSearcher;
      }

這個程式碼裡面有個問題就是舊的Reader物件沒有關閉掉,如果加上這reader.close()這句話又會出現那個異常,不加的話執行沒有問題,但是我總覺得不關掉不太好,感覺佔著資源。然後繼續檢視API文件,發現了SearcherManager這個類,這個是Lucene裡面提供的工具類,主要是用來了管理IndexSearcher物件的,仔細閱讀了該類的說明及其原始碼後覺得用這個工具類更靠譜,所以最後毫不猶豫的重新寫了一個IndexSearcher物件的獲取方式,程式碼如下:

private IndexSearcher getIndexSearcher() throws IOException {
    IndexSearcher indexSearcher = null;
    synchronized (synchronized_r) {
        if(searcherManager == null) {
            searcherManager = new SearcherManager(FSDirectory.open(new File(indexPath)), new SearcherFactory());
        }
        searcherManager.maybeRefresh();//這個方法同DirectoryReader.openIfChanged(dirReader)效果一樣,其實底層還是呼叫的該方法實現的
        indexSearcher = searcherManager.acquire();//借用一個IndexSearcher物件的引用,記住該物件用完之後要歸還的,有借有還再借不難
    }
    return indexSearcher;
}
private void closeIndexSearcher(IndexSearcher indexSearcher) throws IOException {
    if(indexSearcher != null) {
        searcherManager.release(indexSearcher);//歸還從SearcherManager處借來的IndexSearcher物件
    }
    indexSearcher = null;
}

如此實現之後,就不需要我們自己管理這個舊的Reader物件,而是交由Lucene本身自己去進行管理,而且此種實現方式更簡潔明瞭,也完美解決了我的問題。

實現了索引更新後的資料的讀取實時性就可以實現一個簡單的實時搜尋功能。

最後為了保證專案中Lucene使用的穩定性,我對索引檔案的更新和查詢都添加了讀寫鎖ReentrantReadWriteLock來進行控制,更新的時候新增寫鎖,查詢的時候新增讀鎖,這樣更加的保證了Lucene使用的安全性。

簡單介紹下讀寫鎖ReentrantReadWriteLock的機制(多執行緒併發的時候很有用):

  • 1:在某個執行緒獲取到讀鎖時,其他執行緒不能獲取寫鎖,但是可以獲取讀鎖
  • 2:在某個執行緒獲取到寫鎖時,其他執行緒既不能獲取寫鎖也不能獲取讀鎖

給個示例程式碼:

private ReentrantReadWriteLock  w_lock = new ReentrantReadWriteLock();//讀寫鎖
   public void updateDocument(Term term, Document doc) throws Exception {
       try {
           w_lock.writeLock().lock();//獲得寫鎖
           getIndexWriter();
           indexWriter.updateDocument(term, doc);
           indexWriter.commit();
       } catch (IOException e) {
           throw new Exception(e);
       } finally {
           closeIndexWriter();
           w_lock.writeLock().unlock();釋放寫鎖
       }
   }

OK,到這裡我的問題基本上就寫完了,有時間再去研究研究Lucene的其他的特性。