在 ES 生产环境中,性能问题一直是各厂商最头疼的问题,而其中的痛点就是内存相关。ES 作为当前搜索引擎市场的 No.1,其显著特点就是检索速度非常快。之所以 ES 检索速度快,离不开其底层的合理存储结构以及对内存的充分利用,其中包括大量的缓存。由于 ES 和其底层依赖的 Lucene 均为内存的使用大户,在生产环境中经常会遇到一些内存相关的问题以及想要优化的欲望,本文主要浅析 ES 内存使用情况和其自带的内存保险——熔断机制。

首先我们会分析一些生产环境中遇到的内存相关问题;然后我们会从 JVM 层面以及 ES 层面分别介绍内存相关的配置和使用;最后我们会学习 ES 的内存熔断机制。

1 生产中遇到的内存相关问题

你是否也经常在 ES 的生产环境中遇到一下问题?

  • ES 内存占用过高且持续不会缓解
  • 节点频繁 GC 且耗时较长
  • 查询响应时间过长甚至直接失败
  • 修改相关功能或性能配置直接导致集群出现 OOM 等异常情况

希望本篇文章能给你带来一些启发或者问题的解决方案。

2 JVM相关配置

本章主要对 ES 的 JVM 配置进行基本介绍:由于 ES 运行于 JVM 容器之中,合理的配置 JVM 参数可以达到更好的内存使用效果,以下从内存大小和垃圾回收两个方面分析:

  • 内存大小
    XmsXmx 设置为相同的值,且不要超过系统内存的50%。一般建议配置在 JVM 指针压缩技术最大值范围之内,可以通过日志中的compressed ordinary object pointers [true]来判断是否开启了指针压缩;如果有特殊需求(例如查询需要占用大量内存的情况),可以将内存调大,但是需要配置 40GB 乃至 50GB 以上的内存才能追平指针压缩技术带来的性能优化,基本不建议这样使用(对 CPU 和 GC 等都有性能损耗)。
  • 垃圾回收器
    使用 G1 相比 CMS 更适合大内存使用场景,一般默认参数即可:
    **G1ReservePercent:**老年代预留给新生代对象晋升的分配担保比例。如果新生代经常晋升失败而导致 Full GC,可以适当调高此阈值,意味着降低了老年代的实际可用空间,使用 ES 默认配置即可。
    **InitiatingHeapOccupancyPercent:**触发老年代全局并发标记的比例,使用 ES 默认配置即可。
    **MaxGCPauseMillis:**GC 最大的停顿毫秒数。如果业务对 GC 比较敏感,可以适当调小,但是会增加 CPU 的开销,建议 50-200 之间。

3 ES 内存的使用

本章主要浅析 ES 将内存使用到了哪里,可以参考图1 和图2 :ES 使用的内存分为堆内存和堆外内存;同时堆内内存又分为可以 GC 和不可以 GC,不可 GC 的部分采用 LRU 方式进行缓存更新。
图1 ES 内存分布
图2 ES 堆内存分布
接下来具体分析每个占用内存的部分:

  • indices.queries.cache.size
    默认10%,node 级别,用于缓存 filter 查询。
  • indices.fielddata.cache.size
    默认无界,正排索引缓存及全局序数,用于聚合排序等操作。
  • index.requests.cache.size
    默认1%,shard 级别,查询请求缓存,不缓存 hits,只缓存聚合结果。
  • Segment Memory
    倒排索引相关内容,且与上述三个缓存不同,调用POST /_cache/clear不能清除。处理这部分内存的方式有以下几种:
  1. 提前规划好 Mapping:不需要存储的内存不存储(评分,词距,词频和偏移等)。
  2. Force Merge:可以清理 Deleted 的文档,将大量较小的 Segment 和并可以占用更少的内存且加快查询。
  3. 关闭或者删除不需要使用的索引,也可采用快照和可搜索快照方式处理。
    另外 ES 7.x( Lucene 8.x )以后将 FST 从堆内存移到了堆外内存,大大减少了 Segment Memory 对内存的占用(线上测试一个 5GB 的 Segment 占用内存减少了400多倍)。具体实现方案是将 FST 从堆内存中剔除, 直接交由 MMAP 管理,即通过 MMAP 读取倒排索引的索引文件.tip
  • indices.memory.index_buffer_size
    默认10%, bulk 写请求的缓存优化配置。
  • indexing_pressure.memory.limit
    默认10%,用于处理数据索引压力,即数据写入过程中的路由和主副同步工作。
  • 使用中耗费的内存
    非业务高峰期情况下保证集群堆内存负载在 70% 以下较为健康,超过这个值代表数据量过多。除此以外,业务高峰期集群会耗费大量内存,主要是聚合等查询请求占用,前期设置可以有效避免集群这部分压力过大,包括 Mapping 优化、预计算(使用 Ingest Pipeline )等多种方式减小集群查询的压力。
  • 脚本相关及其他未知部分
    脚本(正则)、pipeline 和 scroll 等均会占用部分内存。

