那是一个平静的上午,我因为业务需要决定给我们的Elasticsearch(以下简称ES)集群添加几个节点,当前集群ES的版本为7.5.2,而待添加的节点都是从之前的1.5.2版本的ES集群中移出来的。当前的7.5.2集群已经有了6个数据节点,我这次的工作就是准备再添加5个新的数据节点上去。

因为这5个节点都是之前1.5.2版本的ES集群的数据节点,并且在之前的集群中稳定的运行了好几年也没有出现过任何问题,所以我对它们的配置都是很放心的,这种大意的态度就为接下来的悲剧埋下了伏笔。因为这些节点在之前的集群中都没什么问题,所以我就一次性把这5个节点全部添加到了ES7集群中成为了数据节点。

出现错误

节点添加之后,ES集群开始进行分片的重新平衡,整个集群开始进行分片的搬迁和复制操作,我们的工作似乎很快就要完成了。很快,这种平静就被手机短信的铃声所打破了,短信提示业务突然开始出现了大量的读写失败错误!同时企业微信的告警群也开始大量告警,告警信息显示的都是CircuitBreakingException类型的异常,具体的报错信息我摘录部分如下

[parent] Data too large, data for [<transport_request>] would be [15634611356/14.5gb], which is larger than the limit of [15300820992/14.2gb], real usage: [15634605168/14.5gb], new bytes reserved: [6188/6kb], usages [request=0/0b, fielddata=0/0b, in_flight_requests=6188/6kb, accounting=18747688/17.8mb]

其实报错的原因非常简单,就是当前内存已经触发了parent级别的circuit breaker,导致transport_request无法继续进行。因为如果继续进行transport_request则可能导致ES产生OutOfMemory错误,ES为了避免OOM会设置一些circuit breaker (断路器),这些断路器的作用就是在内存不够的时候主动拒绝接下来的操作,而不是进一步的分配内存最终产生OutOfMemoryError,断路器的作用就是保护整个进程不至于挂掉。

我们已经知道了报错的原因是Java进程的堆内存不够了,那么到底是什么原因导致了内存会不够呢?此时此刻我暂时没有心思考虑这些问题,新增加的5个节点都在频繁报内存不够的问题,这导致了大量的线上读写失败,我当前的首要目标就是解决这些报错。这就是我之前所说的伏笔了,因为我一次性把5个节点全部加到了集群中去,所以此时某个索引的某个分片的主分片和所有副本分片可能全部分布在这些我新添加的节点上,因而我不能一次性把这些节点全部停掉,因为这样会导致这些分片的数据彻底丢失从而使得整个集群变成红色。

事实上我当时因为告警太多太紧张了所以干了这种事情,即停止了多个节点,集群状态立即变成了红色。好在这些分片还存在于停止节点的磁盘上,集群变红之后我赶紧又把这些节点起了起来,集群才又脱离红色状态。之后我只能一边忍受着告警,一边默默地等待分片的复制,只有确认一个节点上不存在某一个分片的唯一分片数据之后,我才能把这个节点停掉。

Anyway,我一边忍受着告警一边停止节点,经过一段时间之后总算是把这5个节点都停掉了,内存不足导致的读写告警终于停止了。坑爹的是此时整个集群出现了一些unassigned的分片,即这些分片未能成功分配。我们使用如下命令找到所有还没有分配的分片(unassigned shards),并且解释这些分片没能分配的原因

GET /_cluster/allocation/explain

得到错误原因如下

shard has exceeded the maximum number of retries

也就是说之前的内存错误导致了这些分片的分配失败,并且多次失败达到了最大的重试次数,此时ES放弃了对这些分片的分配操作。这种情况下我们只需要执行如下命令来手动开始进行重新分配分片

POST /_cluster/reroute?retry_failed=true

之后集群会开始对这些未分配的分片进行分配,等待一段时间的分片分配和复制之后,整个集群终于重新恢复绿色了。

