news 2026/11/5 6:02:52

分布式搜索扩容后QPS不涨:分片、段合并与查询链路排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
分布式搜索扩容后QPS不涨:分片、段合并与查询链路排查

集群从三个节点扩到六个,机器加了一倍,QPS 却没跟着涨,p99 反而在凌晨两点抖到两秒多——这是我在一个日志检索场景里真实遇到过的情况。当时第一反应是"扩容没生效",查完_cat/nodes才发现节点都在,副本也分下去了,问题根本不在扩容本身,而在于分片数、段合并和查询链路这几个藏在背后的东西,平时不显眼,一上量就全冒出来了。

分布式搜索这条线,把单机跑起来、把集群拉成三节点,只能算热身。真正拉开差距的是接下来这层:一条文档到底落到哪个分片、写进去为什么一秒后才搜得到、from + size翻到第 500 页为什么突然变慢、后台那个叫 merge 的任务凭什么能把磁盘 I/O 吃满。这些机制串起来,才叫"分布式搜索"。这篇适合已经能跑通集群、但一遇到延迟抖动和写入拒绝就抓瞎的同学,也适合正在做容量规划、要给集群定分片数的人。下面的内容我尽量按"先讲清为什么,再给能抄的配置"这个顺序走,中间夹一些踩过的坑。

1. 一个文档到底进哪个分片:路由规则决定了分片数不能乱改

很多人对分片的理解停留在"数据切开放到不同机器上",这没错,但漏掉了最关键的一点:切分的依据是路由值,而路由算法直接锁死了主分片数量。这一层想不明白,后面扩容、建索引、reindex 全是被动应付。

1.1 哈希取模这一步,为什么让主分片数变成"一次性决策"

写入请求到了协调节点之后,第一件事不是找机器,而是算一个值:

shard_num = hash(_routing) % number_of_primary_shards

_routing默认就是文档的_id。也就是说,用户传进来的 ID 经过哈希,对主分片数取余,得到的结果就是这条文档该去哪个分片。整个过程不查任何元数据表,纯计算,所以路由是 O(1) 的,这也是 ES 能在毫秒级定位数据的基础。

代价是什么?主分片数一旦定下来,改不了。假设原来 6 个主分片,某条文档 ID 哈希后对 6 取余是 4,落在 P4。你现在想把主分片改成 8,同一个 ID 对 8 取余可能变成 1,落在 P1——但那条数据还躺在 P4 里。系统不会自动搬家,它只会找不到。这不是 bug,是设计上的必然取舍:用"分片数不可变"换"路由零查表"。

所以有一条铁律需要刻在脑子里:主分片数在索引创建那一刻就要定好,后期只能靠 reindex 或 split 重建。副本数随便改,主分片数不行。我在早期项目里吃过这个亏,当时想着"先建 5 个分片,不够再加",结果数据涨到 800GB,5 个分片每个 160GB,单个分片太大导致恢复慢、merge 压力集中,最后花了整整一个周末做 reindex 加别名切换。

补充一点:自定义_routing是允许的,比如按用户 ID 路由,让同一个用户的数据落到同一分片,查询时能直接命中单分片,省掉广播。但这么做的副作用是数据倾斜——大客户的数据全堆在一个分片上,热点问题比性能收益更明显。除非业务上确实需要"按租户隔离查询",否则别轻易自定义。

1.2 分片数怎么算:从单分片容量和堆内存两头倒推

定分片数这件事,网上的建议五花八门,有说 1GB 一个的,有说 50GB 一个的。这些数字不是拍脑袋来的,背后有两个硬约束。

第一个约束是单分片数据量。分片是 ES 调度的最小单位,一个分片只能待在一个节点上。分片越大,恢复、迁移、merge 的时间越长。行业里比较通行的经验区间是10GB 到 50GB,超过 50GB 之后,单分片恢复耗时会明显拉长,节点故障后的数据补齐窗口变得不可控。低于 10GB 则分片数量过多,每个分片都有独立的 Lucene 索引、独立的段文件、独立的元数据开销,协调节点在查询时要等所有分片返回,分片太多反而拖慢。

第二个约束是堆内存。每个分片都要在堆里维护一份段级别的元信息,经验值大约是每分片 1MB 到 2MB 的常驻开销,段数量多的时候更高。一台 64GB 内存的机器,堆一般给到 31GB(超过 31GB 会失去指针压缩,反而更浪费),能撑住的活跃分片数量大概在 600 到 1000 之间。这里说的是"活跃分片",包括主分片和副本。

我一般的算法是这样走一遍:

步骤计算方式举例
1. 估算数据总量日均增量 × 保留天数 × 副本数50GB/天 × 30天 × 2 = 3TB
2. 单分片目标容量取 30GB 左右(保守)30GB
3. 主分片数3TB ÷ 2(副本) ÷ 30GB ≈ 50取 48
4. 对齐节点数主分片数取节点数的整数倍6 节点 × 8 = 48
5. 校验堆占用48 × 2(含副本)× 1.5MB ≈ 144MB可接受