了解了 ES 内存方面的使用,我们还需要在日常维护集群时监控它们,对此 ES 提供了丰富的 API:

  1. GET _cat/nodes?h=name,*heap*,*memory*,*Cache*&format=json可以监控到节点的部分内存使用情况和缓存命中情况。
  2. GET _cat/indices/test1?h=*memory*&format=json可以监控某个索引部分内存占用情况。
  3. GET _cat/indices/?s=segmentsMemory:desc&v&h=index,segmentsCount,segmentsMemory,memoryTotal,mergesCurrent,mergesCurrentDocs,storeSize可以查看集群内每个索引 Segment 的整体情况和合并情况。
  4. GET _cat/segments/test1?v可以查看索引内每个段占用内存情况。
  5. GET _nodes/stats/indexing_pressure?human7.X 版本可以查看 indexing pressure 情况。

建议对 ES 提供的监控类 API 进行较为熟练的运用,包括但不限于:使用?help进行 API的学习、查看配置信息时使用include_defaults展示默认配置及使用flat_settings展铺平配置、使用human可视化以及bytes返回结果单位设置等。

4 熔断器

本章主要对 ES 的熔断器进行详细介绍,包括其核心思想、版本迭代以及具体的实现方式等内容。

1 熔断器的核心思想

通过估算请求使用的内存是否会超过熔断器的限制而避免 OOM。跟踪每个 JAVA 对象的分配申请过程不现实,所以熔断器只跟踪经常出问题的内存使用,不能保证100%生效。

2 ES 熔断器的升级(7.x 版本进行了优化)

熔断器为父子结构,7.x 版本父熔断器相比旧版本更易触发。
-6:跟踪部分内存分配预估,父熔断器根据所有子熔断器计算结果觉定是否触发。
7-:根据系统实时状态决定是否拒绝请求,即实际内存熔断器,父熔断器通过调用 JVM 提供 的方法MemoryUsage getUsage()监控。

3 熔断器的状态监控

通过 GET _nodes/stats/breaker API 可以对熔断器状态进行监控,结果如图3:
图3 熔断器状态监控
具体含义为:配置大小,当前大小,配置担保系数和触发次数。
可以通过收集日志,记录 Data too large 等关键字后判断熔断原因(根据 label) ,具体参见下文源码分析。

4 业务适配熔断器

业务方触发熔断后或收到错误响应,应保证客户端有退避机制和重试机制,例如使用 Rest High Client 的 onSuccess()/onFailure(),BulkProcess 的 afterbulk() 等回调函数处理。

4.1 熔断器种类介绍

定义及部分内容参考于 ES 官方英文文档


ES 具有多个熔断器用于防止各种请求操作造成 OOM ,每个熔断器限制了它可以使用的内存。此外,ES还有一个父熔断器用于限制所有熔断器的内存总量。
熔断器大部分配置可以在运行中的集群上通过 cluster-update-settings API 动态更新

父熔断器

父熔断器具有以下配置:
indices.breaker.total.use_real_memory:
静态配置,默认 true。设置父熔断器是通过jvm接口考虑真实内存使用量(true),还是考虑所有子熔断器使用总量(false)。
indices.breaker.total.limit:
父熔断器启动限制。上一配置为 true,则默认为 JVM 的95%;上一配置为 false,则默认为 JVM 的70%。

Field data 熔断器

此熔断器允许 ES 估计一个字段加载到 fielddata cache 需要的内存量,如果加载该字段会导致超过限制,熔断器将停止载入并返回 error。
indices.breaker.fielddata.limit :
内存限制,默认 JVM 的40%。
indices.breaker.fielddata.overhead :
估算因子,默认为1.03。

Request 熔断器

请求断路器使 ES 可以防止每个请求的数据结构(例如,用于在请求期间计算聚合的内存)超过一定数量的内存。
indices.breaker.request.limit :
内存限制,默认 JVM 的60%。
indices.breaker.request.overhead :
估算因子,默认为 1。

In flight requests 熔断器