出错原因

首先我们想到的就是因为GC的问题导致内存没能及时的回收掉,剩余内存不够导致了错误。我们观察了G1垃圾收集器的GC日志,G1的日志大致分为如下三个部分

# 正常的YoungGC
Pause Young (Normal) (G1 Evacuation Pause)

# 伴随着YoungGC会有多次标记操作
Pause Young (Concurrent Start) (G1 Humongous Allocation)
Concurrent Cycle

# MixedGC
Pause Young (Prepare Mixed) (G1 Evacuation Pause)
Pause Young (Mixed) (G1 Evacuation Pause)

在观察了GC日志之后我们发现堆内存每每在已经达到了很高的占用率之后才会触发GC,这种情况就很有可能导致内存无法及时回收以及剩余的内存不足。如果我们能让GC更早的发生,那么就能够降低剩余内存不够的概率(虽然这样会因为GC的更加频繁而降低整个系统的吞吐量)。通过搜索我们在ES源码的Pull requests中发现了如下的GC配置

-XX:G1ReservePercent=25
-XX:InitiatingHeapOccupancyPercent=30

G1垃圾收集器的官方文档中对这两个参数的解释如下

-XX:G1ReservePercent=10
Sets the percentage of reserve memory to keep free so as to reduce the risk of to-space overflows. The default is 10 percent. When you increase or decrease the percentage, make sure to adjust the total Java heap by the same amount. This setting is not available in Java HotSpot VM, build 23.

-XX:InitiatingHeapOccupancyPercent=45
Sets the Java heap occupancy threshold that triggers a marking cycle. The default occupancy is 45 percent of the entire Java heap.

简单来说-XX:G1ReservePercent是保留的内存空间百分比,其目的是避免内存不够而导致的错误,默认值是10,我们将其提升到了25。-XX:InitiatingHeapOccupancyPercent是触发一次marking cycle的内存占用阈值百分比,默认是45,我们将其减小到了30。修改了这两个虚拟机参数之后,虚拟机就能够更早的进行GC,这样就会大大降低内存不够错误出现的概率。

修改完参数重新启动节点,这次我们一台一台的起,启动一台之后等待几个小时确认没有问题之后再启动下一台。此外,我们在新增节点之前先把cluster.routing.allocation.enable参数设置为none,等待节点确认启动完毕之后再把其设置为all,这样就可以手动控制分片分配的开始和停止。改完参数之后,节点已经不会频繁的发生内存不够的错误了,可见修改配置使得GC时间提前确实降低了GC过慢导致的内存不足问题。虽然一般的情况下节点内存已经不存在压力,但是此时还有另一个问题,就是在加入节点后搬迁分片时还是有几率触发节点内存不够的错误,此时我们只需要减慢分片搬迁的速度即可。

我们将cluster.routing.allocation.node_concurrent_recoveries的值从默认的2修改为1,这样可以降低同一时刻节点上搬迁的分片的数量。此外,我们通过设置indices.recovery.max_bytes_per_sec将每个节点分片搬迁速度从40mb/s降低为20mb/s,这也能减少分片搬迁时节点的内存压力。改完了这些配置之后,节点就再也没有出现过内存不够的错误了。

其实综上我们可以总结出这次出现问题的原因如下

  1. gc速度过慢
  2. 内存增长过快

解决办法就相应地如下

  1. 减小GC触发阈值,提升GC频率
  2. 减少数据同步速度,降低内存增加速度

参考

PB级大规模Elasticsearch集群运维与调优实践
JVM调优实战:G1中的to-space exhausted问题

另外多扯一句,ES分片复制的时候,对于从开始复制到复制结束这段时间产生的数据,是由target的translog负责记录的,之后target会对复制来的数据和自己的translog数据进行合并,得到最终数据。target的数据来源如下:

复制开始前 复制过程中 复制结束后
复制source分片的数据 复制过程中写入的本地translog数据 普通的分片本地写入数据