第 4 步容易被忽略,但很重要:主分片数最好能被数据节点数整除。否则每个节点分到的分片数量不均,导致某几台机器磁盘和 CPU utilisation 明显偏高,扩容时也容易出现"加了机器但分片搬不过去"的尴尬。48 个主分片放在 6 个节点上,正好每个节点 8 个主分片;换成 50 个,就有节点要扛 9 个,长期看是隐患。

还有一个容易踩的点:分片数是给"未来"定的,不是给"现在"定的。刚上线数据量 50GB,建了 48 个主分片,每个分片才 1GB,看着挺浪费——但只要你能确认三个月后数据量会到 1TB 以上,这个数量就是对的。反过来,如果这是个长年不涨的小索引,3 到 5 个分片就够了,别照搬大索引的配置。

1.3 分片定错了怎么救:reindex + 别名切换的完整流程

发现分片数不合理,能走的路径只有两条:reindex 重建或者split / shrink。前者通用,后者有前提条件(split 要求索引是只读的并且源主分片数能被目标整除)。

我推荐的做法是从一开始就用别名,让上层应用只认别名,不认物理索引。这样重建时切换是无感的:

# 第一步:创建新索引,分片数按新的规划来 PUT /orders_v2 { "settings": { "number_of_shards": 24, "number_of_replicas": 1, "refresh_interval": "5s" }, "aliases": { "orders_read": {} } } # 第二步:后台跑 reindex,注意限速,别把线上写入挤死 POST /_reindex?wait_for_completion=false { "source": { "index": "orders_v1", "size": 2000 }, "dest": { "index": "orders_v2" }, "conflicts": "proceed" }

wait_for_completion=false会返回一个 task id,用GET _tasks/<task_id>轮询进度。这一步很关键,大索引 reindex 动辄几小时,同步等待会超时。size参数控制每批拉的文档数,默认是 1000,对于宽表(字段多、单文档大)的场景要往小调,否则容易触发批量请求体过大的限制。

conflicts: proceed也要谨慎用——它会让版本冲突的文档被跳过而不是中断整个任务,适合"数据允许最终一致"的场景。如果是账务类数据,建议先用conflicts: abort跑一遍,确认没有冲突再决定。

数据同步完之后,切换别名是一步原子操作:

POST /_aliases { "actions": [ { "remove": { "index": "orders_v1", "alias": "orders_write" } }, { "add": { "index": "orders_v2", "alias": "orders_write" } }, { "add": { "index": "orders_v2", "alias": "orders_read" } }, { "remove": { "index": "orders_v1", "alias": "orders_read" } } ] }

把读写别名分开是有讲究的。写别名只指向新索引,读别名在同一批次里同时包含新旧两个索引一段时间,等确认数据完整了再摘掉旧的。这样即使 reindex 期间有少量数据没同步过来,读请求也能兜住。我一般会保留旧索引至少一周再删,尤其是那种"数据回补脚本"跑得不及时的团队。

提示:reindex 期间源索引的写入不会被自动捕获。要么停写,要么在 reindex 结束时补一次增量同步(按时间字段过滤),这一步千万别漏。

2. 写入链路上的三跳转发:为什么文档写完要等一秒才搜得到

写入这块,最常见的困惑就一句话:"我明明写成功了,为什么立刻查查不到?"这个问题背后是 refresh、translog、flush 三个机制在分工,搞清楚它们各自管什么,很多"数据丢失"的误判就不会再发生。

2.1 协调节点、主分片、副本:一次写入到底转了几手

一条写入请求进来,先落到协调节点(任何一个节点都能当协调节点)。协调节点算完路由,知道目标主分片在哪个节点上,把请求转发过去。这一步是网络跳转,第一跳。

主分片节点收到请求后,做三件事:写内存缓冲区、写 translog、然后把请求并行转发给所有副本分片。注意是并行,不是串行——这是 ES 写入吞吐能做到不错水平的原因之一。副本数量增加会放大网络和磁盘压力,但不会线性拖慢主分片的响应,因为主分片只等"最快的那批"或者"全部"返回,取决于一致性配置。

副本全部写入成功后,主分片向协调节点返回成功,协调节点再把结果返回给客户端。整个链路是三次网络往返:客户端 → 协调节点 → 主分片 → 副本 → 主分片 → 协调节点 → 客户端。所以在大集群里,协调节点的选择是有讲究的,让客户端轮询所有节点,而不是全打到一个节点上,避免单点成为转发瓶颈。

这里有个容易忽略的细节:协调节点本身也参与分片分配,它可能既是协调者又是主分片的宿主。如果一台机器同时承担大量协调转发和分片读写,CPU 会被两头挤。规模大了之后,一般会把协调角色独立出来,配几台低配高网络的机器专门做转发,数据节点只管存储和计算。

可以用_nodes/stats观察每个节点的转发压力:

GET _nodes/stats/indices,transport?filter_path=nodes.*.indices.indexing,nodes.*.transport

看indexing.index_total和transport.rx_count,如果某台机器的这两个值明显高于其他节点,说明流量分发不均,需要检查客户端侧的节点列表配置。

