1. 先别急着改参数:花一周把集群的“体检报告”做出来
接手过不少 Elasticsearch 集群,我发现性能调优最大的坑不是技术方案不对,而是很多人在没有摸清现状之前就开始改配置。曾经有一次,一个业务团队说他们 8.x集群查询很慢,请求一上来 CPU 直接飙到 90%,他们怀疑是分片太多,准备把索引全重建一遍。我去看了之后发现,问题根本不在分片,而是业务方用了大量的通配符查询,加上索引 mapping 里有一个大字段开了text的fielddata,导致堆内存被打爆。如果按他们的方案去重建分片,折腾一星期大概率还是慢。
所以,做 ES 性能调优,第一步永远是“体检”,而不是“吃药”。这里的“体检”指的是用一套完整的指标采集和诊断流程,把集群的现状量化出来。ES 8.x 本身提供了很丰富的观测手段,比如_cluster/health、_cat/nodes、_nodes/stats、_cluster/stats,还有 per-index 的_stats接口。在动手改任何参数之前,我通常会用至少一天到两天的时间,把下面这几类数据全部拉一遍:
- 集群层面:分片状态、未分配分片数量、磁盘空间、主分片在节点间的分布均衡度
- 节点层面:CPU、load average、堆内存使用率、GC 频率与耗时、磁盘 IO 和网络吞吐
- 索引层面:每个索引的分片数、文档数、段数量、存储大小、查询命中量、写入速率
- 查询层面:slowlog 里的慢查询、
_search的响应分布、hot threads 里最耗 CPU 的线程栈
你会问,为什么要花这么长时间?因为性能调优最怕的就是“用单点采样代替长期观测”。某个时刻的指标只能反映瞬时状态,比如你刚好碰上 merge 高峰期或者 GC 停顿,这时候抓到的数据会严重失真。我一般会配合 Grafana 加 Prometheus 采集至少 24 小时的数据,用趋势图而不是单点值来做判断。如果你是个人开发者或者小团队,可能没有完整的监控体系,那至少要在不同时间段多调几次接口,比如业务低谷期和高峰期各抓一份,对比差异。
在 8.x 里还有一个非常实用的方式:GET /_nodes/hot_threads?interval=500ms。这个接口会返回每个节点上最“热”的线程栈,直接告诉你 CPU 到底耗在哪。是正在执行搜索?是在做 merge?还是正在写 translog?这个信息比任何云监控都直观。我见过一个团队用这个接口定位到问题,发现 CPU 全部耗在 refresh 阶段,因为他们把refresh_interval设置成了1s,而业务写入量又特别大,导致每秒都在强制生成新的段。后来把刷新间隔调整为30s并配合 bulk 写入,CPU 使用率直接从 85% 降到 35%。
体检报告出来后,你需要回答三个问题:第一,这个集群当前最稀缺的资源是什么?是 CPU、内存、磁盘 IO,还是网络带宽?第二,慢的请求主要集中在哪些索引和哪些查询模式?第三,写入和查询是否互相打架,比如写入量大导致 merge 频繁,进而挤占了查询的 CPU 资源?把这三个问题回答清楚,后面所有的调优动作才会有方向。
很多人习惯一上来就改indices.query.bool.max_clause_count、开x-pack监控、或者调 JVM 堆参数,其实这些都属于“经验主义调优”。正确的顺序永远是:先量化现状,再确定瓶颈,最后才动手改配置。每一步改动都应该有对应的指标验证,而不是改完一看“好像不那么卡了”就收工。
2. 索引与分片设计:性能的地基,90%的人一开始就错了
2.1 分片数到底怎么定?别靠“差不多”
ES 的性能上限很大程度上在索引创建那一刻就已经被决定了,因为分片数、副本数、mapping 这些定了之后,后面想改都要付出很大的迁移成本。分片数的经典问题有两个:分片太多和分片太少。
分片太多的问题很常见。默认情况下,8.x 使用数据流或者索引模板时,如果你没显式指定分片数,新建索引就是 1 主分片加 1 副本。但很多人在早期版本里习惯了“一个索引 5 个分片”,甚至按天建索引时直接套用 5 主分片 1 副本,结果每天产生几十个小分片,每个分片只有几十 MB。这样做的直接后果是:每个分片都要消耗一个线程池线程,集群要维护的 segment 数量翻好几倍,查询时需要向更多分片分发请求再汇总,协调节点的开销也会变大。时间久了你会发现,明明数据量不大,集群的 CPU 却很忙,二三十个节点都在空转。
分片太少也有问题。当单个分片的数据量超过 50GB,甚至到了一两百 GB,查询和合并的开销都会显著上升。一次查询要扫描的倒排索引文件更大,磁盘读取的数据量更多,merge 一个超大分片时的停顿也会影响写入。
分片大小的经验区间,我参考了官方和大量生产实践后基本遵循这条原则:主分片容量控制在 20GB 到 50GB 之间。你可以用“预计总数据量 / 期望单分片容量”来算出主分片数量,再乘以1 + 副本数得到总分片数。比如一个索引预计存 600GB 数据,希望单分片在 30GB 左右,那么主分片就设 20 个,如果保留 1 份副本,总分片就是 40 个。同时,还要控制每个节点上的总分片数量,尽量让单节点总分片不超过每个节点可用内存可以承载的范围。以 8GB 堆内存的节点为例,我个人会控制在 300 到 500 个分片以内,如果分片数上千,即使数据量不大,协调开销也够你喝一壶的。
另外一个容易忽略的问题是:分片数要结合业务增长节奏预留。如果业务在未来半年会翻三倍,而你现在只按当前数据量建索引,半年后又要做 shrink 或 reindex,那不如一开始就按可扩展的模板设计,配合 ILM 的 rollover 能力来动态控制。8.x 里推荐的做法是使用数据流(data stream),让索引按时间自动滚动,这样每个后端的索引都有机会控制在最佳大小范围,而不是把鸡蛋全放在一个篮子里。
2.2 mapping 设计:字段类型决定了查询性能的下限
很多性能问题不是出在查询写得烂,而是出在索引阶段就把数据类型存错了。ES 的 mapping 如同数据库的表结构,一旦不规划好,后面要么 reindex,要么只能忍受低效查询。
最典型的反例是:为了省事,所有字段都用dynamic: true让 ES 自动映射。自动映射的问题有三个:
- 字符串会被同时映射成
text和keyword,导致存储膨胀,因为一份数据要建两套索引。 - 没有被查询需求的字段白白占用了磁盘和内存,尤其大文本字段。
- 某些字段类型猜错,比如把时间戳映射成了
text,那你后面做 range 查询就要吃大亏。
我通常在每个索引模板里都会做三件事:第一,关闭dynamic或者设置为false,新字段必须显式加入 mapping;第二,所有字符串字段按需声明为keyword或带一个text子字段;第三,把不需要的字段设置为"index": false或"doc_values": false,减少索引体积和磁盘 IO。
比如一个日志索引,消息正文用text做全文检索没问题,但如果你还需要按日志级别过滤,那它应该是keyword类型,而不是一个text带keyword子字段。对每个字段类型,都要问一句:这个字段真的需要全文搜索吗?如果只是为了筛选、排序或聚合,统一用keyword就够了,搜索和聚合性能都会好很多。
还有一个细节:doc_values默认是开启的,它为聚合和排序提供列式存储。如果你有一个字段完全不参与聚合和排序,把它关掉能减少磁盘占用。反过来,如果一个字段需要参与聚合,但 mapping 被设成了text,那就麻烦了,因为text字段默认不启用fielddata,聚合时内存开销极高,甚至直接 OOM。我曾经见过有人在线上对message字段做 terms 聚合,因为这个字段是text,ES 会在内存里加载全字段的 fielddata,堆内存瞬间被吃满。最后改成了在 mapping 里加一个keyword子字段,聚合走子字段,性能立竿见影。
2.3 数据流与 ILM:把冷热分离变成标准操作
ES 8.x 里有一个经常被低估的功能:数据流(data stream)和索引生命周期管理(ILM)。很多人觉得 ILM 只是“自动删索引”用的,其实它在性能调优里的价值远超于此。
通过 ILM 的 hot-warm-cold-frozen 阶段,你可以把最近写入的数据放在高性能节点上,把几天前的数据自动迁移到容量型节点,把几十天前的数据只保留副本或进入 frozen 状态以节省资源。这套机制不只是省成本,它还直接提升查询性能——因为热数据所在的节点 CPU 和磁盘更好,查询集中在近期数据时,响应会更快;而老数据的查询频率低,即使慢一点也不会影响核心体验。
设置 ILM 时,我建议把 rollover 的条件设置为max_primary_shard_size: 40gb或max_age: 30d,取先到的条件为准。这样索引会按大小和年龄滚动,避免单个索引疯狂增长。注意,在有 ILM 的情况下,索引别手动建,而是用数据流的方式写入,ES 会自动处理别名和后端索引的生命周期。当年很多人手动维护“按天索引 + 别名”的模式,在 8.x 里完全可以用 data stream 替代,不仅代码更简洁,而且 rollover、shrink、delete 这些操作都由 ILM 托管,几乎没有维护成本。
冷热分离在架构上有一个前提:节点要按角色打标签。8.x 的 data tiers 机制已经内置了data_hot、data_warm、data_cold、data_frozen这些角色,你在elasticsearch.yml里给节点分配角色后,ILM 迁移就会自动把分片移动到对应角色的节点上。这比原来手动设置 node.attr 要标准得多。如果你们集群规模不大,也可以先只在配置里区分data_hot和data_warm,把 ILM 从 hot 阶段直接迁移到 warm 阶段,把 force merge 和 shrink 放在 warm 阶段做,效果一样很明显。
3. 查询调优:从 DSL 写法到缓存策略,逐层压榨
3.1 DSL 的常见性能杀手,看看你有没有踩
同样的数据量、同样的集群,查询写法不同,性能可以差出 10 倍。很多慢查询其实不是 ES 的问题,而是查询方式本身不符合它的设计哲学。
第一个杀手是:不分场合地用wildcard、regexp和fuzzy。Elasticsearch 的强项是倒排索引上的精确匹配和词项匹配,通配符查询会演变成对所有倒排表项的扫描,数据量大时 CPU 消耗极其夸张。尤其是*keyword*这种前后都带通配符的查询,它没法利用前缀索引,等于把整个索引的 term dictionary 扫一遍。如果业务确实需要模糊匹配,正确做法是:能用 ngram 分词就用 ngram,或者用match_phrase加prefix的组合,必要时引入专门的检索技术,比如布隆过滤器或数据库侧的 LIKE 查询。
第二个杀手是:在must里放了大量不需要参与评分的条件。ES 查询子句分must和filter,must的子句要算相关性分数,filter的子句只是过滤文档并且结果可以被缓存。所以,凡是时间范围、状态、分类、租户 ID 这种不需要影响排名的条件,一律放进filter。一个案例里,原来的查询把时间范围放在了must里,每次查询都要重新算得分,加上时间范围又是高频条件,导致 CPU 开销大增。改成filter之后,同样的请求延迟从 280ms 降到了 80ms 左右,这个优化一行 DSL 都没多写,就是把子句挪了个位置。
第三个杀手是:深分页。from + size翻页越深,性能越差,因为每个分片都要把from + size条结果全返回给协调节点,协调节点再排序、再取页。比如from=10000, size=20,每个分片实际上要取 10020 条文档,而且这个操作完全不能缓存。8.x 里如果你翻页超过 10000 条,默认会直接报错,因为index.max_result_window的默认值是 10000。这不是 ES 在限制你,而是它在保护你。
实际业务里,如果只是给用户看前几页,控制from + size在 1000 以内完全够用。如果需要真正深翻页,请老老实实用search_after加 PIT(Point In Time),后面会细说。
3.2 缓存:ES 的免费午餐,但很多人根本没吃
Elasticsearch 的缓存体系有三个层次,调优的时候很多人只知道“有缓存”三个字,但不知道什么数据会被缓存、什么场景下缓存无效。
第一个是节点查询缓存(Node Query Cache),它是每个分片级别缓存 filter 子句的结果,默认最多占堆内存的 10%(indices.queries.cache.size)。它缓存的是“哪些文档命中了某个 filter”这个位图。这个缓存对filter子句非常友好,对must子句不生效。所以前面强调把过滤条件放filter,背后还有一个原因就是吃缓存。如果你的查询条件里每个请求的时间范围都不重样,那么这个缓存就会频繁失效。
第二个是分片请求缓存(Shard Request Cache),它缓存的是整个搜索请求的聚合结果和命中总数,默认只对size=0的请求生效。很多人写聚合报表时用的是size=0,完全可以用上这个缓存。比如一个看板的聚合查询,如果基础数据一天才刷一次,那同一个请求反复查询时,缓存命中率会非常高。注意,这个缓存在分片数据变化时会自动失效,所以适合写多读少的场景。
第三个是文件系统缓存,这个大家容易低估。ES 底层依赖 Lucene,而 Lucene 的索引文件读出来之后会被操作系统缓存住。热数据如果都能被 OS page cache 覆盖,查询耗时会大幅下降。为什么很多人说给 ES 机器留一半内存做 OS cache?因为 JVM 堆只是给 ES 内部数据结构用的,真正的 Lucene 索引文件的快速读取靠的是操作系统的文件缓存。如果堆设置过大,留给 OS cache 的空间就会变小,查询可能会频繁读磁盘,这种问题用监控是看不出来的,但它就是慢。
在 8.x 里,indices.query.bool.max_clause_count默认已经是 1024,一般不需要再调。真正需要关注的反而是慢日志阈值,我一般会把 search slowlog 的info设为 500ms,warn设为 2s,索引慢查询也要开。没有慢日志,你都不知道哪些查询在拖后腿。
3.3 深分页、PIT 与 search_after:翻页的正确姿势
前面提到深分页的问题,这里展开讲 8.x 推荐的方案。官方在 7.10 之后不再推荐 scroll 用于实时用户请求,Scroll 更适合做批量导出或 reindex,因为它会为每个分片创建快照,代价高昂,而且长时间持有搜索上下文会占用资源。用户实时翻页应该用search_after配合 PIT。
PIT 的概念可以理解成一个“在某个时间点上的索引视图”。你通过POST /my_index/_pit?keep_alive=1m创建一个游标,后续每一个搜索请求都带上这个 PIT ID,同时用上一次查询结果最后一条记录的排序值作为search_after。这样,ES 不需要从全局从头扫到第 10000 条,而是直接从上一页的最后一条继续往后找,复杂度大幅下降。
实际使用时,PIT 有一个关键细节:PIT 是绑定到索引的,如果索引发生了 rollover(比如数据流切到了新后端索引),旧 PIT 就失效了,这时需要重新创建。翻完页后要显式删除 PIT,DELETE /_pit把 keep_alive 的游标清理掉,否则资源泄漏。
另外,在深分页场景中,如果用户确实需要跳转到第 50 页,search_after也不合适,因为它只能一页页往下走。这种场景要么放弃跳页,要么用不同的策略,比如按时间倒序把“页”转化成“时间范围查询”。我在一个后台管理列表里就是把页码换成“加载更多”的交互,用了 PIT + search_after,查询延迟从原来的 2 秒以上降到 100ms 以内,效果非常明显。
4. 写入调优:吞吐与延迟的权衡艺术
4.1 写入链路里,到底谁在拖慢速度
很多人以为写入性能只取决于磁盘速度,其实 ES 写入路径上依次有 translog、refresh、merge 三个环节,任何一个环节都可能成为瓶颈。
首先是 refresh 机制。ES 写入后会先把文档放在内存 buffer 里,在refresh时才生成新的 Lucene segment,使数据变得可搜索。refresh_interval默认是 1 秒,意思是每秒都可能把 buffer 中的数据固化成一个 segment。如果写入量很大,每秒钟都生成新 segment,那意味着每秒钟都在产生小文件,后面 merge 的压力也会随之增大。如果业务对搜索实时性要求没那么高(比如日志系统、推荐系统的特征回放),可以把refresh_interval调成30s甚至60s。写入吞吐能提升非常明显,因为 refresh 本身是 CPU 和 IO 密集操作。
其次是 translog。ES 为了保证可靠性,每次写入都会写 translog,默认情况下每个请求都要 fsync 到磁盘,这会增加写入延迟。8.x 的默认配置已经优化过了,但如果你做的是批量写,业务上又能接受一丁点数据丢失,可以考虑把index.translog.durability设为async,并调整index.translog.sync_interval和index.translog.flush_threshold_size。这里强调一下:这个设置是可靠性换性能,要结合业务场景来定,日志丢了可以重推可以接受,金融账单丢了可不行。
最后是 merge。当 segment 越来越多,后台线程会进行段合并,把多个小 segment 合并成大 segment。merge 是写入侧的“垃圾回收”,它不可消除,但可以削峰。调优的目标是让 merge 的峰值不要和查询高峰重叠,或者通过indices.merge.scheduler.max_merge_count来限制并发 merge 的资源占用。
4.2 bulk 批量写入的正确姿势
在 ES 里做单条写入(indexAPI)基本是性能反模式,生产环境必须用 bulk。但 bulk 也不是无脑调大批次就行,批次大小、并发数、错误处理都有讲究。
批次大小的经验值:推荐从 1MB 到 5MB 的批量数据开始压测,而不是死记 5000 条或 10000 条。为什么?因为 bulk 的大小对性能的影响取决于文档平均大小。如果你的文档很大,比如一个文档 10KB,那 1000 条就已经 10MB 了;如果文档很小,比如 200 字节,那 5000 条才 1MB。官方推荐的思路是:以“数据体量”而不是“文档条数”为准,把一个 bulk 请求控制在 5MB 到 15MB 之间。你可以用POST /_bulk的响应体里的took字段和节点 CPU 一起评估,逐步增大批次,找到拐点。
并发数方面也很关键。ES 每个节点处理 bulk 请求时都会占用写入线程池,并发太高会导致线程排队,反而降低吞吐。一个常用起点是:如果集群有 3 个数据节点,每节点 8 个写入线程,那么总并发可以控制在 3 到 6 个 bulk 请求同时在途。注意这是“在途”并发数,不是每秒发送的数量。用 esrally 压测时,你可以明显地看到并发从 1 升到 8,吞吐先是上升然后趋于平稳,再继续升反而下降。
bulk 的错误处理也要提前设计。Es 的 bulk 响应是逐条返回结果的,即使整个请求 HTTP 200,也可能里面有单条失败。调优时很多人只盯着吞吐,忽略了失败重试逻辑。比如一次性批量写入 5000 条,里面只要有一条因为文档 id 冲突返回 409,这个 id 如果没有被单独处理,数据就悄悄丢了。我在生产里都会对 bulk 响应做一个简单的逐条检查,把失败项捞出来,按错误码分类:版本冲突可以忽略或者按业务策略覆盖,解析异常要记录原始数据,而 429 就说明 ES 写入过载了,需要降低并发。
4.3 merge 与段合并:8.x 的自动调优,别乱动但要懂
Lucene 的段合并策略叫 tiered merge policy,ES 8.x 默认就是这个策略,它维护了一个分层结构,让相似大小的段合并在一起,避免所有段都往同一个大段堆。默认配置下,性能其实是比较均衡的,新手最容易犯的错是生产环境瞎调merge.policy参数。
比如有人为了让查询更快,把indices.merge.policy.segments_per_tier调得很小,结果每个 tier 的段变小,段数量变多,查询时需要打开的文件数也随之增加。还有人为了“减少段”直接把index.merge.policy.max_merged_segment调成 5GB,强制小索引也合并成大段,这会让写放大变得非常严重。
对于持续写入的索引,优先保证merge的后台线程不要和在线查询抢 CPU。ES 8.x 引入了一些新的并发调节机制,但我个人的经验是:不要轻易修改 merge policy 系数,保持默认,通过限制indices.merge.scheduler.max_thread_count来控制 merge 资源占用就足够了。
对于不再写入或者极少写入的索引,比如 ILM 进入 warm 阶段的索引,可以进行force_merge,把多个 segment 合并成每分片一个段,这样查询时 IO 次数显著减少,同时还可能释放磁盘空间。不过 force_merge 是阻塞式操作,会在合并期间占用较高 IO,务必在业务低峰期执行。用 ILM 的 warm 阶段加上force_merge动作(默认max_num_segments=1),可以做到自动化,不需要每天手动敲命令。注意 8.x 里 force_merge 对searchable snapshot的 frozen 索引不适用,如果你用了 frozen tier,要单独设计。
5. 集群与 JVM 层面调优:把每台机器的性能榨干
5.1 JVM 堆内存与 GC:ES 的生命线
ES 是基于 Java 的,JVM 堆大小和 GC 策略直接影响性能稳定。很多人以为 JVM 堆越大越好,其实不然。官方明确建议堆最大值不要超过物理内存的 50%,并且不要超过 31GB(8.x 仍然沿用这个建议)。为什么要限 31GB?因为 31GB 以内 JVM 可以启用普通对象指针压缩,堆超过这个阈值后对象指针膨胀,内存利用率下降,GC 反而变慢。
实际部署中,如果一台机器有 128GB 内存,我一般分配 31GB 堆,剩下 90 多 GB 留给文件缓存。如果一台机器只有 16GB 内存,那就给堆 8GB,留 8GB 给 OS。很多人舍不得,觉得堆大点缓存多,查询就快,但忽略了 Lucene 的索引文件其实是存在堆外,由 OS page cache 管理的。你给堆越多,留给 OS cache 的空间越少,热索引的读性能反而下降。这也是为什么有时候堆调大后,GC 变频繁了,查询也变慢了。
GC 方面,8.x 默认的 G1GC 在小堆下表现还可以,但如果节点内存大(32GB 以上),官方和社区不少人推荐使用 ZGC 或 Shenandoah 来降低 GC 停顿时间,不过这需要你的 JDK 版本匹配。我的建议是:除非你已经明确观察到 GC 停顿在影响查询延迟,否则先用默认的 G1GC,配合 GC 日志观察gc_epoch和停滞时间。在jvm.options里加上-Xlog:gc*:gc.log:time,level,tags,然后通过日志判断是否需要切换 GC 算法,而不是盲从网上的方案。
还有一个很容易踩的坑:给 ES 进程设置 swap 开启。如果操作系统把 JVM 的堆内存换出到磁盘,整个集群会出现间歇性的长暂停,慢日志里全是几秒甚至几十秒的超时。部署 ES 的机器必须关闭 swap,或者至少设置vm.swappiness=1,让内核优先使用物理内存。在 systemd 启动脚本里可以加LimitMEMLOCK=infinity,并在elasticsearch.yml里设置bootstrap.memory_lock: true来锁定内存。启动时如果看到 “memory locking requested for elasticsearch process but memory is not locked” 的警告,就要检查/etc/security/limits.conf里的 memlock 是否设置正确。
5.2 操作系统、磁盘与网络:ES 的底层环境
JVM 调得再好,底层磁盘和网络跟不上,性能也上不去。ES 是一个几乎完全依赖磁盘 IO 的数据库系统,尤其写多读少场景下,磁盘性能直接决定写入吞吐上限。
磁盘选型上,SSD 基本是标配,而且不要用网络存储(NFS)作为数据目录,因为 NFS 的延迟和可靠性都没法和本地盘比。如果条件允许,多块 SSD 可以配置多个path.data,ES 会做数据条带化。曾经遇到过一种情况:集群监控看起来 CPU 不高、内存不高,但写入吞吐就是上不去,后来发现是宿主机和另一套业务共用了同一块磁盘,IO 在宿主机层面就被打满了。这个用容器部署时特别容易发生,最好提前用iostat -x 1盯一下%util和await。
文件系统方面,推荐使用 ext4 或 xfs,mout 参数中开启noatime可以减少写操作。另外一定要设置vm.max_map_count至少为 262144,否则启动时 ES 会报错。这个问题在新装 8.x 时很常见,安装文档里都写了,但线上总有机器没改。
网络层面,ES 节点间分片恢复和跨节点查询会消耗大量带宽。如果使用千兆网络,而数据量又上了 TB 级别,那么重索引或分片恢复会直接把网络打满。建议至少万兆内网,并且把 ES 的传输网络和业务网络分开。如果做不到,至少要设置cluster.routing.allocation.node_concurrent_incoming_recoveries和outgoing_recoveries,控制并发恢复数量,避免恢复操作影响在线业务。
文件描述符也很重要,单节点要启动超过几万个文件连接并不罕见。ulimit -n需要设为 65535 或更高,否则高并发下 ES 会报 “too many open files”。这些操作看起来基础,但线上我排查过的无数“疑难杂症”,最后都会落到某个系统参数没配好。
5.3 节点角色分离:让每个节点只做一件事
ES 8.x 支持非常细粒度的节点角色配置,性能调优时最容易忽略的就是节点角色混乱。很多中小团队为了省机器,一台节点既做 master 候选,又做数据节点,又做协调节点,还跑 ingest pipeline。在小规模下可能没问题,但一旦写入和查询都上来,这种“全栈节点”就会成为瓶颈源。
建议至少把节点分成三类:
- master 角色:只负责集群管理,不存储数据,不处理请求,通常 3 台做高可用
- 数据节点:负责索引数据存储和查询,是真正的工作节点
- 协调节点:只负责接收客户端请求,做请求分发和结果汇总,不参与数据存储
协调节点的高 CPU 和内存消耗常常是被低估的。当业务发起一个跨 100 个分片的查询时,协调节点要把请求发给 100 个分片,再汇总排序 100 份结果。如果协调节点同时又是数据节点,那么数据节点既要做分片查询,又要做全局汇总,CPU 就很容易被打满。在查询密集型的业务中,专门部署 2 到 3 台协调节点,能显著改善整体查询延迟。我实测过一个场景:将协调角色从数据节点上摘除后,数据节点 CPU 下降 30%,查询 P99 下降 40%。
8.x 里还推荐使用include_to和exclude_from的节点属性配合 ILM 放置,比如给不同节点打box_type=hot、box_type=warm的标签,再用 ILM 把不同阶段的索引分派到对应标签的节点。这样既实现了角色分离,又实现了数据分层,是生产环境的标准做法。
6. 通过压测验证调优效果,而不是“拍脑袋”宣布成功
6.1 用 esrally 建立基线,让每次优化都有据可依
ES 性能调优最忌讳的就是“改完感觉快了 30%”这种模糊描述。真正靠谱的优化流程是:在调优之前,先跑一轮标准压测,记录基线数据;每调整一个参数,再跑一轮,对比吞吐、延迟、CPU、GC 等指标的变化。
官方提供的 esrally(Rally)是首选工具。它自带多个 benchmark 数据集,比如 geonames、logging 等,可以直接对集群进行标准压测。初次使用,可以用esrally configure设置好 track,然后esrally race --track=geonames --target-hosts=192.168.1.10:9200 --pipeline=benchmark-only。注意要关闭 x-pack 安全认证或者在命令里带认证参数,否则压测请求会被拒绝。
Rally 的压测结果会输出Throughput和Service Time(latency),同时会附上分区间的柱状图,比如 50th、90th、99th、100th 的延迟。初次跑完基线后,你做的任何配置改动都可以用同样的 track 再跑一次进行对比。比如我调refresh_interval从 1s 到 30s,压测日志写入场景,吞吐从每秒 2 万条提升到 7 万条,99th 延迟也明显下降。这就是一个非常明确的证据链。
如果你的线上数据不能随便导出,可以用 Rally 里自定义 track 的方式,构造一个和线上 mapping 一致、数据规模缩放的索引来做压测。重点不是完全复现线上流量,而是通过“相对变化”来判断调优是否有效。最简单有效的对照组就是:同一套压测脚本、同一个数据集、同一个集群配置,只改变你要验证的那一个参数。
6.2 从指标曲线反推下一步优化方向
压测结果并不是终点,而是下一轮优化的起点。我拿到压测报告后,会按这个顺序分析:
先看 CPU 是否成为瓶颈。用top、vmstat或者监控面板看 CPU 使用率。如果 CPU 打满,而吞吐上不去,那说明查询或写入的某个环节是 CPU 密集型。配合GET /_nodes/hot_threads定位到具体线程栈。比如大量线程卡在Lucene70SegmentInfoFormat,说明在做 segment 读取,可能是段数量太多;大量线程在InternalEngine.merge,说明 merge 在抢 CPU;大量线程在QueryPhase,说明查询太重。
再看磁盘 IO。如果iostat显示%util持续接近 100%,而 CPU 不高,说明磁盘跟不上。这个时候优化方向就是减少磁盘读取,比如调整index.codec为best_compression(注意它会影响写入 CPU 和查询性能)、减少要读取的字段、优化查询只取必要的 source。
然后看 GC 情况。如果频繁 full GC,说明堆内存不够,或者有字段加载了太多 fielddata,或者聚合查询太重。不要第一反应就是加堆,应该先找内存吃掉的原因。常见的是聚合、排序、search_after频繁使用导致需要加载大量字段值;或者协调节点在大结果集排序时占用过多内存。
最后看网络。如果节点间传输的数据量很大,可能是查询跨分片过多,需要优化查询路由,或者收拢分片分布。数据流 + ILM 能帮助减少分片数量,而自定义 routing 可以让查询只命中部分分片。
压测结束后,把每一步的参数改动都记录下来,形成一个调优日志。比如“2025-01-10,修改index.refresh_interval从 1s 到 30s,写入吞吐提升 80%,搜索实时性下降,但业务接受”。这个日志是最好的团队资产,三个月后你忘了当时为什么改这个配置,翻一下就知道了。
写在后面:做 Elasticsearch 8.x 性能调优这两年,我最深的感受是,调优不是一次性的“大动作”,而是建立“测量-改动-验证-再测量”的循环。很多人问我有没有一个“标准配置清单”,照着填就完事。我的回答是:没有,也不该有。因为每个业务的写入模式、查询模式、机器配置都不一样,适合日志场景的参数套到商品搜索上,可能反而更慢。
如果你现在正被 ES 慢查询困扰,我建议你先别急着抄网上的合并参数,也别一拍脑袋加副本。按照文章里的顺序,先花几天把监控和慢日志搭起来,把分片和 mapping 梳理一遍,再用 Rally 建立基线,之后每改一个参数,用数据说话。这个过程看起来慢,却比“调完靠感觉”要快得多,也稳得多。