ElasticSearch 配置优化

公司的业务日志数据量暴涨,内存很快就不够用,时常出现OOM,并且在 failover 的过程中写入速度不理想,很是揪心。快速学习了一下原理,进行了一系列优化,遂纪录一下。

背景知识

了解 LSM 树的同学就不用看这块了,对标 HBase 的概念来说:

  • Translog 类似 HLog;
  • Type 类似 HTable;
  • Document 类似 Row;
  • Field 类似 Column;
  • Segment 类似 HFile(数据结构当然完全不一样);
  • Optimize/force merge 类似 Compaction;
  • refresh 是 ES 特有的保证数据实时可被搜索;
  • flush 类似 flush;

Segment 的类似日志式的 append 写入,是为了优化写性能;Optimize/force merge 是为了减少 segment 数量(同时影响文件句柄/内存/消耗CPU等资源),优化读性能。

倒排索引

与传统的数据库不同,在 ES 中,每个字段里面的每个单词都是可以被搜索的。如 hobby:"dance,sing,swim,run",我们在搜索关键字 swim 时,所有包含 swim 的文档都会被匹配到,ES 的这个特性也叫做全文搜索。

为了支持这个特性,ES 中会维护一个叫做 invertedindex(也叫倒排索引)的表,表内包含了所有文档中出现的所有单词,同时记录了这个单词在哪个文档中出现过。

例:

当前有3个文档

Doc1:"brown,fox,quick,the"

Doc2:"fox,quick"

Doc3:"brown,fox,the"

那么 ES 会维护如下数据结构:


image.png

这样我们随意搜索任意一个单词,ES 只要遍历一下这个表,就可以知道有些文档被匹配到了。

倒排索引里不止记录了单词与文档的对应关系,它还维护了很多其他有用的数据。如:每个文档一共包含了多少个单词,单词在不同文档中的出现频率,每个文档的长度,所有文档的总长度等等。这些数据用来给搜索结果进行打分,如搜索单词 apple 时,那么出现 apple 这个单词次数最多的文档会被优先返回,因为它匹配的次数最多,和我们的搜索条件关联性最大,因此得分也最多。

倒排索引是不可更改的,一旦它被建立了,里面的数据就不会再进行更改。这样做就带来了以下几个好处:

  1. 没有必要给倒排索引加锁,因为不允许被更改,只有读操作,所以就不用考虑多线程导致互斥等问题;
  2. 索引一旦被加载到了缓存中,大部分访问操作都是对内存的读操作,省去了访问磁盘带来的 IO 开销;
  3. 因为倒排索引的不可变性,所有基于该索引而产生的缓存也不需要更改,因为没有数据变更;
  4. 使用倒排索引可以压缩数据,减少磁盘 IO 及对内存的消耗;

Segment

既然倒排索引是不可更改的,那么如何添加新的数据,删除数据以及更新数据?为了解决这个问题,Lucene 将一个大的倒排索引拆分成了多个小的段 (segment)。每个 segment 本质上就是一个倒排索引。在 Lucene 中,同时还会维护一个文件 commit point,用来记录当前所有可用的 segment ,当我们在这个 commit point 上进行搜索时,就相当于在它下面的 segment 中进行搜索,每个 segment 返回自己的搜索结果,然后进行汇总返回给用户。

引入了segment和commit point的概念之后,数据的更新流程如下图:


image.png
  1. 新增的文档首先会被存放在内存的缓存中;
  2. 当文档数足够多或者到达一定时间点时,就会对缓存进行 commit:
    • 生成一个新的 segment ,并写入磁盘;
    • 生成一个新的 commit point ,记录当前所有可用的 segment;
    • 等待所有数据都已写入磁盘;
  3. 打开新增的 segment,这样我们就可以对新增的文档进行搜索了;
  4. 清空缓存,准备接收新的文档;

文档的更新与删除

segment 是不能更改的,那么如何删除或者更新文档?

每个 commit point 都会维护一个 .del 文件,文件内记录了在某个 segment 内某个文档已经被删除。在 segment 中,被删除的文档依旧是能够被搜索到的,不过在返回搜索结果前,会根据 .del 把那些已经删除的文档从搜索结果中过滤掉。