2.2 refresh 与 translog 的分工:近实时的边界在哪

写入成功后文档在内存缓冲区里,还没有进入可搜索的段文件。这时候你去查,查不到——这就是"近实时"的含义,不是实时。

refresh干的事,是把内存缓冲区的文档生成一个新的 segment,写进文件系统缓存(page cache)。注意是文件系统缓存,不是磁盘。这一步之后文档就能被搜到了。默认refresh_interval是 1 秒,所以最坏情况下你写入后要等 1 秒才能搜到。

调这个参数的收益和代价都很直接。把refresh_interval从 1s 调到 30s,写入吞吐能提升好几倍,因为每次 refresh 都要创建新 segment,segment 多了之后 merge 压力陡增。批量导入场景下,我一般直接把 refresh 关掉:

PUT /logs_bulk { "settings": { "refresh_interval": "-1", "number_of_replicas": 0 } }

导入完成后再改回来,同时把副本数加上去:

PUT /logs_bulk/_settings { "refresh_interval": "30s", "number_of_replicas": 1 }

导入期间副本设 0 是个常用技巧——写入不需要等副本确认,吞吐翻倍,等数据灌完了再补副本,代价是一次全量分片复制,但比全程带着副本写要快得多。

那 translog 是干嘛的?它是预写日志,每次写操作在返回客户端之前都要写进 translog。因为 refresh 之后的 segment 还在文件系统缓存里,机器断电就没了,translog 是唯一的持久化保障。默认配置下index.translog.durability是request,意思是每个写入请求都fsync一次 translog 才返回成功。

改成async能把写入性能再提一截,但代价是index.translog.sync_interval(默认 5 秒)内的数据在断电时会丢。这个取舍要看业务:日志类数据丢几秒无所谓,订单类数据绝对不行。我见过为了压测指标好看把 durability 改成 async,上线后忘了改回来,结果一次机房断电丢了半分钟订单——这种坑不值得踩。

flush则是更重的操作,它把内存里的 segment 强制fsync落盘,然后清空 translog。flush 的触发条件有三个:translog 大小超过阈值(默认 512MB)、距离上次 flush 时间超过 30 分钟、显式调用。日常不用管它,但如果你看到磁盘 I/O 周期性飙高,可以看看是不是 flush 太频繁,适当调大index.translog.flush_threshold_size。

2.3 wait_for_active_shards 与写入拒绝:那些"写不进去"的真实原因

写入被拒绝,最常见的报错是两类:es_rejected_execution_exception和unavailable_shards_exception。前者是线程池满了,后者是分片不可用,处理思路完全不同。

线程池满的判断方法:

GET _cat/thread_pool/write,search?v&h=node_name,name,active,queue,rejected

queue列持续接近上限(write 默认 10000,search 默认 1000),rejected持续增长,就是过载了。write 线程池的线程数默认等于 CPU 核数,队列 10000,这个配置在写入突增时表现为"延迟上升但不断流",一旦队列也满了就开始拒绝。

要不要调大队列?我的经验是不要轻易调。队列本质上是个缓冲区,队列越长,请求在内存里排队的时间越久,最终要么超时要么 OOM,掩盖了真实的容量不足。正确的做法是先看是不是 refresh 太频繁、是不是 merge 抢了太多资源、是不是副本数设得过高,把根因找出来。队列调大只是把爆炸推迟几分钟。

wait_for_active_shards这个参数值得单独说。默认值是 1,意思是只要主分片活着就返回成功,副本还没同步完也照常返回。可以设成all或者具体数字,比如 2 表示"至少 1 个主分片加 1 个副本确认"。设成all的代价是写入变慢,而且任何一个副本出问题都会导致写入失败——生产环境我基本不这么配,除非是极高一致性要求的场景。

真正需要关注的是分片不可用的情况。_cluster/allocation/explain这个接口能告诉你某个分片为什么没分配:

GET _cluster/allocation/explain { "index": "orders_v1", "shard": 3, "primary": false }

返回里会有allocate_explanation和node_allocation_decisions,常见的拒因包括磁盘水位超限(low disk watermark [85%] exceeded)、节点属性不匹配、分片数超过限制。磁盘水位这个特别常见——单节点磁盘用到 85% 之后,ES 就不再往这台机器分配新分片了,很多人扩容后看到黄色状态,查半天才发现是磁盘快满了。

3. 查询为什么跑两趟:query then fetch 的代价与深分页陷阱

查询这块的性能问题,九成以上都跟"分片数 × 单页大小"这个乘积有关。理解了 query then fetch 的两个阶段,你就能自己算出为什么翻到第 100 页会突然变慢。

3.1 协调节点的归并成本:from + size 是怎么被放大的

一次搜索请求打到协调节点,它要做的是:

第一阶段(query phase):把请求广播给索引的所有分片(主分片或副本之一,轮询选)。每个分片在本地执行查询、打分、排序,然后只返回from + size条文档的 ID 和排序值给协调节点。

