Elasticsearch性能优化是个系统工程,涉及索引设计、查询优化、集群配置、硬件调优等多个层面。本文结合实战经验,从一个查询慢的案例出发,一步步讲解如何定位瓶颈、优化索引、优化查询、调优集群,把查询从秒级降到毫秒级。

一、问题背景

我们有一个日志检索系统,用Elasticsearch 7存储业务日志,每天新增约5亿条文档,索引按天划分,每个索引大约200GB。用户反馈查询最近一小时的日志经常要等好几秒,高峰期甚至超时。最开始我们以为是数据量太大,但同样的数据量在测试环境查询很快,说明不是数据量本身的问题,而是配置和使用方式有问题。

于是我们开始了一轮全面的性能优化,最终把平均查询时间从2.3秒降到了120毫秒,P99从8秒降到了500毫秒。下面分享整个优化过程。

二、第一步:定位瓶颈

优化的第一步是定位瓶颈,不能盲目调参。我们用了几个工具来定位问题。

1. 慢查询日志

首先开启慢查询日志,把超过1秒的查询都记录下来。配置方式是在elasticsearch.yml中设置:

index.search.slowlog.threshold.query.warn: 1s
index.search.slowlog.threshold.fetch.warn: 500ms

开启后我们发现,大部分慢查询都是带聚合的复杂查询,而且很多查询的query阶段很快但fetch阶段很慢。这说明问题可能出在文档取回阶段,而不是查询匹配阶段。

2. Profile API

对典型的慢查询用Profile API分析,看看每个阶段花了多少时间。Profile API会返回查询的详细执行计划,包括每个查询子句的耗时、聚合的耗时等。

分析后发现两个主要问题:一是很多查询用了wildcard模糊查询,这种查询无法利用倒排索引,性能很差;二是聚合用了高基数字段的terms聚合,每个分桶都要计算,开销很大。

3. 集群监控

用Kibana的Monitoring或者Prometheus+Grafana监控集群状态。发现查询高峰期CPU使用率很高,尤其是数据节点的CPU经常达到90%以上,但磁盘IO和内存使用率并不高。这说明瓶颈在CPU,也就是查询计算本身,而不是IO或内存。

三、索引层面优化

定位到问题后,我们从索引设计开始优化。

1. 合理设置分片数

最开始我们每个索引设了10个主分片,这是按照早期的数据量设的,但现在数据量变大了,10个分片每个20GB,其实偏大。Elasticsearch的分片不是越多越好,分片太多会增加集群管理开销和查询时的合并开销,分片太大会影响恢复速度和查询并行度。

一般建议每个分片大小在20-50GB之间。我们把每天的索引调整为5个主分片,每个分片约40GB,在合理范围内。同时把副本数从2降到1,因为日志数据可以容忍少量丢失,而且减少副本能降低写入压力和存储成本。

调整分片数后,查询时需要合并的结果从10个分片减少到5个,查询延迟降低了约20%。

2. 字段类型优化

检查mapping发现很多字段类型设置不合理。比如有些只用于精确匹配的字段用了text类型,text类型会分词,索引体积大查询也慢。我们把这些字段改成keyword类型,索引体积减小了30%,精确匹配查询也快了很多。

还有一些数值字段用了long类型但实际取值范围很小,改成integer甚至short类型,减少了内存和磁盘占用。日期字段统一用date类型,不要用字符串存日期。

另外,对于不需要检索的字段,设置index: false,只存储不索引,能显著减小索引体积。比如日志中的request_body字段,只需要查看不需要搜索,就设置为不索引。

3. 禁用_source中的大字段

我们的日志文档中有一个stacktrace字段,内容很长,平均每条几KB。这个字段虽然不常查,但每次查询返回source时都会带上,导致fetch阶段很慢、网络传输量大。

我们把这个字段从source中排除,或者用source_includes在查询时只返回需要的字段。调整后fetch阶段耗时降低了60%,查询整体快了很多。

