Shard Allocation-Elastic Stack 实战手册

Shard Allocation-Elastic Stack 实战手册

· 更多精彩内容,请下载阅读全本《Elastic Stack实战手册》

· 加入创作人行列,一起交流碰撞,参与技术圈年度盛事吧

创作人:毛夏军
审稿人:刘帅

什么是分片分配 (shard allocation)

分片分配 (shard allocation),是指在索引创建、副本增减、节点增减、分片重平衡等,将索引分片落实到实际的物理节点的过程,分片分配可以分为,集群级分配和索引级分配两种,集群级分配常见的包括

  • 要求热索引的分片不要去往低配机器,
  • 商品、订单索引的分片不要分配到同一个节点等。

将分片分布到不同的节点,一方面是为了提高系统的可用性,如当集群中一台机器宕机,使得该节点上的分片不可用时,分布在其他机器上的分片,能通过重新选举继续工作 (但是仍要保证同一分片的主从副本不全在宕机节点上) ;

另一方面是为了提高系统的容量和读写性能,如通过增加节点横向扩容,将集群中部分分片 Rebalance 到新节点,既可以利用新节点的存储容量,提升索引存储容量,迁移过来的分片,可以利用新节点增加的算力提供服务。

总结一下分片分配过程,有两个基本要素:

  1. 分片:来自集群内的全部索引,包括主分片和副本分片
  2. 节点:组成集群的各个 Elasticsearch 进程,能够通过一定的标识来识别个体或划分成组

那么,集群内的各节点是如何被识别和标记的呢?

节点属性 (node attributes)

节点属性 (node attributes),包括内置属性和自定义属性两种。

Elasticsearch 会将常见用于区分不同机器的标记,如主机名 (_host)、IP 地址 (_ip)、节点名称 (_name) 等作为内置属性,供分片分配时区分节点的标记使用,具体包括:

  • _name:节点名称,即在 elasticsearch.yml 中定义的 node.name 属性
  • _host_ip、_publish_ip、_ip:节点的 IP 地址,一般情况下使用 _ip 即可,具体含义可以查阅官方帮助文档
  • _host:主机名
  • _id:集群为节点自动分配的唯一标识符,手动调控时使用较少
  • _tier:节点的数据角色,比如存储冷热数据的 data_cold、data_hot 等,可以在 elasticsearch.yml 中指定 node.roles 属性

内置属性可以用来区分不同节点,但是对于将节点划分成组来说不是很便利,比如我们希望将索引分配到高配节点。如果使用内置属性,比如 _ip 的话,需要在索引设置 index.routing.allocation.include._ip 中,指定多个机器的 IP 地址,在集群增删高配节点的情况下,需要同时调整对应索引的分片分配设置,显得不太便捷。

我们很自然的希望除了内置属性之外,还可以根据机器配置的高低、是否同属于一个网段等情况来标记节点,方便我们将不同类型的节点划分为一个个组,继而将索引的分配配置到节点组,这样在集群增删节点时,只要节点配置了对应的组,集群就会根据对应组的节点变化自动的将分片重新调整,而不需要我们再手动的同步每一个索引的分配设置。

自定义节点属性解决的便是这个问题,我们可以通过:

  1. 在 elasticsearch.yml 中新增配置项,如 node.attr.zone=zone1
  2. 或在启动命令中增加变量,如 bin/elasticsearch -Enode.attr.zone=zone1

以上方式来为节点增加自定义属性。

小结

分布式架构为我们带来了众多容量、性能和可用性方面的优势,但是相应的也提高了保障难度,因为分片在集群自动分配的情况下不一定能达到我们期望的"平衡"状态,需要我们对分片分配机制有较高的掌握程度来调控集群内分片的分配状况,比如主从副本不分布在同一物理节点、解决节点数据倾斜导致新分片集中于某几台负载较低节点的热点问题等。

调控分片分配

Elasticsearch 集群中 master 节点的一项重要功能,就是决定分片如何以最佳的方式,均衡分布到集群内的各个节点上。除了自动分配之外,我们也可以从粗粒度的集群维度和细粒度的索引维度,手动调控分片在各节点的分配。

集群维度 (cluster level) 的分片分配,是将所有分片纳入一起考虑,不会单独考虑某个索引的分片分配情况。