第二阶段(fetch phase):协调节点把所有分片返回的结果做全局归并,取出真正需要的size条,再按文档 ID 去对应分片拉取_source。

关键点在第一阶段的from + size。假设你有 5 个分片,要取第 100 页,每页 20 条,那么from = 1980,size = 20。每个分片都要返回2000条文档的 ID 给协调节点,5 个分片就是 10000 条。协调节点要在内存里对这 10000 条做堆排序,才能确定全局前 2000 名。

分页越深,这个数字涨得越快。翻到第 500 页(from = 9980),每个分片返回 10000 条,5 个分片就是 50000 条。协调节点要处理 5 万条记录的归并,内存和 CPU 都扛不住。这就是为什么 ES 用index.max_result_window(默认 10000)硬性限制from + size的上限。

这个限制不是随便设的,它是"保护协调节点不被打挂"的底线。有人图省事直接改成 100000,短期内不报错了,但在数据量大的索引上,一次深分页查询就能把协调节点的堆打满,进而触发 GC 停顿,影响整个集群。我见过一次事故就是这么来的:某个报表页面为了"能翻到最后一页",把窗口开到 5 万,结果每天早会时间点都会出现集群级别的延迟毛刺。

3.2 scroll、search_after 与导出场景的取舍

深分页有三条路:scroll、search_after、以及调大max_result_window(不推荐)。前两个各有明确的适用边界。

scroll的思路是"给这次查询拍个快照,然后一批一批往下拉"。它会在服务端维持一个搜索上下文,每次请求带上scroll_id,返回下一批结果。快照意味着整个遍历期间数据是固定的,中途新写入的文档不会出现。这个特性对数据导出非常合适,但对"用户翻页"就不合适了——用户看到的应该是实时数据,不是几分钟前的快照,而且 scroll 上下文会占用堆内存,忘了清理就是内存泄漏。

POST /orders/_search?scroll=2m { "size": 1000, "query": { "range": { "created_at": { "gte": "2024-01-01" } } }, "sort": [{ "created_at": "asc" }] }

scroll=2m是上下文保活时间,每次请求会刷新这个时间。用完必须显式清理:

DELETE /_search/scroll { "scroll_id": ["<上一步返回的 scroll_id>"] }

search_after是更现代的方案,思路是"用上一页最后一条记录的排序值作为游标"。它不需要服务端维持上下文,内存开销小,而且能看到实时数据。代价是不能跳页——只能一页一页往后翻,做不了"直接跳到第 800 页"。

GET /orders/_search { "size": 20, "query": { "term": { "status": "paid" } }, "sort": [ { "created_at": "desc" }, { "order_id": "asc" } ], "search_after": [1690000000000, "ORD-88213"] }

这里有个细节要注意:排序字段组合必须是全局唯一的,否则会出现重复或漏数据。像created_at这种时间戳,同一毫秒可能有多条记录,只按它排序会导致游标定位不准。解决办法是加一个唯一的 tiebreaker,比如订单 ID 或_id。用_id做 tiebreaker 性能一般,因为它没有 doc_values,每次都要从存储里读;如果业务表本身有唯一且有序的字段,优先用它。

ES 7.10 之后还有个point in time(PIT)配合search_after,能同时拿到"快照一致性"和"低内存开销",是目前做深分页遍历最推荐的组合。PIT 的用法是先开一个时间点,然后在查询里带上pit.id,用完关掉。相比 scroll,它不会因为遍历过程中段合并而导致结果不一致。

方案数据一致性能否跳页内存开销适用场景
from + size实时能随深度线性增长前 10 页的常规翻页
scroll快照不能中,需手动清理离线导出、全量遍历
search_after实时不能低用户端无限滚动
search_after + PIT快照不能低大数据量导出且要求一致

3.3 分片数对查询延迟的二次放大

除了深分页,分片数量本身也在放大查询开销。一个查询要广播到所有分片,协调节点要等最慢的那个分片返回(长尾效应),所以分片越多,撞上慢分片的概率越高。

假设单个分片的查询延迟分布是 p50 = 5ms、p99 = 40ms,5 个分片时整体延迟大概在 p99 ≈ 45ms 左右,20 个分片时就要接近 80ms 了。这不是线性关系,而是"最大值分布"的统计效应。

这也解释了为什么很多团队在扩容时发现"QPS 没涨、延迟反而升了"——加了节点,但索引没重建,分片数没变,每个分片反而要处理更多数据;或者重建了索引把分片数翻倍,查询要等的分片多了,尾部延迟被放大。

对抗这种放大,有几个实用手段。一是减少不必要的分片查询,如果查询条件里带了路由键(比如租户 ID),用_routing参数直接定位到单分片,能省掉全部广播。二是给慢查询加超时和降级,用timeout参数控制单个查询的最长等待,超时返回部分结果,避免一个慢查询拖垮整个线程池。三是缓存热点查询,request_cache对聚合类查询有效,重复的聚合请求可以直接命中缓存。

