ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Elasticsearch Query DSL实战指南:从查询语法到性能优化

Elasticsearch Query DSL实战指南:从查询语法到性能优化 如果你手头有Elasticsearch服务不管是集群还是单机Demo迟早要面对Query DSL。它是Elasticsearch的核心查询语法说它是ES里最值得精通的东西一点都不夸张。我见过不少开发同学部署ES很溜Docker Compose一拉就起Spring Boot集成也跑得通可真到写查询的时候基本靠match和term两板斧遇到bool就抄网上的模板出了问题也不知道是语法写错还是查询设计不合理。这篇文章想把Query DSL从语法本身讲清楚再结合我这些年实际项目里踩过的坑把那些“教程不会告诉你”的部分补上。无论你是刚接触ES的前端、后端还是已经在用ES但查询一直停留在复制粘贴阶段这篇都值得花十分钟读完。1. 先把Query DSL摆到正确的位置1.1 ES里的“查询语言”为什么长这样先说结论Query DSL本质上是客户端发给ES的一组JSON用来描述“我要查什么、怎么查、返回什么”。用关系型数据库做类比MySQL里一条查询是SELECT ... FROM table WHERE conditionES里则是发送一段JSON到/index/_search。为什么ES不用类SQL语法因为它的核心存储是倒排索引查询方式不止“字段等于值”这一种还有全文相关度、短语匹配、前缀、范围、嵌套对象、地理位置等等用JSON来描述这些查询条件比字符串SQL要灵活得多。举个例子最简单的查询是match_allPOST /my_index/_searchbody是{query:{match_all:{}}}。它的意思是“返回全部文档且每个文档打分相同”是不是很像SELECT *你要查某个字段匹配某个词就用match比如{match:{title:电商}}。ES会先把“电商”分析成一个个词项再去倒排索引里找匹配文档最后按相关性打分。理解这个思路之后你会发现DSL的学习曲线其实很平滑它只是把“条件”嵌套成了JSON对象而已。1.2 一条查询请求在ES内部到底经历了什么接下来很重要你要知道一条查询请求进来之后ES内部大概发生了什么事情。客户端请求会先到协调节点协调节点根据文档的routing、索引的shard布局把请求转发到对应的分片每个分片独立执行查询然后协调节点做结果合并最后返回给客户端。这里有个最容易被忽视的点ES里查询条件分两类执行上下文。一类叫query context查询上下文所有条件都会参与相关性打分返回结果的_score字段会被计算另一类叫filter context过滤上下文条件只负责“过滤掉不匹配的文档”不计算打分。绝大多数初学者写bool查询时什么条件都丢进must里结果就是所有条件都参与打分这既浪费CPU也可能把真正想要的结果排到后面去。从下一章开始我们就沿着“叶子查询-复合查询-性能调优”这条线把Query DSL玩明白。2. Query DSL万丈高楼从“叶子”打起2.1 叶子查询全家桶match、term、range、exists、wildcardQuery DSL按是否包含子查询分成叶子查询和复合查询。叶子查询是不能再拆的查询条件也是整个DSL的地基。我捡最常用的五种说match、term、range、exists、wildcard。match是全文检索的默认选择适合输入一个自然语言片段来查找文档。它会把查询词拆成词项只要一个词命中就返回按相关度排序。需要注意只有当字段的mapping是text类型时match才有意义如果字段是keywordmatch会被当成term处理行为会不一样。term是精确匹配不分析查询词直接拿着原值去倒排索引里查。所以term最合适的对象是keyword、数字、日期、布尔这类精确类型字段。很多人踩过的坑是拿term去查一个text字段写入时“状态”被分词成了“状”“态”你term“状态”根本查不到或者更惨部分匹配到但结果不对。解决方法是要么用match查text要么在mapping里给text字段加keyword子字段用field.keyword做term查询。range用于范围过滤数值、日期、字符串都支持。日期范围查询我太常用了比如查询最近7天的订单range作用在create_time上加上明确的format可以规避很多时区换算引起的错误。exists用来查某字段是否存在。这在数据清洗场景特别有用比如找出所有缺少user_id的脏数据。注意null和空数组都会被判定为不存在这一点很反直觉。wildcard支持通配符*和?都能用但我要提前泼一盆冷水wildcard查询非常慢尤其是在没有前缀的情况下比如*2024*这种写法ES基本要把整个分片扫描一遍。生产环境能用ngram分词器或者keyword加prefixQuery的地方就别用带前置通配符的wildcard。查询子句语义典型场景注意点match全文检索查询词会被分析搜索标题、内容等文本字段类型需为textterm精确匹配不分析状态、ID、分类等keyword字段查text字段会翻车range范围匹配价格、时间、数值区间指定好format和时区exists字段存在性检查找脏数据、空值治理null和空数组算不存在wildcard通配符模糊查询订单号、编码类模糊查询避免左通配会慢2.2 bool复合查询四个子句怎么配合不乱复合查询可以把多个叶子查询组合起来其中最有用的就是bool查询。它包含四个子句must、should、must_not、filter。用生活化一点的说法must是“必须满足”should是“最好满足”must_not是“绝不能有”filter是“必须满足但不参与打分”。给一个bool完整例子POST /orders/_search { query: { bool: { must: [ {match: {customer_name: 张三}} ], filter: [ {term: {status: PAID}}, {range: {create_time: {gte: 2024-01-01}}} ], must_not: [ {term: {cancelled: true}} ], should: [ {match: {remark: 加急}} ] } } }这个查询语义很清晰查张三的支付成功订单创建时间在2024年之后排除已取消的并且如果备注里含“加急”会额外加分。要注意的是ES会用bool的minimum_should_match规则来决定should要不要生效默认情况下要满足一个should才加分但如果整个bool里只有should没有任何must/filter它的语义会变成“任一个满足即可”这也是初学者容易绕晕的地方。从性能角度讲filter子句是最有性价比的存在。ES会缓存常用的filter结果不参与打分所以重复查询同样的filter条件时性能和稳定性都更好。实际开发中凡是状态、时间、类型这类过滤型条件我一律丢进filter。2.3 multi_match和match_phrase让搜索更像“人话”接下来是两个高出场率的查询multi_match和match_phrase。multi_match解决的是“一个查询词要在多个字段里搜”的场景。比如商品搜索用户输入“无线耳机”你想让title、description、tags三个字段都参与匹配用multi_match最省事{ query: { multi_match: { query: 无线耳机, fields: [title, description, tags] } } }还可以给不同字段配权重比如title^3表示title字段的命中更重要权重越高相关度排名越靠前。这在业务上非常实用因为“标题匹配”和“描述匹配”对用户意图的置信度是不一样的。match_phrase则解决的是“短语精确匹配”。它要求字段里包含连续的一段短语顺序也不能乱。比如你搜“北京烤鸭”match_phrase会要求文档中必须按“北京”“烤鸭”这个顺序连续出现。默认slop为0还可以设置slop来允许中间夹杂其他词。像搜索商品名、文章标题这种需要严格短语的场景match_phrase比match准得多。这里再补充一个进阶选项match查询的minimum_should_match。比如match一个句子如果句子里的词很多ES默认是OR逻辑任何词命中都可以这会导致结果噪音很大。设置minimum_should_match为75%可以让至少75%的词命中才算匹配查出来的结果质量会好很多。这个参数我经常在高召回率场景里用来压噪音效果立竿见影。3. 高级查询实战性能和正确性怎么同时拿到3.1 filter和query性能分道扬镳代码里要分开写前面铺垫了那么多query/filter区分这一节先就把它讲透。很多教程只是提了一句“filter不打分”但没告诉你实战中这句话能带来多大收益。我用一个实际对比来说明。假设orders表有100万文档你要查“2024年度、statusPAID”的订单。如果全丢给must{ query: { bool: { must: [ {term: {status: PAID}}, {range: {create_time: {gte: 2024-01-01, lte: 2024-12-31}}} ] } } }ES要对所有候选文档计算相关性得分status和create_time都要参与打分。这个打分虽然单个开销不大但百万级文档下累积起来非常可观。如果改成filter{ query: { bool: { filter: [ {term: {status: PAID}}, {range: {create_time: {gte: 2024-01-01, lte: 2024-12-31}}} ] } } }这两段DSL查询出来的结果集合几乎一样但第二个执行路径不同filter条件不进评分器ES会直接把条件作为位集合去匹配文档结果还可以被缓存。实测下来差不多的数据量下第二次查询的延迟能少一半以上命中缓存的请求甚至个位数毫秒就返回了。所以我的经验准则是条件只是“筛选”比如状态、时间、地域、渠道放filter只有真正需要影响排序权重的条件比如搜索关键词、用户偏好才放must或should。别把所有条件都塞进must否则用户看到搜索结果全是“人工顶置”式的怪异排序你自己也查不明白为什么分数那么玄学。3.2 深度分页from/size的1万上限和search_after分页是查询里的老大难。Query DSL最简单粗暴的分页是from加size。但ES默认设置了result window上限10000也就是说from加size超过10000查询会直接报错Result window is too large。为什么因为ES要先把每个分片上的前fromsize条全部拿到协调节点再排序、截断深度分页意味着每个分片都要付出极高的IO和内存开销。生产环境里我几乎不会用大from。业务上要做“下一页”我推荐search_after。它要求先按某个唯一字段排序然后用上一页最后一条文档的排序值作为search_after跳到下一页。举个例子按order_id排序上一页最后一条的order_id是500下一页查询{ search_after: [500], sort: [ {order_id: asc} ], query: { bool: { filter: [ {term: {status: PAID}} ] } } }search_after没有from/size那种跳跃翻页但换来了性能和稳定性。如果要一次性导出全量数据比如给数仓导数据那就用scroll。scroll的原理是维护一个快照游标适合大批量数据顺序拉取但不适合实时交互页面。记住这三点实时分页用search_after全量导出用scroll千万别为了省事拿大from去翻页。3.3 一个订单列表查询的完整DSL现在组合一下写一个真实的订单列表查询。需求是查看某个用户已支付订单金额在100到5000之间按下单时间倒序取前20条。完整DSL如下POST /orders/_search { query: { bool: { filter: [ {term: {customer_id: U123456}}, {term: {status: PAID}}, {range: {amount: {gte: 100, lte: 5000}}} ] } }, sort: [ {create_time: desc} ], from: 0, size: 20 }这个DSL就是把前面所有知识点串一起了bool负责组合filter负责条件的性能range负责金额范围sort负责排序from/size负责第一页。注意customer_id我用term是因为它是keyword类型如果它是textterm大概率查不出结果。这里的create_time是date类型desc排序完全没问题。实际项目中你还要考虑要不要高亮关键词、要不要二次聚合、要不要带出嵌套对象等。写DSL之前先确认字段的mapping会比什么都重要。我给自己定的习惯是每次接需求先看一下索引mapping再动手设计查询而不是打开Kibana凭记忆瞎写。这个习惯帮我避开了很多term/text不匹配的坑。3.4 用一次聚合把搜索变成报表再有就是聚合。聚合语法和查询其实是一脉相承都是嵌套JSON但作用完全不同查询返回文档聚合返回统计结果。比如统计订单状态分布POST /orders/_search { size: 0, aggs: { status_stats: { terms: { field: status, size: 10 } } } }size设为0意味着我不要文档列表只看聚合结果。terms聚合会把status字段的值分组返回每个状态的文档数。再配合查询条件比如只要2024年已支付订单的金额平均值可以这样{ query: { bool: { filter: [ {term: {status: PAID}}, {range: {create_time: {gte: 2024-01-01}}} ] } }, size: 0, aggs: { avg_amount: { avg: { field: amount } } } }这已经是一个完整的报表接口了。你完全可以在Kibana的Dev Tools里面把Query DSL和聚合结合起来快速验证数据再落到系统里。我经常用这个办法帮运营出临时报表一次POST、一条DSL数据就出来了比写个定时任务快太多。4. 从零开始把Query DSL用进真实项目4.1 先把ES跑起来Windows和Docker Compose的启动姿势聊了这么多DSL总得有环境跑起来。我说说最容易被新人卡住的启动环节。你如果是Windows环境装了ES的zip包一定不要把ES解压到磁盘路径带中文或者空格的目录比如C:\Program Files\elasticsearch这种路径会让启动脚本出各种莫名其妙的问题。还有启动ES之前先打开config/jvm.options把JVM堆设成2G或者4G别让默认堆和机器内存打架。ES默认不允许用管理员权限启动Windows上尽量用普通账户。这些不起眼的细节能卡住你半小时以上。如果不想在本地折腾Docker Compose是最省心的部署方式。一个简单的docker-compose.yml大概长这样version: 3 services: es01: image: docker.elastic.co/elasticsearch/elasticsearch:7.17.9 container_name: es01 environment: - node.namees01 - discovery.typesingle-node - cluster.namees-docker-cluster - ES_JAVA_OPTS-Xms512m -Xmx512m - xpack.security.enabledfalse ports: - 9200:9200 ulimits: nofile: soft: 65535 hard: 65535这段配置启动一个单节点集群禁用安全认证映射9200端口。启动后用curl localhost:9200验证看到带cluster_name、version的JSON就算成功。这里我要强调版本Query DSL的语法在不同大版本之间可能有细微差别所以Spring Boot集成、JDBC驱动、DBeaver连接时版本必须对齐。比如你本地跑7.17客户端和JDBC也尽量用7.x对应版本。KubeSphere这类K8s平台部署ES本质也是配置好存储、StatefulSet和Headless Service用Kibana测试DSL的方式是一样的。4.2 Spring Boot集成用RestHighLevelClient写Query DSL环境起来之后回到Java项目。现在Spring Boot 2.x里最常用的是RestHighLevelClient它支持把Query DSL转成Java API。我贴一个最典型的检索代码// 依赖elasticsearch-rest-high-level-client版本和ES服务端一致 RestHighLevelClient client new RestHighLevelClient( RestClient.builder(new HttpHost(localhost, 9200, http))); SearchRequest searchRequest new SearchRequest(orders); SearchSourceBuilder builder new SearchSourceBuilder(); BoolQueryBuilder boolQuery QueryBuilders.boolQuery() .filter(QueryBuilders.termQuery(status, PAID)) .filter(QueryBuilders.rangeQuery(amount).gte(100).lte(5000)) .must(QueryBuilders.matchQuery(customerName, 张三)); builder.query(boolQuery); builder.sort(SortBuilders.fieldSort(createTime).order(SortOrder.DESC)); builder.from(0); builder.size(20); searchRequest.source(builder); SearchResponse response client.search(searchRequest, RequestOptions.DEFAULT);这段代码和前面那段JSON是同一套语义filter放精确条件和范围must放匹配关键词。翻译过来你会发现Java API和JSON DSL是一一对应的只要脑子里先有DSL再敲Java代码就顺理成章反过来硬背API就会很痛苦。Spring Boot项目里最常见的坑是health check失败。Spring Boot Actuator启动后会自动去连ES如果客户端版本和ES版本不一致或者ES设置了密码而代码没配又或者网络不同你就会在日志里看到e.elasticsearchrestclienthealthindicator : elasticsearch health check failed。这个报错本身不致命但会误导你让你以为服务挂了。排查路径一般是先确认http://localhost:9200能不能返回JSON再确认客户端依赖版本和服务端版本大版本一致最后检查是否启用了xpack.security而缺少认证。一步步走下来十分钟内能定位。4.3 用DBeaver/JDBC连ES版本匹配比你说的还重要如果习惯用数据库工具看ES数据DBeaver是个不错的选择但它连接ES依赖JDBC驱动。这里有个特别容易踩的版本坑ES官方JDBC驱动与ES服务端版本是严格绑定的。你拿一个8.x的JDBC驱动去连7.x的ES启动连接就会报错大意是this version of the jdbc driver is only compatible with Elasticsearch version ...。解决办法很简单用与集群版本一致的driver包别贪新。还要注意ES SQL接口是只读的它不能做UPDATE/DELETE只能做SELECT查询和部分聚合。DBeaver连接时填地址、用户名密码数据库名可以留空或填一个索引名。连接成功后能直接在工具里跑SQL比如SELECT * FROM orders WHERE status PAID。底层翻译成Query DSL后再到ES执行。这个能力日常排查数据很好用但我建议正式业务代码还是直接写DSL因为SQL翻译出来的DSL未必是最优的。5. 常见问题与排查技巧实录5.1 health check failed排查三步走health check failed这个场景我每年都会遇到几次先把最快的排查思路列出来。一是ES本身没起来。先访问http://localhost:9200看看有没有JSON返回。常见原因是JVM内存不够、端口被占用、磁盘空间不足。二是版本不匹配。ES服务端是7.17客户端用8.x健康检查必然失败。三是认证失败。集群开启了安全认证而Spring Boot没配用户名密码。四是网络不通。如果用了Docker、K8s等网络隔离客户端访问的host和port必须对得上。现象可能原因快速动作日志报health check failedES没启动先curl 9200接口health check失败且版本不同客户端与服务端大版本不一致统一依赖和集群版本连接被拒绝或401端口/认证配置不对核对地址、账号密码偶发超时堆内存、线程池打满看monitoring和节点JVM指标这里多说一句Spring Boot 2.7之后官方已经慢慢把high-level client标记为弃用新项目可以考虑用官方新的elasticsearch-java客户端但Query DSL的指导思想完全一样会写JSON和Builder对象迁移成本并不高。5.2 写入慢到底是磁盘不行还是集群配置不对再聊一个经常被问的问题“ES写入变慢了怎么判断是磁盘问题还是别的”这个问题特别典型因为写入慢的原因太多了磁盘只是其中一环但很多人一看到慢就怀疑磁盘。我的排查习惯是分四层看指标。第一看客户端侧bulk请求的耗时。如果整体慢先确认是不是写入量比平时大了而不是ES出了问题。第二看ES的索引线程池。监控指标里index线程池的queue和rejected如果出现大量拒绝说明写入压力已经超过集群能力。第三看segment merge和refresh对磁盘IO的占用。底层是Lucene在后台合并分段合并期间磁盘IO会飙升。第四才是看磁盘本身用iostat看%util和await%util持续90%以上、await居高不下说明磁盘确实在扛不住了。另外ES自己的node stats里store字段会告诉你每个节点的存储总大小数据量增长也能辅助判断。这里有个经验很多时候写入慢不是磁盘硬件问题而是refresh_interval设置太激进、translog持久化策略太重、或者副本数太多。调整refresh_interval从默认1s改成30s批量导入时吞吐量能明显上涨再把副本数在导入期临时降到0导入完再改回1-2写入速度会有肉眼可见的提升。如果这些调整都试过数据量也正常再掉头来看磁盘IO才不容易被误导。5.3 Query DSL语法和设计级“翻车”速查最后整理一张我平时带新人时用的问题速查表每一项都是在真实项目里出现过的。错误场景典型表现解决方案term查中文text字段查不到或结果不全用match或给text加keyword子字段wildcard带左通配慢查询、CPU飙升改prefix或建ngram分词器fromsize过大翻页Result window is too large换search_after或scrollbool里所有条件塞must性能差、排序怪异纯过滤条件挪到filterkeyword字段用match只能精确点查不支持分词按字段类型选匹配方式聚合字段没有doc_values聚合报错fielddata限制确保mapping开启doc_values日期范围结果差8小时返回时间不对指定format和time_zone写到这儿我想起另一个调试利器Kibana的Dev Tools里有个explain API可以查看单个文档的得分拆解。很多查询写出来结果不对不是语法问题而是不清楚文本字段被拆分成了什么。所以遇到怪结果先GET index/_doc/id?_explain再看分词器输出基本能破案。我个人在实际操作中的体会是Query DSL最难的不是记忆几十个查询子句而是建立“查询上下文”的意识和“字段类型决定查询方式”的肌肉记忆。你多看几篇这种讲实战的文章再自己动手把环境拉起来把前面那些包括Spring Boot、DBeaver的坑都踩一遍自然就通了。后续如果遇到更刁钻的查询性能问题建议先把Kibana监控图表打开让数据告诉你答案而不是靠猜。
返回列表