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不依赖于操作系统的虚拟内存,因此在处理大型索引时可能更加稳定。

到底哪种好?还是自己测试吧。

posted @ 2024-01-29 23:36  大熊猫同学  阅读(40)  评论(0编辑  收藏  举报