GET /orders/_search?request_cache=true { "size": 0, "timeout": "800ms", "aggs": { "by_status": { "terms": { "field": "status", "size": 10 } } } }

size: 0表示只要聚合结果不要文档,能省掉整个 fetch 阶段。这个技巧在报表类查询里非常有效,但很多人不知道加,白白让 fetch 拖慢了响应。

4. 段合并:那个不显眼却能把整台机器吃干的后台任务

写完、查完,还有一件事在后台持续发生:段合并。它不出现在任何首页监控里,但只要你的写入量上去了,就一定会在某个时刻跟它正面相遇——通常表现为"磁盘 I/O 突然打满,读写延迟一起飙升"。

4.1 段为什么不可变,删除又是怎么实现的

Lucene 的核心设计是"段不可变"。一个新 segment 一旦写到磁盘,就再也不会被修改。这个设计带来的好处是巨大的:不用加锁、可以缓存、可以并发读、崩溃恢复简单。代价是更新和删除都变成"追加 + 标记"。

删除一条文档,实际上是把它的 doc id 记在一个.del文件里,查询时跳过。更新一条文档,是先标记旧版本删除,再写入一条新的。所以频繁更新的索引,磁盘占用会膨胀——旧文档还在段里躺着,只是被标记为已删。

这部分空间什么时候回收?就是靠 merge。merge 会把若干个小段读出来,去掉被标记删除的文档,写成一个更大的新段,然后删掉旧段。所以 merge 有两个作用:降低段数量(提升查询效率,因为每个段都要单独查)和回收已删除文档占用的空间。

段数量对查询的影响很容易被低估。一次查询要在每个段上执行,段越多,跳表、评分、归并的开销都越大。ES 默认的index.merge.policy是分层策略,会尽量把段数量维持在一个合理区间。但如果你在短时间内大量写入,段数量会迅速上涨,查询延迟随之变差。

看段数量:

GET _cat/segments/orders_v1?v&h=shard,segment,size,docs.count,docs.deleted

docs.deleted这一列如果有大量非零值,说明有很多被标记但没回收的文档,可以考虑在低峰期做一次 forcemerge。

4.2 forcemerge 什么时候该用,什么时候是灾难

forcemerge的作用是强制把多个段合并成指定数量。最常见的用法是对只读索引做max_num_segments=1:

POST /orders_2024_01/_forcemerge?max_num_segments=1

但这里有个前提——索引必须是只读的。对一个还在持续写入的索引做 forcemerge,合并出来的大段很快又会被新写入的小段淹没,而 merge 本身消耗的 I/O 是实打实的。更糟的是,forcemerge 期间会产生巨大的磁盘临时占用(需要同时容纳旧段和新段),如果磁盘水位本来就在 80% 以上,很可能直接触发水位告警,分片开始往其他节点搬。

我的做法是:按时间滚动的索引,在"封存"之后做一次 forcemerge。比如按月建索引,1 月的索引在 2 月初不再写入,这时候挑个低峰期跑 forcemerge,把段数压到 1,查询性能能明显改善,磁盘空间也能回收一批(因为删掉的文档被真正清除了)。

forcemerge的 API 支持异步执行,加wait_for_completion=false,避免请求超时。同时要注意它只有单线程,多个分片的合并是串行的,所以一个大索引可能跑几个小时。用_cat/thread_pool/force_merge能看到排队情况。

还有一个更轻量的替代方案:_forcemerge只合并删除比例高的段。ES 没有直接提供这个粒度,但可以通过only_expunge_deletes=true参数实现,只回收被删除的文档,不改变段的结构。这个操作便宜得多,适合日常清理:

POST /orders_2024_01/_forcemerge?only_expunge_deletes=true

4.3 写入放大与 I/O 的实测观察

merge 的 I/O 放大倍数是可以估算的。假设每秒写入 100MB 原始数据,refresh 每秒生成 1 个段,merge 会不断把这些段合并成更大的段。一次完整的层级合并,总写入量大约是原始数据的2 到 3 倍(因为每一层都要重写一遍)。也就是说 100MB/s 的写入,会带来 200 到 300MB/s 的磁盘写压力。

这个数字如果超过磁盘的写带宽,就会表现为写入队列堆积、refresh 变慢、查询延迟上升。我遇到过一次磁盘 util 长期 95% 以上的情况,查下来是索引的refresh_interval设成了 1s、每天写入 20 亿条日志、分片数又只有 6 个——每个分片每秒都要生成新段,merge 完全跟不上写入速度。

当时的处理是三条一起上:

措施具体操作效果
调大 refresh_interval1s → 30s段生成速度降为 1/30,merge 压力骤减
增加主分片数6 → 24(重建索引)merge 压力分散到更多节点
限制 merge 线程数index.merge.scheduler.max_thread_count: 1避免 merge 抢占过多 I/O

第三条值得展开说。index.merge.scheduler.max_thread_count默认在机械盘上是 1,在 SSD 上是min(4, CPU/2)。如果你的存储是普通云盘(IOPS 有限),把它设成 1 能显著降低 merge 对读写请求的干扰,代价是 merge 完成得更慢、段数量维持得更高。这是个"用空间换稳定"的取舍,在写入压力大而查询不敏感的场景里值得。

