Elasticsearch内存那些事儿

Elasticsearch内存分配设置详解。

前言

「该给 ES 分配多少内存?」
「为什么是给 ES 分配服务器的一半内存?」
「为什么内存使用率不断升高,没有释放?」
「为何经常有某个 field 的数据量超出内存限制的异常?」
「为何感觉上没多少数据,也会经常 Out Of Memory?」

相信每个ES使用者或者运维人员都遇到过这些问题,也踩过不少坑。

这里根据我个人的学习和日常工作的使用心得,谈一谈 Elasticsearch 的内存那些事儿。

一、Elasticsearch为什么吃内存

  • ES 是 JAVA 应用
  • 底层存储引擎是基于 Lucene 的

1. 是JAVA应用,就离不开JVM和GC

对 JVM GC 这里不做深入探讨,我们只要知道:应用层面生成大量长生命周期的对象,是给 heap 造成压力的主要原因

例如读取一大片数据在内存中进行排序,或者在 heap 内部建 cache 缓存大量数据。如果 GC 释放的空间有限,而应用层面持续大量申请新对象,GC 频度就开始上升,同时会消耗掉很多CPU时间。严重时可能恶性循环,导致整个集群停工。因此在使用 ES 的过程中,要知道哪些设置和操作容易造成以上问题,有针对性的予以规避。

2. Lucene的倒排索引是先在内存里生成

Lucene 的倒排索引(Inverted Index)是先在内存里生成,然后定期以段文件(segment file)的形式刷到磁盘的

每个段实际就是一个完整的倒排索引,并且一旦写到磁盘上就不会做修改。API 层面的文档更新和删除实际上是增量写入的一种特殊文档,会保存在新的段里。不变的段文件易于被操作系统 cache,热数据几乎等效于内存访问。

二、Elasticsearch的内存消耗

1. 预留一半内存给Lucene使用

我们经常在网上看见别人说「预留一半内存给Lucene使用」。很多人不理解这句话,一开始我也不理解。

举个实际场景,假设你有一个 64 G 内存的机器,按照正常思维思考,肯定是把 64 G 内存都给 Elasticsearch 比较好,但现实却不是这样。

毋庸置疑,内存对于 Elasticsearch 来说绝对是重要的,但还有一个内存消耗大户 —— Lucene

Lucene 的设计目的是把底层 OS 里的数据缓存到内存中。Lucene 的段是分别存储到单个文件中的,这些文件都是不会变化的,所以很利于缓存,同时操作系统也会把这些段文件缓存起来,以便更快的访问。
Lucene 的性能取决于和 OS 的交互,如果你把所有的内存都分配给 Elasticsearch,不留一点给 Lucene,那你的全文检索性能会很差的。

所以,不难理解为何官方建议 heap size 不要超过系统可用内存的一半,因为 heap 以外的内存并不会被浪费,Lucene 会很开心的利用他们来 cache 被用读取过的段文件

2. 不要超过32G

这里有另外一个原因不分配大内存给 Elasticsearch,事实上 jvm 在内存小于 32 G 的时候会采用一个内存对象指针压缩技术

在 Java 中,所有的对象都分配在堆上,然后有一个指针引用它。指向这些对象的指针大小通常是 CPU 的字长的大小,不是 32 bit 就是 64 bit,这取决于你的处理器,指针指向了你的值的精确位置。

对于 32 位系统,你的内存最大可使用 4 G。对于 64 系统可以使用更大的内存。但是 64 位的指针意味着更大的浪费,因为你的指针本身大了。浪费内存不算,更糟糕的是,更大的指针在主内存和缓存器(例如 LLC,L1 等)之间移动数据的时候,会占用更多的带宽。

Java 使用一个叫内存指针压缩的技术来解决这个问题。它的指针不再表示对象在内存中的精确位置,而是表示偏移量。这意味着 32 位的指针可以引用 40 亿个对象,而不是 40 亿个字节。最终,也就是说堆内存长到 32 G 的物理内存,也可以用 32 bit 的指针表示。

一旦你越过那个神奇的 30 - 32 G 的边界,指针就会切回普通对象的指针,每个对象的指针都变长了,就会使用更多的 CPU 内存带宽,也就是说你实际上失去了更多的内存。事实上当内存到达 40 - 50 GB 的时候,有效内存才相当于使用内存对象指针压缩技术时候的 32 G 内存。