4. 使用索引模板

把优化后的mapping做成索引模板,每天自动创建新索引时自动应用,避免手动配置出错。模板中定义好字段类型、分片数、副本数、刷新间隔等,新索引自动继承。

四、查询层面优化

索引优化之后,我们开始优化查询语句。

1. 替换wildcard查询

慢查询中很多用了keyword这样的wildcard查询,这种查询无法利用倒排索引,需要扫描所有词条,性能极差。我们根据业务场景做了不同的替换:

  • 如果是前缀匹配,用prefix查询或者把字段用edge_ngram分词器建索引
  • 如果是包含匹配,用n-gram分词器在索引时处理,查询时用term匹配
  • 如果是全文搜索,用match查询而不是wildcard

替换后这些查询的速度从秒级降到了毫秒级。

2. 优化聚合查询

高基数字段的terms聚合很慢,因为要为每个唯一值计算分桶。我们做了几个优化:

  • 对不需要精确结果的聚合,设置size参数限制返回的分桶数,或者用shard_size增加每个分片上计算的分桶数来提高准确性
  • 用composite聚合替代深分页的terms聚合,composite支持分页翻页,性能更稳定
  • 对时间序列数据用date_histogram聚合而不是terms聚合时间字段
  • 预聚合:对常用的聚合维度,用rollup索引或者ingest pipeline提前聚合好,查询时直接查预聚合结果

3. 避免深分页

很多查询用了from + size做深分页,比如from=10000 size=10,这种查询需要在每个分片上取出10010条结果再合并排序,非常慢。我们改成用search_after做分页,或者用scroll API导出大量数据。

对于用户交互的分页查询,限制最多翻到第100页,超过的提示用户缩小搜索范围,这样既保护了集群也提升了用户体验。

4. 合理使用filter和query

Elasticsearch中filter上下文不计算相关性得分,结果可以缓存,而query上下文要计算得分且不缓存。很多不需要全文相关性的查询应该放在filter中,比如状态过滤、时间范围过滤、精确值匹配等。

我们把很多查询中的过滤条件从query移到了bool.filter中,利用filter cache提升性能,尤其是重复查询的场景效果明显。

5. 减少返回字段

默认查询会返回整个source,对于大文档来说开销很大。我们在查询时用sourceincludes或者sourceexcludes只返回需要的字段,或者用stored fields单独存储常用字段。对于只需要id的场景,设置source: false,只返回文档id。

五、集群层面优化

查询优化之后,我们调整了集群配置。

1. JVM堆内存设置

Elasticsearch的JVM堆内存设置很关键。最开始我们给每个节点设了31GB堆内存,以为越大越好,但实际上超过32GB之后JVM无法使用压缩指针(Compressed Oops),内存利用效率反而下降。而且堆内存太大会导致GC停顿时间变长。

我们把堆内存调整为26GB,既在压缩指针范围内,又给操作系统留了足够的内存用于文件系统缓存。Elasticsearch严重依赖文件系统缓存来加速查询,给操作系统留足够的内存比给JVM堆更重要。

一般建议堆内存设为机器内存的50%,不超过32GB。剩下的50%留给文件系统缓存。

2. 线程池配置

查询高峰期CPU高,我们检查了线程池配置。Elasticsearch有search、write、get等不同的线程池,默认search线程池大小是CPU核数的两倍加1。我们的机器是16核,默认search线程池是33,在高并发下可能导致线程切换开销大。

我们根据压测结果把search线程池调整为CPU核数,也就是16,同时把队列大小从1000调到500,避免队列积压太多请求导致内存压力。调整后CPU使用率更平稳,查询P99降低了。

注意线程池不是越大越好,太多线程会增加上下文切换和锁竞争,反而降低吞吐量。要根据实际压测调整。

3. 刷新间隔调整