注意:max_thread_count只能通过索引 settings 修改,且对已有索引生效需要关闭再打开(close/open),不能热更新。改之前先评估影响。

5. 集群状态与选主:绿色、黄色和红色到底在说什么

集群健康状态是运维最常看的指标,但很多人只会看颜色,不知道黄色和红色分别意味着什么、该怎么处理。这一节把三个颜色、选主机制和分片分配串起来讲。

5.1 三种颜色的准确含义,以及黄色为什么常被误判

  • 绿色:所有主分片和副本分片都已分配。
  • 黄色:所有主分片已分配,但至少有一个副本分片未分配。数据可读可写,但没有冗余,节点挂掉就可能丢数据。
  • 红色:至少有一个主分片未分配。这部分数据不可用,查询会返回部分结果或者直接报错。

黄色是最容易被忽视的状态。很多单节点集群天生就是黄色(不可能把副本分配到和主分片同一节点上),大家看习惯了就当成正常。但在多节点集群里出现黄色,必须查清楚原因,因为这意味着某份数据只有一份。

排查未分配分片的标准流程:

# 1. 看哪些分片没分配 GET _cat/shards?v&h=index,shard,prirep,state,unassigned.reason # 2. 让 ES 自己解释原因 GET _cluster/allocation/explain

常见原因和对应处理:

未分配原因典型场景处理方式
NODE_LEFT节点下线等delayed_timeout(默认 1 分钟)后自动分配
DISK_THRESHOLD_EXCEEDED磁盘水位超 85%清理数据或扩容,或临时调高水位
ALLOCATION_FAILED分配重试次数耗尽用retry_failed=true重试
NO_VALID_SHARD_COPY主分片数据丢失需要从快照恢复
INDEX_CREATED刚建索引还没分配等待或检查节点属性

NODE_LEFT对应的index.unassigned.node_left.delayed_timeout这个参数很实用。默认 1 分钟,意味着节点短暂重启时,ES 会等 1 分钟再开始搬分片。如果没有这个延迟,每次滚动重启都会触发大规模分片搬迁,等节点回来了又要搬回去,白白消耗大量 I/O。生产环境我一般调到 5 到 10 分钟。

磁盘水位也值得说清楚。ES 有三档水位:low(85%,不再分配新分片)、high(90%,开始尝试把分片迁走)、flood_stage(95%,索引被设为只读)。第三档最麻烦——索引变成read_only_allow_delete,写入全部失败,需要手动解除:

PUT /_all/_settings { "index.blocks.read_only_allow_delete": null }

这个设计初衷是防止磁盘写满导致节点崩溃,但触发时如果没人盯着,业务会直接中断。所以监控里必须给磁盘水位单独设告警,阈值要比 85% 更低,比如 75% 就报警,留出处理时间。

5.2 选主机制与最小投票数:为什么不能随便凑节点

ES 的集群有一个 master 节点,负责管理集群状态(索引元数据、分片分配决策、节点上下线)。master 不参与数据读写,但它的稳定性决定了整个集群的稳定性。

选主用的是类 Raft 的投票机制。节点启动时通过discovery.seed_hosts找到其他节点,通过cluster.initial_master_nodes完成首次引导。一旦集群成型,投票配置由集群自己维护,不再依赖配置文件。

老版本(7.0 之前)有个discovery.zen.minimum_master_nodes参数,要求手动设成(master 候选节点数 / 2) + 1,防止脑裂。7.0 之后这个参数被移除了,由系统自动维护投票配置,配置负担小了很多。但原理还是要懂:任何决策都需要超过半数的投票节点同意,所以候选节点数最好是奇数(3、5、7),偶数节点并不能提升容错能力,反而增加通信开销。

专用 master 节点的配置大概长这样:

node.roles: [ master ] discovery.seed_hosts: ["10.0.0.1:9300", "10.0.0.2:9300", "10.0.0.3:9300"] cluster.initial_master_nodes: ["master-1", "master-2", "master-3"]

三个专用 master 是性价比最高的配置:能容忍一台挂掉,选主仍然可以完成。五个能容忍两台,但大多数场景三个就够。不要让数据节点兼任 master,尤其是负载高的大集群——数据节点 GC 停顿会导致心跳超时,进而触发不必要的选主,集群状态反复震荡。

还有一个容易忽略的点:cluster.initial_master_nodes只在集群首次启动时使用。集群成型后,这个配置要删掉,否则某次全量重启时可能造成节点各自为政。这个坑我踩过,滚动重启时忘了清理配置,结果重启后有节点试图重新引导集群,虽然最终没出事,但集群状态抖了十几分钟。

5.3 扩容时的分片搬迁:限流参数比机器数量更重要

加节点之后,ES 会自动把部分分片迁移到新节点上,这个过程叫 rebalance。默认配置下,每个节点同时进行的恢复任务数是 2(cluster.routing.allocation.node_concurrent_recoveries),每节点恢复带宽限制是 40MB/s(indices.recovery.max_bytes_per_sec)。