对于文档的更新,采用和删除文档类似的实现方式。当一个文档发生更新时,首先会在 .del 中声明这个文档已经被删除,同时新的文档会被存放到一个新的 segment 中。这样在搜索时,虽然新的文档和老的文档都会被匹配到,但是 .del 会把老的文档过滤掉,返回的结果中只包含更新后的文档。

Refresh

ES 的一个特性就是提供实时搜索,新增加的文档可以在很短的时间内就被搜索到。在创建一个 commit point 时,为了确保所有的数据都已经成功写入磁盘,避免因为断电等原因导致缓存中的数据丢失,在创建 segment 时需要一个 fsync 的操作来确保磁盘写入成功。但是如果每次新增一个文档都要执行一次 fsync 就会产生很大的性能影响。在文档被写入 segment 之后,segment 首先被写入了文件系统的缓存中,这个过程仅使用很少的资源之后 segment 会从文件系统的缓存中逐渐 flush 到磁盘,这个过程时间消耗较大。但是实际上存放在文件缓存中的文件同样可以被打开读取。ES 利用这个特性,在 segment 被 commit 到磁盘之前,就打开对应的 segment,这样存放在这个segment中的文档就可以立即被搜索到了。

image.png

上图中灰色部分即存放在缓存中,还没有被 commit 到磁盘的 segment 。此时这个 segment 已经可以进行搜索。

在 ES 中,将缓存中的文档写入 segment,并打开 segment 使之可以被搜索的过程叫做 refresh。默认情况下,分片的 refresh 频率是每秒 1 次。这就解释了为什么 ES 声称提供实时搜索功能,新增加的文档会在 1s 内就可以进行搜索了。

Refresh 的频率通过 index.refresh_interval:1s 参数控制,一条新写入 ES 的日志,在进行 refresh 之前,是在 ES 中不能立即搜索不到的。

通过执行 curl -XPOST 127.0.0.1:9200/_refresh,可以手动触发 refresh 行为。

Flush 与 Translog

前面讲到,refresh 行为会立即把缓存中的文档写入 segment 中,但是此时新创建的 segment 是写在文件系统的缓存中的。如果出现断电等异常,那么这部分数据就丢失了。所以 ES 会定期执行 flush 操作,将缓存中的 segment 全部写入磁盘并确保写入成功,同时创建一个 commit point ,整个过程就是一个完整的 commit 过程。

但是如果断电的时候,缓存中的 segment 还没有来得及被 commit 到磁盘,那么数据依旧会产生丢失。为了防止这个问题,ES 中又引入了 translog 文件。

  1. 每当 ES 接收一个文档时,在把文档放在 buffer 的同时,都会把文档记录在 translog 中。


    image.png
  2. 执行 refresh 操作时,会将缓存中的文档写入 segment 中,但是此时 segment 是放在缓存中的,并没有落入磁盘,此时新创建的 segment 是可以进行搜索的。


    image.png
  3. 按照如上的流程,新的 segment 继续被创建,同时这期间新增的文档会一直被写到 translog 中。


    image.png
  4. 当达到一定的时间间隔,或者 translog 足够大时,就会执行 commit 行为,将所有缓存中的 segment 写入磁盘。确保写入成功后,translog 就会被清空。
    image.png

    执行 commit 并清空 translog 的行为,在 ES 中可以通过 _flush API 进行手动触发。

如:curl -XPOST 127.0.0.1:9200/{INDEX_NAME}|{INDEX_PATTERN}/_flush?v

通常这个 flush 行为不需要人工干预,交给 ES 自动执行就好了。同时,在重启 ES 或者关闭索引之间,建议先执行 flush 行为,确保所有数据都被写入磁盘,避免照成数据丢失。通过调用 sh service.sh start/restart,会自动完成 flush 操作。

Segment 的合并

前面讲到 ES 会定期的将收到的文档写入新的 segment 中,这样经过一段时间之后,就会出现很多 segment 。但是每个 segment 都会占用独立的文件句柄/内存/消耗CPU资源,而且,在查询的时候,需要在每个segment上都执行一次查询,这样是很消耗性能的。

