1. 查询体系的大局观:先看清几个决定成败的基础概念
如果每天都在跟 Elasticsearch 打交道,你很快会发现,百分之八十的日常其实都在写查询。无论是运维排查线上日志、给业务方做实时看板,还是给站内搜索调相关性,翻来覆去就是那几类 DSL。但"会用"和"用得明白"完全是两回事,很多人卡住的点往往不是某个查询写不出来,而是没搞懂 ES 查询背后的几个基础机制。
1.1 三种查询入口,你该用哪个
接触 ES 的人第一件事就是学会发请求。常见的入口有三个:
- REST API:直接用 curl 或者 Postman 打 HTTP 接口,适合脚本化和跨语言调用。
- 官方客户端:Java、Python、Go 客户端都有自己的 API 封装,适合业务系统里嵌代码。
- Kibana 的 Dev Tools:这是我个人最推荐的学习和排查工具。它相当于一个 REPL 环境,能自动补全、能格式化、能直接看到返回的 JSON,调试查询的效率比 curl 高太多。
实际项目里我基本是这么分工的:日常验证、排查问题用 Kibana Dev Tools,生产代码用官方客户端。很多新手一上来就抱着 Java Client 的 API 文档啃,把查询调得焦头烂额,其实先在 Dev Tools 里把 DSL 跑通了,再翻译成客户端代码,思路会清晰得多。
1.2 Query Context 和 Filter Context,不是简单换个单词
这是整个查询体系里最容易被忽略、但影响最大的一个概念。简单说,Query Context 里的条件会参与相关性打分,满分 0 到几十都有可能,而 Filter Context 里的条件只负责过滤数据,完全不参与打分。
我用一个有点俗但很贴切的类比:Query Context 像相亲时的"心动评估"——既要看条件匹配,还要给每个候选人打个心动指数;Filter Context 像简历硬筛——学历不达标直接刷掉,不需要算分。
两者的差别直接体现在性能上。Filter 条件可以被 ES 缓存到节点级过滤器缓存里,第二次执行同样条件的查询时直接走缓存,速度飞快。而 Query 条件因为要算分,难以套用同样的缓存策略。所以凡是"必须满足但不影响排名"的条件,比如时间范围、状态字段、价格区间,一律放进 filter 里。
排查慢查询的时候,我看到过太多人把时间范围写在 must 里,然后抱怨 ES 慢。实际情况是,把这种纯过滤条件挪到 filter 子句里,查询耗能降一个数量级是常有的事。
1.3 从零看懂一个最简查询
先看一个最基础的请求:
GET my_index/_search { "query": { "match_all": {} }, "from": 0, "size": 20, "sort": [ { "@timestamp": "desc" } ] }query.match_all:匹配所有文档,通常用于全量扫描或测试连接。from/size:控制跳过前多少条、返回多少条,这就是分页的基本形态。sort:指定排序字段,默认不写时按相关性分数_score降序。
这个查询虽然简单,但它背后暴露了一个关键事实:ES 的查询结果是 JSON 结构,里面包含took(耗时)、timed_out(是否超时)、hits.total(命中总数)、hits.hits(实际命中的文档列表)等关键信息。我调试任何查询都会先看took和total,前者告诉我响应快慢,后者告诉我条件有没有写宽。
2. 全文检索:搞懂分词才能搞懂 match 系列
全文检索是 ES 的看家本领。很多人搜索"苹果",结果里出现"iPhone 苹果公司",以为 ES 出 Bug 了,其实是没理解分词和倒排索引的工作原理。
2.1 match 查询的隐藏参数:operator 与 minimum_should_match
match是最常用的全文查询,它先把搜索词交给分析器分词,再查倒排索引。默认逻辑是:只要文档命中任意一个分词后的词项,就算匹配,且命中词项越多得分越高。
举个例子,搜索词"无线蓝牙耳机",标准分词器会切成"无""线""蓝""牙""耳""机"多个单字(中文尤其明显),默认情况下文档只要包含其中任何一个单字就会被召回。这就是为什么搜长句子经常"啥都有"。
要收紧召回范围,可以用operator: "and"强制所有词项都要命中:
GET products/_search { "query": { "match": { "title": { "query": "无线蓝牙耳机", "operator": "and" } } } }还有更精细的minimum_should_match,它可以设成数字或百分比,意思是至少多少个词项命中才返回文档。比如搜索词分成 5 个词项,要求至少 60% 命中,就写成"minimum_should_match": "60%"。这个参数在生产环境里调搜索召回率时极其常用,比 operator 的"全有或全无"灵活得多。
2.2 match_phrase 和顺序敏感的场景
match_phrase要求分词后的词项不仅全部出现,而且顺序和位置要保持一致。比如搜"北京天气",如果分词器把文档切出"北京""天气"两个词且相邻出现,才能命中。
它的slop参数可以放宽"必须相邻"的限制,表示允许中间隔几个词。比如搜"北京 天气","slop": 2可以匹配到"北京今天的天气"这种中间插了两个词的情况。这个参数在英文场景特别常用,因为英文分词天然按空格分单词,但中文要看分析器具体切出了多少词项,所以我一直提醒大家:写 match_phrase 之前,先用_analyzeAPI 看一眼这个字段的分词结果,如果分词器把"北京天气"直接切成了一个整词,那 slop 再大也没意义。
2.3 multi_match 做跨字段搜索时的三个 type
业务里经常遇到"一个输入框搜多个字段"的需求,multi_match就是干这个的。它有三种主要的搜索策略:
best_fields:取多个字段中分数最高的那个作为文档总分,适合"标题或描述有一处强相关就行"的场景。most_fields:把多个字段的分数相加,适合"多个字段都相关"的场景,但实际使用容易引入词频噪音。cross_fields:把一个搜索词按词项拆分,再把所有字段当作一个整体来匹配,适合"名字分开存"的典型场景。比如某系统把中文名拆成first_name和last_name,用户搜索三个字的名字时,用best_fields会因为名字被拆到两个字段而各自只命中一部分,得分反而不高,cross_fields则能正确地把跨字段的词项拉回同一位置计算。
GET users/_search { "query": { "multi_match": { "query": "张三丰", "fields": ["first_name", "last_name"], "type": "cross_fields", "operator": "and" } } }2.4 中文分词的选型教训
如果数据是中文,默认的标准分析器按单字切分,效果很差。我在一个项目里踩过这个坑:搜"合同"能召回"合同条款"没问题,但搜"合"也能召回大量带"合"字的无关文档,召回率虚高、准确率极低。
后来换成了 IK 分词器。IK 有两种模式:ik_smart做最粗粒度的切分,比如"武汉市长江大桥"会切成"武汉市""长江""大桥";ik_max_word做最细粒度的枚举,会切出"武汉""武汉市""长江""大桥""江大桥"等一堆词。索引时用ik_max_word保证召回,查询时用ik_smart保证准确率,这是社区里普遍认可的组合。但要注意,改索引分词器意味着你之前的数据必须重建索引,所以字段类型和分词器的选择,最好在一开始就定下来,不然后期迁数据很痛苦。
3. 精确查询:term、terms、range 与最典型的字段类型陷阱
全文检索解决"相关度"问题,精确查询解决"是与否"问题。但恰恰是最简单的 term,坑了一波又一波新手。
3.1 为什么 term 查不到数据
这是个高频问题,几乎每周都能在技术群里看到一次:明明文档里有个字段值是"手机",term查询却返回空。原因很简单——字段类型是text。
text字段在索引时会被分词,倒排索引里存的是分词后的词项,不是完整字符串。term 查询不做分词,直接把"手机"当作一个整体词项去匹配倒排索引。如果分词器把"手机"切成了"手""机"两个单字,你当然查不到"手机"这个词项。解决办法有二:一是查询改用match,让系统对搜索词也做分词;二是映射里同时保留一个keyword子字段,在 keyword 字段上用 term 做精确匹配。
我排查这个问题时的标准动作是:先看 mapping,再用_analyzeAPI 看这个字段到底切成了什么词。这两步能定位 90% 的查询"失踪"问题。
3.2 terms 的语义误区
terms是 term 的复数版本,但它的语义经常被误解。它表示"文档字段值只要匹配给定的任意一个值"就算命中,类似于 SQL 里的IN。
很多人以为terms是让一个数组字段完全等于给定数组,这是一个经典误区。ES 原生没有提供"字段数组必须等于某个数组"的查询,真要做只能通过 script 或者把数组哈希后存到一个 keyword 字段再精确匹配,后者是更推荐的工程方案。
3.3 range 查询与日期数学的坑
range使用频率极高,尤其是时间范围过滤。基本参数是gt、gte、lt、lte,语义分别是大于、大于等于、小于、小于等于,注意不要和gt的大小写混了。
时间范围上有个很隐蔽的天坑:ES 内部存储时间默认是 UTC 时区。如果你在查询里写"gte": "2024-01-01 00:00:00",ES 会当作 UTC 时间处理,也就是北京时间的早上八点,过滤结果自然和预期不一致。解决办法是在查询里显式加"time_zone": "+08:00",或者在写入时统一把时间转成 UTC 再存。
日期还可以做数学运算,比如"gte": "now-1d"表示 24 小时前,"lt": "now/d"表示今天零点。需要小心的是now是当前时刻,now/d是按天取整到零点,两者配合使用时会有边界歧义,建议在关键场景先用一两条带range的查询验证返回范围内的首尾时间是否符合直觉。
3.4 exists 查询与空值过滤
业务里经常要查"某个字段有没有值",比如过滤出填写了手机号的用户。exists查询就是干这个的:
GET users/_search { "query": { "exists": { "field": "phone" } } }这里有个细节必须注意:字段存在但值是空字符串,在exists看来是"存在",因为空字符串也是值;字段存在但值是null,在exists看来是"不存在";但如果你写入时用的是 JSON 的null,ES 有可能根本不建索引,也可能建了一个显式的 null value,取决于 mapping 里的null_value设置。我建议在数据写入时就统一清洗空值和null,避免查询时反复猜测。
4. 组合一切:bool query 才是真正的业务骨架
真实业务里几乎不会只有一个条件,往往要同时满足时间范围、状态、关键词、排除条件多个维度。bool查询就是把这些条件组装起来的骨架。
4.1 must、should、must_not、filter 的含义与分工
一个完整的 bool 查询包含四种子句:
must:必须满足,且参与打分。filter:必须满足,不参与打分,可走缓存。should:满足则加分,但不强制。must_not:必须不满足,纯过滤,不参与打分。
我直接用个例子说明。假设一个商品搜索需求:标题包含"手机",价格在 1000 到 3000 之间,品牌是"小米"或"华为",排除没有库存的商品。
GET products/_search { "query": { "bool": { "must": [ { "match": { "title": "手机" } } ], "filter": [ { "range": { "price": { "gte": 1000, "lte": 3000 } } }, { "terms": { "brand": ["小米", "华为"] } }, { "exists": { "field": "stock" } } ], "must_not": [ { "term": { "stock_status": "sold_out" } } ] } } }这个结构把"影响相关度"的条件和"硬性过滤"条件分得很清楚。must里的 match 负责算分,filter 里的范围和集合条件负责缩小数据量,must_not排除不想看到的。实际使用中,我把绝大多数过滤条件都放在 filter 里,因为它们的集合越小,后面算分的文档就越少,性能越好。
4.2 should 的默认行为和踩坑现场
should的行为有一条容易记混的规则:当 bool 查询里存在must或filter时,should条件满足与否不影响文档是否被命中,只影响加分;当 bool 里只有should没有其他条件时,至少得满足一个should才会返回文档。
这个规则导致的经典事故是:开发者在搜索接口里加了一个"推荐商品加分"的should条件,以为只是加分,结果因为 bool 里没有must,变成了"必须满足推荐条件",线上搜索结果突然少了一大片。排查半天才反应过来,是should的默认语义变了。
如果需要should在存在其他条件时也强制至少满足几条,可以在 bool 外层加"minimum_should_match": 1,但绝大多数场景里,正确理解默认规则比加参数更重要。
4.3 boost 权重:控制相关性的方向盘
同一份文档,在不同字段里出现了同一个关键词,权重应该不一样。比如"手机"出现在标题里,和出现在描述里,重要性完全不同。boost就是干这个的:
GET products/_search { "query": { "bool": { "should": [ { "match": { "title": { "query": "手机", "boost": 3 } } }, { "match": { "description": { "query": "手机", "boost": 1 } } } ] } } }boost 是乘法因子,3 表示"标题命中的得分按 3 倍计算"。实际调优时,我从不用特别大的 boost(比如 100),因为分数会被非线性放大,导致排序畸形;一般控制在 1 到 5 之间,效果最可控。记住一点:boost 只影响排序,不影响召回。
4.4 filter 缓存与短路执行
前面反复强调 filter 不进打分、可缓存,但还有一个细节很多人不知道:ES 在评估 filter 条件时,会先执行能快速排除数据的条件,而且多个 filter 之间是可以短路并复用的。比如你同时过滤了时间和品牌,第二次执行相同条件时,ES 直接复用缓存里的位图结果,几乎零成本。
所以我的习惯是:把所有无需打分的过滤条件集中放到 filter 里,而不是散落在 must 里。这不仅是代码可读性的问题,更是性能问题。
5. 聚合查询:从检索到分析的跃迁
光会把数据查出来还不够,业务方要的是"统计、分组、趋势"。ES 的聚合(Aggregation)能力不亚于专门的 OLAP 系统,关键是理解它的三大类型。
5.1 Metric 聚合与 Bucket 聚合,本质是两层逻辑
Metric 聚合负责算数值,比如平均值、最大值、总和、百分位、去重计数,对应的是"这一个数是多少"。Bucket 聚合负责分组,比如按品牌分桶、按时间分桶、按价格区间分桶,对应的是"有多少组,每组有哪些数据"。真正的分析报表,几乎都是"先分桶,再在桶里算指标"。
举例:按品牌统计商品平均价格、最高价格和商品数量。
GET products/_search { "size": 0, "aggs": { "by_brand": { "terms": { "field": "brand.keyword", "size": 10 }, "aggs": { "avg_price": { "avg": { "field": "price" } }, "max_price": { "max": { "field": "price" } } } } } }注意我把size设成了 0,因为聚合场景下我们通常不关心具体文档明细,只要聚合结果。这一步能显著减少网络传输量,是聚合查询的第一个性能优化动作。
5.2 terms 聚合的文档数和预估误差
terms聚合是最常用的分组聚合,但它有几个行为需要特别注意。
第一,默认只返回分组最多的前 10 个桶,因为 ES 在每个分片上先取 Top N,再合并,全局 Top 值越大,精确度越差。想要更多组就调大size,但结果集也会变大。
第二,返回结果里有个字段叫doc_count_error_upper_bound,它表示当前分桶统计的误差上界。如果这个值不为 0,说明你看到的桶计数可能不是真实的全局精确值。这是分布式求 Top N 的固有代价,正常现象,但做业务报表时千万别把它当成精确数据。
第三,text字段不能直接用于 terms 聚合,报错内容会提示 fielddata 相关。原因还是 text 字段值被分词,聚合到"词项"层面没有业务意义。解决方案是映射里建keyword子字段,在聚合时指定.keyword。
5.3 date_histogram 做时间趋势统计
日志和业务指标场景里,按时间分桶画趋势图是标配需求。date_histogram就是干这个的:
GET logs-*/_search { "size": 0, "aggs": { "requests_over_time": { "date_histogram": { "field": "@timestamp", "calendar_interval": "hour" } } } }calendar_interval支持minute、hour、day、week、month等日历单位。ES 7 之前用的是interval字段,旧代码升级后容易报参数错误,新项目直接用calendar_interval就好。还有一个fixed_interval,用于固定长度的时间窗(比如 90 秒这种非日历单位)。两者的区别是:日历单位会考虑时区和月份天数差异,固定单位就是纯等长切分。
时间聚合里还有个易漏的点:如果某些桶没有数据,默认不会出现在结果里。想要补零,需要在聚合里加"missing": 0参数。做图表时这个参数经常被忽略,输出曲线会有断点。
5.4 Cardinality 聚合与去重计数的精度
统计 UV、去重用户数这类场景,ES 用cardinality聚合。它的实现基于 HyperLogLog 算法,是一个近似值,默认精度是 0.1%。大部分业务场景这个误差完全可以接受,但它确实不是精确值。如果业务要求精确去重计数,ES 未必是合适的工具,需要走精确计算或者外部系统。
6. 排序与分页:驾驭结果集的稳定性
查询结果出来了,第二个问题是怎么排序、怎么一页一页翻。
6.1 默认排序与自定义排序的共存关系
不写sort时,ES 按_score降序返回。一旦写了sort,ES 默认优先按sort字段排序,_score就不再是主排序依据。很多人会惊讶:为什么加了 sort 之后相关性排序就失效了?这就是原因。
多字段排序的写法是数组依次排优先级:
GET products/_search { "sort": [ { "price": "asc" }, { "sold_count": "desc" }, "_score" ] }含义是:先按价格升序,价格相同按销量降序,再相同按相关性得分降序。价格相等的情况在商品场景里太常见了,不追加次级排序会出现不稳定翻页。
6.2 深分页为什么不能碰
from + size是最直观的分页方式,但它在数据量上来后极其危险。假设from: 100000, size: 10,ES 要在每个分片上先拉取 100010 条数据,汇聚到协调节点排序后再丢到 100000 条,最后只剩 10 条给用户。翻到第 1000 页时,每个分片都要准备海量数据,内存和网络开销都受不了。
另一个硬限制是index.max_result_window,默认值为 10000。只要from + size超过这个值,查询直接报错Result window is too large。有人图省事直接把它调大,这是饮鸩止渴——本质上是在透支内存,迟早把节点压垮。
6.3 scroll 和 search_after,各守一摊
深分页的官方方案有两个,适用场景完全不同。
scroll适合一次拉取大量数据的批处理场景,比如导出数据、给数据分析平台刷数据。它的工作方式是一次性做快照,之后靠scroll_id滚动,所以很快,但快照意味着写入了新数据也不会反映在后续滚动结果里。滚动期间会占用搜索上下文资源,用完必须调用DELETE /_search/scroll/{scroll_id}清除,否则内存会悬着不释放。
search_after适合实时翻页场景,比如用户在页面上点"下一页"。它的原理是用上一页最后一条数据的排序值作为下一次查询的起点,天然没有from + size的跳页能力,也没有数据快照问题。它要求排序字段必须稳定且有唯一性兜底,实践上我习惯在排序里追加_id字段:
GET products/_search { "size": 10, "sort": [ { "price": "asc" }, { "_id": "asc" } ], "search_after": [2999, "product_12345"] }这样即使两个商品价格完全相同,_id 也能保证严格有序,翻页不会重复或漏掉。
7. 性能排查与实战避坑:老司机的工具箱
前面讲了各种查询怎么写,现在聊点更值钱的——查询出问题怎么排。
7.1 排查询问题,三件套就够了
第一件:GET index/_mapping。看字段类型是不是和查询匹配,text和keyword混用是最常见的问题来源。
第二件:GET index/_search加上"explain": true,可以看每个文档的得分来源和命中原因,尤其适合排查"为什么这个文档排在了前面"。还有一个GET index/_explain/{id},针对单个文档分析它为什么命中或没命中某个查询。
第三件:"profile": true。在查询请求里加这个参数,ES 会返回每个子查询的耗时分布。我之前排查一个慢查询,发现大部分时间耗在一个script排序上,把 script 改成field排序后,查询时间从 800ms 降到 20ms,这就是 profile 的价值。
再有就是集群的慢查询日志。可以在索引级别配置index.search.slowlog.threshold.query.warn等参数,把超过耗时的查询记录下来,方便事后复盘。线上系统我一直开着这个功能,阈值设成 1 秒,它能帮我自动抓到"偷偷变慢"的查询。
7.2 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| term 查不到值 | 字段是 text 类型,被分词了 | 改用 keyword 子字段,或改用 match |
| match 搜长句召回太滥 | 默认分词后任意词项命中即可 | 加 operator: "and" 或 minimum_should_match |
| 深分页报错 Result window too large | from + size 超过 10000 | 改用 search_after 或 scroll |
| 时间查询结果和预期差八小时 | 没考虑 ES 默认 UTC 时区 | 查询里加 time_zone,或写入时换算 |
| bool 里用了 should 但结果变少 | should 在其他条件存在时不强制,但只有 should 时必须至少命中一条 | 检查 bool 结构,必要时加 minimum_should_match |
| text 字段聚合报错 | text 字段不可直接聚合,需要 fielddata 或 keyword 子字段 | 映射里建 keyword,用 keyword 聚合 |
| 查询慢且 filter 没起作用 | 过滤条件写在 must 里参与打分 | 移到 filter 子句 |
| 搜索结果排序不稳定 | 排序字段有重复值 | 追加唯一字段如 _id 做次级排序 |
| 聚合计数和实际文档数不一致 | terms 聚合是分片 Top N 合并,有误差 | 关注 doc_count_error_upper_bound,或调大 shard_size |
7.3 一次线上"搜索变慢"问题的复盘
这节我用自己的经历讲一个排查过程。某天线上搜索接口平均响应时间从 50ms 飙到 300ms,我先看慢查询日志,发现全是对同一个大索引的match查询。第一反应是看这些查询的条件,发现请求里带上了大范围的时间过滤,但时间条件写在must而不是filter里。同时业务方在bool里加了好几个should条件,导致每次查询都要对所有命中文档做复杂打分。
我做的调整就两步:把时间范围和状态过滤全部挪到filter;把不参与相关性排序的条件从should里拆出去。然后把size从默认的 10 改成了业务真实的 20(默认也可能多查了)。改造后的查询耗时就降到了 35ms 左右。这个经历让我养成了一个习惯:每次写 bool 查询,先数一下哪几个条件是"硬条件",哪几个是"软加分",硬条件一律填 filter。
还有一个容易忽略的细节:查询结果集大小。如果每条文档里都带着巨大的大文本字段,而列表页只需要标题和 ID,可以用_source过滤只拉需要的字段:
GET products/_search { "_source": ["title", "price", "brand"], "query": { ... } }这一步能把网络传输和内存占用降一个量级。列表接口尤其该用。
我个人的体会是,ES 查询写多了,核心并不在背 API,而在理解三个问题:这个字段是什么类型、这个条件要不要打分、这页数据怎么翻。想通了这三个,剩下的都是组合与微调。调试的时候别瞎猜,一步步看 mapping、看 explain、看 profile,问题一般三分钟内能定位。