ES 離線索引構建

本文講解 ES 離線索引構建涉及一些核心功能實現原理,適用於10億數據量,2-3小時內完成 ES 索引構建。
談到索引構建,其實更熟悉的一個場景是:

一個線上服務,接收請求做了某些邏輯處理,然後想要將數據保存到 ES 用於後續的查詢,這個過程是一般是基於 ES restful api 向 ES 集羣寫入 doc,這樣就生成了 ES 索引。

但是,這裏的 ES 離線索引構建一般講的是大數據量級下(10億)快速生成 ES 索引的方案,然後 ES 集羣加載索引文件,對外提供查詢服務的這麼一種使用場景。這一般需要用到分佈式索引構建,即藉助 spark 集羣,提交一個生成 ES 索引的 spark 任務,並行向 ES 寫入10億量級的數據,從而生成 ES 索引。

舉例來說:10億商品數據(一條商品 item 對應一個 ES document),需要用 ES 索引存儲,生成商品索引,對外提供查詢服務。將商品索引拆分成10個分片(shard),離線構建時,通過某種 doc_id 路由策略能夠比較均勻地將10億數據分拆,那麼每個分片存儲1億 doc。提交一個 spark 作業,每個 shard 啓動一個構建任務,10個任務並行地生成 ES 索引,最終再將每個 spark 任務生成的索引數據"聚合"到商品索引中即可。

其中一種方案是:在 spark 集羣的機器上提前安裝好 ES(或者 spark 任務也可以通過 wget 下載 ES 安裝包、解壓安裝、啓動 ES 服務),spark 任務啓動時,通過 Java ProcessBuilder 來啓動 ES 服務,然後再通過 ES 提供的 restful client 的 bulk api 接口 indexing document,記錄下這些生成的索引文件的目錄,最終再合併即可。
這種方案有個明顯的缺點是:bulk api 需要走 ES 的 transport 層,是通過 http 來寫入 doc 的,吞吐量一般。但是好處是:無須任何開發改造、簡單。

另一種方案是:直接在 spark 任務的本地機器上啓動一個 ES 進程,依賴 ES 原生的 NodeClient 類提供的 api 操作,寫入 doc 生成 ES 索引。這種方式避免了ES http transport 層的處理,非常高效。
具體來說,就是繼承原生的 org.elasticsearch.node.Node 類,定義一個單例 ES 節點,然後通過它的 org.elasticsearch.node.Node#start 方法在 spark 任務本地節點啓動一個 ES 進程。然後再通過 org.elasticsearch.node.Node#client 獲取 ES 客戶端 NodeClient,基於 NodeClient 提供的接口 org.elasticsearch.client.support.AbstractClient#bulk(org.elasticsearch.action.bulk.BulkRequest) 批量向 ES 寫入 doc。

對於 10 個 shard 的 ES 索引而言,通過 spark 任務啓動 10 個 ES 進程分別並行 bulk 寫入 doc,就能極大提高寫入速度。此外,對於每個 shard 而言,可以採用多線程批量併發寫入 doc,記錄下該 shard 生成的索引文件所在的磁盤目錄即可。
由於 shard 寫入 doc 生成的索引,其底層是多個段文件(segments),在寫入完畢後,合併索引文件,再對段文件進行合併,以確保每個 shard 只有一個 segment,這樣也能保證在線查詢的性能。

由於是 spark 任務寫入 doc 生成 ES 索引,索引段文件其實就存儲在 HDFS 目錄下,可以併發地將每個sub-collections 所在目錄下的索引文件下載下來,然後通過 Lucene 的 org.apache.lucene.index.IndexWriter#addIndexes(org.apache.lucene.store.Directory...)進行合併,
從該方法的源碼註解可知:

Adds all segments from an array of indexes into this index.
This may be used to parallelize batch indexing. A large document collection can be broken into sub-collections. Each sub-collection can be indexed in parallel, on a different thread, process or machine. The complete index can then be created by merging sub-collection indexes with this method.

大數據量級的文檔集合可以 拆分成子集合,分別並行 indexing,然後將每個子集合下的索引文件合併即可。
那麼,我們可以將各個段文件都下載到某一個目錄下,然後創建 org.apache.lucene.index.IndexWriter,調用 org.apache.lucene.index.IndexWriter#addIndexes 將該 shard 下的所有索引文件合併。

接下來,則是段文件的合併,索引文件合併時,已經創建了 IndexWriter,段文件合併需要指定 org.apache.lucene.index.MergeScheduler,原生的 org.apache.lucene.index.ConcurrentMergeScheduler 有 RateLimit 限制,而我們這個場景是離線索引構建,因此可以繼承:ConcurrentMergeScheduler,自定義一個 MergeScheduler,將 RateLimit 相關功能取消掉,從而達到最佳段文件合併性能。