进行中请求断路器使 ES 可以限制 transport 或 http 级别上所有当前活动的即将传入请求的内存使用,以免超出节点上的特定内存量。内存使用情况取决于请求本身的内容长度。该断路器还认为,不仅需要内存来表示原始请求,而且还需要将其作为结构化对象,这由默认开销反映出来。
network.breaker.inflight_requests.limit :
内存限制,默认 JVM 的100%。受父熔断器限制约束。
network.breaker.inflight_requests.overhead:
估算因子,默认为2。

Accounting requests 熔断器

计费断路器允许限制请求完成后未释放的内存中所保存内容的内存使用量。这包括Lucene 段内存之类的东西,例如 Segment Memory。
indices.breaker.accounting.limit:
内存限制,默认 JVM 的100%。受父断路器限制约束。
indices.breaker.accounting.overhead:
估算因子,默认为1。

Script 编译熔断器

和上述熔断器不同,此熔断器限制一段时间内 Script 编译次数。
script.context.$CONTEXT.max_compilations_rate:
限制一段时间内允许编译的脚本数量。默认75/5m。( es第一次获取到script时会进行编译并缓存。Tips:使用 params 参数化脚本而不是写死到脚本中)

Regex 熔断器

此断路器限制了painless脚本中的正则表达式的使用和复杂度。
script.painless.regex.enabled:
静态配置,接受以下参数:
limit(默认):启用正则但是通过下面的参数限制复杂度
true:启用正则不做限制,即禁用熔断器
false:禁用正则,带有正则的 plainless 脚本将报错
script.painless.regex.limit-factor:
静态配置,限制脚本中正则表达式可以考虑的字符数,ES 通过设置值乘以脚本输入的字符长度来计算此限制。
例如输入 foobarbaz 字符长度为9,此参数设置为6(默认),则基于 foobarbaz 的正则表达式最多可以考虑54个字符。如果表达式超过此限制,则会触发此断路器并返回错误。仅在第一个参数为 limit 时生效。

4.2 熔断器源码浅析

熔断器相关代码主要位于两个包中,包括熔断器接口、一些子类以及异常类和具体服务类等,本节分别阅读两个包中的代码。

common.breaker

这里定义熔断器接口和两个实现类和异常类,具体如图4所示:
图4 熔断器实现
CircuitBreaker 中两个枚举定义了 breaker 细分类:
enum Type {MEMORY,PARENT,NOOP}
enum Durability {TRANSIENT,PERMANENT}
ChildMemoryCircuitBreaker 类 addEstimateBytesAndMaybeBreak() 方法判断是否请求需要占用的 bytes 通过 memoryUserd() 计算后超过限制触发跳闸,如图5所示:
图5 熔断器是否触发判断
例如:terms agg,会通过 BigArrays 这个工具类申请大数组来存放计算数据,在实际分配内存之前,调用 adjustBreaker() 方法 (其内部调用addEstimateBytesAndMaybeBreak()方法) 将新申请数组的大小交给ChildMemoryCircuitBreaker 来判断是否触发跳闸。

indices.breaker

主要包含具体的熔断服务类 HierarchyCircuitBreakerService,具体结构如图6:
图6 熔断器服务类
HierarchyCircuitBreakerService 内部类 MemoryUsage 实现父熔断器获取内存状态准则,如图7所示:
图7 父熔断器行为准则
currentMemoryUsage() 调用的即 JVM 底层的内容:

//构造MemoryMXBean调用getUsed()
MemoryMXBeanMEMORY_MX_BEAN = ManagementFactory.getMemoryMXBean();
return MEMORY_MX_BEAN.getHeapMemoryUsage().getUsed();//MemoryUsage

CircuitBreakerStats 类负责熔断器状态监控,_nodes/stats/breaker调用。
图8 熔断器状态
具体示例如下:
image
其中当前使用子熔断器均为自己统计,父熔断器采用 memoryUsed() 方式统计。触发次数为AtomicLong类型,每次触发熔断都会加1,且会打印如下日志。
image
日志中会输出内存限制和使用数值信息和 label 信息,表示引起熔断具体原因:

  • <reduce_aggs> : 查询结果分片间聚合
  • <reused_arrays> : agg 等申请的 BigArrays 过大
  • <http_request> : http 请求 content 过长
  • “allocated_buckets” :桶太多了( search.max_buckets )
    ……

5. 小结

本篇文章旨在对 ES 内存的使用情况和其在预防 OOM 设置的熔断机制进行简单的分析,了解其中缘由有助于我们更好的优化 ES 内存方面的配置,并在出现内存方面的异常时更快的定位问题和找到解决办法。
由于作者能力有限,文章中存在的错误以及遗漏还望读者可以批评指出,共同学习,共同进步。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