这段描述的意思就是说:即便你有足够的内存,也尽量不要超过 32 G,因为它浪费了内存,降低了 CPU 的性能,还要让 GC 应对大内存

3. JVM参数

从官方建议,Heap 分配不要超过系统可用内存的一半,并且不要超过 32 GB 。那么 JVM 参数呢?对于初级用户来说,并不需要做特别调整,仍然遵从官方的建议,将 Xms 和 Xmx 设置成和 heap 一样大小,避免动态分配 heap size 就好了。虽然有针对性的调整 JVM 参数可以带来些许 GC 效率的提升,当有一些「坏」用例的时候,这些调整并不会有什么魔法效果帮你减轻 heap 压力,甚至可能让问题更糟糕。

确保 Xms 和 Xmx 的大小是相同的,其目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源,可以减轻伸缩堆大小带来的压力。

4. ES的几个内存消耗大户

通过查阅资料,了解到有几个 ES 的内存消耗大户,就是他们把 ES 的 heap 瓜分掉的:

  • segment memory
  • filter cache
  • field data cache
  • bulk queue
  • indexing buffer
  • state buffer
  • 超大搜索聚合结果集的 fetch
  • 对高 cardinality 字段做 terms aggregation

下面对它们分别做解读:

Segment Memory

Segment 不是file 吗?segment memory 又是什么?

前面提到过,一个 segment 是一个完备的 lucene 倒排索引,而倒排索引是通过词典(Term Dictionary)到文档列表(Postings List)的映射关系,快速做查询的。

由于词典的 size 会很大,全部装载到 heap 里不现实,因此 Lucene 为词典做了一层前缀索引(Term Index),这个索引在 Lucene4.0 以后采用的数据结构是 FST(Finite State Transducer)。这种数据结构占用空间很小,Lucene 打开索引的时候将其全量装载到内存中,加快磁盘上词典查询速度的同时减少随机磁盘访问次数。

说了这么多,要传达的一个意思就是,ES 的 data node 存储数据并非只是耗费磁盘空间的,为了加速数据的访问,每个 segment 都有会一些索引数据驻留在 heap 里。

因此 segment 越多,瓜分掉的 heap 也越多,并且这部分 heap 是无法被 GC 掉的!理解这点对于监控和管理集群容量很重要,当一个 node 的 segment memory 占用过多的时候,就需要考虑删除、归档数据,或者扩容了。

怎么知道 segment memory 占用情况呢?CAT API 可以给出答案。

# 查看一个索引所有segment的memory占用情况,重点看size.segment这一列
GET /_cat/segments/cc-xxxx-2018-03-20?v&h=shard,segment,size,size.segment
# 查看一个node上所有segment占用的memory总和
GET /_cat/nodes?v&h=name,port,sm

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

  • 删除不用的索引
  • 关闭索引
    文件仍然存在于磁盘,只是释放掉内存,需要的时候可以重新打开。
  • 段合并
    定期对不再更新的索引做 force merge api(ES2.0 及更早版本叫做 optimize)。也就是强制段合并,可以节省大量的 segment memory。

Request cache(5.x以前叫做Filter Cache)

Request cache 是用来缓存查询中参数 size=0 的请求,所以就不会缓存 hits 而是缓存 hits.total,aggregations 和 suggestions,需要注意的是这个缓存也是常驻 heap,在被 evict 掉之前,是无法被 GC 的。我的经验是默认的 10% heap 设置工作得够好了,如果实际使用中 heap 没什么压力的情况下,才考虑加大这个设置。

Field Data cache

在有大量排序、数据聚合的应用场景,可以说 field data cache 是性能和稳定性的杀手。

对搜索结果做排序或者聚合操作,需要将倒排索引里的数据进行解析,按列构造成 docid -> value 的形式才能够做后续快速计算。

对于数据量很大的索引,这个构造过程会非常耗费时间,因此 ES2.0 以前的版本会将构造好的数据缓存起来,提升性能。但是由于 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 很有可能就不够用,内存溢出了。

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

Indexing Buffer

Indexing Buffer 是用来缓存新数据,当其满了或者 refresh/flush interval 到了,就会以 segment file 的形式写入到磁盘。

这个参数的默认值是 10% heap size。根据经验,这个默认值也能够很好的工作,应对很大的索引吞吐量。

但有些用户认为这个 buffer 越大吞吐量越高,因此见过有用户将其设置为 40% 的。到了极端的情况,写入速度很高的时候,40% 都被占用,导致 OOM。

