Elasticsearch 分片分配与再平衡的底层逻辑:从 allocation decider 到集群扩容抖动
1. 先从一次扩容抖动说起
假设你维护一个 6 节点的 Elasticsearch 集群,每个节点 4 核 16GB 内存、2TB 数据盘。某天索引写入变慢,你决定横向扩容到 9 节点。新节点启动、加入集群,_cat/nodes里能看到它们,但过了半小时,_cat/shards里仍有大量分片集中在老节点上。你重启了几个新节点,分片反而开始来回搬,_cluster/health从 green 掉到 yellow,写入延迟飙升。问题出在哪?
这不是“节点加得不对”,而是 Elasticsearch 的分片分配与再平衡没有按你脑中的节奏走。分片不会因为“新机器有空位”就自动平均迁移,它要先过一整套准入判断,再根据权重决定要不要搬、搬多少、什么时候搬。
先记住一个最小模型:分片分配可以理解为“候选节点过滤 + 权重排序”。过滤由 allocation decider 完成,判断某个分片能不能去某个节点;排序由ShardsAllocator完成,在能去的节点里选一个得分最高的。再平衡只是分配的一种触发场景,扩容抖动则通常由过滤被临时阻断、恢复任务并发不足、磁盘水位线卡住共同造成。
下面这张图先给出全局框架,后续章节按图中的顺序展开。
集群状态变更(新节点加入 / 节点离线 / 索引创建) | v +----------------------------+ | AllocationService | 收集待分配分片 | (分配服务) | +-------------+--------------+ | v +----------------------------+ | AllocationDecider 链 | 能不能去? | 路由 / 磁盘水位 / 并发 | | 副本同节点 / 过滤规则 | +-------------+--------------+ | 能去节点列表 | v +----------------------------+ | ShardsAllocator | 去哪个更均衡? | BalancedShardsAllocator | +-------------+--------------+ | v +----------------------------+ | ClusterState 更新 | 分片进入 INIT / RELOCATING | RoutingTable 落盘 | +-------------+--------------+ | v +----------------------------+ | 节点执行 shard 恢复 | 先小后大,副本再平衡 +----------------------------+这张图能帮助你理解“谁先谁后”,但它不能替代真实源码中的并发状态机。真实集群里,decider 链会在不同阶段被多次调用,分配结果也可能因为节点状态变化而回滚重试。
2. allocation decider 到底是什么
allocation decider 是一组“准入判断器”。每个 decider 只回答一个问题:某个分片能不能分配到某个节点。所有 decider 依次过滤,只要有一个返回NO,这个节点就被排除;如果返回THROTTLE,它不会完全排除节点,而是降低优先级或延后分配。
常见的 decider 包括:
| Decider 名称 | 判断内容 | 典型触发场景 | 返回 NO 的后果 |
|---|---|---|---|
SameShardAllocationDecider | 同一分片的主副本是否已在目标节点 | 单节点上已有该分片副本 | 该节点不可用,强制换节点 |
DiskThresholdDecider | 磁盘使用率是否超过 low/high/flood 水位线 | 高写入集群磁盘接近满 | 达到 high 后停止分配新分片,达到 flood 后索引变只读 |
ShardsLimitAllocationDecider | 单节点上同一索引分片数是否超限 | 索引分片过多、节点少 | 阻止继续堆积,避免热点 |
ConcurrentRebalanceAllocationDecider | 当前并发再平衡分片数是否超限 | 集群正在大量搬迁 | 延迟新再平衡,保护恢复带宽 |
FilterAllocationDecider | 节点属性是否匹配index.routing.allocation.* | 机房、机架、冷热分层 | 节点被排除,分片去其他节点 |
看起来这些规则很零散,但它们的共同作用是:把“物理上能放”与“业务上该放”分开。物理上能放,包括磁盘、JVM、并发;业务上该放,包括机架感知、冷热分层、租户隔离。
这里最容易误解的是:decider 不是“一次性全部跑完”。在分片进入INIT、RELOCATING、UNASSIGNED等不同状态时,decider 链的输入和结果都会变化。例如节点刚加入时,磁盘水位线判断会把它当作“空闲节点”放行;但当它开始接收分片后,磁盘使用率上升,后续分片就会被重新过滤。
3. 一次分片分配的完整过程
现在让一次“新索引创建”完整走一遍。假设你创建了一个 3 主 1 副本的索引,集群有 3 个数据节点。
第一步,主节点收到create index请求,生成新的索引元数据,并把所有主分片放入UNASSIGNED。
第二步,AllocationService扫描路由表,发现 3 个主分片都未分配,于是对每个分片调用 decider 链。对于主分片,decider 会检查:目标节点是否已有同一分片的副本?磁盘是否超过水位线?节点是否被过滤规则排除?
第三步,能去的节点形成候选列表。BalancedShardsAllocator会计算每个候选节点的权重。权重不是简单的“分片数最少”,它还会考虑索引级均衡、节点容量、分片大小等因素。最终每个主分片选择一个节点。
第四步,主节点更新集群状态,分片进入INITIALIZING,目标节点开始从主分片或快照恢复数据。副本分片不会和主分片同节点,分配到其他节点后进入INITIALIZING,等待主分片恢复完成后复制。
这个过程可以用下面的时序图表示:
客户端 主节点 数据节点 A 数据节点 B | | | | | create index | | | |------------->| | | | | 生成 UNASSIGNED | | | | 调用 decider 链 | | | | 计算节点权重 | | | | 选择 A 作为主分片 | | | |-------------------->| | | | | 分片 INIT | | | 选择 B 作为副本 | | | |------------------------------------->| | | | | 分片 INIT | | 等待主分片恢复完成 | | | | | 恢复完成 | | |<--------------------| | | | 副本开始复制 | | | |------------------------------------->| | | | | 副本恢复完成 |<-------------| | | | 索引可用 | | |这个顺序说明了:主分片先恢复,副本分片再跟进。如果主分片恢复慢,副本会一直等待,集群健康度会先显示 yellow,直到副本完成。
4. 分片再平衡:不是“越多越好”
再平衡是分配的一种,但目标不同。分配解决“分片有没有地方去”,再平衡解决“分片分布是否足够均匀”。Elasticsearch 默认会在节点之间移动分片,让每个节点的分片数尽量接近,但再平衡受两个约束:
第一,不能破坏主副本分离。副本必须和主分片在不同节点,否则再平衡会把副本搬到主分片所在节点,触发 decider 的NO。
第二,不能压垮恢复带宽。再平衡会搬运实际数据,如果同时搬太多分片,网络、磁盘、CPU 都会被占满,影响正常搜索和写入。因此cluster.routing.allocation.cluster_concurrent_rebalance默认值是 2,表示整个集群同时最多搬 2 个分片。
这里有一个常见的扩容误区:节点加进来后,不一定立刻发生再平衡。因为再平衡的触发条件包括分片分布差异超过阈值、节点被加入分配过滤、索引级别设置了index.routing.allocation.total_shards_per_node等。如果新节点一开始没有满足这些条件,它只会“站着看”,不会自动接盘。
你可以用下面命令观察再平衡情况:
GET _cat/shards?v&h=index,shard,prirep,state,docs,store,node GET _cat/recovery?v&active_only=true GET _cluster/settings?include_defaults=true&flat_settings=true|grep-E"rebalance|concurrent"第一条看分片分布,第二条看正在恢复的分片,第三条看再平衡相关配置。如果你发现RELOCATING长期不结束,先看_cat/recovery里是否有分片卡在TRANSLOG或FINALIZE阶段,再检查磁盘水位线和网络带宽。
5. 集群扩容为什么会“抖”
扩容抖动不是单一原因,通常是下面四个因素叠加:
第一,并发恢复不足。默认cluster.routing.allocation.node_concurrent_recoveries是 2,意味着每个节点同时最多恢复 2 个分片。新节点加入后,如果它同时被分配大量分片,恢复会排队,集群看起来“没反应”。
第二,副本分片先搬,主分片后搬。再平衡优先搬运副本,因为副本数据可以从主分片复制,风险低。但如果副本很多,搬迁会占用大量网络和磁盘。
第三,磁盘水位线阻断。当节点磁盘使用率超过cluster.routing.allocation.disk.watermark.low(默认 85%),新分片不会分配到该节点;超过high(默认 90%),Elasticsearch 会尝试把分片移走;超过flood(默认 95%),索引变成只读,写入直接失败。
第四,分片恢复和写入竞争。恢复分片时,节点既要读远端数据、写本地磁盘,又要处理正常写入。如果恢复并发太高,写入延迟会上升;如果太低,扩容后均衡太慢。
下面这张表对比了扩容时三种典型现象:
| 现象 | 可能原因 | 观察命令 | 处理方向 |
|---|---|---|---|
| 新节点分片数为 0 | 再平衡阈值未触发、磁盘水位高、过滤规则排除 | _cat/allocation?v、_cluster/allocation/explain | 检查水位线与过滤规则,必要时手动调整 |
| 分片一直 RELOCATING | 恢复带宽不足、目标节点磁盘慢、并发恢复数低 | _cat/recovery?v&active_only=true | 提高并发恢复数,但不要超过磁盘承受能力 |
| 集群健康 yellow 转 red | 主分片恢复失败、磁盘 flood 只读 | _cluster/health、_cat/shards | 先解除磁盘只读,再排查主分片 |
6. shard 恢复:先小后大,先主后副
分片恢复不是简单复制文件。Elasticsearch 的恢复过程大致是:
- 目标节点向主分片所在节点发起恢复请求。
- 源节点创建快照,把分段文件传输到目标节点。
- 目标节点接收文件、校验、写入本地磁盘。
- 目标节点重放 translog,补齐恢复期间的新写入。
- 分片进入
STARTED,开始对外服务。
这个过程决定了两个重要事实:恢复时间与分片大小正相关,但与恢复期间写入量也正相关。一个 50GB 的分片,如果恢复期间还有持续写入,translog 重放会拖长恢复时间。
你可以用_cat/recovery看到每个阶段的耗时:
GET _cat/recovery?v&active_only=true&h=index,shard,time,stage,source_node,target_node,files_percent,translog_ops_percent字段stage常见值有INIT、INDEX、TRANSLOG、FINALIZE、DONE。如果卡在TRANSLOG,说明文件已传完,但在重放写入;如果卡在FINALIZE,通常是目标节点在做最终校验和状态切换。
这里容易改错的地方是:不要为了加快恢复而无限提高并发。恢复并发受节点磁盘 IO、网络带宽、JVM 堆影响。把node_concurrent_recoveries从 2 提到 8,可能导致正常查询超时,因为恢复线程和搜索线程抢 CPU 和 page cache。
7. 磁盘水位线:从根上决定能不能写
磁盘水位线是分配和写入的共同约束。它有三个级别:
| 水位线 | 默认值 | 作用 | 触发后的行为 |
|---|---|---|---|
| low | 85% | 不再向该节点分配新分片 | 新分片去其他节点,可能不均衡 |
| high | 90% | 尝试把分片移走 | 触发再平衡搬离,搬不动则一直尝试 |
| flood | 95% | 索引变只读 | 写入失败,需要清理磁盘或调高水位线 |
水位线判断的是“磁盘使用率”,不是“剩余空间”。在数据盘大小不同的集群里,这会导致小盘节点先触发 high,大盘节点还有空间却接不到分片。因此生产上建议让数据节点磁盘规格一致,或者按磁盘大小设置不同的水位线。
下面的命令可以查看各节点磁盘使用率和分片分布:
GET _cat/allocation?v&h=node,shards,disk.used_percent,disk.avail,disk.total GET _cluster/settings?include_defaults=true&flat_settings=true|grep-E"disk.watermark"PUT _cluster/settings{"persistent":{"cluster.routing.allocation.disk.watermark.low":"85%","cluster.routing.allocation.disk.watermark.high":"90%","cluster.routing.allocation.disk.watermark.flood_stage":"95%"}}注意:调高水位线只是争取时间,不是解决方案。真正要做的是清理旧索引、扩容磁盘、或者把冷数据迁移到对象存储。
8. 用 allocation explain 定位卡点
当分片迟迟不分配时,Elasticsearch 提供了一个非常直接的排查工具:_cluster/allocation/explain。它会告诉你哪个 decider 返回了NO,以及为什么。
假设你有一个未分配的分片,可以执行:
GET _cluster/allocation/explain{"index":"orders-2024.06","shard":0,"primary":false}返回结果中会包含can_allocate、allocate_explanation、node_allocation_decisions。如果can_allocate是no,往下看deciders数组,找到decision为NO的项。常见输出包括:
disk threshold:磁盘超过 high 水位线。same shard:目标节点已有同一分片。filter:节点属性不匹配index.routing.allocation.include/exclude。concurrent rebalance:并发再平衡分片数已满。
这个工具的价值在于:它把“为什么不分配”从猜测变成证据。很多扩容抖动问题,最后都能在 explain 输出里找到一条明确的 decider 拒绝记录。
9. 完整示例一:用 Java 创建索引并观察分配
下面这个最小 Java 示例使用 Elasticsearch Java API Client,创建一个 3 主 1 副本的索引,并打印分片分布。它的目标是让你把“索引创建”和“分片分配”连起来。
前置环境:JDK 17、Maven、Elasticsearch 8.x 本地单节点或三节点集群。
<dependency><groupId>co.elastic.clients</groupId><artifactId>elasticsearch-java</artifactId><version>8.13.0</version></dependency><dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId><version>2.17.0</version></dependency>importco.elastic.clients.elasticsearch.ElasticsearchClient;importco.elastic.clients.elasticsearch.cluster.HealthResponse;importco.elastic.clients.elasticsearch.indices.CreateIndexResponse;importco.elastic.clients.json.jackson.JacksonJsonpMapper;importco.elastic.clients.transport.rest_client.RestClientTransport;importorg.apache.http.HttpHost;importorg.elasticsearch.client.RestClient;publicclassCreateIndexAndCheckAllocation{publicstaticvoidmain(String[]args)throwsException{RestClientrestClient=RestClient.builder(newHttpHost("localhost",9200,"http")).build();RestClientTransporttransport=newRestClientTransport(restClient,newJacksonJsonpMapper());ElasticsearchClientclient=newElasticsearchClient(transport);try{CreateIndexResponseresponse=client.indices().create(c->c.index("orders-2024.06").settings(s->s.numberOfShards("3").numberOfReplicas("1")).mappings(m->m.properties("orderId",p->p.keyword(k->k)).properties("amount",p->p.double_(d->d))));System.out.println("索引创建结果: "+response.acknowledged());HealthResponsehealth=client.cluster().health(h->h.index("orders-2024.06").waitForStatus(co.elastic.clients.elasticsearch._types.HealthStatus.Yellow));System.out.println("集群健康: "+health.status());System.out.println("活动分片: "+health.activeShards());System.out.println("未分配分片: "+health.unassignedShards());}catch(Exceptione){System.err.println("创建索引失败: "+e.getMessage());}finally{transport.close();restClient.close();}}}关键步骤是:先建立客户端连接,再创建索引并设置分片和副本,最后等待集群健康到 yellow 并打印分片统计。预期输出会显示acknowledged=true,健康状态为yellow,活动分片数为 3 或 6,未分配分片数取决于集群节点数。
容易改错的地方:如果本地只有一个节点,副本无法分配,waitForStatus(Yellow)可能超时;如果集群是 green,waitForStatus会直接返回。这个例子适合验证“主分片先分配、副本再分配”的顺序。
10. 完整示例二:用 Spring Boot 触发再平衡并记录状态
这个示例更贴近企业实践:Spring Boot 应用启动后,检查集群是否需要再平衡,并打印每个节点的分片数。它的目标是让你看到“应用侧如何观察扩容抖动”。
前置环境:Spring Boot 3.x、Elasticsearch Java API Client 8.x、一个至少两节点的集群。
importco.elastic.clients.elasticsearch.ElasticsearchClient;importco.elastic.clients.elasticsearch.cat.NodesResponse;importco.elastic.clients.elasticsearch.cat.ShardsResponse;importorg.springframework.boot.CommandLineRunner;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;importorg.springframework.context.annotation.Bean;@SpringBootApplicationpublicclassEsAllocationProbeApplication{publicstaticvoidmain(String[]args){SpringApplication.run(EsAllocationProbeApplication.class,args);}@BeanCommandLineRunnerprobe(ElasticsearchClientclient){returnargs->{try{NodesResponsenodes=client.cat().nodes(n->n.h("name","node.role","disk.used_percent","heap.percent"));System.out.println("节点状态:\n"+nodes.valueBody());ShardsResponseshards=client.cat().shards(s->s.h("index","shard","prirep","state","node").v(true));System.out.println("分片分布:\n"+shards.valueBody());}catch(Exceptione){System.err.println("探针执行失败: "+e.getMessage());}};}}关键步骤是:用cat().nodes()查看节点磁盘和堆使用率,用cat().shards()查看分片状态。预期输出中,如果新节点磁盘使用率低,但分片数为 0,说明再平衡还没有触发,或者被过滤规则排除。
容易改错的地方:cat().shards()的v(true)是显示表头,不是“verbose”的意思;h()里的字段名必须和 Elasticsearch 返回字段一致,否则会得到空列。这个示例适合放在运维探针或健康检查接口里,定时记录分片分布,帮助判断扩容后是否均衡。
11. 完整示例三:生产排障案例——磁盘水位线导致写入失败
这个案例不写完整 Java 类,而是给出一个可复现的排障流程。输入是“某个索引写入返回 403 或 cluster_block_exception”,步骤和结果如下。
第一步,查看集群健康说明:
GET _cluster/health?level=indices如果返回里出现index.blocks.read_only_allow_delete,说明磁盘 flood 水位线已触发。
第二步,确认磁盘使用率:
GET _cat/allocation?v&h=node,shards,disk.used_percent,disk.avail,disk.total找到disk.used_percent超过 95% 的节点。
第三步,临时解除只读:
PUT _all/_settings{"index.blocks.read_only_allow_delete":null}第四步,清理旧索引或扩容磁盘,然后把水位线调回安全值。如果你只是临时调高水位线,要记录变更并在清理后恢复。
预期结果是写入恢复,但根因仍在磁盘。这个案例的边界是:解除只读不会自动清理磁盘,如果磁盘仍然超过 flood 水位线,下一次写入还会被阻断。
12. 设计取舍:均衡、恢复速度与写入延迟
分片分配与再平衡的取舍,本质上是三个目标的平衡:
| 目标 | 倾向配置 | 代价 |
|---|---|---|
| 均衡优先 | 提高并发再平衡、降低水位线 | 恢复占用网络和磁盘,写入延迟上升 |
| 恢复速度优先 | 提高node_concurrent_recoveries | 搜索和写入可能抢不到 CPU |
| 写入稳定优先 | 降低并发恢复、保守水位线 | 扩容后均衡慢,热点可能持续 |
没有一组参数适合所有集群。判断依据是:你的集群是读多写少还是写多读少?扩容时能否接受短时延迟上升?数据节点磁盘是否一致?把这些条件写清楚,再决定参数。
一个实用原则是:先保证不触发 flood 水位线,再谈均衡。因为 flood 会让写入直接失败,而均衡只是“不够快”。在磁盘紧张的集群里,盲目提高再平衡并发,只会让磁盘更快被写满。
13. 常见误区
第一个误区:以为“加节点就会自动均衡”。实际上,再平衡受 decider 过滤和并发阈值约束,新节点可能因为过滤规则、水位线、分片数限制而接不到分片。
第二个误区:把cluster_concurrent_rebalance和node_concurrent_recoveries混为一谈。前者控制整个集群同时搬多少分片,后者控制单个节点同时恢复多少分片。两者影响的范围不同。
第三个误区:认为副本分片越多越安全。副本越多,写入要同步的节点越多,恢复时搬运的数据也越多。分片和副本数量要结合数据量、节点数和写入吞吐来定。
第四个误区:磁盘水位线只看剩余空间。Elasticsearch 默认按使用率百分比判断,不是按绝对值。小磁盘节点更容易触发 high 和 flood。
14. 生产实践建议
第一,保持数据节点磁盘规格一致。如果必须混用,按最小磁盘设置统一水位线,或者用index.routing.allocation把大索引绑定到大磁盘节点。
第二,扩容前先确认_cat/allocation里没有节点接近 high 水位线。否则新节点加入后,老节点仍在触发搬离,会和新节点接收分片叠加,造成更大抖动。
第三,给恢复留出带宽。在业务低峰期执行扩容,或者临时降低搜索负载。恢复和搜索共享 page cache,磁盘随机读会互相影响。
第四,用_cluster/allocation/explain做第一诊断工具。不要一上来就重启节点,重启会触发更多恢复,可能让问题更糟。
第五,监控分片分布和恢复队列。至少记录每个节点的分片数、磁盘使用率、_cat/recovery中的活动恢复数。
15. 排障清单
当你遇到扩容抖动或分片不均衡时,按下面顺序检查:
GET _cluster/health?level=indices:是否有 red 或 yellow,是否有只读块。GET _cat/allocation?v:各节点分片数、磁盘使用率是否异常。GET _cat/shards?v&h=index,shard,prirep,state,node:是否有长期RELOCATING或UNASSIGNED。GET _cluster/allocation/explain:具体是哪个 decider 拒绝分配。GET _cluster/settings?include_defaults=true&flat_settings=true:再平衡、恢复、水位线配置是否被改过。GET _cat/recovery?v&active_only=true:恢复卡在哪个阶段。- 检查节点日志中是否有
disk watermark、allocation decider、failed to relocate关键字。
这张清单的顺序是“先看结果,再看原因,最后看配置和日志”。不要跳过第 4 步,它通常能直接给出答案。
16. 面试与复盘问题
- allocation decider 的返回值
NO、THROTTLE、YES分别意味着什么? - 为什么新节点加入后,分片不会立刻平均迁移?
cluster_concurrent_rebalance和node_concurrent_recoveries的区别是什么?- 磁盘水位线的 low、high、flood 三个阶段分别会触发什么行为?
- 一个分片长期处于
RELOCATING,你会按什么顺序排查? - 主分片恢复和副本分片恢复的先后关系是什么?
- 如何用
_cluster/allocation/explain判断是磁盘问题还是过滤规则问题? - 扩容时如何避免写入延迟飙升?
这些问题可以用于团队复盘,也可以用于面试中考察候选人对 Elasticsearch 集群行为的理解。
17. 总结
把整篇文章收回到一张框架图:
触发事件 | v AllocationService 收集待分配分片 | v AllocationDecider 链过滤节点 | 路由 / 磁盘水位 / 并发 / 过滤规则 v ShardsAllocator 权重排序 | v ClusterState 更新,分片进入 INIT / RELOCATING | v 节点执行 shard 恢复:先主后副,先小后大 | v 再平衡持续调整,直到分布接近均衡你需要记住三件事:第一,分片分配是“过滤 + 排序”,不是简单的轮询;第二,扩容抖动通常来自并发恢复、磁盘水位线和过滤规则,而不是节点数量本身;第三,_cluster/allocation/explain是定位分配问题的第一入口。
当你能把“谁在什么条件下做了什么、结果怎样”说清楚,Elasticsearch 的分片分配与再平衡就不再是黑盒,而是一套可以观测、可以调参、可以复盘的工程机制。
18. 参考资料
- Elasticsearch 官方文档:Cluster-level shard allocation and routing settings
- Elasticsearch 官方文档:Shard allocation filtering
- Elasticsearch 官方文档:Disk-based shard allocation
- Elasticsearch 官方文档:Cluster allocation explain API
- Elasticsearch 官方文档:Index recovery
- Elasticsearch 官方文档:CAT shards API、CAT allocation API、CAT recovery API
- Elasticsearch 官方文档:Java API Client
- 《Elasticsearch: The Definitive Guide》,Clinton Gormley、Zachary Tong 著