为了解决这个问题,ES 会自动定期的将多个小 segment 合并为一个大的 segment 。前面讲到删除文档的时候,并没有真正从 segment 中将文档删除,而是维护了一个 .del 文件,但是当 segment 合并的过程中,就会自动将 .del 中的文档丢掉,从而实现真正意义上的删除操作。

当新合并后的 segment 完全写入磁盘之后,ES 就会自动删除掉那些零碎的 segment,之后的查询都在新合并的segment 上执行。Segment 的合并会消耗大量的 IO 和 CPU 资源,这会影响查询性能。

在 ES 中,可以使用 optimize/force merge 接口,来控制 segment 的合并。

如:curl -X POST/{INDEX_NAME}|{INDEX_PATTERN}/_optimize?max_num_segments=1

这样,ES 就会将 segment 合并为 1 个。但是对于那些更新比较频繁的索引,不建议使用 optimize/force merge 去执行分片合并,交给后台自己处理就好了。

内存优化

为什么会导致OOM?

为什么会导致我们的ES出现了OOM?这个问题比较复杂,不能一概而论,先从内存占用组成说起。

  • Segment Memory(段内存,永驻)

每个 segment 对标一个 Lucene 倒排索引,而倒排索引是通过词典 ( Term Dictionary ) 到文档列表 (Postings List) 的映射关系,用于快速查询。 由于词典的 size 会很大,全部装载到 heap 并里不现实,因此 Lucene 为词典做了一层前缀索引 ( Term Index ) ,这个索引在 Lucene4.0 以后采用的数据结构是 FST ( Finite State Transducer ) 。 这种数据结构占用空间很小,Lucene 打开索引的时候将其全量装载到内存中,加快磁盘上词典查询速度的同时减少随机磁盘访问次数。因此 segment 越多,占用的 heap 也越多,并且这部分内存是无法被 GC 的。理解这点对于监控和管理集群容量很重要,当一个 node 的 segment memory 占用过多,就需要考虑删除、归档数据,或者扩容了。

查看 segment 的占用情况 API:

  • 按照索引维度进行查询:
GET _cat/segments/{INDEX_NAME}|{INDEX_PATTERN}?v&h=shard,segment,size,size.memory
  • 按照节点维度进行查询:
GET _cat/nodes?v&h=name,port,sm
  • Filter Cache(Filter结果集,永驻)

Filter cache 是用来缓存使用过的 filter 的结果集的,需要注意的是这个缓存也是常驻 heap,无法 GC。

  • Field Data Cache

在有大量排序、数据聚合的应用场景,可以说 field data cache 是性能和稳定性的杀手。 对搜索结果做排序或者聚合操作,需要将倒排索引里的数据进行解析,然后进行一次倒排。 这个过程非常耗费时间,因此 ES 2.0 以前的版本主要依赖这个 cache 缓存已经计算过的数据,提升性能。但是由于 heap 空间有限,当遇到用户对海量数据做计算的时候,就很容易导致 heap 吃紧,集群频繁 GC,根本无法完成计算过程。 ES2.0以后,正式默认启用Doc Values 特性 ( 1.x需要手动更改mapping开启 ),将 field data 在 indexing time 构建在磁盘上,经过一系列优化,可以达到比之前采用 field data cache 机制更好的性能。因此需要限制对 field data cache 的使用,最好是完全不用,可以极大释放 heap 压力。 需要注意的是,很多同学已经升级到 ES2.0,或者 1.0 里已经设置 mapping 启用了 doc values,在 kibana 里仍然会遇到问题。 这里一个陷阱就在于 kibana 的 table panel 可以对所有字段排序。 设想如果有一个字段是 analyzed 过的,而用户去点击对应字段的排序是什么后果? 一来排序的结果并不是用户想要的,排序的对象实际是词典; 二来 analyzed 过的字段无法利用 doc values ,需要装载到 field data cache,数据量很大的情况下可能集群就在忙着 GC 或者根本出不来结果。

  • Bulk Queue