我们的日志场景对实时性要求不高,不需要写入后立即可搜索。默认的刷新间隔是1秒,这会产生很多小segment,增加segment合并的开销,也影响查询性能。

我们把刷新间隔调整为30秒,写入性能提升了很多,segment数量减少,查询也更稳定。对于日志这种近实时场景,30秒的延迟完全可以接受。

4. 段合并优化

大量小segment会影响查询性能,因为查询要扫描多个segment。我们调整了段合并策略,用index.merge.scheduler.maxthreadcount限制合并线程数,避免合并占用太多IO影响查询。同时用index.merge.policy.maxmergedsegment限制单个合并segment的大小,避免产生超大segment。

还可以定期用force merge对只读的历史索引做段合并,把多个小segment合并成大segment,提升查询性能。注意force merge只对不再写入的索引使用。

六、硬件和系统层面

最后我们还做了一些硬件和系统层面的优化。

1. 使用SSD

Elasticsearch对磁盘IO很敏感,尤其是在查询大量数据时。我们把数据节点从机械硬盘换成了SSD,查询延迟降低了50%以上。如果预算允许,SSD是性价比最高的优化。

2. 关闭swap

Elasticsearch的性能在使用swap时会急剧下降,因为JVM垃圾回收时如果内存被换出到磁盘,GC停顿会非常长。我们在所有节点上关闭了swap,用swapoff -a临时关闭,并修改/etc/fstab永久关闭。

同时在elasticsearch.yml中设置bootstrap.memory_lock: true,锁定JVM内存不被换出。

3. 调整文件描述符和进程数

Elasticsearch需要打开大量文件,默认的文件描述符限制可能不够。我们在/etc/security/limits.conf中设置了:

elasticsearch soft nofile 65536
elasticsearch hard nofile 65536
elasticsearch soft nproc 4096
elasticsearch hard nproc 4096

4. 合理分配节点角色

我们把集群节点分成了主节点、数据节点、协调节点(客户端节点)三种角色,各司其职。主节点只负责集群管理,不存数据不处理查询;数据节点存数据和处理查询;协调节点负责接收请求、分发查询、合并结果,减轻数据节点的压力。

分离角色后,数据节点可以专注于数据存储和查询,协调节点负责结果合并,整体性能更稳定。

七、优化效果

经过以上优化,我们的Elasticsearch集群性能有了显著提升:

  • 平均查询时间从2.3秒降到120毫秒
  • P99查询时间从8秒降到500毫秒
  • 写入吞吐量从每秒5万条提升到每秒12万条
  • 集群CPU使用率从高峰期90%降到60%
  • 索引体积减小了约35%

这些优化不是一蹴而就的,而是一步步定位、测试、验证的结果。每做一项优化都用压测验证效果,确保确实有提升再上线。

八、常见误区

最后说几个常见的性能优化误区。

误区1:分片越多越好

分片太多会增加集群元数据管理开销、查询合并开销、恢复时间。每个分片20-50GB比较合理,不要盲目多分片。

误区2:堆内存越大越好

堆内存超过32GB会失去压缩指针优势,而且会挤占文件系统缓存。一般设为机器内存的50%且不超过32GB。

误区3:盲目调参数

很多人一上来就调各种参数,但不先定位瓶颈。优化应该先定位问题,再针对性优化,每一步都用数据验证。

误区4:忽略索引设计

很多性能问题的根源是索引设计不合理,比如字段类型不对、分片数不合理、没有用模板。索引设计是基础,基础没打好后面怎么调都效果有限。

九、写在最后

Elasticsearch性能优化是一个系统工程,需要从索引设计、查询语句、集群配置、硬件系统等多个层面综合考虑。优化的关键是先定位瓶颈,再针对性优化,每一步都用数据验证。

希望这篇实战文章能帮助你优化自己的Elasticsearch集群,把查询从慢变快。如果有问题欢迎交流讨论。