举个例子:我们有两个索引,每个索引包含两个分片,将分片分配到两个节点组成的集群。

状态一:

同一个索引的全部分片分配到同一个节点

{
  "node_1": ["index_1_shard_1", "index_1_shard_2"],
  "node_2": ["index_2_shard_1", "index_2_shard_2"]
}

状态二 :

同索引的分片均匀分配到各节点

{
  "node_1": ["index_1_shard_1", "index_2_shard_2"],
  "node_2": ["index_2_shard_1", "index_1_shard_2"]
}

从集群维度,都可以被认为是"平衡"状态,但是从实际角度看,只有状态二是我们期望的平衡状态。因为如果 index_1 和 index_2 负载不均,在状态一下,很可能导致集群内节点负载不均,使得服务整体表现不能达到预期。

要达到我们期望的状态二,就可以使用索引维度 (index level) 的分片分配控制方法,比如通过 index.routing.allocation.total_shards_per_node 参数控制每个节点的分片数量为 1 ,就可以达到我们的目的了。

集群维度分片分配

从集群层面来说,分片分配控制的是集群内各索引的分片,集合在各节点的分布,并且在分片分配过程中,添加一些硬性限制以控制集群负载在合理范围内。

以一个实际操作中可能会遇到的情况来说,公司准备新上一批业务,需要用到大规模的数据检索功能,首先需要处理搭建集群的任务,在开始搭建集群之前,让我们先看看实际的业务场景。

系统用于收集应用打点日志,提供一周内数据供查询。

分片平衡的启发式参数

日志类型数据存储,带有非常明显的时效性,一般情况下当日数据的读写频繁,非当日数据几乎不会有写操作,离当前时间越久的数据读操作也越少。

通过上述判断,可以认为索引的分片,越是平均分布于集群内各节点越好,因为可以充分利用全部节点的算力,来分摊当日数据的高频读写负载。

为了达到这个目的,我们可以通过 Elasticseach 提供的部分启发式参数,让 master 在决策分片如何分配时,更多的向我们期望的方向考虑:

  • cluster.routing.allocation.balance.shard 节点中分片总数对权重的影响因子,默认为 0.45,该值越大则各节点的分片数越趋向于相等。
  • cluster.routing.allocation.balance.index 节点中来自不同索引的分片数对权重的影响因子,默认为 0.55,该值越大,则各索引的分片更倾向于均匀分配到各节点。
  • cluster.routing.allocation.balance.threshold 默认为 1.0,该值越大,则集群对不平衡状态的容忍程度越高。

我们可以适当放大 cluster.routing.allocation.balance.index 的权重来使得集群在分配分片时,更倾向于将一个索引的不同分片,均匀分布到各个节点。不过需要注意的是,这只是一个启发式参数,更多的是"建议",而不是"命令",要完全达到我们的期望,还需要借助索引维度的分配调控手段。

分片迁移的流量控制

回到本节起始提到的日志业务,系统上线运行一段时间后,随着索引量的不断增加,我们需要适时的清理掉过期数据,清理过程中自然的会删除过期数据所在的索引,释放存储空间供新的索引使用。

在集群删除索引时,因为集群内分片总数发生了变化,自然的分片在各节点的分配状态也随之发生变化,可能会出现分片的"不平衡"状态。这时默认情况下集群,会自动触发分片的重平衡操作,将分片在各节点间适当的迁移,以使得分片在集群重新达到"平衡"状态。

在日志类数据情况下,单个分片包含的数据量可能会较大,达到若干 GB。这样在分片发生迁移时,节点必然会触发大量的 IO 操作,为了避免大量的 IO 操作对节点造成冲击,使得集群服务发生抖动,我们可以通过分片迁移的流量控制参数进行干预:

  • cluster.routing.allocation.node_concurrent_incoming_recoveries

用于控制可同时在一个节点上进行初始化或恢复的最大分片数,默认为2,设置过大可能导致节点负载过高 (同时写入大量数据),调整时需要考虑节点的硬件配置。

  • cluster.routing.allocation.node_concurrent_outgoing_recoveries

用于控制该节点可同时为其他节点分片恢复或迁移提供数据源的最大分片数,默认为2,调整同理。

注1: 从节点的角度,分片会出现两种流向:流入流出,其中,流入是指来自某个索引的分片新落入到该节点,流出是指该在其他节点的分片以该节点所属的分片为数据源进行副本恢复或者数据迁移。

