ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Elasticsearch Query DSL从入门到实战:原理、写法与性能优化

Elasticsearch Query DSL从入门到实战:原理、写法与性能优化 我先把话说明白如果你在搜索“DSL”的时候看到满屏都是“Dify导入DSL文件版本不兼容怎么把0.6.0降级到0.3.0”这类内容别急着对号入座——那是Dify工作流的Digital Service Language文件跟Elasticsearch的Query DSL除了同名几乎没有交集。Elasticsearch里的DSL全称是Domain Specific Language说人话就是一套专门用来描述“你想怎么搜”的JSON语法。这篇我基于8.x版本把查询DSL从根上捋一遍包含原理、写法、踩坑和性能建议适合刚学会建索引、但一写_search就犯迷糊的同学也适合用了一阵子但一直被_score、filter、match和term搞到头疼的开发者。1. 先分清查询上下文与过滤上下文一个查询的两种命运很多入门教程会直接甩给你一堆查询示例告诉你“match是模糊搜term是精确搜”然后就没了。但你会发现真正落到业务里一个查询往往同时包含“算分”和“不算分”两部分搞不懂这一点后面所有复杂查询都会越写越乱。1.1 查询上下文和过滤上下文到底差在哪ES里每个查询子句都生活在两种上下文之一查询上下文query context和过滤上下文filter context。查询上下文的核心任务是回答“这个文档有多匹配”也就是计算_score相关性分数。你要搜“笔记本电脑”一个标题叫《笔记本电脑选购指南》的文档和一个正文里只出现一次“笔记本电脑”的文档分数必然不同。算分有成本但它能帮你拿到最相关的头部结果。过滤上下文的核心任务是回答“这个文档匹配还是不匹配”只输出boolean结果要么在结果集里要么不在。它不计算_score也因此享受一个非常重要的红利结果可以被缓存。这两者最典型的组合就是bool查询看这个例子GET /product/_search { query: { bool: { must: [ { match: { title: 笔记本电脑 } } ], filter: [ { term: { status: on_sale } }, { range: { price: { lte: 6000 } } } ] } } }这里must里的match负责算分“笔记本电脑”这个词对标题的匹配程度决定了文档排序而filter里的term和range只管过滤状态必须是on_sale、价格必须小于等于6000但完全不参与算分。查出来的结果的_score只由must那一部分决定。提示filter子句的执行顺序和must不同。ES会先跑filter把结果集缩到很小再在这个小集合上做must算分。所以一个查询里如果既有精确过滤又有文本匹配把过滤条件丢进filter往往比全塞进must快不少。1.2 为什么说filter是性能优化第一刀你可能会问must和filter都能过滤掉不满足条件的文档区别不就是算不算分吗对但这一个区别在性能上的影响是巨大的。filter命中的结果在每个分片上会以bitset的形式缓存起来。bitset你可以理解成一张“某个文档是否命中这个条件”的位图0和1排成一排。下一次再有相同filter查询进来ES直接读缓存连倒排索引都懒得翻同时多个filter条件之间可以高效地做位运算组合比如两个filter的bitset做AND就是一次按位与操作。而must因为要算分每次都要重新计算词频、逆文档频率这些统计信息没法这么干。实际业务里我见过太多人把“删除标记”“状态”“分类ID”这类高基数或高频复用的条件全部塞进must结果查询一个比一个慢。这些条件本质上是“硬性门槛”和相关性一点关系都没有应该无脑放filter。1.3 constant_score明确告诉ES“我不要算分”还有一种场景更极端整个查询你都不关心相关性只要“符合条件就行”。这时可以直接用constant_score包裹GET /order/_search { query: { constant_score: { filter: { term: { pay_status: 1 } }, boost: 1.0 } } }constant_score会执行filter逻辑把所有命中文档的_score统一设成boost值默认1.0。好处是语义极其明确不会有人误以为这里有什么“匹配度”玄学。不过说实话在bool.filter已经很成熟的情况下constant_score使用频率不算高更多是用于某些需要固定分数的聚合、或者脚本排序场景。知道有这个东西比你真正频繁使用它更重要。2. 叶子查询match家族与term家族的选型逻辑把上下文搞明白之后就可以聊具体的“叶子查询”了。叶子查询是不能再拆分的查询子句类似积木里最小的那一块。ES里最常用的是match家族和term家族但它们的应用场景完全不同选错就是经典翻车现场。2.1 match全文检索的主力分词后再查match是最典型的全文查询。它做的事可以拆成三步先对查询字符串做分析分词、归一化再到倒排索引里找每个词项最后按匹配程度算分。比如索引里title字段是text类型标准分析器会把“Elasticsearch 实战”分成elasticsearch和实战两个词项。你搜索match: {title: elasticsearch实战}查询字符串也会被分词成elasticsearch和实战然后取交集或并集返回结果。match默认的operator是or也就是说多个词项只要有一个命中就返回文档匹配的越多分数越高。如果你想要求所有词项都出现可以这样做GET /article/_search { query: { match: { title: { query: elasticsearch 查询优化, operator: and } } } }operator: and要求三个词项全命中行为上更像“AND”但注意它依然在算分不是filter。实际选型时我的建议是只要搜索框输入的是自然语言就优先用match它会处理好大小写、词形、同义词如果配了分析器这些琐碎问题对用户最友好。2.2 match_phrase顺序和位置也是信息match的问题是只关心词项出没出现不关心它们的顺序。搜索“查询优化”一篇说“优化查询”的文章可能也会命中但语序反了。很多业务场景下语序很重要比如搜索“红米手机”你不想返回“手机红米”这种奇怪文本。match_phrase就是干这个的。它要求词项在文档中的位置关系与查询串里的顺序一致中间不能随意插入其他词GET /product/_search { query: { match_phrase: { name: { query: 红米 手机, slop: 0 } } } }slop参数允许词项之间有移动空间。slop: 1表示允许中间隔一个词或者词项交换位置。它实现的是“近似匹配”逻辑代价是查询会慢一些因为需要检查位置信息。经验上除非你明确要做短语搜索或“精确词序”搜索否则别用match_phrase替掉match它对搜索习惯的容错性很低。2.3 multi_match一次搜多个字段业务里最常见的搜索需求是一句话搜多个字段标题、关键词、描述、作者。这时候不需要写三个match再用bool拼直接上multi_matchGET /article/_search { query: { multi_match: { query: elasticsearch 性能, fields: [title^3, summary, content] } } }title^3表示title字段的权重是3倍。这种加权是很实用的手段——标题命中的文档比正文命中的文档理应排得更靠前。multi_match背后有几种执行策略默认是best_fields也就是取多个字段里得分最高的那个作为最终分适合“任一字段命中即可”的场景most_fields适合想把多个字段分数累加的场景比如title和title.synonym同时命中会有加成cross_fields则用于处理“一个概念被拆到多个字段”比如姓和名的查询。注意8.x里multi_match的type如果涉及cross_fields对字段的分析器一致性要求很高不同字段分析器不同时容易得到诡异结果。我的习惯是80%场景用默认best_fields就够了。2.4 term家族精确匹配绕不开的keyword坑和match家族相对的是term家族。term查询是精确定值匹配查询串不会被分词直接拿整个值去倒排索引里做精确查找。它最典型的使用对象是keyword类型的字段、数字、日期、布尔值。这里有个ES新手必踩的坑对text字段用term往往什么都查不到。原因很简单text字段写入时经过分析器分词你存的是一整个短语索引里却是一堆小词项。比如你存了title: Elasticsearch 实战索引里有elasticsearch和实战两个词项但绝不会有Elasticsearch 实战这个完整的词项。你执行term: {title: Elasticsearch 实战}对着一个不存在的词项做精确匹配自然查不到。解决方案很标准mapping里给text字段配一个keyword子字段比如title.keyword然后对title.keyword做term查询。或者直接对keyword字段查询。这是8.x下最标准的做法ES官方也默认给text字段生成keyword子字段除非你关掉了fielddata或者显式关闭了fielddata_frequency_threshold之类的参数大多数场景默认有。terms查询是term的批量版一次查多个值。需要注意terms查询默认有最大terms数量限制默认是65536由index.max_terms_count控制。另一个容易被忽略的是空数组问题terms: []不匹配任何文档但也不会报错业务里要注意对空数组做前置拦截。2.5 其他好用的小叶子range、exists、ids、prefix与wildcard除了match和term两大族还有些叶子查询出场率也很高。range用于范围匹配数值、日期都能用GET /log/_search { query: { range: { timestamp: { gte: 2024-06-01T00:00:00, lte: 2024-06-30T23:59:59, format: strict_date_optional_time, time_zone: 08:00 } } } }日期上最容易被坑的是时区。ES存储的timestamp通常是UTC时间你查询时如果不带time_zoneES就用UTC解释范围边界和用户所在的东八区直接差8小时导致“查凌晨的数据却跑到第二天早上”。exists查询检查字段是否存在非常适合过滤“有某个字段”或“没某个字段”的文档GET /user/_search { query: { bool: { must_not: [ { exists: { field: phone } } ] } } }prefix按前缀匹配keywordwildcard支持通配符。两者都要小心wildcard在通配符开头的模式下性能极差因为它没法走倒排索引的快速路径只能遍历词项。8.x引入了专门的wildcard字段类型来缓解这个问题但查询端如果还是“前导通配符”再优化也有限。能用prefix解决的别用wildcard能用keyword精确匹配的别用prefix。3. bool查询把业务逻辑翻译成JSON的艺术单条叶子查询能解决“怎么搜”但真实业务需求往往是“A条件必须满足B条件有一两个满足也行C条件绝对不允许出现”。这就是bool查询的主场。bool本身不干具体的匹配活它是个逻辑组合器把一堆叶子查询按你定义的逻辑组装起来。3.1 must、should、filter、must_not的分工与协作bool查询有四个常见子句它们的分工必须烂熟于心子句逻辑含义是否算分典型用途mustAND必须满足是核心匹配条件filterAND必须满足否硬性过滤条件shouldOR提升匹配是命中时才加加分项、可选条件must_notNOT必须不满足否排除条件一个实际电商搜索的例子把四个子句全用上GET /product/_search { query: { bool: { must: [ { match: { name: 手机 } } ], should: [ { match: { brand: 某品牌 } }, { match: { tags: 新品 } } ], filter: [ { term: { status: on_sale } }, { range: { price: { lte: 5000 } } } ], must_not: [ { term: { category: 二手 } } ] } } }这条查询的意思是名字里必须包含“手机”品牌是某品牌或带“新品”标签的文档会加分状态必须是在售、价格必须不超过5000分类绝不能是“二手”。注意should比较特殊。当bool查询里同时存在must或filter时should不是硬性条件命中任何一个都会给文档加分但如果整个bool里只有should、没有任何must/filter那么should就变成了“至少命中一个”的硬性条件。3.2 should的默认行为与minimum_should_match正因为should有上面说的“软加分”特性很多人在写多条件匹配时会被它的默认行为搞懵。举个场景搜索一篇文章要求标题或摘要里出现“Elasticsearch”但同时希望“集群”或“性能”出现时加分。不加任何控制的话should里的条件在存在must/filter时是“爱匹配不匹配”匹配了就加分不匹配也不排除结果。但业务上往往希望“这些加分项至少要命中一个否则相关性太差”这时就轮到minimum_should_match上场了GET /article/_search { query: { bool: { must: [ { match: { title: Elasticsearch } } ], should: [ { match: { content: 集群 } }, { match: { content: 性能 } } ], minimum_should_match: 1 } } }加了minimum_should_match: 1之后文档必须在should的两个条件里至少命中一个才被认为是合格结果。这在召回阶段很有用避免了一堆“标题匹配但正文完全不相关”的垃圾结果混进来。minimum_should_match还可以设置百分比比如50%表示词项按比例最少匹配数但具体到should子句数量上还是数字更直观。我的建议先统计业务里可选的加分项数量再反推最小值比盲目设百分比可靠。3.3 boosting查询在不排除结果的前提下压低权重有时候你可能想“干掉”某些结果但又不想彻底排除它们只是希望它们排到后面。比如搜索“苹果”用户可能是想买手机但结果里混着大量水果类内容。这时用must_not会直接把水果内容排除太粗暴更好的做法是用boosting查询GET /product/_search { query: { boosting: { positive: { match: { name: 苹果 } }, negative: { match: { category: 水果 } }, negative_boost: 0.5 } } }positive里的查询负责召回和正常算分negative里命中的文档分数会被乘以negative_boost小于1从而排名下降。核心思想是“降权但不排除”在召回量很大、排序很敏感的场景非常实用。4. 相关性打分从TF-IDF到BM25_score不再神秘很多人用ES只管查得出来不管查得准不准。但只要涉及排序_score就绕不开。8.x的默认打分模型是BM25它决定了为什么“关键词出现多、文档短、词越稀有”的文档排得更靠前。4.1 从TF-IDF到BM25为什么8.x长这样简单回顾一下历史。ES早期版本用的是TF-IDF模型核心逻辑是词在一个文档里出现的次数TF越多越重要但包含该词的文档数越多DF该词的区分力越弱所以要乘一个逆文档频率IDF来压低常见词的权重。TF-IDF有个问题词频带来的分数增长是线性的。一个词出现10次TF贡献就是出现1次的10倍。这导致长文档或者关键词反复出现的文档容易拿到不合理的高分而且文档越长每个词的相对重要性被稀释得越厉害又需要一个长度归一化来矫正。BM25在TF-IDF基础上做了两个改进词频饱和和文档长度归一化。核心公式里多了两个参数k1默认1.2控制词频饱和曲线b默认0.75控制文档长度的影响力度。词频到达一定程度后再增加次数对分数的提升越来越小而不是线性暴涨文档比平均长度短的匹配权重会略微上调比平均长度长的则会被压低。这些参数可以按索引级别调整PUT /my_index/_settings { index: { similarity: { default: { type: BM25, k1: 1.2, b: 0.75 } } } }实践里我很少动这两个参数除非你明确感受到“长文档吃亏太严重”或者“词频过高的文档霸榜”才去微调b和k1。调参前一定要先看数据分布别凭感觉。4.2 explain API把打分摊开给你看_score最让人头疼的地方是黑盒感。你只知道结果排出来了不知道为什么排前面。想打破黑盒用explain接口GET /product/_search { explain: true, query: { match: { name: 手机 } } }返回结果里每个命中文档会多一个_explanation字段一层层列出分数来源词项频率、逆文档频率、字段长度归一化以及boost值。你甚至能看到“该词项贡献了多少分”这样明细。explain在调试阶段极其好用但绝不要在生产环境开着它跑全量查询因为它会对每个候选文档单独计算解释信息开销非常大。我一般是在Kibana Dev Tools里对一两个文档做explain定位完问题就关掉。4.3 boost与function_score的使用边界想要影响相关性排序最直接的手段是boost。它可以加在查询子句上也可以加在字段上比如前面multi_match的title^3。boost本质上是给_score乘一个权重因子。但要注意boost的作用不是线性的“最终分数乘以N”这么简单不同查询上下文下boost的生效位置不同可能作用在词频上也可能作用在子句得分上。当业务需要“按某个数值字段或时间远近影响排序”时boost就不够用了得请出function_scoreGET /hotel/_search { query: { function_score: { query: { match: { name: 酒店 } }, functions: [ { gauss: { location: { origin: 39.9075,116.39723, scale: 5km } } }, { field_value_factor: { field: star, factor: 1.2 } } ], boost_mode: multiply, score_mode: sum } } }gauss是高斯衰减函数适合“距离中心点越近分越高”的地理或时间衰减场景field_value_factor可以把某个字段的数值作为排序因子比如星级越高分越高。boost_mode控制函数得分与原始查询得分怎么合并score_mode控制多个函数得分之间怎么合并。注意function_score很强大但它把算分过程变得复杂也牺牲了可解释性。我建议只在排序刚需场景使用而且函数数量别贪多一到两个即可。能用boost解决的就别上function_score。5. 排序、分页与_source8.x里容易翻车的三件套查询写对了只是拿到了结果集。怎么按业务规则排序、怎么翻页、怎么控制返回字段这三件事看似简单但每个都有专属的“坑”。5.1 排序别直接sort一个text字段默认情况下ES按_score降序返回。需要业务字段排序时直接在sort里声明GET /product/_search { query: { match_all: {} }, sort: [ { price: { order: asc } }, { _score: { order: desc } } ] }最常见的报错场景是想按某个text字段排序结果ES直接抛异常提示“Fielddata is disabled on text fields by default”。因为text字段经过分词不能直接按完整字段值排序你需要用它的keyword子字段GET /product/_search { query: { match_all: {} }, sort: [ { title.keyword: { order: asc } } ] }如果字段有缺失值可以用missing参数控制排序位置GET /product/_search { query: { match_all: {} }, sort: [ { stock_count: { order: desc, missing: _last } } ] }missing: _last表示没库存数量的文档排到最后这种细节对用户体验影响挺大。5.2 深度分页from/size、scroll、search_after与PIT这是ES查询里争议最多、翻车最多的地方。from/size是最朴素的翻页方式但它有个硬性限制默认最多翻到index.max_result_window默认10000条。也就是说from size 10000时查询直接报错。为什么要限制因为ES是分布式系统一个索引的数据分散在多个分片上要返回第10000条之后的结果每个分片都必须先把前10000N条结果取出来汇总到协调节点后再排序最后丢弃前面一万条。翻得越深内存和CPU开销越大整个集群都可能被一个深度分页拖垮。scroll是ES早期为大批量数据导出的方案相当于给查询结果拍了一张“快照”然后在快照上滚动。它适合后台导出、reindex、大规模遍历但不适合给用户做实时翻页因为scroll快照不感知增量变更而且长时间维护scroll上下文会占用大量资源。8.x里官方已经不怎么推荐scroll用于常规搜索了。search_after是目前官方推荐的分页方案核心思路是用上一页最后一条结果的排序值作为下一页的游标跳过硬性offset。8.x里search_after的最佳搭档是Point In TimePIT用来固定查询快照避免分页过程中数据变化导致结果漂移或重复。完整流程分三步。第一步创建PITPOST /product/_pit?keep_alive5m返回一个pit_id。第二步用search_after带着上次的排序值查询GET /_search { size: 10, query: { match_all: {} }, pit: { id: pit_id_here, keep_alive: 5m }, sort: [ { _shard_doc: desc } ], search_after: [12000, 4294967298] }第三步用完删除PITDELETE /_pit { id: pit_id_here }_shard_doc是PIT特有的排序字段代表“文档在分片内的序号”相比按业务字段排序更高效因为不需要额外比较业务值。实际开发中我建议用户端翻页只在最近几页用from/size一旦有“跳页深”的诉求立刻切换到search_after PIT。搜索引擎场景里“跳到第100页”本身就不符合用户心智大多数产品最终都改成了“下拉加载更多”这也正是search_after的主场。5.3 _source、fields、stored_fields返回内容瘦身默认情况下ES会返回_source里存储的完整原始文档很多时候这个文档非常庞大可能包含几十个字段而前端只需要其中三四个。不做瘦身网络IO和解析开销都很浪费。控制返回字段有几种方式。_source过滤是首选它决定返回原始JSON的哪些字段GET /product/_search { query: { match_all: {} }, _source: [name, price, brand] }也支持通配符和不包含模式GET /product/_search { query: { match_all: {} }, _source: { includes: [name, brand.*], excludes: [description, internal_note] } }fields参数和_source容易混淆。fields返回的是字段经过mapping解析后的值适合doc_values或keyword字段它还能处理一些_source做不到的场景比如返回text字段的运行时字段值。stored_fields则对应mapping里设置了store: true的字段大多数情况下你不会这么设计所以日常用得不多。我的建议是如果前端只需要列表页的摘要信息让查询只返回必要字段既省流量又省解析开销。到了详情页再按ID单独取全量文档这是性价比最高的方案。6. 性能自查清单那些能让DSL慢到离谱的写法DSL写多了你会发现大部分性能问题不是集群不行而是查询写法太“野”。这里列一下我实际遇到的高频性能坑和对应的自查标准。6.1 通配符与模糊查询慢查询的重灾区wildcard前导通配符比如*keyword会迫使ES遍历该字段的全量词项复杂度随字典规模线性上升分片越多越明显。prefix虽然能前缀匹配但如果前缀本身区分度低比如就一个字母a同样会扫出一大片词项。fuzzy、regexp也属于高开销查询。fuzzy默认fuzziness是AUTO处理得当还好但一旦你把fuzziness调得过大、或者对text长文本做模糊匹配CPU开销直接起飞。自查标准很简单看看你的查询里有多少是“非确定性匹配”。如果只是要模糊搜索“用户可能拼错了词”优先考虑match 分析器层面的同义词、NGram分词而不是直接上fuzzy和wildcard。8.x提供了wildcard字段类型来优化部分通配场景但“能不用就不用”依然是第一原则。6.2 分页深了集群就抖了前面聊过from/size的10000上限。我见过不止一次有同学把from调到几百万然后跑来问“为什么集群CPU突然100%”。这问题从根上就不是集群容量不够而是翻页越深每个分片都要参与排序和丢弃协调节点内存被撑爆。上线前一定要对用户翻页行为做约束只允许浅翻页深翻页全部切search_after或限制最大页码。6.3 真正实用的小优化filter优先、返回瘦身、索引设计配合最后给一份可以直接抄的检查清单能用filter的绝不用must。状态、类目、时间区间、删除标记这些硬性条件全部放filter能命中bitset缓存收益立竿见影。查询字段别贪多。除非必要别对一整个text大字段做match能搜title和keyword字段就不搜body。字段越多参与算分的词项越多越慢。_source只返回需要的字段。列表接口尤其重要。深分页默认search_after PIT。别犹豫。让mapping设计配合查询。keyword、text、date、integer各归各位不要所有字段一股脑都用text。一个干净合理的mapping顶得上一百个查询优化技巧。我实际调优过一个搜索接口改动就三条把三个should改成filter、_source收窄到5个字段、把一次match_phrase降级成match。结果就是查询延迟从180ms降到40ms集群CPU直接降一半。很多时候不是ES不行是查询写法太“胖”。最后分享一点个人经验学DSL最忌讳的是一上来就死背语法。真正值钱的是理解它背后的三个底层支柱——倒排索引、分析器、相关性打分。DSL只是这三个支柱的JSON外壳你把外壳的骨架摸清了面对再复杂的搜索需求第一反应都不会是“这个API怎么调”而是“这个业务本质上是哪种匹配逻辑”。先用bool filter match把思路搭出来再去查具体语法你会上手得远比想象中快。
返回列表