这两个默认值在中小集群里够用,但在大集群扩容时往往会成为瓶颈。比如一次加 6 台机器,每台要接收几十个分片,按 40MB/s 的速率,一个 30GB 的分片要搬 12 分钟,串行下来要几个小时。期间集群处于"部分分片在迁移"的状态,查询可能变慢。

调大限流能加速,但要小心别把线上读写挤死:

PUT /_cluster/settings { "persistent": { "indices.recovery.max_bytes_per_sec": "200mb", "cluster.routing.allocation.node_concurrent_recoveries": 4 } }

我一般会在业务低峰期临时调高,扩容完成后调回默认值。判断标准是看_cat/recovery的进度和节点 I/O:

GET _cat/recovery?v&active_only=true&h=index,shard,source_node,target_node,bytes_percent,time

如果bytes_percent长时间不动,可能是目标节点磁盘写不过来,这时候调高并发反而更糟。扩容不是"加机器"这么简单,搬迁速率、磁盘带宽、查询影响三者要一起权衡。

6. 一次真实的扩容翻车:从现象到根因的完整排查链路

前面讲的都是机制,这一节还原一次完整的排查过程。写这部分是因为很多同学遇到问题时不知道从哪下手,缺的不是知识点,而是排查顺序。

6.1 现象:扩完节点,延迟反而涨了三倍

场景是这样:一个日志检索服务,原有 3 个数据节点,每天写入约 800GB,索引每天一个,每个索引 6 个主分片 1 个副本。随着数据量上涨,磁盘快满了,于是加了 3 个节点,变成 6 节点。

扩容操作本身很顺利,节点都起来了,_cat/nodes显示 6 个节点全部在线。但接下来的两个小时里,查询 p99 从 200ms 涨到了 700ms,写入开始出现零星拒绝。第一反应是"扩容过程中搬迁影响了性能",这确实是常见原因,但等了三个小时搬迁完成后,延迟没有回落。

这时候才开始正式排查。第一步看集群状态:

GET _cluster/health GET _cat/indices?v&h=index,health,pri,rep,docs.count,store.size

状态是绿色,没有未分配分片,说明不是分配问题。

第二步看节点负载:

GET _cat/nodes?v&h=name,heap.percent,cpu,load_1m,disk.used_percent

发现三台老节点的 heap 都到了 75% 以上,三台新节点只有 40% 左右。CPU 也是老节点明显更高。这个分布非常可疑——分片应该均匀分配才对。

第三步看分片分布:

GET _cat/shards?v&h=index,shard,prirep,node,store

问题浮出水面:每天的索引都是 6 个主分片,分到 6 个节点上,理论上应该一节点一个。但因为索引是按天创建的,创建时的节点数和分配策略决定了分片落在哪,加上部分索引只有 6 个分片而节点有 6 个,导致有些老索引的分片全落在老节点上,新节点根本没分到老索引的数据。新节点只承载了新增的索引,老索引的查询压力全部压在 3 台老机器上。

6.2 逐层排除:线程池、merge、还是热点分片

确认了分布不均,还要排除其他叠加因素,因为在真实场景里,问题很少是单一原因造成的。

查线程池:

GET _cat/thread_pool/write,search?v&h=node_name,name,active,queue,rejected

老节点的search.queue有持续堆积,rejected有小幅增长,新节点则很空闲。这印证了"查询压力集中在老节点"的判断。

查热点分片:

GET _cat/segments?v&h=index,shard,segment,size,docs.count

发现前一天的索引里有几个分片的段数量特别多(超过 50 个),其他分片只有十几个。段多的分片查询自然慢,这也是分布式查询里"长尾"的来源之一——协调节点要等最慢的分片,一个热点分片就能拖慢整个查询。

查 merge 状态:

GET _cat/thread_pool/force_merge?v

没在跑。但看节点 I/O 的时候发现,老节点在扩容期间经历了大量分片搬迁(搬出去了),搬迁完成后又在做 merge(因为搬迁过来的分片要重新整理段)。两个 I/O 密集型任务叠在一起,把老节点的磁盘带宽吃掉了。

到这里,根因链条清楚了:

  1. 分片按索引创建时分配,老索引的分片集中在老节点,新节点参与不进来。
  2. 老节点承载了大部分历史数据查询,同时还在做分片迁出。
  3. 迁出完成后,接收方节点做 merge,又反过来影响整体 I/O。
  4. 查询长尾叠加,p99 被放大三倍。

6.3 修复方案与验证:三步落地

修复不是"再调几个参数"能解决的,得从分配层面动手。

第一步:让老索引的分片真正均匀分布。用的是分片分配过滤,临时把某个老索引的分片强制迁到新节点上:

PUT /logs_2024_01/_settings { "index.routing.allocation.include._name": "new-node-1,new-node-2,new-node-3", "index.routing.allocation.total_shards_per_node": 2 }

这一步是"定向搬迁",比等自动 rebalance 快得多。搬完之后把设置清掉:

