对于刚接触Elasticsearch(以下简称ES)的人来说,最容易产生的困惑就是:明明照着文档把查询语句写出来了,结果却不尽如人意——要么搜不到想要的文档,要么搜出来一堆不相关的结果。我见过不少项目组把ES当关系型数据库用,写出来的查询全是精准匹配,完全没发挥出搜索引擎的威力。这篇文章就结合我这些年做搜索、日志平台、电商检索的实操经验,把ES搜索这件事从头到尾捋一遍。你会搞清楚搜索引擎底层在做什么,日常开发里最常用的查询怎么写,Spring Boot项目怎么集成,以及线上搜索变慢、写入变慢的时候,到底该看哪些指标、按什么顺序排查。
无论你是后端开发、运维,还是刚接触ES的学生,这篇文章的目标就一个:让你看完之后能直接上手写查询,遇到问题知道往哪个方向查,而不是对着Kibana里满屏的JSON发呆。
1. 搜索引擎到底在搜什么:先搞懂ES的搜索逻辑
很多人把ES当成一个“存JSON的数据库”,这其实是一个很大的误解。ES底层是基于Lucene构建的倒排索引,它的核心设计目标不是“存数据”,而是“快速找到包含某个词的内容”。如果你不理解这个差异,后续所有的查询优化、问题排查都会绕远路。
1.1 从倒排索引聊起:为什么传统数据库搜不快
关系型数据库(比如MySQL)的搜索一般走B+树索引,适合的是“等值匹配”和“范围匹配”。但如果你要在两三千万行记录里做全文搜索,比如找“哪个订单的备注里包含‘发票’两个字”,MySQL只能靠LIKE '%发票%'这种全表扫描,数据量一大基本就卡死了。
ES的解法是倒排索引。简单说,ES在写入文档时会做分词处理,把一段文本切成一个个词条,然后维护一张映射表:词条 -> 文档ID列表。比如有两条文档:
- 文档1:小米手机性价比很高
- 文档2:华为手机性能不错
分词之后大致会得到小米 / 手机 / 性价比 / 华为 / 性能这些词条,倒排索引就是记录手机 -> [文档1, 文档2]、小米 -> [文档1]这样的对应关系。搜索“手机”的时候,直接查这个词条命中了哪些文档ID,效率非常高。
这就像你去图书馆找书:目录按书名、作者、主题做了索引,你想查“有哪些讲Spring的书”,直接翻主题索引就行,不用把整个图书馆的书一本本地翻。我用一个更生活化的比喻:倒排索引相当于一本书的“关键词索引页”,而MySQL的LIKE相当于从第一页翻到最后一页。
1.2 分词器:搜索准不准的第一道关口
倒排索引能不能正常工作,完全取决于分词器怎么切词。分词器不同,同一个查询词可能得到完全不同的结果。
ES默认的standard分析器对英文是按空格和标点切分的,对中文会切成单个汉字,所以直接用默认分析器搜中文,效果通常不理想。比如“中华人民共和国”会被切成“中/华/人/民/共/和/国”,搜“中华”会出现一堆相关性很低的结果。
生产环境做中文搜索,基本都会用IK分词器。IK有两种粒度:
ik_smart:最粗粒度切分,比如“中华人民共和国”切成“中华人民共和国”ik_max_word:最细粒度切分,尽可能分出更多词,比如“中华人民共和国/中华人民/中华/华人/人民共和国/人民/共和/国”
自定义索引的时候用ik_max_word保证索引覆盖面,搜索的时候用ik_smart提高精准度,这是很常见的组合。另外IK分词器还支持自定义词典,可以把公司名、产品名、专业术语加进去,避免它们被错误切碎。这个操作很多人会忽略,但往往直接影响搜索质量。
1.3 ES搜索体系全景:Query DSL、全文检索、聚合分析
ES所有的搜索能力都通过_search接口暴露,查询体用的是JSON格式的Query DSL。这套DSL按功能可以划分成几大类:
- 叶子查询:
match、term、range、exists、wildcard,这类查询只处理单个字段,不与其他查询组合 - 复合查询:
bool、dis_max、boosting,这类查询可以把多个叶子查询组合起来,控制它们之间的逻辑关系 - 聚合查询:
aggs,不是一个单纯的搜索结果,而是对结果集做统计分析,比如计算平均值、分组计数、按时间直方图统计 - 其他能力:排序、分页、高亮、脚本打分,这些是对查询结果做后处理的机制
实际业务中的搜索几乎不可能只有一个条件,所以bool查询是全篇的核心。bool下面有must、should、filter、must_not四个子句,分别对应“必须匹配”“应该匹配(影响打分不强制)”“必须匹配(不影响打分,只做过滤)”“必须不匹配”。
这里有个非常关键的细节:must和filter虽然都表示“必须是这个条件”,但must会参与相关度计算,filter只是直接过滤,不计分。对于“分类=手机且名称含华为”这种查询,分类条件应该放filter而不是must,因为分类是精确属性,不需要影响相关性排序。很多初学者写查询不区分这两个子句,代码能跑,但性能差了一截。
2. 基础搜索实操:5分钟跑通第一个查询
了解了原理,接下来直接上手。这一章我会按实际开发中最常用的顺序,从环境准备、基础查询、精准匹配到组合查询,一步步演示最常见的ES搜索操作。
2.1 环境准备:Windows上启动一个单机ES
如果是自己本地学习,最简单的方式是直接用Docker跑,但考虑到部分人所在环境不便用容器,我也把Windows直接启动的方式说一下。
Windows启动ES有几个关键点:
- ES不允许用root用户启动,Windows下也要避免路径中包含中文和空格
- JDK版本必须匹配:ES 7.x需要JDK 8或11,ES 8.x自带JDK,但如果要用本地JDK,需要JDK 17
- 启动前先设置JVM堆内存,修改
config/jvm.options,比如-Xms2g -Xmx2g,堆内存不要超过物理内存的一半
下载好压缩包解压后,进入bin目录执行elasticsearch.bat。启动成功的标志是控制台出现started日志,这时浏览器访问http://localhost:9200能看到一个JSON,里面包含集群名称、节点名称和ES版本号。
在Kubernetes环境里部署ES的话,过程会复杂不少。StatefulSet、持久化存储、资源配额、反亲和性都要配置,生产集群还涉及专用数据节点和专用主节点的分离。如果你用的是Kubesphere,可以通过其应用商店直接部署ES,但建议还是自己维护一份Helm values,因为默认配置通常不能满足生产需求。部署完成后的验证方式不变,仍然是访问9200端口的REST接口。
2.2 写入测试数据:先有数据才能搜
ES提供了非常灵活的索引接口。我先创建一个示例索引并写入几条商品数据,后面的查询都基于这份数据演示。
PUT /products { "mappings": { "properties": { "name": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "category": { "type": "keyword" }, "price": { "type": "double" }, "tags": { "type": "keyword" }, "description": { "type": "text", "analyzer": "ik_max_word" } } } }写入几条文档:
POST /products/_bulk {"index": {"_id": 1}} {"name": "小米14 Pro 手机", "category": "手机", "price": 4999, "tags": ["小米", "旗舰"]} {"index": {"_id": 2}} {"name": "华为Mate 60 手机", "category": "手机", "price": 6999, "tags": ["华为", "旗舰"]} {"index": {"_id": 3}} {"name": "小米手环8", "category": "智能穿戴", "price": 249, "tags": ["小米", "运动"]} {"index": {"_id": 4}} {"name": "华为智能手表GT4", "category": "智能穿戴", "price": 1488, "tags": ["华为", "运动"]} {"index": {"_id": 5}} {"name": "Redmi Note 13 手机", "category": "手机", "price": 1299, "tags": ["小米", "性价比"]}这里字段类型的选择本身就是搜索效果的关键。name和description用text类型,在倒排索引里被分词,支持全文检索;category和tags用keyword类型,不分词,支持精确匹配和聚合分析。两类字段的查询方式完全不同,我见过很多人在keyword字段上强行用match,结果怎么都搜不到想要的数据。
2.3 match查询:最常用的全文搜索
match查询是ES全文检索的主力,它会把查询字符串也做分词处理,然后逐个词条去倒排索引里找匹配的文档。查询“小米手机”时,ES会把它分成“小米”“手机”两个词,然后找同时包含这两个词或者其中一个词的文档。
GET /products/_search { "query": { "match": { "name": "小米手机" } } }这个查询返回的文档会包含:小米14 Pro手机、Redmi Note 13手机,甚至可能包含小米手环(因为包含了“小米”这个词)。所有命中文档都有一个_score分数,ES按分数降序返回,分数越高说明文档与查询的相关性越强。相关度计算默认用的是BM25算法,它综合考虑词频、文档频率和文档长度,长文档里出现一次关键词的权重会低于短文档里出现一次。
match还有一个常用变体match_phrase,它把查询字符串当作一个整体短语来匹配,要求词条必须按顺序相邻出现。搜“小米手机”的时候,match_phrase要求文档里必须连续出现“小米”和“手机”这两个相邻的词,就不会返回“小米手环”这类文档。这个查询在搜索商品标题、文章摘要时非常有用。
2.4 term查询:精确匹配与keyword字段
term查询做的事情和match完全不同,它不做分词处理,直接用你给的词条去倒排索引里找精确值。所以term查询最适用的场景是keyword类型字段的精确匹配,比如分类等于“手机”。
GET /products/_search { "query": { "term": { "category": { "value": "手机" } } } }搜索结果只会返回category精确等于“手机”的两条手机文档。这里必须特别提醒:不要在text字段上用term查询,因为text字段在写入时已经分过词,你用一整句话去term几乎匹配不到任何文档。比如索引里有一条description是“这是一款高性能手机”,分词后词条是“这是/一款/高性能/手机”,你用term查“高性能手机”这个词条是找不到的,因为这个词根本不在倒排索引里。正确做法是用match或match_phrase。
2.5 bool组合查询:多条件筛选的正确姿势
现实的搜索需求往往是复合的:“分类是手机,价格在1000到6000之间,品牌最好是小米”。这种查询就应该用bool来组合。我的推荐做法是:精确分类、价格区间这些不需要影响相关度的条件放filter,品牌偏好这类希望影响排序的放should,关键词匹配放must。
GET /products/_search { "query": { "bool": { "must": [ { "match": { "name": "手机" } } ], "filter": [ { "term": { "category": "手机" } }, { "range": { "price": { "gte": 1000, "lte": 6000 } } } ], "should": [ { "term": { "tags": "小米" } } ], "minimum_should_match": 0 } } }这个查询的含义是:名称必须包含“手机”关键词,分类必须为“手机”,价格必须在1000到6000之间,如果tags里包含了“小米”,则文档会获得额外的相关度加分。设置了minimum_should_match: 0之后,should子句变成纯加分项,不强制必须匹配。这是电商搜索里非常典型的查询结构——精确条件负责圈定范围,关键词负责筛选内容,标签偏好负责排序。
关于filter还有一个性能优势:filter条件会被ES缓存,同一个filter在后续查询中复用缓存结果时速度极快。尽量把高基数的词条查询和范围查询放进filter,既能减负又能提速。
3. 进阶搜索:排序、分页、高亮和聚合
基础查询能解决“把数据筛出来”,但真实业务还需要对结果排序、分页点击、高亮展示、统计分析。这一章讲清楚这些进阶能力,以及那些文档上写得不细致、实际使用到处都是坑的地方。
3.1 相关性打分:为什么结果排序是这样
ES默认按_score降序排序,BM25算法计算分数时综合了三个因素:
- 词频(TF):关键词在文档中出现的次数越多,分数越高
- 文档频率(DF):在所有文档中出现越稀有的词,权重越高,比如搜“手机”时,如果所有文档标题都带“手机”,这个词的区分度就低
- 字段长度(Field Length):字段内容越长,关键词出现一次的价值越低
大多数场景下默认排序够用,但有些业务却需要自定义排序。比如搜索“小米”,你希望商品优先展示,新闻其次,活动页最后,那么可以在查询里指定按类型字段排序:
GET /products/_search { "query": { "match": { "name": "小米" } }, "sort": [ { "category": { "order": "desc" } }, { "_score": { "order": "desc" } } ] }这个查询先按分类排序,同分类内再按相关度降序。如果你想彻底忽略相关度、只按价格排序,就把排序字段里的_score去掉。ES还支持自定义打分脚本,比如根据库存量调整分数、根据时间衰减调整热度权重,不过这属于高阶玩法,这里不展开。
3.2 排序和分页:from/size、scroll、search_after怎么选
ES提供三种分页方案,选错方案在数据量大时会有明显问题。这里说清楚各自的适用场景,我见过太多人在深分页上踩坑。
from + size是最直观的分页方式,适合页面点击跳转。但ES会从所有分片取出from + size条数据,再在协调节点排序聚合。from到10000以后,性能会断崖式下降。如果单个查询需要跨很多页扫描,服务端就需要消耗大量内存。
scroll是专门为大数据量遍历设计的方案,它会生成一个快照,服务器端保存上下文,像游标一样一批批拉取数据。适合做数据导出、reindex索引重建,不适合用户实时分页,因为快照下的数据不具备实时性。
search_after是三种方式里最推荐的分页方案。它不计算总条数,而是用上一页最后一条文档的排序值作为下一页的起点,天然避免深分页性能劣化。用户翻页时数据有新增也不会引起偏移,非常适合“加载更多”这种滚动式列表。
GET /products/_search { "size": 2, "query": { "match_all": {} }, "sort": [ { "price": "asc" }, { "_id": "asc" } ], "search_after": [249, "3"] }注意search_after必须配合一个唯一的排序字段组合,数据量大的时候用_id做tiebreaker保证顺序唯一。[249, "3"]就是上一页最后一条文档的price和_id值。
3.3 高亮显示:把命中的词标出来
搜索结果页通常需要把命中的关键词用特殊样式标出来,ES内置的highlight能力可以直接返回高亮片段。在查询中加上highlight段:
GET /products/_search { "query": { "match": { "description": "高性能" } }, "highlight": { "pre_tags": ["<em>"], "post_tags": ["</em>"], "fields": { "description": { "fragment_size": 50, "number_of_fragments": 3 } } } }高亮返回时会自动截取文档中包含关键词的片段。pre_tags和post_tags可以自定义HTML标签,前端直接渲染这个内容就能显示红色或加粗效果。这里有个小技巧:如果查询里用了match_phrase或bool多条件查询,高亮也能正常工作;高亮字段必须和查询字段一致,否则找不到高亮词。高亮的本质是ES对文档内容重新走一遍分词过程,启用高亮会增加查询耗时,如果对性能敏感,可以选择在前端自己处理。
3.4 聚合分析:从数据里挖出更多信息
ES的聚合(aggregations)是除了搜索之外的另一大能力,它可以在搜索结果的基础上直接做统计。电商后台要统计“每个分类下商品的平均价格”,可以用terms桶聚合配合avg指标聚合:
GET /products/_search { "size": 0, "aggs": { "category_group": { "terms": { "field": "category", "size": 10 }, "aggs": { "avg_price": { "avg": { "field": "price" } } } } } }把size设为0表示不返回原始文档,只要聚合结果。返回里会看到每个分类的名称、文档数量以及平均价格。terms桶聚合默认按文档数降序排列,可以设置order改成按子聚合结果排序,比如按平均价格从高到低。
聚合还有一个高频场景是时间序列统计。监控告警平台通常在ES里按分钟或小时做计数统计,用date_histogram很容易实现:
GET /products/_search { "size": 0, "aggs": { "sales_over_time": { "date_histogram": { "field": "create_time", "calendar_interval": "day" } } } }这种直方图聚合是日志分析和业务监控的基础,配合Kibana能直接画出曲线图。聚合查询的代价是额外的计算开销,在做大范围聚合时要注意控制size值,避免一次性返回过多桶。
4. Spring Boot集成:把搜索能力接到业务里
后端开发最关心的就是如何在自己的项目里调用ES搜索。这一章以Spring Boot 2.x为主,完整演示从引入依赖到写出一个可用搜索接口的全过程,同时把版本兼容、客户端选型、常见坑都梳理一遍。
4.1 从零搭建Spring Boot ES工程
Spring Boot集成ES主要有两代客户端:传统的RestHighLevelClient(Spring Boot 2.x风格)和全新的ElasticsearchClient(Elasticsearch 8.x官方风格)。考虑到目前存量项目里Spring Boot 2.x比例仍然很高,这里以RestHighLevelClient为例,但最后我会给出新老选择的建议。
先加依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency>并且在application.yml里配置ES地址:
spring: elasticsearch: uris: http://127.0.0.1:9200 connection-timeout: 3s read-timeout: 10s这里有个容易踩的坑:Spring Boot 2.x中spring.elasticsearch.uris属性对应的是RestHighLevelClient的自动配置,但有些版本还需要你手动声明RestHighLevelClientBean。我建议不要完全依赖自动配置,而是自己显式定义:
@Configuration public class EsConfig { @Bean public RestHighLevelClient restHighLevelClient() { return new RestHighLevelClient( RestClient.builder(new HttpHost("127.0.0.1", 9200, "http")) ); } }4.2 用RestHighLevelClient还是官方新客户端
ES 8.x之后官方主推新客户端ElasticsearchClient,API更符合现代Java风格。但旧项目的存量代码大多还基于RestHighLevelClient。如果追求稳,Spring Boot 2.x环境用RestHighLevelClient完完全全够用,只是后续ES版本升级时要注意兼容性。
判断客户端与ES服务端是否兼容,最简单的办法是检查版本号是否在同一大版本内。看到诸如“this version of the JDBC driver is only compatible with Elasticsearch version...”这类报错,本质就是版本不匹配。ES的JDBC驱动和REST客户端都有严格的版本对应关系,升级ES服务端时一定同步升级客户端依赖。另外,连接ES也可以用DBeaver这类通用数据库工具,但需要注意:DBeaver对ES的JDBC驱动兼容性同样受版本限制,老版本DBeaver连接ES 8.x经常报错,升级DBeaver到较新版本就能解决。
4.3 关键词搜索接口完整实现
假设我们要在商品服务里提供一个搜索接口,入参是关键词keyword和分类category,返回商品列表。先定义查询逻辑:
@Service public class ProductSearchService { @Autowired private RestHighLevelClient client; public List<Product> search(String keyword, String category, int page, int size) throws IOException { SearchRequest searchRequest = new SearchRequest("products"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // 关键词全文检索 if (StringUtils.hasText(keyword)) { boolQuery.must(QueryBuilders.matchQuery("name", keyword)); } // 分类精确过滤 if (StringUtils.hasText(category)) { boolQuery.filter(QueryBuilders.termQuery("category", category)); } // 默认排序用关联度,也可以按价格 sourceBuilder.query(boolQuery); sourceBuilder.from((page - 1) * size); sourceBuilder.size(size); sourceBuilder.sort("_score"); searchRequest.source(sourceBuilder); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); // 解析结果(略) return parseResponse(response); } }这段代码对应的JSON查询体就相当于第2章里的bool组合查询。实际项目中建议把这种查询构建封装成一个工具类,避免每个接口重复写一大段Java代码。有个更优雅的替代方案是Spring Data Elasticsearch提供的ElasticsearchRestTemplate,它封装了大量常用操作,代码量可以缩减一半以上。但封装也意味着你在定制精准查询时会更受限,我的建议是:简单查询用ElasticsearchRestTemplate,复杂聚合、嵌套查询、脚本打分等用原生RestHighLevelClient。
4.4 常见集成坑:连接版本不匹配、Nested查询、字段映射
Spring Boot集成ES的过程中,有几个坑几乎每个项目都会遇到。
一是版本不匹配。Spring Data Elasticsearch对ES服务端版本非常敏感,Spring Boot 2.4搭配ES 7.10通常没问题,但如果ES升到8.x而Spring Boot还停留在2.x,启动时大概率会报Unsupported major.minor version或者兼容性异常。升级策略应该是先升级Spring Boot,再升级ES客户端。
二是Nested嵌套查询。如果你的索引结构里有对象数组,想在嵌套对象上做组合过滤时,bool查询需要配合nested查询。比如订单索引里包含商品明细数组,要找“包含商品A且数量大于2”的订单,必须用nested:
QueryBuilders.nestedQuery( "items", QueryBuilders.boolQuery() .must(QueryBuilders.termQuery("items.productId", "A")) .must(QueryBuilders.rangeQuery("items.quantity").gt(2)), ScoreMode.None );不写nested而直接查items.productId,ES会报错或者返回错误结果。“搜索二叉树”里提到的二叉树和ES没关系,千万别混淆。
三是字段映射不一致。索引里字段如果已经被keyword类型存储,Java代码里却用matchQuery查询,往往查不到数据。Java查询的类型语义必须和索引mapping保持一致,text字段用match,keyword字段用term。
5. 搜索慢、写入慢怎么排查:那些指标才是关键
线上ES最常被问的问题就两个:写入慢了怎么办?搜索慢了怎么办?很多人上来就怀疑磁盘坏了,或者要求加机器,其实排查方向完全错了。这一章我按实际运维经验把排查顺序和关键指标讲清楚。
5.1 写入慢:先分清楚是磁盘问题还是索引配置问题
判断ES写入慢的根因,第一步是看磁盘写入延迟。ES写入数据要先写translog,再写Lucene的segment文件,这两步都依赖磁盘性能。使用iostat -x 1命令可以观察到%util和await指标,如果await持续高于10ms,说明磁盘I/O有压力,但还不一定是问题;如果tps很小但await很高,大概率是磁盘随机写性能差。
磁盘是否是瓶颈,还可以结合ES自身的nodes.stats接口来验证:
GET /_nodes/stats?filter_path=nodes.*.fs,nodes.*.jvm,nodes.*.indices重点看fs.total.free_in_bytes剩余空间和jvm.gc.collectors.old.collection_time_in_millis垃圾回收耗时。如果磁盘空间剩余低于15%,ES会触发防雪崩机制,自动把索引置为只读;如果老年代GC很长且频繁,写入线程会被阻塞,比其他硬件问题更容易导致写入变慢。
如果磁盘I/O没问题,下一步看refresh_interval设置。ES默认每秒自动refresh一次,每次refresh都把内存buffer里的数据刷成segment,这是一个高频I/O操作。对于大批量写入场景,可以临时调大refresh间隔,比如改为30秒,吞吐会有明显提升。我在批量导数据时经常先把副本数降为0,refresh间隔调到30s甚至更高,导完再恢复,速度能快好几倍。
写入慢还有一个容易忽略的元凶是未合并的巨大segment。ES后台有merge逻辑,把零散小segment合并成大segment,但如果写入过快或者segment过小,merge线程会一直忙碌。查看:
GET /_cat/segments/products?v如果看到大量很小的segment,就需要调大index.merge.policy.segments_per_tier参数。另外mapping里字段过多也是个隐患,ES在写入时对每个字段都要生成倒排索引,字段数翻倍,写入开销也接近翻倍。能用keyword的就不要用text,能用doc_values: false的就关掉。
5.2 搜索慢:从慢日志到profiling逐层定位
搜索慢的排查思路和写入不同。第一步永远是看慢查询日志。ES支持按查询耗时阈值记录慢日志,配置在索引模板里长期生效:
PUT /_template/slowlog { "index_patterns": ["logs-*", "products-*"], "settings": { "index.search.slowlog.threshold.query.warn": "1s", "index.search.slowlog.threshold.query.info": "500ms", "index.search.slowlog.threshold.fetch.warn": "1s" } }一旦出现超过阈值的查询,ES会打印出完整的查询语句。很多慢查询一眼就能看出问题:比如在text字段上用了wildcard查询(*小米*这种),这个操作不走倒排索引,性能非常差;再比如过滤条件没用filter而全放在must里,导致ES反复计算相关度分数。
慢日志定位到具体的慢查询之后,可以用ES提供的能力做更深分析。从ES 7.x开始支持Profiling接口,它会返回查询各阶段的耗时分布:
POST /products/_search { "profile": true, "query": { "match": { "description": "高性能" } } }返回结果里的time字段能看到每个子查询在Lucene阶段耗费了多少时间,breakdown下面可以继续拆分成更细的统计项。正常情况下,搜索慢的几个热门原因是:
- 查询结果集过大,一次拉取上万条数据
score计算量过大,比如match没有加filter减小结果集- 深分页,
from值太大导致协调节点内存消耗 - 聚合和搜索混在一起,
aggs在大数据集上计算开销极高
5.3 集群层面:CPU、内存、GC怎么配合排查
单节点排查只是第一步,集群层面还要关注三件事:CPU使用率、堆内存占用、垃圾回收频率。
查看集群健康:
GET /_cat/health?v=true GET /_cat/nodes?v=true&h=name,cpu,load_1m,heap.percent,ram.percent,node.role,masterCPU持续100%一般意味着查询或聚合CPU密集,排查方向聚焦到具体查询上。如果JVM堆内存使用率长期超过85%,GC压力会陡增,老年代回收时间超过500ms/次就必须介入。堆内存设置过高也未必好,ES建议最大不要超过30GB,这是JVM压缩指针的临界值;超出后内存浪费会很严重。
还有一个常见但很多人不看的重要指标:线程池队列和拒绝数。ES有search、write、bulk等多个线程池,如果请求量超过了线程池处理能力,任务会堆积在队列里,队列满后直接拒绝新请求。查看方式:
GET /_cat/thread_pool?v=true重点关注search_pool和write_pool。如果出现大量rejected计数,说明集群确实过载了,此时最应该做的是增加节点横向扩容,或者降低单个请求的大小,而不是继续调节点参数。
5.4 实战案例:一次搜索从500ms到80ms的优化过程
说一个我实际经历过的场景。某个电商后台的商品搜索接口,高峰期查询平均耗时500ms,前端体验很糟糕。初步排查发现有两个问题:查询里几乎所有条件都放在must里,包括分类、价格区间、库存状态这些精确过滤字段;同时接口每次都执行了一个大聚合,统计全量商品的分类分布。
优化动作分三步走。第一步,把分类、价格区间、库存全部从must挪到filter,让ES跳过相关度计算并启用过滤缓存,这部分查询消耗直接减半。第二步,把大聚合从列表接口中移除,改成单独的统计接口,后端用定时任务预热聚合结果到Redis,列表页不再实时聚合。第三步,给索引配置了慢日志阈值,方便后续持续监控。
三步改完之后,搜索查询耗时从500ms下降到80ms左右,效果非常明显。这个案例想说明一个排查原则:性能优化不是盲目升级硬件,先看查询结构是否合理,再看聚合是否必要,最后才考虑资源扩容。
6. 结合实际场景聊搜索:日志平台、电商检索的通用设计
搜索能力在不同场景下的用法差异很大,但底层的思路相通。以日志搜索平台为例,ELK架构是最典型的实践:Filebeat采集日志、Logstash或Logstash轻量管道解析、ES建索引、Kibana做可视化。日志场景对搜索的要求集中在全文检索和时间范围过滤,日志量通常巨大,所以索引按天或按月滚动、定期清理旧索引是常规操作。日志搜索常用命令里,最核心的就是grep和tail,但到了ES这层,大家用得最多的反而是Kibana的KQL语法,它的核心实现同样是match和range的组合。
电商检索则需要考虑更复杂的业务规则:搜“小米手机”,需要处理品牌词、类目词、产品词的识别和权重分配;搜索结果页可能要优先展示有库存、有销量的商品;搜索“华为”的时候,可能还想推荐关联的配件。这类需求通常会用function_score来调权,用bool组合业务条件,再配合term的filter进行品牌、价格筛选。但无论场景多复杂,核心永远是:全文搜索用match,精确筛选用term加filter,相关性不够时加function_score做加权。
关于搜索排序和推荐,还有一个比较轻量的做法:把用户点击率、购买量、最新上架时间这些业务指标写入索引,用脚本分数或字段排序做最终排序。这些做法本质上是在“相关度”之外引入“业务权重”,你可以根据自己的需求自定义,没有标准答案,只有不断迭代的实践。
7. 这里还有几个容易踩坑的地方,集中提醒
搜索引擎看似简单,坑其实都在细节里。
查询不出数据的时候先查mapping。很多“为什么搜不到”的问题,翻一下索引的mapping就明白了——字段类型错了,分词器没生效,或者字段根本没建索引。查看mapping的命令很简单:
GET /products/_mapping分页页数太深导致OOM。用户端无限滑动的列表,必须用search_after,别用from/size硬翻一百页。每次查询都带全字段返回也会拖慢速度,_source只需要返回需要的字段,在SearchSourceBuilder里做字段过滤:
sourceBuilder.fetchSource(new String[]{"name", "price"}, null);聚合桶太多导致内存爆炸。terms聚合的size设成几万,ES会把所有桶放入内存排序,数据量大时直接OOM。聚合的桶数控制在业务实际需要的范围内。
高亮、排序、分页在同一个查询里叠加时,性能下降可能是指数级的。线上高并发场景下,如果不需要高亮,就别加这个功能;不需要精确总条数,就别调用_count接口。
还有一类问题是数据不一致。ES的实时性不等于事务性,写入的数据要等refresh之后才能被搜到。默认1秒的refresh间隔意味着刚写入的数据最多延迟1秒可见。如果需要实时性更强,可以调refresh=true强制刷新,但代价是性能折损。索引生命周期管理能帮你自动清理过期数据,很多团队一开始不考虑这个问题,运行半年后才想起来要删除旧索引,那时磁盘已经满了。
8. 最后说点我的实际经验
做ES的人,踩坑是绕不过去的学习路径。我自己最深刻的体会有三条。
第一,ES的所有操作都围绕倒排索引展开,理解了这个核心概念,很多问题会迎刃而解。比如为什么text字段不能用term精确查,为什么keyword字段不能用match做全文检索,为什么filter能缓存而must不能,这些看着像记忆的点,其实都是倒排索引的必然结果。
第二,慢搜慢写不要急着加机器,先看查询结构,再看索引配置,然后是GC和磁盘,最后才是扩容。我见过很多集群节点很多,但查询结构一团糟,机器加得再多也是白搭。反过来,一个合理的查询结构加合理的索引配置,往往比盲目扩节点带来的收益更大。
第三,ES的运维最怕的是“集群没监控裸奔”。慢日志一定要开,GC监控一定要做,磁盘用量预警一定要配。你不需要一开始就搭一套完整的监控平台,先用最简单的_cat接口定期记录几个核心指标,出问题的时候至少有个参考基线。等业务壮大了,再用专门的监控组件补全。
最后分享一个小技巧:排查搜索结果不准的问题,直接对比同一关键词在MySQL里的结果和ES里的结果,通常一眼就能发现是分词、mapping还是查询写法的问题。搜索引擎不怕数据量大,怕的是你根本没弄懂它的设计逻辑。希望这篇文章能帮你少走一些弯路。