一般来说,Bulk queue 不会消耗很多的 heap,但是见过一些用户为了提高 bulk 的速度,客户端设置了很大的并发量,并且将 Bulk Queue 设置到不可思议的大,比如好几千。 Bulk Queue 是做什么用的?当所有的 bulk thread 都在忙,无法响应新的 bulk request 的时候,将 request 在内存里排列起来,然后慢慢清掉。 这在应对短暂的请求爆发的时候有用,但是如果集群本身索引速度一直跟不上,设置的好几千的 queue 都满了会是什么状况呢? 取决于一个 bulk 的数据量大小,乘上 queue 的大小,heap 很有可能就不够用,内存溢出了。

  • Indexing Buffer

Indexing Buffer 是用来缓存新数据,由 data node 上所有 shards 共享,当其满了或者 flush interval 到了,就会以 segment file 的形式写入到磁盘。

  • Cluster State Buffer

ES 被设计成每个 node 都可以响应用户的 API 请求,因此每个 node 的内存里都包含有一份集群状态的拷贝。这个 cluster state 包含诸如集群有多少个 node,多少个 index,每个 index 的 mapping 是什么?有多少 shard,每个 shard 的分配情况等等 ( ES 有各类 stats API 获取这类数据)。 在一个规模很大的集群,这个状态信息可能会非常大的,耗用的内存空间就不可忽视了。并且在 ES2.0 之前的版本,state 的更新是由 master node 做完以后全量散播到其他结点的。 频繁的状态更新都有可能给 heap 带来压力。

  • 超大搜索聚合结果集的 fetch

ES 是分布式搜索引擎,搜索和聚合计算除了在各个 data node 并行计算以外,还需要将结果返回给汇总节点进行汇总和排序后再返回。无论是搜索,还是聚合,如果返回结果的 size 设置过大,都会给 heap 造成很大的压力,特别是数据汇聚节点。超大的 size 多数情况下都是用户用例不对,比如本来是想计算 cardinality,却用了 terms aggregation + size:0 这样的方式,对大结果集做深度分页、一次性拉取全量数据等等。

怎么进行内存优化?

  • Segment Memory

那么有哪些途径减少 data node 上的 segment memory 呢? 总结起来有三种方法:

  • 删除不用的索引;

删除索引 API:

DELETE {INDEX_NAME}|{INDEX_PATTERN}
  • 关闭索引 (文件仍然存在于磁盘,只是释放掉内存),需要的时候可以重新打开;

关闭索引 API:

POST {INDEX_NAME}|{INDEX_PATTERN}/_close
  • 定期对不再更新的索引做 optimize (ES2.0以后更改为 force merge API)。这Optimze的实质是对segment file 强制做合并,可以节省大量的 segment memory;(会占用大量 IO,建议业务低峰期触发)

在合并前需要对合并速度进行合理限制,默认是20MBps:

PUT /_cluster/settings
{
    "persistent" : {
        "indices.store.throttle.max_bytes_per_sec" : "20mb"
    }
}

强制合并 API,示例表示的是最终合并为一个 segment file:

POST /{INDEX_NAME}|{INDEX_PATTERN}/_forcemerge?max_num_segments=1
  • Filter Cache

默认的 10% heap 设置工作得够好,如果实际使用中 heap 没什么压力的情况下,才考虑加大这个设置。

  • Field Data Cache

升级至 ES 2.0+(我想现在至少是5.X了吧?),并且对需要排序的字段不进行 analyzed,尽量使用 doc values。对于不参与搜索的字段 ( fields ), 将其 index 方法设置为 no,如果对分词没有需求,对参与搜索的字段,其 index方法设置为 not_analyzed

  • Bulk Queue

一般来说官方默认的 thread pool 设置已经能很好的工作了,建议不要随意去调优相关的设置,很多时候都是适得其反的效果。

  • Indexing Buffer

这个参数的默认值是10% heap size。根据经验,这个默认值也能够很好的工作,应对很大的索引吞吐量。 但有些用户认为这个 buffer 越大吞吐量越高,因此见过有用户将其设置为 40% 的。到了极端的情况,写入速度很高的时候,40%都被占用,导致OOM。

  • Cluster State Buffer

在超大规模集群的情况下,可以考虑分集群并通过 tribe node 连接做到对用户透明,这样可以保证每个集群里的 state 信息不会膨胀得过大。

  • 超大搜索聚合结果集的 fetch

