1. 两种查询方式到底指什么——先把概念理清楚
1.1 从“怎么查数据”说起,ES查询的本质
很多人学 ElasticSearch 学到进阶这一步,会突然觉得有点乱:q=xxx能查,body里写query也能查,term和match长得差不多,用起来结果却不一样。我也经历过这个阶段,翻了几天文档才意识到,问题不在于记不住 API,而在于没把 ES 查询的“底层逻辑”理顺。
ES 的查询,本质上就做两件事:匹配哪些文档,以及这些文档有多匹配。前者决定结果集长什么样,后者决定结果的排序权重。理解了这一点,再回头看各种查询写法,就会发现它们都是在用不同的方式告诉 ES 这两件事。
先给个生活化类比。你在电商网站搜“智能手表 防水”,后端如果走 ES,请求最终会落到两个层面:一个负责“把包含相关词的宝贝找出来”,另一个负责“算一算每个宝贝跟你的搜索意图有多贴近”。找哪些、怎么排,就是查询要解决的全部问题。
而“两种查询方式”,在我这套 ES 实战笔记里,指的是两条完全不同的查询入口:URI Search和Query DSL。它们都能查,但设计哲学、适用场景、表达能力完全不一样。下面我会把两条路都走一遍,然后告诉你实际项目里到底该选哪条。
1.2 先看一眼两种方式的样子
直接看代码最有感觉。假设我有个博客索引blog,想查标题包含“ElasticSearch”的文档。
方式一:URI Search,参数直接拼在请求路径上
GET /blog/_search?q=title:ElasticSearch方式二:Query DSL,把查询条件写成 JSON,放到请求体里
GET /blog/_search { "query": { "match": { "title": "ElasticSearch" } } }这两种写法,就是标题里说的“两种查询方式”。但注意,这只是“入口”层面的两种方式。等我们真正用起来,还会发现 URI Search 内部有q语法的字符串解析规则,Query DSL 内部又分成全文检索、精确匹配、复合查询等好多子类型。进阶要做的,就是把这两条路线各自的规则吃透,而不是看到match就以为掌握了全文检索。
2. URI Search:一条 URL 参数打天下
2.1 最基础的路径查询写法
URI Search 也叫“查询字符串语法”,核心就一个参数:q。它的格式是字段名:搜索词,多个条件用+或空格隔开,逻辑关系用AND、OR、NOT控制。
我平时最常用的几个例子:
# 查询 title 字段包含 ElasticSearch 的文档 GET /blog/_search?q=title:ElasticSearch # 查询 title 包含 ElasticSearch 或 Lucene 的文档 GET /blog/_search?q=title:ElasticSearch OR Lucene # 查询 title 包含 ElasticSearch 但 tags 不含 "性能优化" GET /blog/_search?q=title:ElasticSearch AND NOT tags:"性能优化" # 不指定字段,默认搜索所有分词字段 GET /blog/_search?q=ElasticSearch第二和第三个例子里,OR、AND、NOT这些逻辑运算符必须大写,小写会被当成普通搜索词。这是我踩过最多次的坑,没有之一。
单看这些例子,URI Search 确实简洁,一行命令就能完成一个基础查询。在 Kibana 的 Dev Tools 里调试、或者用curl快速验证数据时,这种写法效率极高。
2.2 参数生效规则与常用参数
URI Search 看着简单,但有几个参数规则不搞清楚,很容易翻车。
第一个:字段名不是必需的。不写字段名时,ES 会在所有被索引的文本字段里做全文检索。听起来方便,但代价是性能开销变大、结果精度下降。生产环境里我几乎不写不带字段的q,宁可多打几个字符,也要明确告诉 ES“去哪查”。
第二个:q参数的值需要进行 URL 编码。尤其是查询词里带空格、冒号、中文、特殊符号的时候。我用curl测试时经常直接写:
curl -X GET "http://localhost:9200/blog/_search?q=title:ElasticSearch%20进阶"%20就是空格的 URL 编码。如果不编码,Shell 会先把参数拆开,ES 收到之后就全乱了。这个细节在浏览器地址栏试的时候不太明显(浏览器会自动编码),但在脚本里就非常要命。
第三个:q走的是 Lucene 查询语法,不是 ES 的 Query DSL。这就意味着,+、-、*、?这些符号都有特殊含义。比如:
# 必须包含 ElasticSearch GET /blog/_search?q=+title:ElasticSearch # 不含性能优化 GET /blog/_search?q=title:ElasticSearch -title:性能优化 # 通配符查询,搜索 Elastic 开头的词 GET /blog/_search?q=title:Elastic*Elastic*能匹配 ElasticSearch、Elasticsearch 等以 Elastic 开头的词。不过要提醒一句:通配符尽量不要放在词的前面,比如*Search,这种写法会强制 Lucene 扫描大量分词项,性能非常差,数据量大时能让集群直接报警。
除了q,URI Search 还带一批配套参数,我做成了速查表:
| 参数 | 含义 | 例子 |
|---|---|---|
df | 不指定字段时的默认字段 | GET /blog/_search?q=ElasticSearch&df=title |
sort | 排序字段 | GET /blog/_search?q=title:ES&sort=view_count:desc |
size | 返回条数,默认10 | GET /blog/_search?q=title:ES&size=20 |
from | 分页起始位置 | GET /blog/_search?q=title:ES&from=10&size=10 |
_source | 返回哪些原始字段,逗号分隔 | GET /blog/_search?q=title:ES&_source=title,author |
analyzer | 指定分词器 | GET /blog/_search?q=title:ES&analyzer=ik_max_word |
default_operator | 多个词之间的默认逻辑,OR或AND | GET /blog/_search?q=title:ES进阶&default_operator=AND |
2.3 什么时候用 URI Search 更合适
我必须坦白说:URI Search 不是项目主力查询方式,但绝不是没用。它最适合三个场景:
第一,快速验证数据和排查问题。怀疑某条数据没进索引,直接用curl -X GET "http://localhost:9200/blog/_search?q=title:测试"查一下,比写一段完整的 DSL 快太多。
第二,简单的搜索接口透传。有些场景只需要按关键词搜一下,不需要复杂过滤和聚合,把前端搜索框的值拼到q参数上就能完成。很多中小型项目的“站内搜索”就是这么做的。
第三,配合 Kibana 做快速探查。在 Dev Tools 里粘一行 URL 查询,回车出结果,不用写 JSON body,调试体验很舒服。
但它的短板也很明显:查询条件一复杂,URI 会变得巨长且难以阅读;过滤逻辑全靠AND、NOT堆,可读性极差;数值范围、嵌套对象、多条件组合等场景写起来更痛苦。这时候就得请出第二套方案——Query DSL。
3. Query DSL:进阶阶段真正的重头戏
3.1 请求体里的查询上下文
Query DSL 的全称是Domain Specific Language,说白了就是基于 JSON 的查询语言。它比 URI Search 晚出现,但设计得更规整,把“查哪些文档”和“怎么计算相关度”都结构化地表达出来了。
最基础的结构长这样:
GET /blog/_search { "query": { "match": { "title": "ElasticSearch" } } }query关键字下面放的是查询条件,这个区域在 ES 术语里叫Query Context。在 Query Context 里,ES 不仅会匹配文档,还会计算_score相关度分数,然后按分数倒序返回。
与之对应的是Filter Context,它只负责“筛选”,不计算相关度分数。写法也简单:
GET /blog/_search { "query": { "bool": { "must": [ { "match": { "title": "ElasticSearch" } } ], "filter": [ { "term": { "status": "published" } }, { "range": { "view_count": { "gte": 1000 } } } ] } } }这里must里的match走 Query Context 需要算分,filter里的term和range走 Filter Context 不算分。看着差不多,性能差距却很实在:Filter Context 的结果会被 ES 缓存,下次遇到相同过滤条件直接走缓存;Query Context 每次都要重新算分。所以实际项目中,纯过滤类条件——比如状态、类型、时间范围——我一律放到filter里,省下来的性能非常可观。
从“两种查询方式”这个标题来看,理解 Query Context 和 Filter Context 是进阶的第一道分水岭。如果能讲清楚“match 查文本、term 查精确值、bool 串联所有条件”这三板斧,Query DSL 基本就入门了。
3.2 全文检索的核心:match 家族
match查询是全文检索的代表。它会把传入的搜索词先做分词,再利用分词结果去倒排索引里查找。这里跟term的本质区别就出来了:
match:搜索词先分词,比如“ElasticSearch 基础教程”会被拆成elasticsearch、基础、教程等词项,再进行匹配term:搜索词不做分词,当成一个整体精确值去匹配倒排索引里的某个词项
我记得第一次排查线上数据问题时,发现某条记录的标题明明是“Docker入门”,用term查title:Docker入门却返回空,折腾半天才反应过来:title字段是分词的,索引里的词项是docker和入门,根本没有Docker入门这个完整词项,term当然命中不了。
match家族里有几个常用变体,进阶阶段必须掌握:
第一个:multi_match,一次查多个字段。
GET /blog/_search { "query": { "multi_match": { "query": "ElasticSearch 性能优化", "fields": ["title", "summary", "tags"] } } }这个场景非常常见:搜索框里输入一句关键词,希望在标题、摘要、标签多个字段里同时匹配。fields里还能用^给字段加权重,比如title^3表示标题字段的相关度权重乘以 3,让标题命中的文档排得更靠前。
第二个:match_phrase,短语匹配。
GET /blog/_search { "query": { "match_phrase": { "title": "ElasticSearch 从入门到实战" } } }match_phrase要求搜索词的分词结果按顺序且相邻地出现在文档中。它解决的是“词都对但顺序不对”的尴尬场景。比如搜“实战入门”,如果文档写的是“入门实战”,match能查到(两个词都命中),match_phrase查不到(顺序不对)。需要做精确短语搜索的场景,比如论文标题匹配、产品名匹配,用它准没错。
第三个:match配合operator参数控制分词语之间的逻辑。
GET /blog/_search { "query": { "match": { "title": { "query": "ElasticSearch 性能优化 实践", "operator": "and" } } } }默认情况下,match对多个分词后的词项使用OR逻辑,也就是命中任意一个词就能返回,相关度分数高的排前面。但有些场景要求所有词都必须命中,就得把operator显式改为and。我在给搜索框做“更严格匹配”的切换时,就是靠它实现的。
3.3 精确匹配与复合查询:term 和 bool 的搭配
全文检索解决了“文档内容跟搜索词相关”的问题,但业务里还有大量精确匹配的需求。用户状态是1还是0、订单号是不是20240001、标签是不是Java——这些不该也不可能被分词后再匹配。此时主角就是term。
GET /blog/_search { "query": { "term": { "status": "published" } } }有几个坑必须提:
坑一:term查询的是倒排索引里的“词项”,不是字段原始值。如果字段没分词,词项就是原始值本身,能正常查;如果字段分词了,那term查的其实是分词后的某个词。这点我在 3.2 节已经说过了,再强调一次,因为实际项目中因为这个出错的比例非常高。
坑二:keyword 类型字段的term匹配是全值匹配。比如keyword类型的status字段存的是"published",你用term查"Published"(大小写不同)就查不到。keyword不分词,大小写敏感,这一点和text类型的全文检索逻辑完全不同。
坑三:term查询值如果是字符串,需要精确写出整个词项。它不像match那样先分词再宽松匹配,所以查询条件里写错一个字符,结果集就是空的。
单个term显然不够用,业务条件往往是“全文匹配 + 状态过滤 + 时间范围 + 排除某些类型”的组合。这时候就得靠bool查询把所有条件串起来。
bool有四个子句,我平时是这样分工的:
| 子句 | 行为 | 使用场景 |
|---|---|---|
must | 必须满足,参与算分 | 核心匹配条件,比如全文检索关键词 |
filter | 必须满足,不算分,结果可缓存 | 状态、类型、时间范围等过滤条件 |
should | 满足任意一个即可,提升相关度 | 可选的加分条件,比如标签命中加分 |
must_not | 必须不满足,不算分 | 排除条件,比如剔除已删除内容 |
一个能直接“抄作业”的综合查询例子:
GET /blog/_search { "query": { "bool": { "must": [ { "multi_match": { "query": "ElasticSearch", "fields": ["title^3", "summary"] } } ], "filter": [ { "term": { "status": "published" } }, { "range": { "publish_date": { "gte": "2024-01-01" } } } ], "must_not": [ { "term": { "category": "private" } } ], "should": [ { "term": { "tags": "elastic-stack" } } ] } } }这段查询的意思:标题或摘要里要有“ElasticSearch”,标题命中权重更高,文档必须是已发布状态、发布日期晚于 2024 年 1 月 1 日、不属于私密分类,如果带了elastic-stack标签,相关度分数可以加一点。这就是实际项目中查询条件的典型形态——一个bool外面套match/term/range,把“搜什么”和“过滤什么”彻底拆开。
4. 两种查询方式的对比与选型
4.1 同一个查询,两种写法的差异到底在哪
我曾经把同一需求用两种方式各写了一遍,非常直观地感受到差别。需求:查标题含“ElasticSearch”的已发布文章,按阅读量倒序,取前 5 条。
URI Search 写法:
GET /blog/_search?q=title:ElasticSearch AND status:published&sort=view_count:desc&size=5Query DSL 写法:
GET /blog/_search { "query": { "bool": { "must": [ { "match": { "title": "ElasticSearch" } } ], "filter": [ { "term": { "status": "published" } } ] } }, "sort": [ { "view_count": { "order": "desc" } } ], "size": 5 }URI Search 看着确实短,可一旦条件多起来,q参数里的字符串会变得无比难读。AND、NOT、括号、引号全挤在一行里,改一个条件,眼睛都得盯半天。Query DSL 虽然写起来啰嗦,但每个条件都有清晰的层级和字段名,改起来不容易出错,review 代码时也友好得多。
更关键的是表达能力的差距。URI Search 没法优雅地表达嵌套布尔逻辑、加权字段、短语匹配里复杂的slop参数、基于地理位置的距离查询……这些高级功能基本都是 Query DSL 的专属领地。可以这么说:URI Search 是查漏补缺的轻骑兵,Query DSL 才是完整战场上的主力部队。
4.2 性能与可读性的博弈
很多初学者以为“性能差异”是选型的关键,我在实践里倒觉得,可读性和可维护性的权重远高于那点性能差异。
ES 对两种查询方式的执行引擎是同一套,底层的 Lucene 查询构建过程基本一样。一个简单的term查询,用 URI Search 写和用 Query DSL 写,执行计划几乎没有任何差别。真正影响性能的是查询条件本身——字段有没有分词、有没有走 filter 缓存、要不要算分、是不是深度分页——而不是你用了哪种入口。
所以我的建议很明确:
- 可以说服自己“用 Query DSL 会慢一点”——实际上没有这回事
- 但要说服自己“项目里全部用 Query DSL”——因为你是写给同事和维护者看的,不是写给搜索引擎看的
维护一个几百行的大 JSON 查询,比维护一串几百字符的 URL 参数要容易得多。这一点,真正接手过别人留下的一坨查询代码的人会深有体会。
4.3 我实际项目中的选型建议
基于我自己的项目经验,给出这套选型策略,可以按需取用:
| 场景 | 推荐方式 | 理由 |
|---|---|---|
| Kibana Dev Tools 快速调试 | URI Search | 一行命令,出结果快 |
| curl 脚本验证索引数据 | URI Search | 方便拼参数,适合临时任务 |
| 项目后端写搜索接口 | Query DSL | 可读性强,易维护,表达能力完整 |
| 组合条件复杂(bool+filter+range等) | Query DSL | URI Search 表达不了这种复杂度 |
| 团队协作开发 | Query DSL 为主 | 代码 review、bug 定位都更轻松 |
| 简单关键词透传接口 | URI Search | 如果后端只是透传参数,两者皆可 |
我目前负责的搜索模块,所有正式接口全部用的 Query DSL,内部封装了一层查询构建器,把常用的过滤条件(状态、分类、时间)做成公共方法,业务方只用传核心关键词。至于 URI Search,我只在排查数据、验证分词结果时用一下,定位问题非常快。
5. 进阶实操中的常见问题与排查思路
5.1 查询结果为空,先别慌,按顺序排查
这是我在实际项目中遇到最多的一个问题。查询返回空结果,很多人的第一反应是“索引里没数据”,但绝大多数时候并不是。我总结了一套排查顺序:
第一步:确认索引里到底有没有数据。
GET /blog/_count如果count是 0,那就是数据没进去,先检查写入链路。
第二步:确认字段名和字段类型。
GET /blog/_mapping看目标字段是text还是keyword。这是match和term选择的关键依据。我前面说的term查中文分词字段返回空的问题,就是这一步没做导致的。
第三步:确认查询值是不是被分词了。用 ES 自带的分词接口直接看:
GET /blog/_analyze { "field": "title", "text": "ElasticSearch从入门到实战" }返回结果会列出分词后的词项。看完你就明白,为什么term查"ElasticSearch从入门到实战"查不到东西——索引里根本没有这个完整词项。
第四步:确认是不是_source过滤导致“看起来没数据”。有时候查询本身命中了,但_source里指定的字段不含你要看的内容,前端展示出来就是空的。这种问题尤其隐蔽,排查时留意一下。
第五步:确认是不是大小写、空格、特殊字符问题。keyword字段全值匹配,多一个空格、大小写不同都会导致查不到。
把这个顺序记录下来,之后遇到“查不到数据”我基本能在两分钟内定位问题。
5.2 查询结果排序不对,检查这里
搜索结果顺序不对,也是进阶阶段绕不开的问题。常见的原因有:
原因一:没有显式指定sort,结果按_score倒序。如果所有文档分数相同,顺序可能和预期不一致。业务上需要按时间、热度等业务字段排序时,必须显式写sort。
原因二:文本字段不能直接排序。这是新手踩得最多的坑。ES 里text类型字段经过分词后不能直接用于排序,会报错。解决办法是给字段额外配置keyword子字段,用.keyword排序:
GET /blog/_search { "sort": [ { "title.keyword": { "order": "asc" } } ] }原因三:空值字段排序。某些文档排序字段缺失时,ES 默认会把它排在最后(_last),可以显式设置:
"sort": [ { "view_count": { "order": "desc", "missing": "_last" } } ]5.3 排查 ES 查询,我离不开的几个调试技巧
最后分享几个我调试 ES 查询时总结的小技巧,全是在项目里验证过好用的。
技巧一:用_validate/query验证 DSL 语法。不用真正执行查询,就能知道写的查询有没有语法问题:
GET /blog/_validate/query?explain=true { "query": { "match": { "title": "ElasticSearch" } } }返回valid: true说明语法没问题,还可以看到查询被重写后的 Lucene 表达式,对理解内部行为非常有帮助。
技巧二:用explain看单个文档的相关度分数来源。
GET /blog/_explain/20240001 { "query": { "match": { "title": "ElasticSearch" } } }返回结果会把每个词的得分贡献列出来。搜索某个文档排得靠前或靠后时,用它看细节非常直观。
技巧三:把term查询和match查询交叉验证分词器配置。我遇到分词相关的 bug 时,最常用的方法就是先用_analyze看分词结果,再用term查具体的词项,两者一比,基本能立刻判断是分词器配置问题还是查询条件问题。
技巧四:生产环境所有查询尽量走filter缓存。之前说过,filter的结果会缓存。如果是频繁执行的查询,把过滤条件都放filter里,性能提升非常明显。我在一个日志检索项目里,把时间范围从must移到filter后,同样查询的响应时间下降了接近一半。
技巧五:多条件查询先做减法,再做加法。调试复杂bool查询时,先把所有条件删掉,确定数据能查出;再一个一个加条件,每次加完看结果集变化。这样定位“是哪个条件导致结果为空”非常高效,比对着大段 JSON 猜测强太多了。这招对初学者尤其有用,强烈建议试试。
结尾:写给我自己的几条心得
这篇文章从“两种查询方式”这个入口出发,把 URI Search 和 Query DSL 都完整过了一遍。说到底,两种方式对应的是两种设计思路:一种追求极简和快速调试,一种追求结构化和完整表达。对我个人而言,理解 Query Context 和 Filter Context 的区别,是进阶阶段真正的分水岭。明白了哪些条件该算分、哪些条件该走缓存,才算真正在用 ES 的思维方式思考查询。
最后分享一个小习惯:我每次写完一个复杂查询,都会顺手用_validate/query验证一次语法,再用_explain在关键文档上过一遍得分逻辑。这两个调试工具,比盲目调参靠谱得多。ES 进阶的路还很长,但把查询方式这个地基打牢,后面学聚合、学优化、学运维,都会顺很多。