Elasticsearch在Hdfs上build的实现及优化

1,537 阅读8分钟
原文链接: click.aliyun.com

引言

前段时间参与Elasticsearch离线平台化项目,主要是做一套Elasticsearch的buildservice, 一方面通过bahamut的数据流定义能力,直接对接用户原始数据,实现全增量一体化,解决用户准备数据的痛点。另一方面,社区的elasticsearch并没有全量增量的概念,所有数据都是用户通过sdk一条一条发给es在线服务构建索引,很难处理海量数据的场景,而且也难免对在线的性能产生影响,尤其是索引Merge的时候会严重影响线上服务的稳定性。所以,需要给Es做一个BuildService, 使之能够在blink集群上直接构建索引。

志宸老师在阿里云Elasticsearch离线平台化建设这篇文章中介绍了总体架构,本文再详细对ElasticBuild如何在Hdfs上构建索引以及一些相关的优化做一些介绍。

加载Hdfs索引

Elasticsearch在启动的时候,会为每个Index的每个Shard初始化一个InternalEngine实例,主要工作是恢复lucene的indexWriter及es的translog,原生的es只支持从本地加载索引文件,而修改后的ElasticBuild由于索引不落盘直接写到hdfs, 所以需要实现一种绕过本地磁盘直接加载hdfs上索引的方案。

HdfsDirectory

直接想到的一种办法是将索引拖到本地后再加载,显然这种方式时间代价太大,而且blink上的实例磁盘不一定能支持这么大的索引。所幸的是,lucene的索引读写接口Directory支持各种读写扩展,参考开源的组件,引入HdfsDirectory可以解决这个问题。
image.png

简单剖析下HdfsDirectory的实现,它实现了Directory的Input、Writer的各种读写接口:
image.png

给HdfsDirectory加层Cache

性能瓶颈

方案制定到这里,感觉信心满满,似乎最主要的难点已经找到解法,但是接下来的性能测试让项目回到了最初的不确定。。。

开发机上起了一个HdfsDirectory版本的es服务,并通过esrally工具构建一个70G的索引,一开始跑得挺欢乐,但是Build任务就是结束不了,而且cpu也变得一阵一阵地跑不上去,又等了4个小时,还是跑不出来。。

经过分析,发现是索引merge结束不了,主要性能消耗在hdfs的读写上面,也就是说,直接用HdfsDirecoty读写hdfs是行不通的。

BlockDirectory

回头想想,hdfs上读写索引和本地磁盘上读写索引,除了网络的开销,还有一个重要原因是本地磁盘有PageCache, 而hdfs没有(hdfs自身对物理block是有缓存的,但在es机器上没有相应的缓存)。那是不是可以在HdfsDirctory上面加一层Cache来达到同样的效果呢。

通过调研,发现solr上实现了一个BlockDirectory,它调用开源的CaffeineCache可以在普通的Directory上层加上Cache功能。我们结合ElasticBuild的场景对BlockDirectory做了少许定制:

  • ElasticBuild是跑在blink上的,同一个Blink实例里面会有多个shard,我们不单独给每个shard申请一块cache, 而是全局使用一个静态的cache,这样可以有效地避免热点数据导致的cache大小分配不合理。
  • 只有从Hdfs上读出来的数据进Cache, 从内存写到hdfs的数据是不直接进cache的。原因有两个:一是我们通过日志发现,IndexWriter在merge的时候会高频率地重复读取一些有重叠的数据块,而写入的时候并没有这个现象,所以可以多留些内存给读数据用。另一方面,写的文件通常会比较大,可能一下子就把cache里的内容刷新一遍,导致cache命中率实然下降。

从而ElasticBuild使用的BlockDirectory如下图所示:
image.png

经过测试,BlockDirectory的cache命中率达到90+%,大大提升了Hdfs上的索引读写性能,主要是Merge的性能得到大大改善。

NrtCachingDirectory

回过头来想想,有了BlockDirectory之后,节省了很大一部分读Hdfs的开销,但是写Hdfs还是有些消耗的,阅读es代码后发现,InternalEngine在生成IndexWriter的config时指定了索引最终以CompoundFile形式存在:

image.png

CompoundFile的存在主要是为了减少索引文件数目,避免打开索引时文件句柄过多,所以es里的索引生成过程其实是这样的:

image.png

换句话说,中间产生的那一大堆只是临时文件,对最终结果没有任何影响,只有最终的四种格式的文件才是需要持久化的。那么,这些文件就不必从hdfs绕一圈了,直接在内存里消化掉就可以了。

lucene-core里提供了一个NRTCachingDirectory,它可以在其它Directory上层再封装一个RAMDirectory, 实现将某些小文件直接在内存里消化掉,我们可以把它拿过来,修改一下关于 哪些文件在内存处理,哪些文件扔给其它Directory处理 的逻辑,从而达到只处理最终文件的目的:

image.png