Cluster State Buffer

ES 被设计成每个 node 都可以响应用户的 api 请求,因此每个 node 的内存里都包含有一份集群状态的拷贝。

这个 cluster state 包含诸如集群有多少个 node,多少个 index,每个 index 的 mapping 是什么?有少 shard,每个 shard 的分配情况等等 (ES 有各类 stats api 获取这类数据)。

在一个规模很大的集群,这个状态信息可能会非常大的,耗用的内存空间就不可忽视了。并且在 ES2.0 之前的版本,stat e的更新是由 master node 做完以后全量散播到其他结点的。频繁的状态更新就可以给 heap 带来很大的压力。在超大规模集群的情况下,可以考虑分集群并通过 tribe node 连接做到对用户 api 的透明,这样可以保证每个集群里的 state 信息不会膨胀得过大。

超大搜索聚合结果集的 fetch

ES 是分布式搜索引擎,搜索和聚合计算除了在各个 data node 并行计算以外,还需要将结果返回给汇总节点进行汇总和排序后再返回。

无论是搜索,还是聚合,如果返回结果的 size 设置过大,都会给 heap 造成很大的压力,特别是数据汇聚节点。超大的 size 多数情况下都是用户用例不对,比如本来是想计算 cardinality,却用了 terms aggregation + size:0 这样的方式; 对大结果集做深度分页;一次性拉取全量数据等等。

对高 cardinality 字段做 terms aggregation

所谓高 cardinality,就是该字段的唯一值比较多。

比如 client ip,可能存在上千万甚至上亿的不同值。对这种类型的字段做 terms aggregation 时,需要在内存里生成海量的分桶,内存需求会非常高。如果内部再嵌套有其他聚合,情况会更糟糕。

在做日志聚合分析时,一个典型的可以引起性能问题的场景,就是对带有参数的 url 字段做 terms aggregation。对于访问量大的网站,带有参数的 url 字段 cardinality 可能会到数亿,做一次 terms aggregation 内存开销巨大,然而对带有参数的 url 字段做聚合通常没有什么意义。

对于这类问题,可以额外索引一个 url_stem 字段,这个字段索引剥离掉参数部分的 url。可以极大降低内存消耗,提高聚合速度。

5. 小结

  • 倒排词典的索引需要常驻内存,无法 GC,需要监控 data node 上segment memory 增长趋势。
  • 各类缓存,field cache, filter cache, indexing cache, bulk queue 等等,要设置合理的大小,并且要应该根据最坏的情况来看 heap 是否够用,也就是各类缓存全部占满的时候,还有 heap 空间可以分配给其他任务吗?避免采用 clear cache 等「自欺欺人」的方式来释放内存。
  • 避免返回大量结果集的搜索与聚合。确实需要大量拉取数据的场景,可以采用 scan & scroll api 来实现。
  • cluster stats 驻留内存并无法水平扩展,超大规模集群可以考虑分拆成多个集群通过 tribe node 连接。
  • 想知道 heap 够不够,必须结合实际应用场景,并对集群的 heap 使用情况做持续的监控。
  • 根据监控数据理解内存需求,合理配置各类 circuit breaker,将内存溢出风险降低到最低。

三、Elasticsearch节点的内存怎么分配

上面说过,32 GB 是 ES 一个内存设置限制,那如果你的机器有很大的内存怎么办呢?现在的机器内存普遍都大,一般都有 300 - 500 GB 内存的机器。

当然,如果有这种机器,那是极好的,接下来有两个方案:

  • 如果主要做全文检索,可以考虑给 Elasticsearch 32 G 内存,剩下的交给 Lucene 用作操作系统的文件系统缓存,所有的 segment 都缓存起来,会加快全文检索。
  • 如果需要更多的排序和聚合,那就需要更大的堆内存。可以考虑一台机器上创建两个或者更多的 ES 节点,而不要部署一个使用 32 + GB 内存的节点。仍然要坚持 50% 原则,假设你有个机器有 128 G 内存,你可以创建两个 node,使用 32 G 内存。也就是说 64 G 内存给 ES 的堆内存,剩下的 64 G 给 Lucene。

PS:如果选择第二种方案,需要配置 cluster.routing.allocation.same_shard.host: true。这会防止同一个 shard 的主副本存在同一个物理机上(因为如果存在一个机器上,副本的高可用性就没有了)。


  目录