节点的水位线

随着打点应用的接入越来越多,单日的日志索引量上涨迅速,节点的磁盘水位吃紧,我们希望新的分片在分配时,能考虑到节点存储容量的状态,避免将新分片分配到磁盘容量快满的节点。

这个情况下如果要自行通过节点属性来调控,至少需要:

  1. 自动监测磁盘水位,并为节点打上 low/medium/high 的属性,而且更改属性还需要重启节点。
  2. 为新分配的索引设置 index.routing.allocation.require.* 属性,来让索引避开高水位节点。
  3. 在节点的磁盘水位属性变更时,自动为集群内的索引更新 allocation 配置来避免自动平衡。

看上去就很麻烦,那么有没有简便方法呢?

答案是使用内置的水位 cluster.routing.allocation.disk.watermark.* 属性。

水位限制分为高低两种,其中:

  • cluster.routing.allocation.disk.watermark.low

低水位,默认为磁盘容量的 85%,Elasticsearch 会避免将分片分布至磁盘容量超过低水位的节点。但是新创建索引的主分片 (primary shards),仍然可以分配到超过低水位的节点。

  • cluster.routing.allocation.disk.watermark.high

高水位,默认为磁盘容量的 90%,Elasticsearch 会将磁盘容量超过高水位节点上的分片迁移至其他节点。

  • cluster.routing.allocation.disk.watermark.flood_stage

警戒水位,默认为磁盘容量的 95%,当节点磁盘容量超过警戒水位时,该节点所属分片所在的索引,都会被执行写禁止操作,即索引变为只读状态。

比如 A 索引的 shard 1 分布在 N1 节点,shard 2 分布在 N2 节点,如果 N1 节点磁盘容量超过警戒水位,索引 A 即被执行写禁止操作,成为只读索引。但是容量绝对值和百分比不能混用,比如指定了磁盘低水位为 500mb,则高水位相应的也必须使用绝对值表示。

索引的增、删、改都会对所在节点的磁盘水位产生影响,为了动态的感知磁盘水位,相应的就有了水位采集参数:

  • cluster.info.update.interval

磁盘水位采集频率,即每隔多久去检查一次磁盘用量,默认为 30 秒。

  • cluster.routing.allocation.disk.include_relocations

是否将正在迁移到当前节点的分片磁盘用量 (将占用的磁盘空间) ,计入当前节点的磁盘用量,默认打开。

热点问题

虽然根据实际数据更替情况,合理配置了节点的高低水位,但是随着时间推移,我们发现集群发生了热点数据倾斜问题,由于冷数据占用了大量的存储空间,导致热点数据 (当日新创建的索引), *分配到空间用量相对较少的几个节点,使得集群的负载不均。

针对日志服务等索引频繁创建、删除的场景,数据带有明显的时效性,可以考虑集群分组,对冷热数据使用不同的分配标记 (allocation attributes) ,来隔离冷热数据 (或者使用 data tier allocation) ,目的是避免访问较少的冷数据,占用磁盘容量,导致集群将新创建的索引分配到少数几个"看起来比较合适"的节点,导致热点问题出现。

Elasticsearch 新版本中已经将类似的功能集成为 data_tier 插件,详见下一小节。

针对索引创建、删除不频繁的场景,比如电商后端常见的商品搜索、订单搜索等,一方面可以考虑将集群节点分组,或者部署多个集群,将不同业务进行资源隔离。

另一方面,创建索引时需要考虑到,未来数据量的增长情况,以设置合理的分片数量,将分片尽量均匀分配到每个节点,以更合理的利用节点硬件资源;

一般来说,商品、订单等业务数据长尾效应比较明显,针对热点的店铺、类目等引起的数据倾斜问题,可以将热点数据单独拆出一个索引,配合前端的引擎代理将请求路由到对应的索引。

管理节点

系统资源吃紧,需要通过横向扩容增加集群容量。

节点扩/缩容

当向集群扩容节点时,其他节点会迁移部分分片到新节点,如果并发迁移的分片过多,可能造成瞬时的高 IO 负载,引起服务抖动。