PUT /logs_2024_01/_settings { "index.routing.allocation.include._name": null, "index.routing.allocation.total_shards_per_node": null }

total_shards_per_node这个参数值得记住,它能硬性限制单个节点上某个索引的分片数,防止分片挤在一台机器上。日常建索引时就该加上,比如每个索引 6 个分片、6 个节点,设成 2 就有余量。

第二步:治理长尾分片。对已经封存的只读索引做 forcemerge,把段数压到 1,消掉段数量不均带来的长尾:

POST /logs_2024_01/_forcemerge?max_num_segments=1&wait_for_completion=false

这一批操作要串行做,一个索引一个索引来,避免同时触发大量 I/O。

第三步:调整写入侧参数,降低整体的 merge 压力。把当天的索引refresh_interval从 1s 改成 10s,写入吞吐没有明显下降(因为日志场景对实时性要求不高),但段生成速度降了一个数量级。

三步做完,观察了 24 小时,p99 回到 220ms 左右,写入拒绝归零。但这个结果不是"调参调出来的",而是先把分片分布这个结构性问题解决了,参数优化才有意义。如果顺序反过来,先调refresh_interval,可能短期指标好看一点,但老节点的压力依然存在,过几天又会复发。

后来这个集群做了两个长期改进:一是索引模板里固定加上total_shards_per_node和routing.allocation.awareness(让分片在机架级别打散),二是把索引从每天一个改成按数据量滚动(比如单个索引超过 50GB 就滚动),避免"数据量波动导致分片大小差异过大"。

我个人在实际操作中的体会是,分布式搜索的大部分性能问题,最后都能归结到"数据分布"和"资源竞争"这两件事上。参数能调,但调参数之前先看一眼_cat/shards和_cat/segments,往往比翻十页文档更管用。另外再分享一个小技巧:给所有关键查询加上"profile": true跑一次,看query和fetch两个阶段各花了多少时间,如果 fetch 占比很高,说明是取_source拖慢的,可以考虑用_source过滤只取必要字段,这一步在大宽表场景里经常能省掉一半响应时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/5 6:02:00

Outlook 登录 QQ 邮箱总失败?一文讲透授权码与 IMAP 配置

很多人第一次在 Outlook 里添加 QQ 邮箱账号时&#xff0c;都会卡在同一个地方&#xff1a;账号密码明明没错&#xff0c;网页端也能正常登录&#xff0c;但 Outlook 就是反复弹窗要求重新输入凭据&#xff0c;或者干脆报一个 0x800CCC0E 之类的连接错误。这个问题的根源不在 O…

作者头像 李华
网站建设 2026/11/5 6:01:18

文本引导图像生成:从提示词结构化到迭代收敛的完整指南

1. 先搞清楚文本引导图像生成到底在做什么很多人第一次接触这类工具时&#xff0c;脑子里想的都是“我打一句话&#xff0c;它给我画一张图”&#xff0c;这个理解不算错&#xff0c;但太粗了。真正上手之后你会发现&#xff0c;文本引导的图像生成本质上是一场你和模型之间的“…

作者头像 李华
网站建设 2026/11/5 6:00:45

请求头改写失效:搜索区域判定的多信号一致性校验与工程化替代

1. 先把现象拆开&#xff1a;一次"改请求头失效"到底发生了什么做爬虫、做搜索聚合、做浏览器扩展的同行&#xff0c;最近应该都碰到过同一类反馈&#xff1a;原本靠自定义Accept-Language、User-Agent、Referer这几项请求头就能把搜索服务的返回结果切到国际版&…

作者头像 李华
网站建设 2026/11/5 5:58:50

Docker全流程实践:开发到生产的最佳指南

1. Docker 最佳实践&#xff1a;从开发到生产的全流程指南作为一名经历过多个大型项目容器化改造的工程师&#xff0c;我深知 Docker 在实际应用中的痛点和陷阱。本文将分享我在开发、测试和生产环境中积累的实战经验&#xff0c;这些经验帮助我们的团队将部署效率提升了 300%&…

作者头像 李华
网站建设 2026/11/5 5:56:36

Milvus向量数据库与RAG系统生产级优化实战

1. RAG技术进阶&#xff1a;Milvus向量数据库集成与生产级优化实战在AI技术快速发展的今天&#xff0c;检索增强生成&#xff08;RAG&#xff09;已成为连接大模型与专业领域知识的重要桥梁。作为一名长期深耕AI应用落地的技术专家&#xff0c;我将分享如何通过集成Milvus向量数…

作者头像 李华
网站建设 2026/11/5 5:56:28

gRPC流式通信:实时数据传输的高效解决方案

1. 为什么gRPC流式输出是实时通信的终极方案在分布式系统开发中&#xff0c;实时数据传输一直是个棘手的问题。传统REST API采用请求-响应模式&#xff0c;就像打电话一样——你说一句&#xff0c;我回一句&#xff0c;这种同步阻塞的方式在需要持续数据推送的场景下显得力不从…

作者头像 李华