经70G数据的测试,单个shard的NRTCaching中,一直维持着30多个文件直接在内存中处理,无需到Hdfs上绕一圈。这对性能的提升还是有些作用的,因为没有NRTCaching的话,所有文件需要写到hdfs, 在flush时又将那些文件读回来,再合并成cfs,cfe,si等文件。

自适应内存分配

以上分析了如果通过cache来提升hdfs的读写性能,现在实例的内存主要由以下几个部分消耗:

  • lucene的indexWriter需要RamBuffer
  • BlockDirectory需要一段BlockCache
  • NRTCachingDirectory里的RAMDirectory需要一段RAM

由于在blink上的实例规格是不固定的,所以我们没法直接写死每个模块的内存需要多少,最好能做到自动适配,减少运维成本。经过一段测试,我们得出一些实验结论:

  • 将es的indices.memory.index_buffer_size调整到40%,这样es在indexwriter内存达到系统内存的40%时,会触发flush动作。
  • 将IndexWriter的indexingBufferSize调整到堆上freeMem的40%, 这时IndexWriter在内存达到限制后也会触发flush动作。
  • 当IndexWriter内存调整到40%时,flush出来的索引大小预计会在20%左右,这些文件会流转到RAMDirectory中,由于RAMDirectory中的文件大小是预估的,有时候相差还是比较大的,为了避免内存用超了,给RAMDirectory一些空间,所以NRTCachingDirectory的内存分配也给个40%, 也就是最多跟IndexWriter里一样。
  • 剩下的20%给BlockDirectory, 实验下来看,BlockDirectory分配这么多,已经足够了。

这样,我们就不需要为每种规格的实例单独配置各个模块的内存比例了。

Meta信息同步

上面说到的都是indexWriter索引如果同步到hdfs上,除此之外,还有些额外的数据需要跟着es一起同步到hdfs上。

shard_state

Shard的_state文件记录了shard的primary,indexUUID等信息。需要在es更新本地shard的_state时同步copy到hdfs上去。

index_state

Index的_state文件里记录了一些index的Setting,shard数等关键信息,需要在es更新本地index的_state时同步copy到hdfs上去。

transLog

translog比较特别,它的文件比较多,而且同步点也比较多,不能每次整个目录同步到hdfs上,性能上扛不住。一路上改了很多个同步点后发现行不通,感谢 @志宸 老师的建议,translog因为在elasticBuild中不起作用,恢复点都是通过blink的checkpoint实现的,而且elasticBuild只要保证atleast once就可以了,所以直接不同步translog, 在elasticbuild failover或者暂停继续后,新建一个空的translog目录,保证进程能起来就可以了。

展望

我们规划了一些优化点由于时间原因没有在一期完成,后面可以考虑调研起来。

shard级别并发

上面做了很多的优化,但是有一个限制,就是同一个shard只能落到单个sink上处理,不能够做到shard级别的并发build.

其实将shard拆成多个任务只要解决以下两个问题即可:

多个shard的segment合并及snapshot功能的外围实现

各个子shard单独build索引后,需发在外层合并成同一个可以被load的索引,简单看了看,其实只需要改一下cfe,cfs,si的generation号,并合并一下segmentInfo信息到segments_0文件里就可以了。

合并的信息包括:

  • Translog.TRANSLOG_GENERATION_KEY
  • Translog.TRANSLOG_UUID_KEY
  • SequenceNumbers.LOCAL_CHECKPOINT_KEY
  • Engine.SYNC_COMMIT_ID
  • SequenceNumbers.MAX_SEQ_NO
  • MAX_UNSAFE_AUTO_ID_TIMESTAMP_COMMIT_ID
  • HISTORY_UUID_KEY

这些信息都是可以合并的。

另外,我们在hdfs上合并好索引后,还需要在外层合并成一个snapshot上传到oss, 供在线es来加载,这也是可以实现的,唯一的问题是,在外层上传的话,时间比较久,需要后面直接同步到oss的功能支持下。

image.png

全局seqNo分配

elasticsearch 6.x还引入了一个_seqNo的概念,这个_seqNo是写到doc里的一个默认字段,由于它是shard粒度严格自增的,所以需要在多个子shard外层分配这个_seqNo传给各个子shard.

image.png

直接同步到oss

当前版本,是借住了HdfsDirectory将索引写在hdfs上,然后最终在hdfs上生成了snapshot上传到oss. 这个上传还是有些代价的,所以最好能实现一个OssDirectory, 理论上可以直接替换HdfsDirectory, 省去一次上传的代价,而且也可以在外层高效地做segments合并了.

致谢

从对Elasticsearch和Lucene的零认知到项目功能点的完成,少不了各路大神的协助。感谢 @洪震老大、@昆仑老大在Lucene及blink上的指导,感谢 @万喜团队的兄弟们一起探讨方案,解决疑各种难杂症,后面二期需求再一起合作~