我们可以通过 cluster.routing.allocation.node_concurrent_incoming_recoveries 参数控制分片迁移的速度,如果是在集群负载较高的情况下横向扩容新节点,建议分开两步操作:

  1. 关闭 cluster.routing.rebalance.enable (主要是考虑到分片移出后可能会引起集群重平衡操作)
  2. 通过手动对目标索引进行 index.routing.allocation.include 配置,将新节点纳入到分片的分布范围,逐个迁移索引分片。目的是减少大规模持续的分片迁移,导致集群负载继续升高,甚至发生雪球效应。

同样的,在缩容集群时,如果直接关闭节点,可能存在两个风险点:

  1. 在集群规模较大情况下,会有大量索引同时进行分片主从切换和分片重新分配操作,瞬时对 master 节点带来很大的负载,尤其是日志类数据的大集群。因为分片数较多,可能导致 master 节点 CPU 飙高,使得新创建索引等操作被阻塞;
  2. 小概率情况下。如果其他节点发生意外宕机,索引将存在数据丢失风险。

对于类似缩容,存在一定风险性的主动操作,建议与扩容类似,首先设置全部索引的 index.routing.allocation.exclude 或者直接在集群范围内设置 cluster.routing.allocation.exclude 属性将待下线的节点排除,待分片全部移出之后再关闭节点进程。

节点重启

除了横向扩容外,对节点纵向扩容,或者升级 Elasticsearch 版本,都需要对节点进行重启操作。

在重启节点时,自然会有节点的上下线操作,节点下线同时会让该节点所属的分片处于 unassigned 状态。正常情况下,集群会将这些未分配分片,重新分配到集群内其他节点上,在日常运维的节点重启操作中,这显然不是我们期望的,无端的带来了大量的 IO 操作。

正常的滚动重启操作中,建议是:

  1. 通过 cluster.routing.allocation.enable: none 关闭分片分配;
  2. 重启节点;
  3. cluster.routing.allocation.enable 重置为 all 打开分片分配。

调控分片的物理分配

因为日志检索方面表现良好,公司决定将商品、订单等系统的检索功能也迁移到 Elasticsearch 集群,并提供了高配机器用于集群搭建。

分片在单台物理机的分配

在实际的部署过程中,有时会遇到大容量高配机器,比如 32 核 128GB 内存,我们可以考虑单节点部署,将对内存数据量扩大到 32GB 以上 (一个是对象指针压缩技术不再可用,造成内存空间膨胀,另一个是堆内存回收压力也增大,可能造成 gc 停顿时间变长);

也可以考虑单机多节点的部署方式,在这种情况下,为了数据可用性的考虑,索引内同一分片的主副本数据,需要分配到不同的物理节点上,这时可以使用如下参数:

  • cluster.routing.allocation.same_shard.host

阻止同一分片的多个实例 (主副本) 落到同一个主机,同一主机的判定条件为相同的主机名 (host name) 和主机地址 (host address),默认情况下该参数为关闭状态,强烈建议在单机多节点部署的情况打开该配置,避免可能的数据丢失风险。

对商品、订单的搜索场景,一般单节点的负载会控制在最大值的 50% 左右,以提供足够的余量来承载瞬时的流量高峰,为了达到这个目的,在分片分配层面,可以设置单节点的分片数上限:

  • cluster.routing.allocation.total_shards_per_node

单节点最多能支撑的分片数,当节点包含的分片数高于该数值时,新分片不会在该节点创建。

分片在机房内的分配

为了数据高可用的考虑,我们在管理集群的时候,可能会考虑索引的主从分片,不要都落到某一台宿主机,或者同一个机架上,这个时候可以通过一些提示性参数,让集群在选择节点时有一定的倾向性:

  • cluster.routing.allocation.awareness.attributes

设置分片分布时,会考虑将分布交叉分布到属性不同的节点上,比如集群包含两个节点 N1 node.attr.zone=zone1、N2 node.attr.zone=zone2,如果我们在 elasticsearch.yml 中设置 cluster.routing.allocation.awareness.attributes: zone,则我们新建带一个副本的索引时,集群会将同一分片的主副本交叉分布在不同的节点上。节点属性可通过上述自定义属性方式设置。

  • cluster.routing.allocation.awareness.force.zone.values

