做ElasticSearch性能调优这件事,我这两年踩了不少坑,也攒了不少可以直接用的经验。很多朋友一上来就盯着JVM堆内存调参,或者疯狂加副本数,其实大多数情况下,读写慢的根子根本不在那一层。这篇东西我不打算写教科书式的概念罗列,就按我实际在项目里排查和优化ES集群的顺序来聊,从写入链路、查询链路、索引设计到系统层配置,把每一个“为什么这么做”都说清楚。无论你是刚接手一个慢到让人抓狂的ES集群,还是在设计阶段想避开性能坑,这篇都适合你花十分钟看完,然后照着去改。
1. 读写路径与瓶颈判断:先搞清楚慢在哪一环
1.1 写入链路:从bulk请求到segment可见
先说写入。ES的写入链路大致是:客户端发来bulk请求,协调节点(coordinating node)把数据按路由分发到对应的主分片(primary shard),主分片写入内存buffer,同时追加translog,然后经过refresh变成OS cache里的segment,最后通过flush落盘。这里面每一个环节都可能成为瓶颈,但大部分人的认知只停留在“ES写入慢就调bulk大小”这一步,其实远远不够。
内存buffer的本质是一块暂存区,数据先进来,等refresh触发时才生成segment并清空buffer。refresh默认每秒执行一次,所以刚写入的数据大概有1秒的延迟才可见,这就是ES的“近实时”特性。在性能调优时,这个1秒的间隔是完全可以放宽或收紧的。translog则负责崩溃恢复,每次写入都会落盘(默认是同步刷盘),这块磁盘IO开销很不小。如果业务允许一定的数据丢失风险,可以把index.translog.durability改成async,让translog异步刷盘,写入性能会有立竿见影的提升,但代价是节点宕机时可能丢几秒的数据。
再往后是segment合并。ES会不断把小的segment合并成大的,合并过程需要读和写磁盘,如果合并线程配置得不合理,它会反过来拖慢正常的写入和查询。这里有个反直觉的调优方向:segment合并虽然是后台任务,但过于频繁的合并会让磁盘IO长期处于高水位,影响线上请求。我后面会专门讲怎么压住合并的“火气”。
1.2 读取链路:query、fetch与缓存机制
读请求的链路一样有讲究。一个search请求到了协调节点,会被拆分到相关的分片并行执行,每个分片先在自己的segment里做query阶段(找到匹配的doc id和排序值),然后在fetch阶段去取完整文档内容返回给协调节点,由协调节点合并排序后返回客户端。这里最常见的问题是深分页和fetch阶段读取了大字段,但更隐蔽的是缓存没有生效,导致同样的查询反复消耗CPU。
ES的查询缓存分三层:request cache缓存查询结果,但只对size=0的聚合生效;query cache(filter cache)缓存filter子句的位集;fielddata缓存则是给排序和聚合用的。很多调优新手只盯着“加filter就能快”,但没搞明白filter为什么快、什么时候缓存失效。filter查询会生成一个bitset(位数组),每个文档都有对应位标记是否匹配,这个bitset可以复用。但如果你每次查询都带上随机参数,比如带时间戳的now-1h,缓存命中率会非常低。这个问题我后面会展开讲。
1.3 先判断瓶颈,再动手:CPU、IO还是GC
很多人在调优前根本不做诊断,上来就改配置,搞得集群反而更糟。我建议第一件事是看监控。ES节点上重点看几个指标:CPU使用率、磁盘IO等待、JVM堆内存使用率、Full GC频率与耗时、thread pool队列积压情况。这一套指标看下来,基本能定位瓶颈的大方向。
- CPU高:多半是查询复杂、聚合多、或者segment过多导致查询需要扫描太多文件。
- IO高:写入量大、translog同步刷盘、segment合并、或者分片副本过多,重点看磁盘类型是否支持得上。
- GC频繁:堆内存不足或过大、fielddata占用太多、或者缓存设置不合理,优先检查堆内缓存和mapping设计。
- thread pool排队:说明写入或搜索请求积压,往往是上游bulk并发设置过大,超过了节点处理能力。
这四类指标的优先级有讲究。我见过不少人CPU都快打满了还在加bulk并发,结果队列爆炸、响应超时反而更严重。先看监控,再决定调什么,这个顺序千万别省。
2. 写入性能调优实操:照着配、照着改
2.1 Bulk批次大小与并发策略
写入优化绕不开bulk接口。最简单的调优是调批次大小和并发度,但网上流传的“一条5000、并发10”的说法属于拍脑袋,得看实际数据大小。我建议按“数据体量”而不是“条数”来定:单批次数据量控制在5MB到15MB之间,大约对应ES官方建议的“单次bulk请求体大小5-15MB”范围。先用curl之类工具拿1000条数据量一下体积,反推合适的条数,比固定1000条或5000条靠谱得多。
并发方面,得结合客户端所在机器的资源来设。以Java的RestHighLevelClient为例,我一般会先按“客户端节点数乘以分片数”粗算一个并发上限,再压测确认。一个实用的判断指标是:如果ES节点的thread pool里bulk队列出现积压,说明并发开大了;如果CPU没吃满、写入延迟也不高,说明并发还有提升空间。另外,批量写入务必关闭refresh的每次请求自动刷新,否则indexing性能会明显下降。创建一个索引做大批量数据导入时,我通常直接设"refresh_interval": "-1",导入完成后再改回正常的秒级刷新。
2.2 Refresh间隔与Translog策略的取舍
这个部分是写入调优最核心的“拧螺丝”环节,也最容易出问题。
refresh_interval默认是1秒,如果你写入量大且允许分钟级的数据可见延迟,可以直接改成30秒甚至更大。这样做的收益是:一秒钟内产生的数据在内存buffer里攒着,一次refresh才生成一个segment,减少了segment数量,降低了后续merge压力,同时也减少了refresh产生的CPU和磁盘IO开销。这里有个坑——如果你只改了索引级别的refresh_interval,但忘了关掉bulk请求里自带的刷新,等于白干。用ES的bulk API时,要注意请求参数里不要带refresh=wait_for或refresh=true,否则每个请求都会强制刷新,性能立刻退化。
translog的落盘策略影响的是可靠性,但我不建议无脑改成异步。只有在充分评估过数据丢失风险后,才建议把index.translog.durability改成async,同时把index.translog.sync_interval调大(比如5秒或10秒)。我曾经在一个日志系统里做过对比:同步刷盘时写入吞吐大概只有1万条每秒,改成异步后能跑到2.5万条每秒,但那次系统对日志丢失容忍度高,所以能接受。业务数据、订单数据这类绝对不允许丢的场景,还是老老实实保持同步刷盘。
2.3 副本、路由与Merge调优
副本数是影响写入性能的一个隐秘变量。每个副本都会完整复制一份主分片的数据,所以写入时副本越多,节点需要处理的复制流量就越大。大促或离线导入场景,我经常临时把副本数降到0,导入完成再调回去。这个操作性能收益非常明显,因为省下了一半以上的写放大。
路由(routing)也一样。默认情况下ES按_id做哈希路由,如果你写入的数据在某个字段上有明显的分组特征,比如按用户ID或租户ID分布,可以指定routing让同一分组的数据落到同一分片。这样写入和查询都可能更高效,尤其是做按租户隔离的场景。但要注意,routing一旦设置,查询时也最好带上同一个字段,否则查询会广播到所有分片,反而更慢。
merge方面,我是这样调的:先通过_cat/segments看索引的segment数量,如果单个分片的segment数量长期超过两三百个,就得考虑加大index.merge.policy.max_merged_segment或者调大index.merge.scheduler.max_thread_count。但在SSD上max_thread_count建议不要太大,我一般保持默认或调到max(1, min(4, processor/2))。合并太凶猛时,可以在业务低峰期手动执行_forcemerge,把segment合并成少数几个大段,这样查询时扫描的文件数大幅减少。
3. 查询读取性能调优实操:慢查询终结指南
3.1 正确区分filter与query,把缓存吃到饱
在ES里,query和filter的关键区别是:query会计算相关度分数,filter只看是否匹配,不参与评分。凡是不需要按相关度排序的场景,都应该用filter,这样做有两个好处:一是省去了算分的开销,二是可以走filter缓存。
我见过最直观的一个优化案例:一个带时间范围、状态码、用户ID的查询,原本用了should和must的各种组合,查询要一两秒。改成把时间范围、状态码、用户ID全部放进filter,只用must保留必须参与评分的全文检索条件,查询耗时直接降到200毫秒左右。这个变化基本不需要改硬件,只是语义对调。
但是filter缓存有个前提条件:查询条件得能被缓存复用。如果你每次filter里都带一个动态变化的值,比如"timestamp": {"gte": "now-1h"}这种,ES每次生成的bitset都不一样,缓存命不中。解决办法是使用date_histogram这类固定桶的方式,或者自己算好固定的时间边界,再塞进filter里。类似地,如果业务上允许,把“最近24小时”的查询改成“昨天0点到今天0点”这种固定区间,对缓存命中率友好得多。
3.2 深分页、Search After与Scroll的适用边界
深分页是ES查询性能的经典杀手。from=100000, size=10这种写法,协调节点会把前10万条结果的排序信息都捞出来,然后丢弃前99990条,浪费非常严重。应对方式有三类:
- 如果只翻前几百页,且可以接受实时性,用
search_after。它通过上一页最后一条结果的排序值来定位下一页,避免了深分页的全局排序开销。 - 如果你要批量导出大量数据,用
scroll。scroll本质是快照游标,适合顺序全量拉取,但注意它会在内存里保留一个上下文,用完要立刻清理,不然堆内存会被撑爆。 - 如果业务面临的是用户随机翻几百页的需求,那基本说明产品交互有问题,不如改成“加载更多”的方式。
有朋友用过search_after之后反馈排序字段不稳定,出现漏数据或重复数据。这个坑一般是排序值不唯一导致的。解决办法是加一个唯一字段作为排序辅助,比如在sort里带上_id或者一个包含唯一标识的doc value字段,让排序结果具备确定性。
3.3 聚合性能、Fielddata与Doc Values
做聚合时,最怕的是对text类型字段做terms聚合。text字段默认经过分词器处理后存倒排索引,如果直接对它做聚合,会触发fielddata(内存中构建的字段数据),这玩意一旦数据量大,会疯狂占用堆内存,然后导致GC暴涨。
最佳实践是在mapping阶段就规划好:需要做精确聚合、排序的字段,用keyword类型,并开启doc_values;全文检索才用text类型。doc_values是列式存储在磁盘上的正排索引,做聚合排序时直接从磁盘顺序读,不占堆内存。我们线上有一个大索引,原先有几十个text字段默认开启了fielddata的坑,把堆内存打到了85%以上,GC频繁。后来重新索引,把只需要精确匹配和聚合的字段全部改成keyword并启用doc_values,堆内存直接降到45%,查询和聚合都稳定了很多。
我建议每次创建索引前都过一遍mapping,强制自己回答每个字段的用途。那些又想做全文搜索又想做精确聚合的字段,拆成两个子字段(fields多字段特性)是最常见的解法。
4. 索引设计、映射与部署层面的“前置调优”
4.1 Mapping设计:类型选择与Doc Values
mapping设计属于“调优前置”环节,等数据都进去了再改,代价会非常大。核心决策点有三个:字段类型选什么、是否需要分词、是否需要doc_values。文本字段要做全文检索选text,同时如果需要精确匹配或聚合,就在该字段下加一个keyword的fields子字段。最适合的mapping,不是在写入前靠猜,而是拿一份真实业务文档做模板,逐个字段过一遍。ES允许你创建索引时用dynamic_templates做兜底规则,避免未知字段默认映射成text然后产生一堆无意义的倒排索引。
doc_values开启后会占用一定的磁盘空间,但换来的好处是排序、聚合不再吃堆内存。如果你有明确的排序聚合场景,建议直接开启;如果干脆不需要排序聚合,可以关掉以省磁盘。但要注意,修改doc_values配置必须重建索引,所以这个决策尽量在规划期就定下来。
4.2 分片数量规划与容量评估
分片数不是越大越好。过多的分片会带来两方面的开销:一是每个分片都有自己的segment和内存开销,分片多了会浪费堆内存;二是查询一个索引时,协调节点要把请求广播到所有分片,分片越多,合并结果的成本也越高。反过来了,分片太少又会导致单个分片过大,分片恢复和迁移都很慢。
我的经验是,单个分片控制在30GB到50GB之间比较合适。一个索引的总数据量如果预估在300GB以内,设5到10个分片通常够了。还要考虑分片数和节点数的关系,建议让每个节点上的分片数控制在10到20个以内(包含副本),超过这个数,节点管理分片的开销会明显上升。这里没有银弹公式,先按数据量和查询模式算出大概值,再用压测修正。
4.3 JVM堆内存、Swap与系统层配置
ES的JVM堆内存不是越大越好,官方建议最大不超过32GB。原因是超过32GB后,JVM会启用压缩指针失效的OOP模型,对象头变大,内存利用率反而下降。更重要的是,ES大量使用OS cache来缓存磁盘上的数据,堆内存给得太多,留给OS cache的空间就少了,而OS cache对读写性能的影响往往是决定性的。我一般建议物理机内存一半给ES堆,一半留给OS缓存。
Swap必须关掉,或者至少在配置里设为极小值。可以用bootstrap.memory_lock: true锁定内存,避免运行时发生swap导致GC问题。另外,Linux系统层注意文件描述符上限,ES对打开文件数量要求很高,/etc/security/limits.conf里的nofile建议调到65536以上。磁盘方面,有条件就上SSD/NVMe,写入和合并都能受益;如果必须用机械盘,至少要确保translog和segment不共用同一个物理盘,不然写入和查询互相抢IO,性能会很差。
5. 常见问题与排查实录
5.1 写入性能突然下降的排查思路
一个ES集群写入正常,突然某天变慢,我建议按这个顺序排查:先看bulk响应里有没有reject,再看磁盘IO是不是被打满了,然后看段合并是否在疯狂运行,最后看GC情况。
有一次线上日志集群写入变慢,我上去看到bulk线程池队列积压了几千条,磁盘IO也到了90%以上。当时第一反应是扩磁盘IO,后来细查发现是一个大索引长期没有forcemerge,segment数量多得离谱,后台merge线程一直在抢IO。最后在低峰期对旧索引做了forcemerge,并调整了新索引的refresh_interval,写入就恢复了。这个过程说明一个问题:写入慢经常是“合并引起的次生灾害”,单纯加机器不解决问题。
5.2 查询慢且缓存命中率低
查询慢如果集中在相似的查询模式,先看缓存命中率。ES的_cat/nodeattrs和_nodes/stats里能看到各节点缓存情况。缓存命中率低通常是查询条件里有动态参数,或者filter条件顺序不对。ES的filter缓存是按子句顺序生成缓存key的,相同语义下如果子句顺序每次不一样,也可能导致缓存无法复用。
另一个常见问题是查询字段没有建索引。某个字段如果在mapping里设置成"index": false,它就只能存不能查,如果拿它做filter,性能会很差。这种问题通过_mapping接口检查即可确认。出现这类情况只能重建索引,所以事前规划mapping真的很重要。
5.3 Segment合并拖垮IO
_forcemerge是个好东西,但用不好也很坑。forcemerge会触发生成超大segment,这个过程中IO压力非常大,所以只能在业务低峰期执行,且要分批做。我曾经在一个每分钟都有大量写入的索引上执行过forcemerge,结果磁盘IO飙升,整个节点的查询都跟着抖动。后来改成对“只读索引”做forcemerge,或者只对几天前的旧索引段做,才彻底避免这个问题。
日常情况下,我建议给merge线程留一点余量,不要让合并占满磁盘IO。可以通过index.merge.scheduler.max_thread_count限制并发合并线程数,但这里有个细节——在SSD上磁盘IO不是主要瓶颈,线程数反而可以稍微调大;在机械盘上必须保守。还有一个经验:max_merged_segment设得很大,大segment的存储效率高,查询扫描文件少;但是大segment的生成过程可能很慢。建议按查询性能优先还是写入性能优先来决定。
5.4 多轮调优后的效果数据参考
给一个我在日志场景下的实测数据供参考:原始配置用默认值,bulk批次1万条、并发16,refresh间隔1秒,translog同步刷盘,写入吞吐约8000条每秒;调整后批次按数据量控制在10MB左右、并发降低到8,refresh_interval调成30秒,translog异步5秒刷盘,同一批机器写入吞吐提升到2.2万条每秒。查询方面,把动态filter改成固定时间桶,命中缓存后,类似查询从800毫秒降到150毫秒。单说数字不一定适用你,但这个量级的变化说明,调优的空间确实很大。
最后再分享一点个人体会
ES调优这个事,最忌一上来就抄别人的配置。每套集群的数据量、查询模式、硬件条件都不一样,正确的做法是用监控数据说话:先定位瓶颈在CPU、IO还是内存,再针对性地调一个参数,观察一段时间,再动下一个。我踩过最深的坑就是同时改了好几个配置,出了问题都不知道该回滚哪一个。
如果你手头正有一个慢集群,建议从最容易见效的几件事试起:关闭bulk请求里的自动refresh、把不需要评分的查询改成filter、重新设计mapping里的字段类型、控制好分片数。这几步做下来,集群一般会有可感知的提升。ES的调优不是一门玄学,只要链路通了、原理明白了,大部分性能问题都能找到对应的解法。希望这篇经验能帮你少走些弯路。