ElasticSearch的查询语法我记了又忘、忘了又记。早期做日志检索的时候,每次遇到复杂DSL都得翻文档,一个bool套aggs能折腾一整个下午。后来我换了个笨办法,把项目里真正用过的查询一条条记录成实例,附带索引结构、查询场景和踩坑说明,下次碰到同类需求直接抄改,反而把这块彻底吃透了。这篇文章就是那本记录整理出来的精华版。
如果你平时做日志检索、商品搜索、数据统计这类需求,里面有很多可以直接改改就用的查询实例;如果你刚开始接触ES,我也把每个查询背后的逻辑、环境准备和常见误区写清楚了,照着跑一遍就能上手。
1. 先搭一套能随查随用的本地ES环境
写查询实例之前,我强烈建议先准备一个本地测试环境。ES的查询结果受索引映射、分词器影响非常大,同一个查询在不同索引上完全可能是两种表现。不亲眼看一下真实返回的_score和hits,很多问题根本发现不了。
1.1 本地单节点启动:参数与内存
本地调试用单节点就够了,没必要搞三节点集群。我用Docker启动,好处是不污染宿主机、换版本方便,一条命令就能删掉重来。
docker run -d \ --name es-local \ -p 9200:9200 \ -e "discovery.type=single-node" \ -e "xpack.security.enabled=false" \ -e "ES_JAVA_OPTS=-Xms1g -Xmx1g" \ docker.elastic.co/elasticsearch/elasticsearch:8.11.3这里有几个关键参数值得解释一下。discovery.type=single-node是让ES以单节点模式运行,不会因为找不到其他节点而陷入集群引导失败;xpack.security.enabled=false是为了关掉安全认证,本地测试时每次请求都要带账号密码非常影响调试效率;ES_JAVA_OPTS里给1G堆内存就够了,默认会去读系统内存的一半,本地机器很容易被它吃满。
启动后用curl localhost:9200验证,看到带版本号的JSON响应就说明起来了。等ES真正能用通常需要十几秒,不要刚start就急着跑查询,容易遇到connection refused。
1.2 准备一份覆盖常见类型的测试数据
我建了一个products商品索引,字段类型尽量覆盖业务里常见的几种:文本、关键词、数字、日期、数组。这样后面演示精确匹配、全文检索、聚合、排序时不需要再建第二个索引。
PUT /products { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "name": { "type": "text", "fields": { "keyword": { "type": "keyword" } } }, "category": { "type": "keyword" }, "brand": { "type": "keyword" }, "price": { "type": "double" }, "stock": { "type": "integer" }, "sales": { "type": "integer" }, "tags": { "type": "keyword" }, "created_at": { "type": "date" }, "description": { "type": "text" } } } }注意name字段我给它加了一个keyword子字段,这是一个非常实用的小技巧。text类型用于全文检索,而name.keyword保存未经分词的原始值,可以用于精确匹配、排序和聚合。很多新手会在索引创建之后才发现text字段不能排序,这时候再改映射就要重建索引了。
接着导入几条测试数据,我选了手机配件、数码周边这类大家比较熟悉的商品:
POST /_bulk {"index":{"_index":"products","_id":"1"}} {"name":"防摔手机壳 iPhone 15","category":"手机配件","brand":"某品牌","price":39.9,"stock":200,"sales":1500,"tags":["防摔","透明"],"created_at":"2024-01-05","description":"四角气囊设计 通过两米跌落测试"} {"index":{"_index":"products","_id":"2"}} {"name":"铝合金笔记本支架 桌面升降折叠","category":"电脑配件","brand":"某品牌","price":129,"stock":80,"sales":890,"tags":["铝合金","折叠"],"created_at":"2024-02-12","description":"六档高度调节 支持15.6寸笔记本"} {"index":{"_index":"products","_id":"3"}} {"name":"65W氮化镓充电器 快充头","category":"充电设备","brand":"某品牌","price":89,"stock":500,"sales":3200,"tags":["氮化镓","快充"],"created_at":"2024-01-28","description":"支持手机平板笔记本 三口输出"} {"index":{"_index":"products","_id":"4"}} {"name":"5A快充数据线 Type-C编织线","category":"手机配件","brand":"某品牌","price":19.9,"stock":1000,"sales":5200,"tags":["快充","编织"],"created_at":"2024-03-02","description":"支持100W功率传输 坚固耐用"} {"index":{"_index":"products","_id":"5"}} {"name":"桌面理线器 走线槽收纳神器","category":"桌面配件","brand":"某品牌","price":15.9,"stock":300,"sales":430,"tags":["收纳","桌面"],"created_at":"2024-02-20","description":"免打孔安装 一贴即用"}导入之后用GET /products/_count确认一下文档数量,看到count: 5就说明数据进去了。
1.3 调试查询时最顺手的两类工具
日常调试我主要用两个入口。Kibana的Dev Tools是最常用的,它自带DSL语法补全、JSON格式化和历史记录,写长查询的时候非常省事;另外我习惯在写脚本验证时用curl配合jq,把返回JSON里的hits、aggregations单独摘出来看,比如curl -s localhost:9200/products/_search -H 'Content-Type: application/json' -d @query.json | jq '.hits.total, .hits.hits[]._source.name'。
还有一个调试利器是_explain接口。当你觉得某个文档的得分不符合预期时,可以用GET /products/_explain/1加上同样的查询条件,ES会把你计算分数的每一步都拆开,包括每个词项的IDF、TF、字段长度归一化,这对理解相关度排序非常有帮助。
2. 精确匹配与组合过滤:查询的地基
2.1 term查不到中文:不是数据不存在
先看一个最经典的坑。想在products索引里查名字包含"手机壳"的商品,很多人第一反应写这样的查询:
GET /products/_search { "query": { "term": { "name": "手机壳" } } }结果返回0条。数据明明在里面,为什么查不到?问题出在term查询的机制上。term是精确匹配,它把查询词作为一个完整词项去倒排索引里找,而name是text类型,写入时会被分词。默认的standard分词器对中文是按单字切分的,"防摔手机壳"被切成了"防""摔""手""机""壳",倒排索引里根本没有"手机壳"这个整体词项,自然匹配不到。
解决办法有两个。一是改成match查询,让ES对"手机壳"也做分词后再匹配;二是对name.keyword做term查询,因为keyword子字段保存的是完整原始值。这两种方式语义完全不同:match是包含关系,只要文档中包含"手机"或"壳"都可能命中;而term对name.keyword是整体相等,只有字段值恰好是"手机壳"才会命中。
这里我总结了一个判断标准:做筛选条件时用term,做搜索时用match。比如筛选品牌、分类、状态这些keyword字段,term是最合适的;而用户输入的关键词搜索,一定要走全文检索字段。
2.2 bool查询的四个子句
实际业务中几乎不会只用一个条件查询,bool查询是所有组合查询的骨架。它包含四个子句,很多人只记得must和should,却把filter的威力浪费了。
| 子句 | 作用 | 是否参与打分 | 典型场景 |
|---|---|---|---|
must | 必须满足 | 参与,影响_score | 用户输入的关键词匹配 |
filter | 必须满足 | 不参与,结果可缓存 | 分类、品牌、价格区间等筛选 |
should | 满足则加分 | 满足时影响 | 偏好推荐、可选标签 |
must_not | 必须不满足 | 不参与 | 排除某些分类或关键字 |
filter和must的区别是最值得注意的。两者都是"必须满足",但filter不计算_score,这意味着ES可以跳过打分阶段的很多开销,而且相同filter的结果会在节点层面被缓存。对日志检索、商品筛选这类大多数字段都是固定条件的场景,把筛选条件全部放进filter里,性能提升非常明显。
should单独使用的时候语义是"或",多个should中至少满足一个;但如果bool里同时有must或filter,should就变成"可选加分",即使一个都不满足,文档也能返回,只是分数低一些。
2.3 把筛选、搜索、排序、分页合成一条查询
下面这个查询覆盖了商品搜索最常见的场景:用户输入"防摔",同时要筛选分类为"手机配件"、价格在10到200之间、按价格升序排列,每页20条。
POST /products/_search { "query": { "bool": { "must": [ { "match": { "name": "防摔" } } ], "filter": [ { "term": { "category": "手机配件" } }, { "range": { "price": { "gte": 10, "lte": 200 } } } ] } }, "from": 0, "size": 20, "sort": [ { "price": "asc" } ] }这条查询的思路很清晰:用户输入词决定相关性排序,所以放在must里让ES计算得分;分类和价格是刚性条件,用户不会因为得分高低就改变预期,放在filter里既不丢结果又能享受缓存。
range查询这里补充一个细节:针对数字字段可以直接写gte和lte,针对日期字段同样适用。后面第6节的实战实例里会专门演示日期的写法。
3. 中文全文检索与相关度排序的实战细节
3.1 中文分词器的选择直接决定搜索质量
如果用默认分词器做中文搜索,会发现匹配结果相当离谱。我用_analyze接口看一下默认分词效果:
POST /products/_analyze { "text": "防摔手机壳" }返回的tokens是"防""摔""手""机""壳",每个字一个词。这意味着你在match查询里搜"手机",实际上是在搜包含"手"或"机"的文档,这种结果对用户来说几乎没有可用性。
解决中文检索问题的主流方案是给文本字段指定中文分词器。我惯用的是IK分词器,它有ik_max_word和ik_smart两种模式,前者尽量切出更多词,后者切出更粗粒度的词。索引时用ik_max_word保证召回率,搜索时用ik_smart保证精准度是比较常见的组合。如果使用的是Docker部署,需要进入容器安装对应ES版本的IK插件,再重启容器生效。
装好之后修改映射,让name字段使用中文分词:
PUT /products/_mapping { "properties": { "name": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart", "fields": { "keyword": { "type": "keyword" } } } } }改完映射之后要重新索引数据,之前的数据不会自动按新分词器重新处理。这也是为什么我建议把所有验证都在测试环境做完再上生产,线上动态改映射要重建索引,挺折腾的。
3.2 match_phrase与slop:控制词的位置关系
有时候希望搜索词必须按顺序紧挨着出现,match_phrase就是干这个的。比如用户搜"手机壳",不希望返回只有"手机"和"壳"但位置相隔很远的文档;match_phrase要求所有词项都出现,并且顺序一致、位置连续。
但现实数据里,产品名可能是"防摔手机壳",也可能是"手机 全包防摔 壳",用严格短语匹配就会漏掉后者。这时候slop参数派上用场:它表示允许词项之间最多间隔多少个位置。
POST /products/_search { "query": { "match_phrase": { "name": { "query": "手机 壳", "slop": 2 } } } }slop背后的原理是词项位置距离。在"手机 全包防摔 壳"里,"手机"的位置是0,"壳"的位置是3,中间需要移动两次才能把"手机"和"壳"拉到相邻位置,所以slop: 2可以命中。你可以在调试时用_analyze接口查看每个词的位置编号,这样设置slop的时候心里就有底,不用瞎猜。
3.3 multi_match的字段权重与function_score热度加权
搜索往往不是只对单个字段做匹配。比如搜索"手机壳",希望标题命中比描述命中更重要,multi_match可以做到:
POST /products/_search { "query": { "multi_match": { "query": "手机壳", "fields": ["name^3", "description"], "type": "best_fields" } } }name^3表示name字段的得分权重乘以3。为什么这样做?逻辑很简单:用户搜索一个词,标题里命中通常是用户最想要的结果,描述里命中则相关性弱一些,权重差距能让排序结果更接近用户预期。type这里先不用纠结,best_fields在单字段最强匹配为主时表现稳妥。
function_score是用来干预排序的利器。比如我想让销量高的商品在搜索结果里排名靠前,但又不希望销量完全主导相关性:
POST /products/_search { "query": { "function_score": { "query": { "match": { "name": "充电器" } }, "field_value_factor": { "field": "sales", "modifier": "log1p", "factor": 0.5, "missing": 1 } } } }field_value_factor直接用字段值参与分数计算,但不建议裸用原始值,因为销量3000和300的差距会被原样放大到分数里,导致销量成为唯一决定因素。modifier: log1p对字段值做对数压缩,missing: 1处理字段缺失的文档。至于更复杂的按条件加权逻辑,直接用script_score写脚本更方便,但要注意脚本性能开销。
4. 聚合查询:把数据变成指标
4.1 terms聚合的默认值坑
商品数量多了之后,想统计"每个分类下有多少商品",用的是terms聚合:
POST /products/_search { "size": 0, "aggs": { "by_category": { "terms": { "field": "category", "size": 20 } } } }这里有两个坑值得单独说明。
第一个是size。terms聚合默认只返回词频最高的前10个桶,如果你不设置size,数据里有15个分类,后5个直接看不到。但把size设成0也不行,在新版本里这不再表示"返回全部",而是一个无效值,返回结果为空或者报错。当分组数量非常大时,正确做法是设置一个业务上合理的size上限,比如100、500,真要做全量分组遍历得用composite聚合逐页取。
第二个是分片计数误差。terms聚合每个分片只返回本地排名靠前的桶,协调节点再合并,所以全局的计数是近似值。误码率在分片多、基数大时会出现明显偏差。如果对精确度有要求,可以设置shard_size大于size,让每个分片多返回一些桶,减小合并误差。
4.2 date_histogram时间桶与空桶问题
时间维度上的分布统计是日志场景的刚需。比如统计每个月的商品创建数量:
POST /products/_search { "size": 0, "aggs": { "by_month": { "date_histogram": { "field": "created_at", "calendar_interval": "month" } } } }这段查询用calendar_interval定义时间桶大小,相比老的interval参数,它更明确地区分了日历时间和固定时间。比如month是日历月,不同月份天数不同也按自然月分桶;而fixed_interval里写"30d"就是固定30天一个桶,两者在业务语义上差别很大。
时间桶一个隐蔽的问题是没有数据的时段不会生成空桶。比如2月新建商品为零,你期望聚合结果里有2月且doc_count为0,但实际返回里2月直接被跳过。前端画趋势图的时候数据点会缺失。解法是给date_histogram加上min_doc_count: 0和extended_bounds.min/max,强制ES生成完整时间范围内的桶。
4.3 多层嵌套聚合:分类下再看时间趋势
聚合的嵌套能力和查询的bool一样,可以组合出很立体的统计。我之前做过一个看板,要求展示每个分类下每个月的平均价格变化趋势,一条聚合就能搞定:
POST /products/_search { "size": 0, "aggs": { "by_category": { "terms": { "field": "category", "size": 10 }, "aggs": { "avg_price": { "avg": { "field": "price" } }, "by_month": { "date_histogram": { "field": "created_at", "calendar_interval": "month" } } } } } }返回结构是三层嵌套:最外层by_category下面有buckets数组,每个桶里又有avg_price的数值和by_month的桶数组。代码里层级关系越深,DSL的缩进就越要清晰,不然写错一个括号,ES返回的解析错误会让人找半天。
嵌套聚合的性能也要注意。外层桶数量越多,内层聚合的计算量成倍增加。我一般会在外层加上合理的size限制,绝对不用composite无限展开嵌套。另外凡是参与聚合的字段,最好都确保是keyword、date、integer这类doc_values默认开启的字段类型,否则又回到text字段不能聚合的老问题。
5. 查询性能的三个深水坑
5.1 wildcard前导通配符为什么不建议用
业务里偶尔会有"输入一部分关键词,匹配所有包含它的文档"这样的需求,新手最容易写wildcard查询。比如搜"name": "*手机*",看起来能匹配所有名字带"手机"的商品。在5条数据上跑确实秒回,但在线上千万级索引上跑一次,可能直接把集群CPU打满。
原因在于通配符的匹配机制。wildcard本质上是遍历词项字典做模式匹配,当模式以*开头时,ES无法利用倒排索引快速定位起始词项,只能全量扫描词项,复杂度随着词项数量线性上升。
我知道有的同学会说"我的数据还不够多,先用着"。但查询性能问题有个特点:数据量小的时候一切正常,等数据量大了,问题爆发往往没有预兆。我一个常见的替代方案是:如果确实需要"包含"匹配,在索引阶段用ngram分词器把文本切成连续子串,查询时用term或match命中,走的是倒排索引快速路径,性能天差地别。
5.2 深分页的三种解法与各自边界
ES默认的from + size分页在数据量大时会变得异常慢,而且有硬上限。默认index.max_result_window是10000,意味着from + size超过10000时查询直接报错。这不是ES抠门,而是深分页的计算代价实在太大了:每个分片都要把前from + size条结果取出来排序,再丢弃前面的from条,翻到第100000页时,内存和CPU开销是灾难性的。
三种分页方案的适用场景完全不同,我整理了一个对比:
| 方案 | 适用场景 | 注意点 |
|---|---|---|
from + size | 前几页、结果集小 | 默认窗口上限10000 |
scroll | 大批量导出、异步批处理 | 保留查询快照,数据不是实时;用后必须清理 |
search_after | 用户逐页翻页、无限滚动 | 依赖排序唯一性,要加tie_breaker |
日志导出、数据迁移这类离线任务用scroll最合适,因为它基于查询时刻的快照,不受新增文档影响。但代价是scroll上下文会一直占着内存,遍历完要记得DELETE /_search/scroll/{scroll_id}清理。用户在前台翻页不要用scroll,数据会不一致。
search_after是滚动翻页的正确姿势。它把上一页最后一条的排序值作为游标传给下一页,避免了"跳过大偏移量"的问题。但要求排序字段必须是唯一的,否则相同排序值的文档在翻页时可能重复或丢失,我通常会在排序里追加_id字段来保证唯一性。
5.3 text字段不能排序,得用keyword子字段
直接对name字段排序,会看到这样的报错:Fielddata is disabled on text fields by default。原因还是分词:text字段被切成了多个词项,排序时ES不知道该按哪个词排。所以创建索引时给需要排序的text字段配一个keyword子字段是规范操作,排序时用name.keyword。
类似的坑也出现在聚合上。text字段默认不开启fielddata就是因为性能太差,正确的做法永远是使用keyword或数值类型字段聚合。如果老索引没有keyword子字段,需要重建索引或者用runtime field救急。
还有一个容易忽略的是total返回值上限。ES默认只在响应里统计前10000条命中数,hits.total.value到了10000就不再增长,relation变成gte,意思是"实际值大于等于10000"。做分页判断时如果不加track_total_hits: true,页面底部的"共X条"就是错的。这个细节不坑性能,但坑业务数据展示。
6. 三个可以直接改改就用的查询实例
前面讲的是基础知识,现在给三个相对完整的实例,都是我平时直接抄改的模板,每条都有明确的使用场景。
6.1 搜索商品时给关键词加高亮
电商搜索场景里,搜索结果页要把命中的关键词标红,ES的高亮功能可以省去前后端大量的字符串处理:
POST /products/_search { "query": { "match": { "name": "充电器" } }, "highlight": { "pre_tags": ["<b class=\"hl\">"], "post_tags": ["</b>"], "fields": { "name": {}, "description": { "fragment_size": 80, "number_of_fragments": 2 } } } }返回结果里每个命中文档会多出一个highlight字段,里面是带<b>标签的片段。这里我自定义了pre_tags,避免默认的<em>样式和前端样式表冲突。fragment_size控制摘要片段长度,number_of_fragments控制返回几个片段。注意高亮是配合查询词的分词结果来切片的,所以中文搜索要确保字段分词器已经正确配置,否则高亮位置经常错乱。
6.2 按日期区间筛选和排序的"近30天新品"查询
运营后台经常需要拉取最近一段时间上架的高价商品,这类查询的骨架固定,改一下字段和时间范围就能套用:
POST /products/_search { "query": { "bool": { "filter": [ { "range": { "created_at": { "gte": "now-30d/d", "lte": "now" } } }, { "range": { "price": { "gte": 50 } } } ], "must": [ { "match": { "name": "支架" } } ] } }, "sort": [ { "created_at": "desc" } ], "size": 20 }这里有两个时间表达式的细节。now-30d/d表示"30天前的当天零点",末尾的/d是时间舍入,让起点对齐到天;如果不加/d,就是精确到秒的相对时间。now表示当前时刻。这类date math表达式是ES里非常实用但容易被忽视的能力,除了d,还支持M(月)、y(年)、h(小时)等单位。
排序字段我用了created_at,因为它是date类型,天然支持排序。如果业务上还要按其他字段排序,就得提前确认字段类型满足要求,别等到上线前才发现报错。
6.3 日志场景:统计错误请求的耗时分布
最后给一个日志检索场景的实例。假设有一个logs索引,字段包括timestamp、level、latency,现在要统计最近一小时错误请求的耗时百分位和每分钟平均耗时:
POST /logs/_search { "size": 0, "query": { "bool": { "filter": [ { "term": { "level": "error" } }, { "range": { "timestamp": { "gte": "now-1h" } } } ] } }, "aggs": { "latency_percentiles": { "percentiles": { "field": "latency", "percents": [50, 95, 99] } }, "by_minute": { "date_histogram": { "field": "timestamp", "fixed_interval": "1m" }, "aggs": { "avg_latency": { "avg": { "field": "latency" } } } } } }这条查询把前面几章的知识点都串起来了:时间范围筛选放进filter,term精确匹配日志级别,percentiles聚合直接算P95/P99,date_histogram按分钟分桶后求平均耗时。一次请求把"整体延迟画像"和"随时间变化趋势"两个图表的数据都拿到了,不需要额外写脚本在应用层计算。
fixed_interval: "1m"是固定1分钟间隔,适合日志时序这种严格等间隔的场景。如果你关心的是业务高峰时段,只需要把filter里的时间范围改成now-1d,聚合间隔改成1h,马上就能变成一个每天定时跑的趋势报表。
7. 一点写查询的真实体会
写ES查询这几年,我最大的体会是:先想清楚"这个字段是什么类型,我应该用term还是match",再动手写DSL,能省掉后面一整个下午的排查时间。很多看起来"查询结果不对"的问题,追到根上都是映射设计阶段埋下的坑。
调试顺序我固定成一条线:先看_mapping确认字段类型,再_analyze看分词结果,然后_explain查得分细节,最后再调整DSL结构。这条线基本能覆盖80%的查询异常。
另外,每个项目我都维护一份"查询配方"笔记,把验证过的DSL连同数据规模、响应耗时一起记下来。这次文里的这些实例就是从那本笔记里挑出来的。以后写ES查询我建议你也这样积累,遇到类似的业务需求直接抄改,比自己从语法树开始推导可靠得多。