假设集群节点仍然是 N1、N2,如果 N2 因为故障宕机,默认情况下,N2 所属的分片会在 N1 节点重新恢复出来,但是在同一个节点运行相同分片的主副本并没有实际意义,这时我们在 elasticsearch.yml 中设置 cluster.routing.allocation.awareness.force.zone.values: zone1,zone2 来避免这种操作,集群会在 zone2 属性节点恢复后再将相应的副本分片恢复到该节点。

集群分片上限

系统跑起来了,那么集群最多能负载的分片数是多少呢?

对日志类的数据,通常会以应用、时间、日志级别等多个维度建立索引,这样一来集群内的总分片数变得相当可观,那么集群最多能负载的分片数是多少呢?

答案也很简单,就是节点数与单个节点能最多能支撑的分片的积,但是这里的单节点最多支撑的分片数:

  • cluster.max_shards_per_node

用于限制整个集群最多能支撑的分片数,当集群内活跃分片数大于 cluster.max_shards_per_node * number_of_data_node 时,集群会阻止新索引的创建,直到有索引被删除或者关闭 (closed) ,使得活跃分片总数低于阈值。这里的活跃分片数是指非 closed 状态的分片,包括 unassigned / initializing / relocating / started。

不会像 cluster.routing.allocation.total_shards_per_node 真正的限制单节点的分片数。

这里列举了部分较为常用的配置,更多参数可以参阅官方文档。

小结

我们调整或干预集群的分片参数,从根本上说,是为了在集群稳定的情况下将性能最大化,从分片分配 (allocation) 的角度来说,稳定意味着分片尽量少的移动,性能意味着同一个索引的分片尽量均匀分布到各节点,而不要集中到少数几个节点。

索引维度分片分配

如同设计系统需要先从架构角度考虑系统模块设计,再细化到每一个应用设计应用自身的功能模块,索引分片的调整也需要先从集群角度,设定大的分配策略导向,如节点分布 (shard awareness)、平衡 (shard rebalance) 等,再细化到每一个索引考虑单索引分片如何调整以达到最佳的表现。

再以上一节中的商品、订单检索系统为例,因为这分属两个子系统,符合我们预期的理解是即使商品检索系统负载很高,也不应该影响订单系统的检索耗时。

隔离不同索引

对不同业务所属索引进行物理隔离,实现 A 索引在执行大负载操作时,不会对 B 产生影响,前提是使用节点属性,将集群按照需要分割为多个群组。

比如集群包含以下设置的节点:

通过 index.routing.allocation.* 配置可以启用索引的分片控制,具体的:

  • index.routing.allocation.include.{attribute}

    将索引分片分配到包含任一指定属性的节点上。{attribute} 可以指定为节点内置属性,如 _ip、_host 等,也可以指定为自定义属性,如 zone 等,也可以混用。

  • index.routing.allocation.require.{attribute}

将索引分片分配到指定节点上,节点必须包含指定的全部属性。

  • index.routing.allocation.exclude.{attribute}

不要将索引分配到包含任一指定属性的节点上。

如果设置索引的分片控制参数为:如果参数设置为:如果参数设置为:则索引分片不会被分配到 Node C。

排查分片分配

当我们在创建索引后,发现索引分片不能被正常分配时,可以通过 explain 接口来查看原因,如下:

curl -XGET '{host:port}/_cluster/allocation/explain'

在 response 中可以看到具体分片未被正常分配的原因,如:

{
  "index": "test_v1",
  "current_state": "unassigned",
  "unassigned_info": {
    "reason": "INDEX_CREATED",
    "last_allocation_status": "no"
  },
  "can_allocate": "no",
  "allocate_explanation": "cannot allocate because allocation is not permitted to any of the nodes",
  "node_allocation_decisions": [
    {
      "node_decision": "no",
      "deciders": [
        {
          "decider": "filter",
          "decision": "NO",
          "explanation": "node does not match index setting [index.routing.allocation.require] filters [node:\"xxx\",_ip:\"1.1.1.1\"]"
        }
      ]
    }
  ]
}

表示因为没有节点能够同时满足 node.attr.node: xxx 且 IP 地址为 1.1.1.1 的节点 (response 内容适当删减了非必要信息)。

平衡索引分片在各节点的分配

在索引按业务分组隔离之后,以商品检索为例,后续又追加了商品评价索引,用于存放商品的评价记录,由于用量较低,我们希望将分组内机器资源,主要用来承载商品检索服务。