ES 原生 org.apache.lucene.index.ConcurrentMergeScheduler#wrapForMerge 段文件合併源碼如下,有 RateLimit 限制。

  public Directory wrapForMerge(OneMerge merge, Directory in) {
    Thread mergeThread = Thread.currentThread();
    if (!MergeThread.class.isInstance(mergeThread)) {
      throw new AssertionError("wrapForMerge should be called from MergeThread. Current thread: "
          + mergeThread);
    }

    // Return a wrapped Directory which has rate-limited output.
    RateLimiter rateLimiter = ((MergeThread) mergeThread).rateLimiter;
    return new FilterDirectory(in) {
      @Override
      public IndexOutput createOutput(String name, IOContext context) throws IOException {
        ensureOpen();

        // This Directory is only supposed to be used during merging,
        // so all writes should have MERGE context, else there is a bug 
        // somewhere that is failing to pass down the right IOContext:
        assert context.context == IOContext.Context.MERGE: "got context=" + context.context;
        
        // Because rateLimiter is bound to a particular merge thread, this method should
        // always be called from that context. Verify this.
        assert mergeThread == Thread.currentThread() : "Not the same merge thread, current="
          + Thread.currentThread() + ", expected=" + mergeThread;

        return new RateLimitedIndexOutput(rateLimiter, in.createOutput(name, context));
      }
    };
  }

自定義的 MergeScheduler 示例如下:

IndexWriterConfig config = new IndexWriterConfig(null);
// CustomConcurrentMergeScheduler extends ConcurrentMergeScheduler 取消了 RateLimit 限制。
CustomConcurrentMergeScheduler mergeScheduler = new CustomConcurrentMergeScheduler();
mergeScheduler.setDefaultMaxMergesAndThreads(true);
mergeScheduler.disableAutoIOThrottle();
config.setMergeScheduler(mergeScheduler);
//...其它代碼
//創建 IndexWriter 用於索引文件合併、段文件合併
IndexWriter writer = new IndexWriter(dir, config); 

總結一下,本文主要講了2個核心點,基於這2點實現離線 ES 索引的快速構建。

  1. 基於ES 原生的 NodeClient 類寫入 doc,而不是 HTTP restful api。
  2. 基於 Lucene IndexWriter#addIndices 合併索引文件,然後再自定義 MergeScheduler 合併段文件

段文件合併時,即可以採用:MMapDirectory 打開索引文件所在的目錄,也可以使用:NIOFSDirectory 打開索引文件的目錄。這裏我諮詢了下 chatgpt,它給出的合併流程如下:

讀源碼,不懂的諮詢 gpt 效率非常高(比 google 搜索效果好)

  1. 創建MMapDirectory實例
import org.apache.lucene.store.MMapDirectory;
import java.nio.file.Paths;

MMapDirectory mmapDir = new MMapDirectory(Paths.get("/path/to/index"));
  1. 創建IndexWriter配置
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.MergePolicy;
import org.apache.lucene.index.TieredMergePolicy;

//
IndexWriterConfig iwc = new IndexWriterConfig(null);
  1. 設置合併策略
TieredMergePolicy mergePolicy = new TieredMergePolicy();
mergePolicy.setMaxMergeAtOnce(10);
mergePolicy.setSegmentsPerTier(10);
iwc.setMergePolicy(mergePolicy);
  1. 創建IndexWriter
IndexWriter writer = new IndexWriter(mmapDir, iwc);
  1. 執行合併
writer.forceMerge(1);
  1. 關閉IndexWriter
writer.close();

最後,段文件合併,其實也是整個離線索引構建過程中比較耗時的一環,基於 MMapDirectory 或者 NIOFSDirectory 打開索引文件進行 segment merge 誰更優呢?

  • MMapDirectory使用內存映射文件的方式來讀取索引。這種方式可以利用操作系統的虛擬內存和頁緩存,對於讀取密集型的操作通常有較好的性能。在有大量可用內存和較新的操作系統上,MMapDirectory通常可以提供更快的讀取速度,這可能會加速合併過程中的讀取操作。

  • NIOFSDirectory使用Java的FileChannel來讀取索引。這種方式是基於Java的NIO包,可以提供穩定的文件I/O性能。NIOFSDirectory不依賴於操作系統的虛擬內存,因此在處理大型索引時可能更加穩定。

到底哪種好?還是自己測試吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章