避免用户 fetch 超大搜索聚合结果集,确实需要大量拉取数据可以采用 scan & scroll API 来实现。

开启慢查询日志

不论是数据库还是搜索引擎,对于问题的排查,开启慢查询日志是十分必要的,ES 开启慢查询的方式有多种,但是最常用的是调用模板 API 进行全局设置:

PUT  /_template/{TEMPLATE_NAME}
{

  "template":"{INDEX_PATTERN}",
  "settings" : {
    "index.indexing.slowlog.level": "INFO",
    "index.indexing.slowlog.threshold.index.warn": "10s",
    "index.indexing.slowlog.threshold.index.info": "5s",
    "index.indexing.slowlog.threshold.index.debug": "2s",
    "index.indexing.slowlog.threshold.index.trace": "500ms",
    "index.indexing.slowlog.source": "1000",
    "index.search.slowlog.level": "INFO",
    "index.search.slowlog.threshold.query.warn": "10s",
    "index.search.slowlog.threshold.query.info": "5s",
    "index.search.slowlog.threshold.query.debug": "2s",
    "index.search.slowlog.threshold.query.trace": "500ms",
    "index.search.slowlog.threshold.fetch.warn": "1s",
    "index.search.slowlog.threshold.fetch.info": "800ms",
    "index.search.slowlog.threshold.fetch.debug": "500ms",
    "index.search.slowlog.threshold.fetch.trace": "200ms"
  },
  "version"  : 1
}

对于已经存在的 index 使用 settings API:

PUT {INDEX_PAATERN}/_settings
{
    "index.indexing.slowlog.level": "INFO",
    "index.indexing.slowlog.threshold.index.warn": "10s",
    "index.indexing.slowlog.threshold.index.info": "5s",
    "index.indexing.slowlog.threshold.index.debug": "2s",
    "index.indexing.slowlog.threshold.index.trace": "500ms",
    "index.indexing.slowlog.source": "1000",
    "index.search.slowlog.level": "INFO",
    "index.search.slowlog.threshold.query.warn": "10s",
    "index.search.slowlog.threshold.query.info": "5s",
    "index.search.slowlog.threshold.query.debug": "2s",
    "index.search.slowlog.threshold.query.trace": "500ms",
    "index.search.slowlog.threshold.fetch.warn": "1s",
    "index.search.slowlog.threshold.fetch.info": "800ms",
    "index.search.slowlog.threshold.fetch.debug": "500ms",
    "index.search.slowlog.threshold.fetch.trace": "200ms"
}

这样,在日志目录下的慢查询日志就会有输出记录必要的信息了。

{CLUSTER_NAME}_index_indexing_slowlog.log
{CLUSTER_NAME}_index_search_slowlog.log

写入性能优化

之前描述了 ES 在内存管理方面的优化,接下来梳理下如何对写入性能进行优化,写入性能的优化也和 HBase 类似,无非就是增加吞吐,而增加吞吐的方法就是增大刷写间隔、合理设置线程数量、开启异步刷写(允许数据丢失的情况下)。

增大刷写间隔

通过修改主配置文件 elasticsearch.yml 或者 Rest API 都可以对 index.refresh_interval 进行修改,增大该属性可以提升写入吞吐。

PUT  /_template/{TEMPLATE_NAME}
{
  "template":"{INDEX_PATTERN}",
  "settings" : {
    "index.refresh_interval" : "30s"
  }
}
PUT {INDEX_PAATERN}/_settings
{
    "index.refresh_interval" : "30s"
}

合理设置线程数量

调整 elasticsearch.yml ,对 bulk/flush 线程池进行调优,根据本机实际配置:

threadpool.bulk.type:fixed
threadpool.bulk.size:8 #(CPU核数)
threadpool.flush.type:fixed
threadpool.flush.size:8 #(CPU核数)

开启异步刷写

如果允许数据丢失,可以对特定 index 开启异步刷写:

PUT  /_template/{TEMPLATE_NAME}
{
  "template":"{INDEX_PATTERN}",
  "settings" : {
    "index.translog.durability": "async"
  }
}
PUT  {INDEX_PAATERN}/_settings
{
  "index.translog.durability": "async"
}