不考虑分片数据倾斜的问题,即每个分片的负载一致,我们可以将索引的分片数 (主分片和副本分片的总和) ,设置为与节点个数一致,并通过设置索引分片,在各节点的分配个数来强迫索引在各节点间均衡分配。

要控制索引在单个节点的数量,可以通过 index.routing.allocation.total_shards_per_node 参数设置。

比如现有 6 个节点,我们可以将索引的分片数 index.number_of_shards 设置为 3,副本数 index.number_of_replicas 设置为 2,同时将索引在每个节点上的最大分片数 index.routing.allocation.total_shards_per_node 设置为 1,即可以保证索引在每个节点分配一个分片,充分利用每个节点的算力。

冷热隔离/归档

回到日志数据的问题,为了便于回溯问题,我们期望将数据的存档时间,从一周延长为半年,但是不要求全部的数据,都有同样的可用性和响应时间,

  • 一周内需要有高性能的读写能力
  • 一月内数据仅需保证秒级的查询 RT 即可
  • 一年内的数据不需要实时保证可读,只要能保证数据在必要情况下可恢复使用即可。

对于冷热时效明显的数据场景 (比如日志类) ,热数据 (如当日数据) 的读写频率都要明显高于冷数据 (如一周前数据),这个时候从成本的角度出发,我们期望将冷数据存放于容量较大、IO 及 CPU 性能较弱的存储类机器,将热数据存放于 IO、CPU 性能较好的计算类机器。

我们可以将节点按照存储优化型、计算优化型、通用型等定向优化的类型分组,使用 node.roles 将节点分组,可使用的角色包括:

  • data_content 存储用户定义的业务数据,需要保证高性能的读写。
  • data_hot 存储新的时序数据,读写频繁,CPU、IO 敏感型数据。
  • data_warm 读写频率降低,写很少,可容忍读 RT 变高。
  • data_cold 保留只读数据,比如历史记录。
  • data_frozen 用于保留数据快照,比如对冷数据将其副本作为 searchable snapshot 存放到 data_frozen 节点,并关闭原始索引的副本,在保证数据可用性的情况下进一步减少空间冗余,降低数据成本。

实际应用中,通常会搭配 ILM (index lifecycle management) 来使用这类 data tier 属性,如:

{ 
  "phases" : { 
    "hot" : { 
      "actions" : { 
        "rollover" : { 
          "max_age" : "1d", 
          "max_size" : "5gb" 
        } 
      } 
    }, 
    "warm" : { 
      "min_age" : "7d", 
      "actions" : { 
        "forcemerge" : { 
          "max_num_segments" : 1 
        } 
      } 
    }, 
    "cold" : { 
      "min_age" : "30d", 
      "actions" : { 
        "searchable_snapshot": { 
          "snapshot_repository" : "snapshot_house" 
        } 
      }
    }
  } 
}

将一周内数据作为热点数据,存储于 data_hot 节点,其他一月内数据作为温数据,存储于 data_warm 节点,超过一个月的数据,启用 searchable_snapshot,集群内只保留主分片,副本备份到指定的远端路径下。

Elasticsearch 内部使用 index.routing.allocation.include._tier_preference 属性来实现这个操作,与 index.routing.allocation.include._tier 不同,_tier_preference 属性不是仅限定一种角色的节点,比如设置

{
     "index.routing.allocation.include._tier_preference": "data_hot,data_warm,data_cold" 
}

表示将索引优先存储于 data_hot 节点,如果不存在 data_hot 次选 data_warm,最后使用 data_cold 兜底存储。

在索引的生命周期中,新创建的索引 _tier_preference 先设置为 data_hot,在超过热点周期后,更新 _tier_preference 为 data_warm。data_hot 将其迁移到存在的 data_warm 节点。

通过这样的手段,我们可以根据索引数据的使用场景,合理的调配硬件资源,达到成本利用的最优化。

创作人简介:
毛夏君,关注研究中间件,比如 ES,Redis,RocketMQ 等技术领域。
博客:微信公众号——tiaotiaoba_abc**
上一篇:APM-Elastic Stack 实战手册


下一篇:《Hadoop与大数据挖掘》——第一篇 基 础 篇 第1章 浅谈大数据 1.1 大数据概述